diff --git a/docs/designs/management-backend.md b/docs/designs/management-backend.md index 2f6409b84..803e7e9ed 100644 --- a/docs/designs/management-backend.md +++ b/docs/designs/management-backend.md @@ -24,14 +24,19 @@ reconciles hooks/MCP, maintains recall indexes and reports activity. The backend's wider resource model must not falsely imply every current CLI handler already supports every write operation. -The current [local agent](../../src/local-agent.ts) uses -`/api/projects/mine`, `/api/local-agent/report`, `/api/local-agent/sync`, -`/api/local-agent/commands/ack` and `/api/local-agent/get-config`. It delivers -commands/resources and manages workspace bindings, rather than materializing a -complete versioned team repository. These routes remain a separate compatibility -adapter; they are not aliases for the new API. The provider abstraction proposed -in [#469](https://github.com/Tencent/teamai-cli/pull/469) can host a future management -adapter if accepted; this design does not assume that PR has landed. +The ClawPro HTTP client (now at +[providers/http/adapters/clawpro/client.ts](../../src/providers/http/adapters/clawpro/client.ts), +with [src/local-agent.ts](../../src/local-agent.ts) kept as a deprecated +re-export) uses `/api/projects/mine`, `/api/local-agent/report`, +`/api/local-agent/sync`, `/api/local-agent/commands/ack` and +`/api/local-agent/get-config`. It delivers commands/resources and manages +workspace bindings, rather than materializing a complete versioned team +repository. These routes remain a separate compatibility adapter; they are not +aliases for the new API. The Git/HTTP `ResourceProvider` abstraction (issue +[#404](https://github.com/Tencent/teamai-cli/issues/404), phases 1–2 landed: +`ResourceProvider`/`HttpBackendAdapter` with ClawPro as an HTTP adapter) is the +seam a future management adapter would plug into as another HTTP adapter; the +ownership-ledger and multi-provider arbitration it needs are later phases. The [data-directory design](data-directory-layout.md) distinguishes a local workspace partition from a logical project. The [multi-project design](multi-project-management.md) diff --git a/docs/designs/management-backend.zh-CN.md b/docs/designs/management-backend.zh-CN.md index 4a2ae9b19..dec105864 100644 --- a/docs/designs/management-backend.zh-CN.md +++ b/docs/designs/management-backend.zh-CN.md @@ -20,13 +20,17 @@ [remove](../../src/remove.ts) 当前只暴露 skills、rules、agents 和 MCP 的删除。 后端覆盖更多资源,不代表当前每个 CLI 处理器已经支持所有写操作。 -现有 [local-agent](../../src/local-agent.ts) 使用 +ClawPro HTTP 客户端(现位于 +[providers/http/adapters/clawpro/client.ts](../../src/providers/http/adapters/clawpro/client.ts), +[src/local-agent.ts](../../src/local-agent.ts) 保留为已弃用的 re-export)使用 `/api/projects/mine`、`/api/local-agent/report`、`/api/local-agent/sync`、 `/api/local-agent/commands/ack` 和 `/api/local-agent/get-config`, 负责命令与资源下发及工作区绑定,并不生成完整的版本化团队仓快照。 这些路由保留为独立兼容适配器,不作为新 API 的别名。 -[#469](https://github.com/Tencent/teamai-cli/pull/469) 提议的 Provider 抽象若获合入, -可以承载未来的管理后端适配器;本文不假设该 PR 已经落地。 +Git/HTTP `ResourceProvider` 抽象(issue +[#404](https://github.com/Tencent/teamai-cli/issues/404),阶段 1–2 已落地: +`ResourceProvider`/`HttpBackendAdapter`,ClawPro 作为一个 HTTP adapter)即未来管理后端 +适配器可作为又一个 HTTP adapter 接入的接缝;其所需的 ownership ledger 与多 provider 仲裁属后续阶段。 [数据目录设计](data-directory-layout.md) 区分机器上的工作区分区与逻辑项目。 [多项目设计](multi-project-management.md) 通过项目和角色选择器决定资源命名空间。 diff --git a/docs/usage-guide.md b/docs/usage-guide.md index cfd76b34d..0ce31a003 100644 --- a/docs/usage-guide.md +++ b/docs/usage-guide.md @@ -1854,6 +1854,32 @@ teamai source remove-http An HTTP source reports status and pulls skill commands via hook dispatch on every session. Only one HTTP source is supported per install. If the main repo is already in HTTP mode (`init --http`), `add-http` is unavailable (the main repo already occupies the HTTP config). +#### Named HTTP provider + +`source add-http` / `init --http` configure a single global HTTP backend whose state sits in `~/.teamai/local-agent/`. `teamai provider` mounts an HTTP backend as a **named** provider instead, with its credential, resource manifest and cache isolated under its own directory: + +```bash +# Add a named HTTP provider (a backend behind a protocol adapter, e.g. clawpro) +teamai provider add http https://company-host/api --name company --adapter clawpro --token + +# List, sync, remove +teamai provider list +teamai provider sync +teamai provider remove company +``` + +The provider's state lives under `~/.teamai/providers/http//`; its token is stored `0600` at `~/.teamai/credentials/`, never in a config file. On every session, hook dispatch syncs it, reporting a per-provider result so a failing backend surfaces as a failure rather than a false success. + +> **One HTTP provider at a time.** Mounting several HTTP backends concurrently needs cross-provider ownership arbitration (so same-name resources don't overwrite or delete each other) — that is a later phase (issue #404). Until then `provider add http` refuses a second provider, so there is no priority to configure yet. + +To move an existing single HTTP backend (`init --http` / `source add-http`) onto the named-provider model, run: + +```bash +teamai provider migrate-legacy --name company +``` + +This promotes `~/.teamai/local-agent/` to a named provider (copying its state and extracting its credential to the isolated `0600` file), then removes the old directory. It is idempotent — once migrated, re-running is a no-op. + --- ## Command Reference diff --git a/docs/usage-guide.zh-CN.md b/docs/usage-guide.zh-CN.md index 462475e0b..f66452470 100644 --- a/docs/usage-guide.zh-CN.md +++ b/docs/usage-guide.zh-CN.md @@ -1786,6 +1786,32 @@ teamai source remove-http HTTP 源通过 hook dispatch 在每次 session 中上报状态并拉取 skill 指令。每个安装仅支持一个 HTTP 源。若主仓本身已是 HTTP 模式(`init --http`),则 `add-http` 不可用(主仓已占用 HTTP 配置)。 +#### 具名 HTTP provider + +`source add-http` / `init --http` 只能配置一个全局 HTTP 后端,状态落在 `~/.teamai/local-agent/`。`teamai provider` 则把 HTTP 后端挂成一个**具名 provider**,其凭据、资源清单和缓存隔离在各自目录下: + +```bash +# 添加具名 HTTP provider(一个协议 adapter 后的后端,如 clawpro) +teamai provider add http https://company-host/api --name company --adapter clawpro --token + +# 列出、同步、删除 +teamai provider list +teamai provider sync +teamai provider remove company +``` + +该 provider 的状态存放在 `~/.teamai/providers/http//`;其 token 以 `0600` 权限单独存于 `~/.teamai/credentials/`,绝不写入任何配置文件。每次 session 中,hook dispatch 会同步它,并返回每个 provider 的结果——后端失败会如实报为失败,而非假成功。 + +> **目前每台机器只支持一个 HTTP provider。** 同时挂载多个 HTTP 后端需要跨 provider 的归属仲裁(避免同名资源互相覆盖或删除),这是后续阶段(issue #404)。在此之前,`provider add http` 会拒绝添加第二个 provider,因此暂无优先级可配置。 + +要把已有的单个 HTTP 后端(`init --http` / `source add-http`)迁移到具名 provider 模型,运行: + +```bash +teamai provider migrate-legacy --name company +``` + +这会把 `~/.teamai/local-agent/` 提升为具名 provider(复制其状态,并把凭据抽取到隔离的 `0600` 文件),随后删除旧目录。该操作幂等——迁移完成后重跑为 no-op。 + --- ## 命令参考 diff --git a/skill-data/core/references/commands.md b/skill-data/core/references/commands.md index 1bfbeed3b..f6a923394 100644 --- a/skill-data/core/references/commands.md +++ b/skill-data/core/references/commands.md @@ -162,6 +162,20 @@ Generated: do not edit by hand. Regenerate with - `teamai source list` — List all configured sources - `teamai source browse ` — Browse public skills from a source +## provider + +- `teamai provider` — Manage named HTTP resource providers + - `teamai provider add` — Add a resource provider + - `teamai provider add http ` — Add a named HTTP provider (e.g. a ClawPro backend) + - `--name ` — Unique name for this provider + - `--adapter ` — Protocol adapter (default: clawpro) + - `--token ` — API token (stored 0600 outside config, never committed) + - `teamai provider list` — List configured HTTP providers + - `teamai provider sync` — Sync all configured HTTP providers now + - `teamai provider remove ` — Remove an HTTP provider and clean up its resources + - `teamai provider migrate-legacy` — Promote the legacy ~/.teamai/local-agent/ singleton to a named provider + - `--name ` — Name for the migrated provider + ## update - `teamai update` — Check for updates and upgrade teamai CLI diff --git a/skill-data/setup/references/join-member.md b/skill-data/setup/references/join-member.md index 83be86a23..e45ff6334 100644 --- a/skill-data/setup/references/join-member.md +++ b/skill-data/setup/references/join-member.md @@ -88,6 +88,24 @@ teamai init --http https://your-team-host/api --token This is a read-only consumer mode — `push` / `contribute` are not available, but skills and rules still sync. +**Named HTTP provider (isolated state):** instead of the global `init --http` +singleton, an HTTP backend can be mounted as a *named* provider whose config and +manifest are isolated under `~/.teamai/providers/http//`, with its +credential stored separately (0600) at `~/.teamai/credentials/`, never in a +config file: + +```bash +teamai provider add http https://your-team-host/api --name --token +teamai provider list +teamai provider remove +``` + +To move an existing `init --http` singleton onto this model without losing state, +run `teamai provider migrate-legacy --name ` (it copies the state, isolates +the credential, then removes the old directory; idempotent). Only one HTTP +provider is supported per install for now — mounting several concurrently needs +cross-provider ownership arbitration, which is a later phase (issue #404). + **Claude Code kept in a different directory (`CLAUDE_CONFIG_DIR`):** `init` records that directory (as `toolRoots.claude` in the local config) and syncs every Claude path there, so run `init` from a shell that has the variable exported. Re-running diff --git a/src/__tests__/hook-handlers.test.ts b/src/__tests__/hook-handlers.test.ts index 3e074fd3c..c47c68c18 100644 --- a/src/__tests__/hook-handlers.test.ts +++ b/src/__tests__/hook-handlers.test.ts @@ -108,6 +108,16 @@ vi.mock('../local-agent.js', () => ({ reportAndSyncFromHook: mockReportAndSyncFromHook, })); +// local-agent-sync now dispatches named HTTP providers first, then falls back +// to the legacy singleton (issue #404). These tests exercise the legacy path: +// no named providers configured, singleton active. +vi.mock('../providers/http/registry.js', () => ({ + loadHttpResourceProviders: vi.fn().mockResolvedValue([]), +})); +vi.mock('../providers/http/store.js', () => ({ + legacySingletonActive: vi.fn().mockResolvedValue(true), +})); + vi.mock('../pkg/pkg-hint.js', () => ({ packageManifestHashForCwd: mockPackageManifestHash, stashPackageHintAfterPull: mockStashPackageHint, diff --git a/src/__tests__/hooks.test.ts b/src/__tests__/hooks.test.ts index 9076077a2..a48e2189e 100644 --- a/src/__tests__/hooks.test.ts +++ b/src/__tests__/hooks.test.ts @@ -401,6 +401,31 @@ describe('hooks', () => { } }); + it('returns an attempted/succeeded tally that excludes uninstalled tools (issue #404)', async () => { + const originalHome = process.env.HOME; + process.env.HOME = '/test-home'; + + const { pathExists: mockedPathExists } = await import('../utils/fs.js'); + // Only .claude is installed; .tclaude's root is absent. + (mockedPathExists as ReturnType).mockImplementation(async (p: string) => + (p as string).includes('.claude') && !(p as string).includes('.tclaude'), + ); + + try { + const result = await injectHooksToAllTools({ + claude: { settings: '.claude/settings.json' }, + tclaude: { settings: '.tclaude/settings.json' }, + }); + // Only the installed tool is attempted and counted — an uninstalled tool + // is neither attempted nor a "success", so a single real injection is + // distinguishable from "nothing landed". + expect(result).toEqual({ attempted: 1, succeeded: 1 }); + } finally { + (mockedPathExists as ReturnType).mockImplementation(async () => true); + process.env.HOME = originalHome; + } + }); + it('filterAgents limits injection to specified tools only', async () => { const originalHome = process.env.HOME; process.env.HOME = '/test-home'; diff --git a/src/__tests__/http-provider-multi.test.ts b/src/__tests__/http-provider-multi.test.ts new file mode 100644 index 000000000..1a136542d --- /dev/null +++ b/src/__tests__/http-provider-multi.test.ts @@ -0,0 +1,169 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import path from 'node:path'; +import os from 'node:os'; +import fse from 'fs-extra'; + +vi.mock('../utils/logger.js', () => ({ + log: { info: vi.fn(), success: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, +})); + +let tmpDir: string; +let origHome: string | undefined; + +beforeEach(async () => { + tmpDir = await fse.mkdtemp(path.join(os.tmpdir(), 'teamai-http-multi-')); + origHome = process.env.HOME; + process.env.HOME = tmpDir; +}); + +afterEach(async () => { + process.env.HOME = origHome; + await fse.remove(tmpDir); + vi.restoreAllMocks(); + vi.resetModules(); +}); + +describe('multiple HTTP providers: hook dispatch', () => { + it('dispatches every configured provider once, isolating one failure', async () => { + const seen: Array<{ name: string; endpoint: string; home: string }> = []; + + // Intercept the ClawPro client entry the adapter calls, capturing the + // active provider context so we can assert per-provider isolation without a + // real backend. + vi.doMock('../providers/http/adapters/clawpro/client.ts', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + reportAndSyncFromHook: vi.fn(async () => { + const ctx = actual.currentHttpProvider(); + const cfg = await actual.loadLocalAgentConfig(); + seen.push({ name: ctx?.name ?? '?', endpoint: cfg?.endpoint ?? '?', home: ctx?.home ?? '?' }); + if (ctx?.name === 'flaky') throw new Error('backend down'); + return `hint:${ctx?.name}`; + }), + }; + }); + + const { upsertHttpProviderConfig, httpProviderExecutionContext } = await import('../providers/http/store.js'); + const { withHttpProvider, initLocalAgentHttp } = await import('../providers/http/adapters/clawpro/client.js'); + for (const p of [ + { name: 'good', endpoint: 'https://good/api', priority: 40 }, + { name: 'flaky', endpoint: 'https://flaky/api', priority: 80 }, + ]) { + await upsertHttpProviderConfig({ name: p.name, adapter: 'clawpro', endpoint: p.endpoint, priority: p.priority }); + // Seed each provider's isolated config.json so loadLocalAgentConfig + // returns its endpoint (initLocalAgentHttp injects no hooks without tools). + await withHttpProvider(httpProviderExecutionContext(p.name), () => + initLocalAgentHttp({ endpoint: p.endpoint, force: true }), + ); + } + + const { loadHttpResourceProviders } = await import('../providers/http/registry.js'); + const { syncResourceProviders } = await import('../providers/resource-registry.js'); + const providers = await loadHttpResourceProviders(); + const results = await syncResourceProviders(providers, { + trigger: 'hook', + tool: 'claude', + stdin: { hook_event_name: 'SessionStart' }, + cwd: tmpDir, + }); + + // Each provider dispatched exactly once. + expect(seen.map((s) => s.name).sort()).toEqual(['flaky', 'good']); + // Each ran against its own endpoint / state home (isolation). + const good = seen.find((s) => s.name === 'good')!; + expect(good.endpoint).toBe('https://good/api'); + expect(good.home).toContain(path.join('providers', 'http', 'good')); + const flaky = seen.find((s) => s.name === 'flaky')!; + expect(flaky.home).toContain(path.join('providers', 'http', 'flaky')); + + // Failure isolation: one down backend does not sink the other. + const byName = Object.fromEntries(results.map((r) => [r.provider, r])); + expect(byName.good.ok).toBe(true); + expect(byName.good.hookOutput).toBe('hint:good'); + expect(byName.flaky.ok).toBe(false); + expect(byName.flaky.message).toBe('backend down'); + }); + + it('reports ok:false when the client swallows a report/sync error (outcome signal)', async () => { + // reportAndSyncFromHook does NOT throw here — it returns normally but fills + // the outcome out-param with a failure, exactly as the real client does when + // its internal try/catch swallows a network error. The adapter must still + // surface ok:false, not a false success. + vi.doMock('../providers/http/adapters/clawpro/client.ts', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + reportAndSyncFromHook: vi.fn(async (_stdin, _tool, outcome) => { + if (outcome) { + outcome.failed = true; + outcome.error = 'sync FAILED: network down'; + } + return null; + }), + }; + }); + + const { upsertHttpProviderConfig, httpProviderExecutionContext } = await import('../providers/http/store.js'); + const { withHttpProvider, initLocalAgentHttp } = await import('../providers/http/adapters/clawpro/client.js'); + await upsertHttpProviderConfig({ name: 'solo', adapter: 'clawpro', endpoint: 'https://solo/api', priority: 50 }); + await withHttpProvider(httpProviderExecutionContext('solo'), () => + initLocalAgentHttp({ endpoint: 'https://solo/api', force: true }), + ); + + const { loadHttpResourceProviders } = await import('../providers/http/registry.js'); + const { syncResourceProviders } = await import('../providers/resource-registry.js'); + const results = await syncResourceProviders(await loadHttpResourceProviders(), { + trigger: 'hook', + tool: 'claude', + stdin: { hook_event_name: 'SessionStart' }, + cwd: tmpDir, + }); + + expect(results).toHaveLength(1); + expect(results[0].ok).toBe(false); + expect(results[0].message).toBe('sync FAILED: network down'); + }); + + it('plugin reconcile worker re-enters the named provider context from env (review #3)', async () => { + // The detached worker gets the provider name via env (AsyncLocalStorage does + // not cross process boundaries). runPluginReconcileWorker must re-establish + // that context so it talks to the provider's OWN endpoint (read from the + // provider home), not the legacy dir. We observe the endpoint the worker's + // get-config fetch hits to prove the context was re-entered. + const { upsertHttpProviderConfig, httpProviderExecutionContext } = await import('../providers/http/store.js'); + const { withHttpProvider, initLocalAgentHttp, runPluginReconcileWorker } = await import( + '../providers/http/adapters/clawpro/client.js' + ); + await upsertHttpProviderConfig({ name: 'company', adapter: 'clawpro', endpoint: 'https://company-be/api', priority: 50 }); + await withHttpProvider(httpProviderExecutionContext('company'), () => + initLocalAgentHttp({ endpoint: 'https://company-be/api', force: true }), + ); + // A legacy singleton with a DIFFERENT endpoint — if the context were not + // re-entered, the worker would read this one instead. + const legacy = path.join(tmpDir, '.teamai', 'local-agent'); + await fse.ensureDir(legacy); + await fse.writeJson(path.join(legacy, 'config.json'), { + endpoint: 'https://legacy-be/api', workspaceBindings: {}, createdAt: '2026-01-01T00:00:00.000Z', + }); + + const fetchedUrls: string[] = []; + const fetchMock = vi.fn(async (url: string) => { + fetchedUrls.push(String(url)); + return new Response(JSON.stringify({ plugins: [] })); + }); + vi.stubGlobal('fetch', fetchMock); + process.env.TEAMAI_HTTP_PROVIDER_NAME = 'company'; + try { + await runPluginReconcileWorker(); + } finally { + delete process.env.TEAMAI_HTTP_PROVIDER_NAME; + vi.unstubAllGlobals(); + } + + // The worker fetched get-config against the NAMED provider's endpoint, not + // the legacy one — proving the provider context was re-established. + expect(fetchedUrls.some((u) => u.includes('company-be'))).toBe(true); + expect(fetchedUrls.some((u) => u.includes('legacy-be'))).toBe(false); + }); +}); diff --git a/src/__tests__/http-provider-store.test.ts b/src/__tests__/http-provider-store.test.ts new file mode 100644 index 000000000..d325353f9 --- /dev/null +++ b/src/__tests__/http-provider-store.test.ts @@ -0,0 +1,294 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; +import os from 'node:os'; +import fse from 'fs-extra'; + +vi.mock('../utils/logger.js', () => ({ + log: { info: vi.fn(), success: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, +})); + +let tmpDir: string; +let origHome: string | undefined; + +beforeEach(async () => { + tmpDir = await fse.mkdtemp(path.join(os.tmpdir(), 'teamai-http-store-')); + origHome = process.env.HOME; + process.env.HOME = tmpDir; +}); + +afterEach(async () => { + process.env.HOME = origHome; + await fse.remove(tmpDir); + vi.restoreAllMocks(); +}); + +const teamai = () => path.join(tmpDir, '.teamai'); + +describe('http provider store: config registry', () => { + it('rejects an unsafe provider name', async () => { + const { assertValidProviderName } = await import('../providers/http/store.js'); + expect(() => assertValidProviderName('../evil')).toThrow(/Invalid provider name/); + expect(() => assertValidProviderName('bad/name')).toThrow(/Invalid provider name/); + expect(() => assertValidProviderName('good-name.1')).not.toThrow(); + // Cross-platform path-segment hazards: trailing dot and Windows reserved names. + expect(() => assertValidProviderName('name.')).toThrow(/must not end with/); + expect(() => assertValidProviderName('CON')).toThrow(/reserved device name/); + expect(() => assertValidProviderName('com1')).toThrow(/reserved device name/); + // Windows also forbids a reserved name with any extension (CON.txt → device). + expect(() => assertValidProviderName('CON.txt')).toThrow(/reserved device name/); + expect(() => assertValidProviderName('LPT1.foo')).toThrow(/reserved device name/); + // A name that merely starts with those letters is fine. + expect(() => assertValidProviderName('console')).not.toThrow(); + }); + + it('rejects a name that collides case-insensitively with an existing provider', async () => { + const { upsertHttpProviderConfig } = await import('../providers/http/store.js'); + await upsertHttpProviderConfig({ name: 'Company', adapter: 'clawpro', endpoint: 'https://a/api', priority: 10 }); + // Same lowercase form, different spelling → collides on a case-insensitive FS. + await expect( + upsertHttpProviderConfig({ name: 'company', adapter: 'clawpro', endpoint: 'https://b/api', priority: 20 }), + ).rejects.toThrow(/collides with existing/); + // Exact-name replacement is still allowed (intentional upsert). + await expect( + upsertHttpProviderConfig({ name: 'Company', adapter: 'clawpro', endpoint: 'https://c/api', priority: 30 }), + ).resolves.toBeUndefined(); + }); + + it('isolates two providers by name in registry and per-provider home', async () => { + const { upsertHttpProviderConfig, listHttpProviderConfigs, getHttpProviderConfig, httpProviderHome } = + await import('../providers/http/store.js'); + + await upsertHttpProviderConfig({ name: 'company', adapter: 'clawpro', endpoint: 'https://a/api', priority: 80 }); + await upsertHttpProviderConfig({ name: 'community', adapter: 'clawpro', endpoint: 'https://b/api', priority: 40 }); + + const all = await listHttpProviderConfigs(); + expect(all.map((c) => c.name).sort()).toEqual(['community', 'company']); + expect((await getHttpProviderConfig('company'))?.endpoint).toBe('https://a/api'); + + // Each provider gets a self-describing config in its own home. + const companyConfig = await fse.readJson(path.join(httpProviderHome('company'), 'provider.json')); + expect(companyConfig.endpoint).toBe('https://a/api'); + expect(fs.existsSync(httpProviderHome('community'))).toBe(true); + }); + + it('removes only the selected provider from the registry', async () => { + const { upsertHttpProviderConfig, removeHttpProviderConfig, listHttpProviderConfigs } = + await import('../providers/http/store.js'); + await upsertHttpProviderConfig({ name: 'a', adapter: 'clawpro', endpoint: 'https://a/api', priority: 10 }); + await upsertHttpProviderConfig({ name: 'b', adapter: 'clawpro', endpoint: 'https://b/api', priority: 20 }); + + expect(await removeHttpProviderConfig('a')).toBe(true); + expect((await listHttpProviderConfigs()).map((c) => c.name)).toEqual(['b']); + expect(await removeHttpProviderConfig('missing')).toBe(false); + }); + + it('removes a provider state home and credential file', async () => { + const { upsertHttpProviderConfig, removeHttpProviderState, httpProviderHome, httpProviderCredentialPath } = + await import('../providers/http/store.js'); + const { writeTokenFile } = await import('../local-agent.js'); + + await upsertHttpProviderConfig({ name: 'a', adapter: 'clawpro', endpoint: 'https://a/api', priority: 10 }); + await fse.ensureDir(path.dirname(httpProviderCredentialPath('a'))); + await writeTokenFile(httpProviderCredentialPath('a'), 'secret'); + expect(fs.existsSync(httpProviderHome('a'))).toBe(true); + expect(fs.existsSync(httpProviderCredentialPath('a'))).toBe(true); + + await removeHttpProviderState('a'); + expect(fs.existsSync(httpProviderHome('a'))).toBe(false); + expect(fs.existsSync(httpProviderCredentialPath('a'))).toBe(false); + }); +}); + +describe('http provider: named-context credential isolation', () => { + it('keeps the token in a 0600 file, never in config.json', async () => { + const { withHttpProvider, initLocalAgentHttp, loadLocalAgentConfig } = await import('../local-agent.js'); + const { httpProviderExecutionContext, httpProviderCredentialPath, httpProviderHome } = await import( + '../providers/http/store.js' + ); + + await withHttpProvider(httpProviderExecutionContext('company'), () => + initLocalAgentHttp({ endpoint: 'https://a/api', token: 'super-secret', force: true }), + ); + + // config.json under the provider home carries NO token. + const cfg = await fse.readJson(path.join(httpProviderHome('company'), 'config.json')); + expect(cfg.endpoint).toBe('https://a/api'); + expect(cfg.token).toBeUndefined(); + + // The credential lives in an isolated 0600 file. + const credPath = httpProviderCredentialPath('company'); + expect(fs.existsSync(credPath)).toBe(true); + if (process.platform !== 'win32') { + expect(fs.statSync(credPath).mode & 0o777).toBe(0o600); + } + + // Loading inside the context re-reads the token from the credential file. + const loaded = await withHttpProvider(httpProviderExecutionContext('company'), () => loadLocalAgentConfig()); + expect(loaded?.token).toBe('super-secret'); + }); + + it('routes two providers to separate state homes', async () => { + const { withHttpProvider, initLocalAgentHttp } = await import('../local-agent.js'); + const { httpProviderExecutionContext, httpProviderHome } = await import('../providers/http/store.js'); + + await withHttpProvider(httpProviderExecutionContext('a'), () => + initLocalAgentHttp({ endpoint: 'https://a/api', token: 'ta', force: true }), + ); + await withHttpProvider(httpProviderExecutionContext('b'), () => + initLocalAgentHttp({ endpoint: 'https://b/api', token: 'tb', force: true }), + ); + + expect((await fse.readJson(path.join(httpProviderHome('a'), 'config.json'))).endpoint).toBe('https://a/api'); + expect((await fse.readJson(path.join(httpProviderHome('b'), 'config.json'))).endpoint).toBe('https://b/api'); + // Neither run wrote to the legacy singleton location. + expect(fs.existsSync(path.join(teamai(), 'local-agent', 'config.json'))).toBe(false); + }); +}); + +describe('http provider: legacy singleton migration', () => { + async function seedLegacy(token?: string) { + const legacyDir = path.join(teamai(), 'local-agent'); + await fse.ensureDir(legacyDir); + await fse.writeJson(path.join(legacyDir, 'config.json'), { + endpoint: 'https://legacy/api', + ...(token ? { token } : {}), + createdAt: '2026-01-01T00:00:00.000Z', + workspaceBindings: {}, + }); + // A manifest file, to prove the whole state home is copied. + await fse.writeJson(path.join(legacyDir, 'manifest.json'), { scopes: {} }); + return legacyDir; + } + + it('reports legacy singleton active until migrated', async () => { + const { legacySingletonActive } = await import('../providers/http/store.js'); + expect(await legacySingletonActive()).toBe(false); + await seedLegacy('t'); + expect(await legacySingletonActive()).toBe(true); + }); + + it('promotes the legacy singleton to a named provider with an isolated credential', async () => { + const legacyDir = await seedLegacy('legacy-token'); + const { + migrateLegacyHttpProvider, + legacySingletonActive, + httpProviderHome, + httpProviderCredentialPath, + getHttpProviderConfig, + } = await import('../providers/http/store.js'); + + const config = await migrateLegacyHttpProvider({ name: 'company', priority: 70 }); + expect(config).toMatchObject({ name: 'company', adapter: 'clawpro', endpoint: 'https://legacy/api', priority: 70 }); + + // Registered and self-describing. + expect((await getHttpProviderConfig('company'))?.endpoint).toBe('https://legacy/api'); + // State copied over. + expect(fs.existsSync(path.join(httpProviderHome('company'), 'manifest.json'))).toBe(true); + // Token extracted to the isolated 0600 file, stripped from migrated config.json. + expect(fs.readFileSync(httpProviderCredentialPath('company'), 'utf-8').trim()).toBe('legacy-token'); + expect((await fse.readJson(path.join(httpProviderHome('company'), 'config.json'))).token).toBeUndefined(); + + // The legacy dir is DELETED (no rollback snapshot) — so it no longer counts + // as an active singleton and cannot be revived or double-uninstalled. + expect(fs.existsSync(legacyDir)).toBe(false); + expect(await legacySingletonActive()).toBe(false); + }); + + it('extracts the token from the legacy ~/.teamai/token file and removes the original (review P2)', async () => { + await seedLegacy(); + await fse.writeFile(path.join(teamai(), 'token'), 'file-token\n'); + const { migrateLegacyHttpProvider, httpProviderCredentialPath } = await import('../providers/http/store.js'); + + await migrateLegacyHttpProvider({ name: 'company' }); + // Moved into the isolated 0600 credential … + expect(fs.readFileSync(httpProviderCredentialPath('company'), 'utf-8').trim()).toBe('file-token'); + // … and the shared plaintext ~/.teamai/token is deleted, not stranded. + expect(fs.existsSync(path.join(teamai(), 'token'))).toBe(false); + }); + + it('is idempotent: a second migration is a no-op', async () => { + await seedLegacy('t'); + const { migrateLegacyHttpProvider } = await import('../providers/http/store.js'); + expect(await migrateLegacyHttpProvider({ name: 'company' })).not.toBeNull(); + expect(await migrateLegacyHttpProvider({ name: 'company2' })).toBeNull(); + }); + + it('returns null when there is no legacy singleton', async () => { + const { migrateLegacyHttpProvider } = await import('../providers/http/store.js'); + expect(await migrateLegacyHttpProvider({ name: 'company' })).toBeNull(); + }); + + it('refuses to migrate onto a name a real provider already occupies', async () => { + await seedLegacy('t'); + const { migrateLegacyHttpProvider, upsertHttpProviderConfig } = await import('../providers/http/store.js'); + // A registered provider is the real conflict. + await upsertHttpProviderConfig({ name: 'company', adapter: 'clawpro', endpoint: 'https://x/api', priority: 10 }); + await expect(migrateLegacyHttpProvider({ name: 'company' })).rejects.toThrow(/already exists/); + }); + + it('is retriable: a home left by a crashed migration is discarded and rebuilt', async () => { + await seedLegacy('legacy-token'); + const { migrateLegacyHttpProvider, httpProviderHome, getHttpProviderConfig } = await import( + '../providers/http/store.js' + ); + // Simulate a crash after the state-dir move but before the registry write: + // a home dir exists but no registry entry. + await fse.ensureDir(httpProviderHome('company')); + await fse.writeFile(path.join(httpProviderHome('company'), 'stale.txt'), 'leftover'); + + const config = await migrateLegacyHttpProvider({ name: 'company' }); + expect(config).not.toBeNull(); + // The leftover was discarded and the home rebuilt from legacy (manifest copied). + expect(fs.existsSync(path.join(httpProviderHome('company'), 'stale.txt'))).toBe(false); + expect(fs.existsSync(path.join(httpProviderHome('company'), 'manifest.json'))).toBe(true); + expect((await getHttpProviderConfig('company'))?.endpoint).toBe('https://legacy/api'); + }); + + it('resumes when a prior attempt already wrote the registry entry', async () => { + await seedLegacy('legacy-token'); + const { migrateLegacyHttpProvider, upsertHttpProviderConfig, legacySingletonActive } = await import( + '../providers/http/store.js' + ); + // Simulate a crash AFTER upsert (registry entry with the legacy endpoint) + // but BEFORE the legacy dir was deleted: legacy is still active. A retry + // must resume, not fail with "already exists". + await upsertHttpProviderConfig({ + name: 'company', adapter: 'clawpro', endpoint: 'https://legacy/api', priority: 50, + }); + expect(await legacySingletonActive()).toBe(true); + + const config = await migrateLegacyHttpProvider({ name: 'company' }); + expect(config).not.toBeNull(); + // Migration completed: the legacy dir is deleted, so it is no longer active. + expect(await legacySingletonActive()).toBe(false); + }); + + it('deletes the legacy dir on migration (no snapshot) and is idempotent', async () => { + await seedLegacy('t'); + const { migrateLegacyHttpProvider, legacySingletonActive } = await import( + '../providers/http/store.js' + ); + const first = await migrateLegacyHttpProvider({ name: 'company' }); + expect(first).not.toBeNull(); + // No rollback snapshot is kept — the legacy dir is gone, so it is inactive + // and a second migration is a no-op (returns null). + expect(await legacySingletonActive()).toBe(false); + expect(await migrateLegacyHttpProvider({ name: 'company2' })).toBeNull(); + }); + + it('a re-written legacy config after migrate+remove is active again by presence (review #5)', async () => { + await seedLegacy('t'); + const { migrateLegacyHttpProvider, legacySingletonActive } = await import( + '../providers/http/store.js' + ); + await migrateLegacyHttpProvider({ name: 'company' }); + expect(await legacySingletonActive()).toBe(false); // legacy dir deleted + + // Simulate `source add-http` / `init --http` writing a fresh legacy config + // after the provider was removed: presence alone makes it active again, with + // no marker bookkeeping to get stuck. + await seedLegacy('t2'); + expect(await legacySingletonActive()).toBe(true); + }); +}); diff --git a/src/__tests__/provider-command.test.ts b/src/__tests__/provider-command.test.ts new file mode 100644 index 000000000..fc5a5abc7 --- /dev/null +++ b/src/__tests__/provider-command.test.ts @@ -0,0 +1,221 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import path from 'node:path'; +import os from 'node:os'; +import fse from 'fs-extra'; + +vi.mock('../utils/logger.js', () => ({ + log: { info: vi.fn(), success: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, +})); + +let tmpDir: string; +let origHome: string | undefined; +let exitSpy: ReturnType; + +beforeEach(async () => { + tmpDir = await fse.mkdtemp(path.join(os.tmpdir(), 'teamai-provider-cmd-')); + origHome = process.env.HOME; + process.env.HOME = tmpDir; + // process.exit(1) marks a CLI failure; throw so the test can assert it and + // the function stops (as it would in the real CLI). + exitSpy = vi.fn((code?: number) => { + throw new Error(`process.exit(${code})`); + }); + vi.spyOn(process, 'exit').mockImplementation(exitSpy as never); +}); + +afterEach(async () => { + process.env.HOME = origHome; + await fse.remove(tmpDir); + vi.restoreAllMocks(); + vi.resetModules(); +}); + +describe('provider add http: single-provider gate (issue #404 phase 2)', () => { + it('refuses a second HTTP provider while one is configured', async () => { + const { upsertHttpProviderConfig } = await import('../providers/http/store.js'); + await upsertHttpProviderConfig({ name: 'first', adapter: 'clawpro', endpoint: 'https://a/api', priority: 50 }); + + const { providerAddHttp } = await import('../provider-command.js'); + await expect( + providerAddHttp('https://b/api', { name: 'second' }), + ).rejects.toThrow(/process.exit\(1\)/); + expect(exitSpy).toHaveBeenCalledWith(1); + + // The second provider was never registered. + const { listHttpProviderConfigs } = await import('../providers/http/store.js'); + expect((await listHttpProviderConfigs()).map((c) => c.name)).toEqual(['first']); + }); + + it('refuses to add a provider while the legacy singleton is active', async () => { + const legacy = path.join(tmpDir, '.teamai', 'local-agent'); + await fse.ensureDir(legacy); + await fse.writeJson(path.join(legacy, 'config.json'), { + endpoint: 'https://legacy/api', workspaceBindings: {}, createdAt: '2026-01-01T00:00:00.000Z', + }); + + const { providerAddHttp } = await import('../provider-command.js'); + await expect( + providerAddHttp('https://b/api', { name: 'company' }), + ).rejects.toThrow(/process.exit\(1\)/); + + const { listHttpProviderConfigs } = await import('../providers/http/store.js'); + expect(await listHttpProviderConfigs()).toEqual([]); + }); + + it('rejects an unknown adapter before writing anything', async () => { + const { providerAddHttp } = await import('../provider-command.js'); + await expect( + providerAddHttp('https://a/api', { name: 'x', adapter: 'nope' }), + ).rejects.toThrow(/process.exit\(1\)/); + const { listHttpProviderConfigs } = await import('../providers/http/store.js'); + expect(await listHttpProviderConfigs()).toEqual([]); + }); + + it('rejects an invalid name with a clean exit, not an uncaught throw', async () => { + const { log } = await import('../utils/logger.js'); + const { providerAddHttp } = await import('../provider-command.js'); + // A Windows reserved name with an extension must be a clean error + exit(1). + await expect( + providerAddHttp('https://a/api', { name: 'CON.txt', token: 't' }), + ).rejects.toThrow(/process.exit\(1\)/); + expect(log.error).toHaveBeenCalledWith(expect.stringContaining('reserved device name')); + const { listHttpProviderConfigs } = await import('../providers/http/store.js'); + expect(await listHttpProviderConfigs()).toEqual([]); + }); +}); + +describe('provider migrate-legacy: single-provider gate (issue #404 phase 2)', () => { + it('refuses to migrate while a named provider already exists', async () => { + // Seed a legacy singleton AND a named provider. + const legacy = path.join(tmpDir, '.teamai', 'local-agent'); + await fse.ensureDir(legacy); + await fse.writeJson(path.join(legacy, 'config.json'), { + endpoint: 'https://legacy/api', workspaceBindings: {}, createdAt: '2026-01-01T00:00:00.000Z', + }); + const { upsertHttpProviderConfig, listHttpProviderConfigs } = await import('../providers/http/store.js'); + await upsertHttpProviderConfig({ name: 'existing', adapter: 'clawpro', endpoint: 'https://a/api', priority: 50 }); + + const { providerMigrateLegacy } = await import('../provider-command.js'); + await expect( + providerMigrateLegacy({ name: 'migrated' }), + ).rejects.toThrow(/process.exit\(1\)/); + + // Still only the original named provider; no second one was created. + expect((await listHttpProviderConfigs()).map((c) => c.name)).toEqual(['existing']); + }); + + it('stays idempotent: re-running after a completed migration is a no-op, not an error', async () => { + const legacy = path.join(tmpDir, '.teamai', 'local-agent'); + await fse.ensureDir(legacy); + await fse.writeJson(path.join(legacy, 'config.json'), { + endpoint: 'https://legacy/api', workspaceBindings: {}, createdAt: '2026-01-01T00:00:00.000Z', + }); + await fse.writeJson(path.join(legacy, 'manifest.json'), { scopes: {} }); + + const { providerMigrateLegacy } = await import('../provider-command.js'); + // First migration succeeds and registers "company". + await providerMigrateLegacy({ name: 'company' }); + const { listHttpProviderConfigs } = await import('../providers/http/store.js'); + expect((await listHttpProviderConfigs()).map((c) => c.name)).toEqual(['company']); + + // Re-running with the SAME name must NOT error on the single-provider gate + // (regression: the gate previously rejected any existing entry, including + // this migration's own). It is a clean no-op. + await expect(providerMigrateLegacy({ name: 'company' })).resolves.toBeUndefined(); + expect((await listHttpProviderConfigs()).map((c) => c.name)).toEqual(['company']); + }); +}); + +describe('provider add http: registry published only after init succeeds (issue #404)', () => { + it('cleanly rolls back (no registry, no home) when init fails and teardown succeeds', async () => { + // init fails; teardown succeeds → clean rollback removes everything. + vi.doMock('../providers/http/registry.js', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + getHttpAdapter: (name: string) => { + const backend = actual.getHttpAdapter(name); + return { + ...backend, + initialize: async () => { throw new Error('bad token'); }, + teardown: async () => {}, + }; + }, + }; + }); + + const { providerAddHttp } = await import('../provider-command.js'); + await expect( + providerAddHttp('https://a/api', { name: 'company', token: 'x' }), + ).rejects.toThrow(/process.exit\(1\)/); + + // No registered provider, no leftover state home. + const { listHttpProviderConfigs, httpProviderHome } = await import('../providers/http/store.js'); + expect(await listHttpProviderConfigs()).toEqual([]); + expect(fse.existsSync(httpProviderHome('company'))).toBe(false); + }); + + it('keeps a retriable state home (no registry) when init AND rollback teardown fail', async () => { + // init fails; teardown also fails (e.g. an injected hook is locked) → keep + // the home + a self-describing provider.json so `provider remove` can retry, + // but drop the registry entry so dispatch won't load a broken provider. + vi.doMock('../providers/http/registry.js', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + getHttpAdapter: (name: string) => { + const backend = actual.getHttpAdapter(name); + return { + ...backend, + initialize: async () => { throw new Error('bad token'); }, + teardown: async () => { throw new Error('hook file locked'); }, + }; + }, + }; + }); + + const { providerAddHttp } = await import('../provider-command.js'); + await expect( + providerAddHttp('https://a/api', { name: 'company', token: 'x' }), + ).rejects.toThrow(/process.exit\(1\)/); + + const { listHttpProviderConfigs, httpProviderHome, readHttpProviderHomeConfig } = await import( + '../providers/http/store.js' + ); + // No registry entry (dispatch won't load it) … + expect(await listHttpProviderConfigs()).toEqual([]); + // … but the home + provider.json survive so `provider remove` can recover it. + expect(fse.existsSync(httpProviderHome('company'))).toBe(true); + expect((await readHttpProviderHomeConfig('company'))?.endpoint).toBe('https://a/api'); + }); + + it('provider remove recovers a home whose registry entry was dropped (review #3)', async () => { + // Seed a home with a self-describing provider.json but NO registry entry — + // the state a failed add leaves behind. + const { writeHttpProviderHomeConfig, httpProviderHome, getHttpProviderConfig } = await import( + '../providers/http/store.js' + ); + await writeHttpProviderHomeConfig({ name: 'company', adapter: 'clawpro', endpoint: 'https://a/api', priority: 50 }); + expect(await getHttpProviderConfig('company')).toBeUndefined(); + + const { providerRemove } = await import('../provider-command.js'); + // teardown for a bare/no-endpoint config no-ops (no manifest); remove succeeds. + await providerRemove('company'); + expect(fse.existsSync(httpProviderHome('company'))).toBe(false); + }); + + it('provider remove resolves the name case-insensitively and clears the registry (review #3)', async () => { + const { upsertHttpProviderConfig, listHttpProviderConfigs, httpProviderHome } = await import( + '../providers/http/store.js' + ); + await upsertHttpProviderConfig({ name: 'company', adapter: 'clawpro', endpoint: 'https://a/api', priority: 50 }); + + const { providerRemove } = await import('../provider-command.js'); + // Different-case input must resolve to the registered `company` and remove + // BOTH its state home and its registry record — never delete state while + // leaving a dangling registry entry (case-insensitive FS hazard). + await providerRemove('Company'); + expect(await listHttpProviderConfigs()).toEqual([]); + expect(fse.existsSync(httpProviderHome('company'))).toBe(false); + }); +}); diff --git a/src/__tests__/resource-provider-registry.test.ts b/src/__tests__/resource-provider-registry.test.ts new file mode 100644 index 000000000..c30da75fe --- /dev/null +++ b/src/__tests__/resource-provider-registry.test.ts @@ -0,0 +1,97 @@ +import { describe, it, expect, vi } from 'vitest'; +import { syncResourceProviders } from '../providers/resource-registry.js'; +import type { + ResourceProvider, + SyncContext, + ProviderResult, +} from '../providers/types.js'; + +/** A minimal stub ResourceProvider for sync tests. */ +function stubProvider( + name: string, + priority: number, + opts: { + sync?: (ctx: SyncContext) => Promise; + pull?: boolean; + report?: boolean; + } = {}, +): ResourceProvider { + return { + name, + type: 'http', + priority, + capabilities: { + pull: opts.pull ?? true, + push: false, + report: opts.report ?? false, + commands: false, + }, + sync: + opts.sync ?? + (async () => ({ provider: name, ok: true, changed: false })), + describe: async () => ({ + name, + type: 'http', + priority, + capabilities: { pull: true, push: false, report: false, commands: false }, + }), + teardown: async () => {}, + }; +} + +const HOOK: SyncContext = { trigger: 'hook' }; + +describe('syncResourceProviders', () => { + it('isolates a failing provider from the others', async () => { + const good = stubProvider('good', 10, { + sync: async () => ({ provider: 'good', ok: true, changed: true }), + }); + const bad = stubProvider('bad', 20, { + sync: async () => { + throw new Error('backend down'); + }, + }); + + const results = await syncResourceProviders([good, bad], HOOK); + const byName = Object.fromEntries(results.map((r) => [r.provider, r])); + + expect(byName.good.ok).toBe(true); + expect(byName.good.changed).toBe(true); + expect(byName.bad.ok).toBe(false); + expect(byName.bad.message).toBe('backend down'); + }); + + it('skips providers with neither pull nor report capability', async () => { + const sync = vi.fn(async () => ({ + provider: 'silent', + ok: true, + changed: false, + })); + const silent = stubProvider('silent', 10, { sync, pull: false, report: false }); + + const results = await syncResourceProviders([silent], HOOK); + + expect(sync).not.toHaveBeenCalled(); + expect(results).toEqual([]); + }); + + it('applies providers lowest-priority first so the winner runs last', async () => { + const order: string[] = []; + const low = stubProvider('low', 10, { + sync: async () => { + order.push('low'); + return { provider: 'low', ok: true, changed: false }; + }, + }); + const high = stubProvider('high', 100, { + sync: async () => { + order.push('high'); + return { provider: 'high', ok: true, changed: false }; + }, + }); + + await syncResourceProviders([high, low], HOOK); + + expect(order).toEqual(['low', 'high']); + }); +}); diff --git a/src/hook-handlers.ts b/src/hook-handlers.ts index 30fe12116..7697ce3a3 100644 --- a/src/hook-handlers.ts +++ b/src/hook-handlers.ts @@ -499,12 +499,48 @@ const packageHintHandler: HookHandler = { }, }; -/** HTTP local-agent report/sync + workspace binding prompts. */ +/** + * HTTP provider report/sync + workspace binding prompts (issue #404). + * + * Dispatches every configured named HTTP provider once, isolating failures so a + * slow/unreachable backend cannot block the others or the hook itself. Falls + * back to the legacy ~/.teamai/local-agent/ singleton only while it is still + * active (not yet migrated to a named provider), so upgraders keep working + * until they run `teamai provider migrate-legacy`. + */ const localAgentHandler: HookHandler = { name: 'local-agent-sync', async execute(stdin, tool) { - const { reportAndSyncFromHook } = await import('./local-agent.js'); - return reportAndSyncFromHook(stdin, tool); + const outputs: string[] = []; + + const { loadHttpResourceProviders } = await import('./providers/http/registry.js'); + const { syncResourceProviders } = await import('./providers/resource-registry.js'); + const providers = await loadHttpResourceProviders(); + if (providers.length > 0) { + const results = await syncResourceProviders(providers, { + trigger: 'hook', + tool, + stdin, + cwd: resolveHookCwd(stdin) ?? process.cwd(), + }); + for (const r of results) { + if (r.hookOutput) outputs.push(r.hookOutput); + } + } else { + // Legacy singleton fallback runs ONLY when no named provider is + // configured. Once a named provider exists it has taken over delivery, so + // running the legacy path too would double-dispatch (duplicate report / + // command execution) during the migration window where the registry entry + // is published but the legacy dir is not yet deleted (issue #404). + const { legacySingletonActive } = await import('./providers/http/store.js'); + if (await legacySingletonActive()) { + const { reportAndSyncFromHook } = await import('./local-agent.js'); + const legacyOutput = await reportAndSyncFromHook(stdin, tool); + if (legacyOutput) outputs.push(legacyOutput); + } + } + + return outputs.length > 0 ? outputs.join('\n') : null; }, }; diff --git a/src/hooks.ts b/src/hooks.ts index dceccf9fd..88d64b880 100644 --- a/src/hooks.ts +++ b/src/hooks.ts @@ -1415,57 +1415,88 @@ async function reconcilePiExtension( * Only writes to tools whose root directory already exists on disk, * preventing creation of config dirs for tools the user hasn't installed. */ -export async function injectHooksToAllTools(toolPaths: Record, baseDir?: string, filterAgents?: string[]): Promise { +/** Per-run tally of hook injection: how many tools were attempted vs. succeeded. */ +export interface HookInjectionResult { + /** Tools whose hook injection was attempted (installed, not skipped). */ + attempted: number; + /** Tools whose hook injection succeeded. */ + succeeded: number; +} + +export async function injectHooksToAllTools(toolPaths: Record, baseDir?: string, filterAgents?: string[]): Promise { const resolvedBaseDir = baseDir ?? getUserHome(); const skipped = skipToolsWithoutShell( Object.keys(toolPaths).filter(t => !filterAgents || filterAgents.includes(t)), ); + let attempted = 0; + let succeeded = 0; + const attempt = async (fn: () => Promise, onError: (e: Error) => void): Promise => { + attempted += 1; + try { + await fn(); + succeeded += 1; + } catch (e) { + onError(e as Error); + } + }; for (const [tool, paths] of Object.entries(toolPaths)) { if (filterAgents && !filterAgents.includes(tool)) continue; if (skipped.has(tool)) continue; if (tool === 'pi') { - try { - await reconcilePiExtension(resolvedBaseDir); - } catch (e) { - log.warn(`Failed to inject Pi hook: ${(e as Error).message}`); - } + // Pi's adapter no-ops (returns success) when Pi is not installed, so gate + // on its presence — otherwise an uninstalled Pi would count as a + // successful injection and mask "no tool actually got a hook". + if (!await isPiInstalled(resolvedBaseDir)) continue; + await attempt( + () => reconcilePiExtension(resolvedBaseDir), + (e) => log.warn(`Failed to inject Pi hook: ${e.message}`), + ); } else if (paths.settings) { const toolRoot = path.join(resolvedBaseDir, toolInstallRoot(paths.settings)); if (!await pathExists(toolRoot)) continue; const settingsPath = path.join(resolvedBaseDir, paths.settings); - try { - await injectHooks(settingsPath, tool); - } catch (e) { - log.warn(`Failed to inject hook into ${tool}: ${(e as Error).message}`); - } + await attempt( + () => injectHooks(settingsPath, tool), + (e) => log.warn(`Failed to inject hook into ${tool}: ${e.message}`), + ); } else if (OPENCLAW_TOOLS.has(tool)) { - try { - const { injectOpenClawHooks } = await import('./openclaw-hooks.js'); - await injectOpenClawHooks(undefined, tool); - } catch (e) { - log.warn(`Failed to inject OpenClaw hook into ${tool}: ${(e as Error).message}`); - } + // Only count when the OpenClaw workspace actually resolves; otherwise the + // adapter no-ops and would inflate the success count. + const { resolveOpenclawWorkspaceDir } = await import('./openclaw-hooks.js'); + if (!await resolveOpenclawWorkspaceDir()) continue; + await attempt( + async () => { + const { injectOpenClawHooks } = await import('./openclaw-hooks.js'); + await injectOpenClawHooks(undefined, tool); + }, + (e) => log.warn(`Failed to inject OpenClaw hook into ${tool}: ${e.message}`), + ); } else if (tool === 'hermes') { - try { - const { injectHermesHooks } = await import('./hermes-hooks.js'); - await injectHermesHooks(); - } catch (e) { - log.warn(`Failed to inject Hermes hook: ${(e as Error).message}`); - } + const { getHermesHome } = await import('./hermes-home.js'); + if (!await pathExists(getHermesHome())) continue; + await attempt( + async () => { + const { injectHermesHooks } = await import('./hermes-hooks.js'); + await injectHermesHooks(); + }, + (e) => log.warn(`Failed to inject Hermes hook: ${e.message}`), + ); } else if (tool === 'opencode') { - try { - await reconcileOpencodePlugin(resolvedBaseDir); - } catch (e) { - log.warn(`Failed to inject OpenCode hook into ${tool}: ${(e as Error).message}`); - } + if (!await pathExists(path.join(resolvedBaseDir, '.config', 'opencode')) + && !await pathExists(path.join(resolvedBaseDir, '.opencode'))) continue; + await attempt( + () => reconcileOpencodePlugin(resolvedBaseDir), + (e) => log.warn(`Failed to inject OpenCode hook into ${tool}: ${e.message}`), + ); } else if (tool === 'omp') { - try { - await reconcileOmpExtension(); - } catch (e) { - log.warn(`Failed to inject OMP hook into ${tool}: ${(e as Error).message}`); - } + if (!await pathExists(path.join(resolvedBaseDir, '.omp'))) continue; + await attempt( + () => reconcileOmpExtension(), + (e) => log.warn(`Failed to inject OMP hook into ${tool}: ${e.message}`), + ); } } + return { attempted, succeeded }; } /** diff --git a/src/index.ts b/src/index.ts index ce8606439..c169f09a1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -546,6 +546,64 @@ sourceCmd await sourceBrowse(name, globalOpts); }); +// ─── Provider subcommands (Git/HTTP resource backends, #404) ── + +const providerCmd = program + .command('provider') + .description('Manage named HTTP resource providers') + .action(async () => { + const { providerList } = await import('./provider-command.js'); + await providerList(); + }); + +const providerAddCmd = providerCmd + .command('add') + .description('Add a resource provider'); + +providerAddCmd + .command('http ') + .description('Add a named HTTP provider (e.g. a ClawPro backend)') + .requiredOption('--name ', 'Unique name for this provider') + .option('--adapter ', 'Protocol adapter (default: clawpro)') + .option('--token ', 'API token (stored 0600 outside config, never committed)') + .action(async (endpoint: string, cmdOpts) => { + const { providerAddHttp } = await import('./provider-command.js'); + await providerAddHttp(endpoint, cmdOpts); + }); + +providerCmd + .command('list') + .description('List configured HTTP providers') + .action(async () => { + const { providerList } = await import('./provider-command.js'); + await providerList(); + }); + +providerCmd + .command('sync') + .description('Sync all configured HTTP providers now') + .action(async () => { + const { providerSync } = await import('./provider-command.js'); + await providerSync(); + }); + +providerCmd + .command('remove ') + .description('Remove an HTTP provider and clean up its resources') + .action(async (name: string) => { + const { providerRemove } = await import('./provider-command.js'); + await providerRemove(name); + }); + +providerCmd + .command('migrate-legacy') + .description('Promote the legacy ~/.teamai/local-agent/ singleton to a named provider') + .requiredOption('--name ', 'Name for the migrated provider') + .action(async (cmdOpts) => { + const { providerMigrateLegacy } = await import('./provider-command.js'); + await providerMigrateLegacy(cmdOpts); + }); + // ─── Other subcommands ──────────────────────────────────── program @@ -931,11 +989,25 @@ program .option('--project-id ', 'Project ID from /projects/mine') .option('--skip', 'Mark current workspace as skipped (never prompt again)') .action(async (cmdOpts) => { - const { bindCurrentProject } = await import('./local-agent.js'); - await bindCurrentProject({ + const { bindCurrentProject, withHttpProvider } = await import('./local-agent.js'); + const args = { projectId: cmdOpts.projectId ? Number.parseInt(cmdOpts.projectId, 10) : undefined, skip: !!cmdOpts.skip, - }); + }; + // Bind inside the named HTTP provider's context so it reads/writes that + // provider's own bindings, not the legacy ~/.teamai/local-agent/ singleton. + // Falls back to the legacy path when no named provider is configured. + const { listHttpProviderConfigs, httpProviderExecutionContext } = await import( + './providers/http/store.js' + ); + const [provider] = await listHttpProviderConfigs(); + if (provider) { + await withHttpProvider(httpProviderExecutionContext(provider.name), () => + bindCurrentProject(args), + ); + } else { + await bindCurrentProject(args); + } }); // ─── Contribute commands ────────────────────────────────── diff --git a/src/init.ts b/src/init.ts index 0431f195a..2e79f75a4 100644 --- a/src/init.ts +++ b/src/init.ts @@ -442,6 +442,21 @@ export async function initHttp( ): Promise { const { resolveApiKey, saveApiKey, getApiKeyPath } = await import('./api-key.js'); + // Single-provider gate (issue #404 phase 2): refuse to stand up the legacy + // HTTP singleton when a named HTTP provider already exists, so the two are + // never dispatched together (cross-provider ownership arbitration is phase 4). + const { listHttpProviderConfigs } = await import('./providers/http/store.js'); + const namedProviders = await listHttpProviderConfigs(); + if (namedProviders.length > 0) { + log.error( + `A named HTTP provider ("${namedProviders[0].name}") is already configured; ` + + 'running it alongside a legacy HTTP init is not supported yet (issue #404 phase 4).', + ); + log.info(`Remove it first with \`teamai provider remove ${namedProviders[0].name}\`, or manage this endpoint via \`teamai provider add http\`.`); + process.exit(1); + return; + } + log.info('Initializing teamai (HTTP read-only consumer)...'); // Step 0: scope (same rules as git init — default project) diff --git a/src/local-agent.ts b/src/local-agent.ts index b185090c8..270ba8e91 100644 --- a/src/local-agent.ts +++ b/src/local-agent.ts @@ -1,3506 +1,8 @@ -import fs from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; -import { execFile } from 'node:child_process'; -import { promisify } from 'node:util'; -import fse from 'fs-extra'; -import { log } from './utils/logger.js'; -import { detachChild } from './utils/exec.js'; -import { parseFrontmatter } from './utils/frontmatter.js'; -import { - ensureDir, - listDirs, - listFilesRecursive, - pathExists, - readFileSafe, - readJson, - remove, - writeFile, - writeJson, - writeJsonAtomic, -} from './utils/fs.js'; -import { isToolInstalledForConfig, ResourceHandler } from './resources/base.js'; -import { RulesHandler, SkillsHandler } from './resources/index.js'; -import { injectHooksToAllTools, applyAgentHook, removeAgentHook, isAgentHookSupportedTool, isAgentHookEvent, OPENCLAW_TOOLS } from './hooks.js'; -import { parseHookEvent } from './dashboard-collector.js'; -import { resolveHookCwd } from './utils/hook-cwd.js'; -import { isInteractive } from './utils/prompt.js'; -import { getAgentVersion } from './agent-version.js'; -import { getMachineId, deriveLocalAgentId } from './machine-id.js'; -import { EXCLUDED_RULE_NAMES } from './builtin-rules.js'; -import { ruleStemFromFilename } from './resources/rule-format.js'; -import { resolveTeamaiEntryScript } from './builtin-hooks.js'; -import { resolveOpenclawWorkspaceDir } from './openclaw-hooks.js'; -import { assertSafeResourceName } from './utils/path-safety.js'; -import { - detectMcpFormat, - supportsTransport, - renderJsonEntry, - renderCodexBlock, - entryHash, - MCP_SERVER_KEY, -} from './resources/mcp-format.js'; -import { - readJsonDoc, - writeJsonDoc, - writeCodexAtomic, - spliceCodexBlock, - codexServerNames, -} from './mcp-reconcile.js'; -import { normalizeAgentType } from './utils/tool-names.js'; -import { logHttpRequest, logHttpResponse } from './utils/http-log.js'; -import { injectClaudeMdSection, removeClaudeMdSection } from './utils/claudemd.js'; -import { reconcilePlugins, teardownAllPlugins, parseGetConfig, substituteVars, unresolvedPlaceholders, type ReconcileDeps, type PluginState } from './plugin-lifecycle.js'; -import { - resolveBaseDir, - resolveToolBaseDir, - scopedToolPaths, - applyToolRoots, - resolveToolRootDir, - CLAUDE_TOOL_ID, - DEFAULT_CLAUDE_ROOT, - COPILOT_TOOL_ID, - getTokenPath, - TEAMAI_CLAUDEMD_START, - TEAMAI_CLAUDEMD_END, - TeamaiConfigSchema, - managedMcpManifestPath, - managedMcpManifestKey, - managedMcpWorkspaceId, - type DashboardEvent, - type LocalConfig, - type ManagedMcpManifest, - type ManagedMcpRecord, - type McpServerDef, - type McpTransport, - type Scope, - type TeamaiConfig, -} from './types.js'; -import { getUserHome } from './utils/home.js'; -import { resolveAnchors } from './utils/git.js'; - -const execFileAsync = promisify(execFile); - -const LOCAL_AGENT_DIR = 'local-agent'; -const CONFIG_FILE = 'config.json'; -const MANIFEST_FILE = 'manifest.json'; -const MODEL_MANIFEST_FILE = 'model-manifest.json'; -const REPORTER_ERROR_LOG = 'reporter/errors.jsonl'; - /** - * Abort timeout for local-agent network calls. - * - * Prevents a fetch from hanging indefinitely when the endpoint is unreachable, - * which would otherwise keep a socket pending on the event loop and stall the - * hook subprocess until the host IDE's default hook timeout fires. + * @deprecated Moved to the ClawPro HTTP provider adapter as part of the + * Git/HTTP ResourceProvider unification (issue #404). The implementation now + * lives in `providers/http/adapters/clawpro/client.ts`; this module re-exports + * it so existing importers keep working. New code should depend on the provider + * abstraction (`providers/http`) rather than importing this path directly. */ -const LOCAL_AGENT_FETCH_TIMEOUT_MS = 15_000; - -/** - * Per-fetch timeout to use while running inside a *foreground* hook. Foreground - * hooks block the host IDE and must finish under its per-event hook timeout - * (UserPromptSubmit/PostToolUse = 10s). Kept under 5s — and safely below the - * foreground handler's dispatch budget (LOCAL_AGENT_FG_TIMEOUT_MS = 4.5s) — so a - * slow/unreachable endpoint fails fast and the whole handler returns before the - * host aborts it. Healthy endpoints answer in well under a second, so this is - * invisible in normal use and never degrades the experience. - */ -const LOCAL_AGENT_HOOK_FETCH_TIMEOUT_MS = 3_000; - -/** Active per-fetch timeout; overridden to the hook value inside foreground hooks. */ -let activeFetchTimeoutMs = LOCAL_AGENT_FETCH_TIMEOUT_MS; - -type LocalAgentScope = 'instance' | 'user' | 'project'; -type ResourceKind = 'skills' | 'rules' | 'claudemd'; -type CommandResourceKind = 'skill' | 'rule' | 'claudemd'; - -// Command types recognized but not yet implemented by this reporter. Skipped -// silently (see isUnimplementedCommand) so the suffix logic in commandKind() -// cannot misfire (e.g. uninstall_hook_rule ends in _rule and would otherwise be -// treated as a destructive rule uninstall). uninstall_teamai is NOT here — it -// carries a `cmd` and is executed by runCmdCommand (see executeCommand), so the -// local agent actually uninstalls itself and acks. -// install_hook_rule / uninstall_hook_rule are now implemented (see runHookRuleCommand) and are NOT skipped. -const UNIMPLEMENTED_COMMAND_TYPES = new Set([]); - -/** Hook commands this reporter implements (see runHookRuleCommand). Excluded from - * the handle_type==='hook' skip so they dispatch instead of being silently dropped. */ -const IMPLEMENTED_HOOK_COMMAND_TYPES = new Set(['install_hook_rule', 'uninstall_hook_rule']); - -interface WorkspaceBinding { - projectId: number; - projectName?: string; - boundAt: string; - /** Normalized owning tool (via normalizeAgentType). Optional for back-compat with existing config.json; - * absent means "not yet attributed". */ - ideType?: string; -} - -export interface LocalAgentConfig { - endpoint: string; - token?: string; - /** - * @deprecated No longer the id source. local_agent_id is now derived at - * runtime per detected tool via resolveLocalAgentId(). Kept optional so - * older config.json files still load without a rewrite. - */ - localAgentId?: string; - createdAt: string; - userGroupId?: number; - userGroupName?: string; - workspaceBindings: Record; - /** - * Optional per-endpoint path overrides. Maps a logical route name to a custom - * path so a backend that does not use the default `/api/local-agent/*` layout - * can be pointed at its own routes. Unspecified routes fall back to DEFAULT_ROUTES. - * Example: { "getConfig": "/api/plugins/config", "sync": "/v2/agent/sync" } - */ - routes?: Partial>; -} - -/** - * Logical names for every backend endpoint the local agent talks to, mapped to - * their default paths. A deployment can override any of these via config.routes - * (see LocalAgentConfig.routes) without touching call sites. - */ -export const DEFAULT_ROUTES = { - projects: '/api/projects/mine', - report: '/api/local-agent/report', - sync: '/api/local-agent/sync', - ack: '/api/local-agent/commands/ack', - getConfig: '/api/local-agent/get-config', -} as const; - -export type RouteName = keyof typeof DEFAULT_ROUTES; - -interface LocalAgentProject { - id: number; - name: string; - description?: string; -} - -interface ManifestResource { - slug: string; - version?: string; - display_name?: string; - source?: string; - installed_at: string; - /** - * Actual on-disk directory name for skills. Equals the SKILL.md `name:` when - * it differs from the server slug, else the slug. Used at uninstall time to - * locate the directory by slug (the manifest key stays the slug). - */ - dir_name?: string; -} - -interface ManifestScope { - skills: Record; - rules: Record; - claudemd: Record; -} - -interface LocalAgentManifest { - scopes: Record; -} - -interface LocalAgentCommand { - id: number; - type?: string; - scope?: string; - workspace_path?: string; - download_url?: string; - skill_slug?: string; - skill_version?: string; - rule_slug?: string; - rule_version?: string; - rule_type?: string; - handle_type?: string; - claudemd_slug?: string; - claudemd_version?: string; - resource_slug?: string; - resource_version?: string; - slug?: string; - name?: string; - version?: string; - display_name?: string; - cmd?: string; - event?: string; - matcher?: string; - timeout?: number; - mcp_config?: { - transport: string; - url?: string; - headers?: Record; - command?: string; - args?: string[]; - env?: Record; - timeout?: number; - requires?: string[]; - }; -} - -interface DeliveredModel { - provider: string; - model_id: string; - name: string; - base_url: string; - api_key: string; - max_tokens?: number; - context_window?: number; -} - -interface BuddyModelManifest { - codebuddy?: Record; - workbuddy?: Record; - providersByAgent?: Record>; -} - -interface ModelConfigManifest extends BuddyModelManifest { - claudeEnv?: Record; - /** - * model_id → provider for every model this reporter has applied. Claude - * stores its gateway as plain ANTHROPIC_* env vars that carry no provider, - * so this is the only way to report back the provider the server sent. - */ - providers?: Record; - workspaceModels?: Record; -} - -type ModelAgentKind = 'codebuddy' | 'workbuddy' | 'claude'; -type BuddyAgentKind = 'codebuddy' | 'workbuddy'; - -function modelAgentKind(tool: string | undefined): ModelAgentKind | undefined { - const normalized = normalizeAgentType(tool ?? ''); - if (normalized === 'codebuddy' || normalized === 'codebuddy-internal') return 'codebuddy'; - if (normalized === 'workbuddy') return 'workbuddy'; - if (normalized === 'claude') return 'claude'; - return undefined; -} - -/** - * Whether a sync command is recognized-but-unimplemented and must be skipped - * before dispatch. Matches both the known unimplemented type strings and any - * hook command (handle_type === 'hook'), so a future hook `type` outside - * UNIMPLEMENTED_COMMAND_TYPES still skips silently instead of falling through - * to commandKind() and being acked as a failure. - */ -function isUnimplementedCommand(command: LocalAgentCommand): boolean { - const type = command.type ?? ''; - if (IMPLEMENTED_HOOK_COMMAND_TYPES.has(type)) return false; - return UNIMPLEMENTED_COMMAND_TYPES.has(type) || command.handle_type === 'hook'; -} - -interface LocalAgentContext { - cwd?: string; - tool?: string; - status?: string; - event?: DashboardEvent; -} - -function getTeamaiHomePath(): string { - return path.join(getUserHome(), '.teamai'); -} - -function getLocalAgentHome(): string { - return path.join(getTeamaiHomePath(), LOCAL_AGENT_DIR); -} - -function getConfigPath(): string { - return path.join(getLocalAgentHome(), CONFIG_FILE); -} - -function getManifestPath(): string { - return path.join(getLocalAgentHome(), MANIFEST_FILE); -} - -function getModelManifestPath(): string { - return path.join(getLocalAgentHome(), MODEL_MANIFEST_FILE); -} - -function getErrorLogPath(): string { - return path.join(getTeamaiHomePath(), REPORTER_ERROR_LOG); -} - -function compileClaudemdBlock(contents: string[]): string | null { - const parts = contents.map((content) => content.trim()).filter(Boolean); - if (parts.length === 0) return null; - return [ - TEAMAI_CLAUDEMD_START, - '', - '', - parts.join('\n\n'), - '', - TEAMAI_CLAUDEMD_END, - ].join('\n'); -} - -function normalizeEndpoint(endpoint: string): string { - return endpoint.trim().replace(/\/+$/, ''); -} - -/** Normalize a route override so it is a leading-slash path (endpoint has no trailing slash). */ -function normalizeRoute(route: string): string { - const trimmed = route.trim(); - return trimmed.startsWith('/') ? trimmed : `/${trimmed}`; -} - -/** - * Resolve a logical route name to its path, applying config.routes overrides - * over DEFAULT_ROUTES. A blank/whitespace override is ignored (falls back to default). - */ -export function resolveRoute(config: Pick, name: RouteName): string { - const override = config.routes?.[name]; - if (override && override.trim()) return normalizeRoute(override); - return DEFAULT_ROUTES[name]; -} - -/** - * Resolve the per-tool install directory that seeds the local_agent_id hash. - * - * This must match the historical status-report口径 — `~/.` — so that a - * machine upgrading from the status-report era keeps the same id instead of - * drifting. It is derived from the same toolPaths map buildReportPayload uses: - * `~/` (e.g. `.codebuddy/skills` → `~/.codebuddy`). Unknown - * tools fall back to `~/.`, still deterministic and distinct per tool. - * Note: install_path only feeds the local hash — it never leaves the machine. - */ -function resolveAgentInstallPath(agentType: string): string { - const home = getUserHome(); - const skillsRel = createLocalAgentTeamConfig('').toolPaths[agentType]?.skills; - const rel = skillsRel ? path.dirname(skillsRel) : `.${agentType}`; - return path.join(home, rel); -} - -/** - * Resolve the local_agent_id for the current invocation. - * - * Deterministic per (detected tool + machine + install dir) — same tool on the - * same machine always yields the same id, so the backend sees a stable agent - * instead of a fresh random one every hook fire. The tool is auto-detected from - * the hook's --tool flag (context.tool); different tools (claude / codebuddy / - * workbuddy) get different ids because agent_type AND the per-tool install dir - * (~/.) both feed the hash. install_path uses the tool's own dir (not the - * teamai home) to stay byte-for-byte identical to the historical status-report - * derivation, avoiding an id change on upgrade. TEAMAI_LOCAL_AGENT_ID still - * overrides for explicit pinning. - */ -function resolveLocalAgentId(context: LocalAgentContext): string { - const envOverride = process.env.TEAMAI_LOCAL_AGENT_ID; - if (envOverride) return envOverride; - const agentType = context.tool ?? 'workbuddy'; - return deriveLocalAgentId(agentType, getMachineId(), resolveAgentInstallPath(agentType)); -} - -/** - * Detect whether we are running inside a CloudStudio container sandbox. - * - * WorkBuddy can spawn a CloudStudio Linux container that runs its own teamai - * hooks. That container has a different machine_id than the macOS host, so it - * derives a second local_agent_id and reports a duplicate agent card. Both - * signals below are absent on a normal Linux user machine, so this never - * suppresses reporting for legitimate standalone Linux users. - */ -function isCloudStudioSandbox(): boolean { - if (process.env.X_IDE_IS_CLOUDSTUDIO === 'TRUE') return true; - try { - return fs.existsSync('/var/run/cloudstudio'); - } catch { - return false; - } -} - -/** - * Build the unified log tag for local-agent debug output: `[] []` — - * the last 6 chars of the derived agent id plus the agent name (tool), so every - * line (HTTP request/response, report/sync, command ack) reads the same way. - */ -function localAgentTag(context: LocalAgentContext): string { - const tool = context.tool ?? 'workbuddy'; - return `[${resolveLocalAgentId(context).slice(-6)}] [${tool}]`; -} - -function scopeKey(scope: LocalAgentScope, workspacePath?: string): string { - return scope === 'project' ? `project:${workspacePath ?? ''}` : scope; -} - -function emptyManifestScope(): ManifestScope { - return { skills: {}, rules: {}, claudemd: {} }; -} - -async function loadManifest(): Promise { - const manifest = await readJson(getManifestPath()); - return manifest ?? { scopes: {} }; -} - -async function saveManifest(manifest: LocalAgentManifest): Promise { - await writeJson(getManifestPath(), manifest); -} - -/** One HTTP-source agent hook recorded locally so teardown can find & remove it - * across all formats (codex has no in-file marker, so its command is stored). */ -interface AgentHookRecord { - tool: string; - event: string; - command: string; - matcher?: string; - timeout?: number; -} - -/** slug → record. Kept separate from the resource manifest and from the team - * managed-hooks.json so a team pull never treats agent hooks as stale. */ -type AgentHookManifest = Record; - -function getAgentHookManifestPath(): string { - return path.join(getLocalAgentHome(), 'agent-hooks.json'); -} - -async function loadAgentHookManifest(): Promise { - const data = await readJson(getAgentHookManifestPath()); - return data && typeof data === 'object' ? data : {}; -} - -async function saveAgentHookManifest(manifest: AgentHookManifest): Promise { - await writeJsonAtomic(getAgentHookManifestPath(), manifest); -} - -/** - * The member's per-machine tool roots, from the teamai config that governs this - * directory: the project one when there is one, else the user-scope one. - * - * The local agent carries no LocalConfig — it addresses tool roots under $HOME - * directly — but it writes the same files `teamai pull` does, so a root the - * member relocated (CLAUDE_CONFIG_DIR, recorded by `teamai init`) has to reach - * them too. No config, or no entry, leaves the paths exactly as they were. - */ -async function memberToolRoots(workspacePath?: string): Promise | undefined> { - const { resolveMemberToolRoots } = await import('./config.js'); - return resolveMemberToolRoots(workspacePath ?? process.cwd()); -} - -/** Claude Code's user root on this machine, honoring a relocated CLAUDE_CONFIG_DIR. */ -async function claudeUserRoot(): Promise { - return resolveToolRootDir(CLAUDE_TOOL_ID, DEFAULT_CLAUDE_ROOT, await memberToolRoots()); -} - -/** Resolve the current tool's settings file absolute path (user scope, $HOME base). */ -async function resolveToolSettingsPath(config: LocalAgentConfig, tool: string): Promise { - const teamConfig = createLocalAgentTeamConfig(config.endpoint); - const toolPath = applyToolRoots(teamConfig.toolPaths, await memberToolRoots())[tool]; - if (!toolPath?.settings) { - throw new Error(`unsupported tool: ${tool} (no settings path)`); - } - return path.join(getUserHome(), toolPath.settings); -} - -function getPluginStatePath(): string { - return path.join(getLocalAgentHome(), 'plugins.json'); -} - -async function readPluginState(): Promise> { - return (await readJson>(getPluginStatePath())) ?? {}; -} - -/** - * Atomically mutate the plugin-state file under an exclusive lock. If the lock - * cannot be acquired within the timeout, throws (the caller skips this cycle - * rather than writing without the lock — reconcile is throttled, so skipping is safe). - */ -async function withPluginStateLock(mutate: (m: Record) => void): Promise { - const statePath = getPluginStatePath(); - const lockPath = `${statePath}.lock`; - await ensureDir(path.dirname(lockPath)); - const deadline = Date.now() + 5000; - let acquired = false; - while (Date.now() <= deadline) { - try { const fd = await fs.promises.open(lockPath, 'wx'); await fd.close(); acquired = true; break; } - catch (e) { - if ((e as { code?: string }).code !== 'EEXIST') throw e; - try { - const st = await fs.promises.stat(lockPath); - if (Date.now() - st.mtimeMs > 30_000) { await fs.promises.rm(lockPath, { force: true }); continue; } - } catch { /* lock vanished */ } - await new Promise((r) => setTimeout(r, 50)); - } - } - if (!acquired) throw new Error('could not acquire plugin-state lock'); - try { - const m = await readPluginState(); - mutate(m); - await writeJson(statePath, m); - } finally { - await fs.promises.rm(lockPath, { force: true }); - } -} - -function getManifestScope( - manifest: LocalAgentManifest, - scope: LocalAgentScope, - workspacePath?: string, -): ManifestScope { - const key = scopeKey(scope, workspacePath); - manifest.scopes[key] ??= emptyManifestScope(); - return manifest.scopes[key]; -} - -/** - * Canonicalize a workspace path to its physical on-disk form via realpath. - * On case-insensitive filesystems (macOS) this collapses casing variants of the - * same physical directory to one identity; it also resolves symlinks. Falls back - * to the resolved absolute path when the target does not exist (dead binding) or - * realpath fails for any other reason. - */ -async function canonicalizeWorkspacePath(value: string): Promise { - const absolute = path.resolve(value); - try { - return await fs.promises.realpath(absolute); - } catch { - return absolute; - } -} - -function mergeWorkspaceBindings( - existing: WorkspaceBinding | undefined, - incoming: WorkspaceBinding, - canonicalKey: string, -): WorkspaceBinding { - if (!existing) return incoming; - // pick base = whichever has a non-zero projectId; prefer existing on tie - const existingReal = existing.projectId !== 0; - const incomingReal = incoming.projectId !== 0; - if (existingReal && incomingReal && existing.projectId !== incoming.projectId) { - log.warn( - `local-agent: workspace ${canonicalKey} has conflicting project bindings ` + - `(${existing.projectId} vs ${incoming.projectId}); keeping ${existing.projectId}`, - ); - } - const base = existingReal || !incomingReal ? { ...existing } : { ...incoming }; - // A physical workspace tracks one owning tool (same single-owner model as - // stampWorkspaceTool). Only backfill ideType when the base lacks one; if two - // aliases were stamped by different tools, the base's ideType wins — the - // other tool re-stamps itself on its next report from the canonical path. - if (!base.ideType) base.ideType = existing.ideType ?? incoming.ideType; - return base; -} - -export async function loadLocalAgentConfig(): Promise { - const fileConfig = await readJson(getConfigPath()); - if (fileConfig?.endpoint) { - const config = { - ...fileConfig, - endpoint: normalizeEndpoint(fileConfig.endpoint), - workspaceBindings: fileConfig.workspaceBindings ?? {}, - }; - // Migrate: clear legacy group-based bindings (groupId without projectId) - const removedLegacyPaths: string[] = []; - for (const [wsPath, binding] of Object.entries(config.workspaceBindings)) { - if ('groupId' in binding && !('projectId' in (binding as Record))) { - delete config.workspaceBindings[wsPath]; - removedLegacyPaths.push(wsPath); - } - } - if (removedLegacyPaths.length > 0) { - log.warn( - `Removed ${removedLegacyPaths.length} legacy group-based workspace binding(s); ` + - `you will be prompted to re-bind on the next session.`, - ); - try { - await saveLocalAgentConfig(config); - } catch (e) { - log.debug(`local-agent: failed to persist binding cleanup: ${(e as Error).message}`); - } - } - // Migrate: canonicalize binding keys to their physical on-disk path so - // case-only / symlink aliases of the same workspace collapse to one entry. - const migrated: Record = {}; - let migrationChanged = false; - for (const [wsPath, binding] of Object.entries(config.workspaceBindings)) { - const canonicalKey = await canonicalizeWorkspacePath(wsPath); - if (canonicalKey !== wsPath) migrationChanged = true; - if (migrated[canonicalKey]) migrationChanged = true; - migrated[canonicalKey] = mergeWorkspaceBindings(migrated[canonicalKey], binding, canonicalKey); - } - config.workspaceBindings = migrated; - if (migrationChanged) { - await saveLocalAgentConfig(config); - } - return config; - } - - // Backfill: if config.json is missing but a legacy ~/.teamai/config.yaml has - // an HTTP team repo, auto-create config.json so v0.17.x upgraders keep capability. - const { loadLocalConfig } = await import('./config.js'); - const { resolveApiKey } = await import('./api-key.js'); - const legacy = await loadLocalConfig(); - if (legacy?.repo?.kind === 'http' && legacy.repo.url) { - const endpoint = normalizeEndpoint(legacy.repo.url); - const token = resolveApiKey() ?? undefined; - const backfilled: LocalAgentConfig = { - endpoint, - token, - createdAt: new Date().toISOString(), - workspaceBindings: {}, - }; - try { - await saveLocalAgentConfig(backfilled); - log.debug('local-agent: backfilled config.json from legacy ~/.teamai/config.yaml (http repo)'); - } catch (e) { - log.debug(`local-agent: backfill persist failed, using in-memory config: ${(e as Error).message}`); - } - return backfilled; - } - - const envEndpoint = - process.env.TEAMAI_HTTP_ENDPOINT ?? - process.env.TEAMAI_ENDPOINT ?? - process.env.TEAMAI_API_BASE_URL; - if (!envEndpoint) return null; - - return { - endpoint: normalizeEndpoint(envEndpoint), - token: process.env.TEAMAI_API_TOKEN ?? process.env.TEAMAI_TOKEN, - createdAt: new Date().toISOString(), - workspaceBindings: {}, - }; -} - -async function saveLocalAgentConfig(config: LocalAgentConfig): Promise { - await writeJsonAtomic(getConfigPath(), { - ...config, - endpoint: normalizeEndpoint(config.endpoint), - workspaceBindings: config.workspaceBindings ?? {}, - }); -} - -function createLocalAgentTeamConfig(endpoint: string): TeamaiConfig { - return TeamaiConfigSchema.parse({ - team: 'local-agent', - repo: endpoint, - description: 'HTTP local agent resource cache', - }); -} - -async function createResourceLocalConfig( - config: LocalAgentConfig, - scope: LocalAgentScope, - repoPath: string, - workspacePath?: string, -): Promise { - const projectScope = scope === 'project'; - return { - repo: { localPath: repoPath, remote: config.endpoint }, - username: os.userInfo().username, - scope: projectScope ? 'project' : 'user', - projectRoot: projectScope ? workspacePath : undefined, - additionalRoles: [], - // User-scope paths resolve under $HOME here, so a tool the member relocated - // must be addressed at its recorded root — the same one `teamai pull` uses. - ...(projectScope ? {} : { toolRoots: await memberToolRoots(workspacePath) }), - }; -} - -async function getResourceRepoPath(scope: LocalAgentScope, workspacePath?: string): Promise { - if (scope === 'project' && workspacePath) { - // Project resource cache is A1 (per-project) AND per-worktree: the resource - // cache (claudemd/skills/rules fragments) is what each worktree installs - // independently, and syncClaudemd merges EVERY file in this dir. The partition - // data home is shared by all linked worktrees, so the cache must live in a - // per-worktree subdir — otherwise worktree B's CLAUDE.md would merge in - // worktree A's instructions. Mirror managed-mcp's per-worktree layout. - const { resolveDataHomeForScope } = await import('./config.js'); - const dataHome = await resolveDataHomeForScope('project', workspacePath); - return path.join(dataHome, 'workspaces', managedMcpWorkspaceId(workspacePath), LOCAL_AGENT_DIR, 'resources'); - } - return path.join(getLocalAgentHome(), 'resources', scope); -} - -async function ensureProjectGitignore(workspacePath: string): Promise { - const teamaiDir = path.join(workspacePath, '.teamai'); - await ensureDir(teamaiDir); - const gitignorePath = path.join(teamaiDir, '.gitignore'); - const existing = await readFileSafe(gitignorePath); - if (!existing) { - await writeFile(gitignorePath, ['# teamai local state', 'local-agent/', ''].join('\n')); - return; - } - if (!existing.split('\n').some((line) => line.trim() === 'local-agent/')) { - await writeFile(gitignorePath, existing.trimEnd() + '\nlocal-agent/\n'); - } -} - -function authHeaders(config: LocalAgentConfig, json = true): Record { - const headers: Record = {}; - if (json) headers['Content-Type'] = 'application/json'; - if (config.token) { - headers.Authorization = `Bearer ${config.token}`; - headers['X-API-Token'] = config.token; - } - return headers; -} - -async function localAgentFetch( - config: LocalAgentConfig, - tag: string, - route: RouteName, - init?: RequestInit, - opts?: { redactResponseLog?: boolean }, -): Promise { - const method = init?.method ?? 'GET'; - const url = `${config.endpoint}${resolveRoute(config, route)}`; - const headers: Record = { - ...authHeaders(config, init?.body !== undefined), - ...((init?.headers as Record | undefined) ?? {}), - }; - logHttpRequest(tag, method, url, headers, init?.body); - - const response = await fetch(url, { - ...init, - headers, - signal: init?.signal ?? AbortSignal.timeout(activeFetchTimeoutMs), - }); - const text = await response.text(); - let body: unknown = null; - if (text.trim()) { - try { - body = JSON.parse(text); - } catch { - body = text; - } - } - logHttpResponse(tag, method, url, response.status, response.statusText, opts?.redactResponseLog ? '' : body); - if (!response.ok) { - const message = typeof body === 'object' && body && 'error' in body - ? String((body as { error: unknown }).error) - : text || `${response.status} ${response.statusText}`; - throw new Error(message); - } - return body as T; -} - -async function appendErrorLog(entry: unknown): Promise { - try { - await ensureDir(path.dirname(getErrorLogPath())); - await fs.promises.appendFile( - getErrorLogPath(), - JSON.stringify({ at: new Date().toISOString(), entry }) + '\n', - 'utf-8', - ); - } catch { - // Best-effort; hook execution must not fail on I/O. - } -} - -export async function fetchUserProjects(config: LocalAgentConfig): Promise { - const response = await localAgentFetch<{ ok?: boolean; projects?: LocalAgentProject[] }>( - config, - localAgentTag({}), - 'projects', - { method: 'GET' }, - ); - return response.projects ?? []; -} - -/** Mask secret values (CLI flags / key=value / bearer tokens) so they don't reach logs. */ -function redactSecrets(s: string): string { - // Secret-bearing identifiers, matched case-insensitively in flag and key=value forms. - const names = 'secret[_-]?(?:key|id)|api[_-]?key|access[_-]?token|token|password|passwd|pwd'; - return s - .replace(new RegExp(`(--(?:${names})[= ]+)\\S+`, 'gi'), '$1***') - .replace(new RegExp(`((?:${names})"?\\s*[:=]\\s*"?)[^"\\s,}]+`, 'gi'), '$1***') - .replace(/(bearer\s+)[\w.\-]+/gi, '$1***'); -} - -/** - * Execute a shell command string with a timeout. - * - * Completion is gated on the process 'exit' event, NOT 'close': a setup command that - * daemonizes and leaves the inherited stderr pipe open in a background process would never - * emit 'close', producing a false timeout even though the command itself finished. - * Rejects on non-zero exit, termination by signal, or timeout. - */ -export async function execPluginCommand(cmd: string, timeoutMs: number): Promise { - const { spawn } = await import('node:child_process'); - await new Promise((resolve, reject) => { - const child = process.platform === 'win32' - ? spawn('cmd', ['/c', cmd], { windowsHide: true, stdio: ['ignore', 'ignore', 'pipe'] }) - : spawn('bash', ['-lc', cmd], { stdio: ['ignore', 'ignore', 'pipe'] }); - let stderr = ''; - let settled = false; - let timer: ReturnType; - child.stderr?.on('data', (d) => { stderr += d.toString(); if (stderr.length > 8192) stderr = stderr.slice(-8192); }); - const finish = (fn: () => void): void => { - if (settled) return; - settled = true; - clearTimeout(timer); - detachChild(child); - fn(); - }; - timer = setTimeout(() => { - child.kill('SIGKILL'); - finish(() => reject(new Error(`command timed out after ${timeoutMs}ms`))); - }, timeoutMs); - child.on('error', (e) => finish(() => reject(e))); - child.on('exit', (code, signal) => - finish(() => { - if (signal) return reject(new Error(`command killed by ${signal}`)); - if (code === 0) return resolve(); - const tail = stderr ? ' :: ' + redactSecrets(stderr.slice(0, 200).trim()) : ''; - reject(new Error(`command failed (exit ${code})${tail}`)); - }), - ); - }); -} - -/** - * Fetch backend plugin config. - * Route = 'getConfig' (default path /api/local-agent/get-config, overridable via config.routes). - * localAgentFetch builds `url = config.endpoint + resolveRoute(...)`, matching report/sync pattern. - */ -async function fetchPluginConfig(config: LocalAgentConfig, tag: string): Promise { - return localAgentFetch(config, tag, 'getConfig', { method: 'GET' }, { redactResponseLog: true }); -} - -const PLUGIN_PULL_INTERVAL_MS = 12 * 60 * 60 * 1000; -const PLUGIN_FAIL_BACKOFF_MS = 60 * 60 * 1000; - -function getPluginPullStatePath(): string { - return path.join(getLocalAgentHome(), 'plugin-pull.json'); -} - -function buildReconcileDeps(config: LocalAgentConfig, tag: string): ReconcileDeps { - return { - readPlugins: () => readPluginState(), - mutatePlugins: (fn) => withPluginStateLock(fn), - execCommand: (cmd, t) => execPluginCommand(cmd, t), - now: () => Date.now(), - log: { - debug: (msg) => log.debug(`${tag} ${msg}`), - // The reconcile worker runs detached (stdio: 'ignore'), so console-only log.warn - // output is discarded. Mirror warnings to debug.log so failures are traceable. - warn: (msg) => { - log.warn(`${tag} ${msg}`); - log.debug(`${tag} WARN: ${msg}`); - }, - }, - }; -} - -/** On session start, throttle-check and spawn a detached worker for plugin reconcile. Never blocks. */ -async function maybeReconcilePlugins(context: LocalAgentContext): Promise { - try { - const state = (await readJson<{ lastPullAt?: number; lastFailAt?: number }>(getPluginPullStatePath())) ?? {}; - const now = Date.now(); - if (state.lastPullAt && now - state.lastPullAt < PLUGIN_PULL_INTERVAL_MS) return; - if (state.lastFailAt && now - state.lastFailAt < PLUGIN_FAIL_BACKOFF_MS) return; - const tool = context.tool ?? 'workbuddy'; - const localAgentId = `${tool}-${resolveLocalAgentId(context)}`; - const { spawn } = await import('node:child_process'); - if (!process.argv[1]) { log.debug('[local-agent] plugin reconcile: no CLI entrypoint (argv[1]), skipping'); return; } - const child = spawn(process.execPath, [process.argv[1], 'source', 'reconcile-plugins'], - { detached: true, windowsHide: true, stdio: 'ignore', env: { ...process.env, TEAMAI_PLUGIN_LOCAL_AGENT_ID: localAgentId } }); - child.unref(); - } catch (e) { log.debug(`[local-agent] plugin reconcile spawn skipped: ${(e as Error).message}`); } -} - -/** Detached worker: pull get-config and reconcile plugins once, guarded by a reconcile lock. */ -export async function runPluginReconcileWorker(): Promise { - const config = await loadLocalAgentConfig(); - if (!config) return; - const lockPath = path.join(getLocalAgentHome(), 'plugin-reconcile.lock'); - await ensureDir(path.dirname(lockPath)); - let acquired = false; - try { - try { - const fd = await fs.promises.open(lockPath, 'wx'); - await fd.close(); - acquired = true; - } catch (e) { - if ((e as { code?: string }).code !== 'EEXIST') throw e; - try { - const st = await fs.promises.stat(lockPath); - if (Date.now() - st.mtimeMs > 30 * 60 * 1000) { - await fs.promises.rm(lockPath, { force: true }); - const fd = await fs.promises.open(lockPath, 'wx'); - await fd.close(); - acquired = true; - } - } catch { /* ignore */ } - if (!acquired) return; - } - const tag = '[local-agent] [plugin-reconcile]'; - const statePath = getPluginPullStatePath(); - try { - const resp = await fetchPluginConfig(config, tag); - const { vars, plugins } = parseGetConfig(resp); - const declaredSlugs = plugins.length ? ` [${plugins.map((p) => p.slug).join(', ')}]` : ''; - log.debug(`${tag} get-config: ${plugins.length} plugin(s) declared${declaredSlugs}`); - const laid = process.env.TEAMAI_PLUGIN_LOCAL_AGENT_ID; - if (!laid) log.debug(tag + ' no local_agent_id in env; plugins needing it will be skipped'); - const allVars = { ...vars, ...(laid ? { local_agent_id: laid } : {}) }; - const resolved: typeof plugins = []; - for (const p of plugins) { - const rp = { - ...p, - installCmd: substituteVars(p.installCmd, allVars), - updateCmd: p.updateCmd ? substituteVars(p.updateCmd, allVars) : undefined, - uninstallCmd: substituteVars(p.uninstallCmd, allVars), - runCmd: substituteVars(p.runCmd, allVars), - }; - const missing = [...new Set([ - ...unresolvedPlaceholders(rp.installCmd), - ...unresolvedPlaceholders(rp.runCmd), - ...unresolvedPlaceholders(rp.uninstallCmd), - ...(rp.updateCmd ? unresolvedPlaceholders(rp.updateCmd) : []), - ])]; - if (missing.length) { - log.warn(`${tag} plugin ${p.slug}: unresolved placeholders [${missing.join(',')}], skipping`); - log.debug(`${tag} WARN: plugin ${p.slug}: unresolved placeholders [${missing.join(',')}], skipping`); - continue; - } - resolved.push(rp); - } - await reconcilePlugins(resolved, buildReconcileDeps(config, tag)); - log.debug(`${tag} reconcile complete (${resolved.length} plugin(s) processed)`); - await writeJson(statePath, { lastPullAt: Date.now() }); - } catch (e) { - const prev = (await readJson<{ lastPullAt?: number; lastFailAt?: number }>(statePath)) ?? {}; - await writeJson(statePath, { ...prev, lastFailAt: Date.now() }); - log.debug(`${tag} reconcile failed: ${(e as Error).message}`); - } - } finally { - if (acquired) await fs.promises.rm(lockPath, { force: true }); - } -} - -async function askViaTty(prompt: string): Promise { - // Only prompt on a real interactive terminal (e.g. the user running - // `teamai bind-project` directly). In non-interactive contexts such as an - // IDE-invoked hook, stdin is piped; opening /dev/tty there succeeds when the - // host GUI keeps a controlling terminal, and readline then blocks forever - // waiting for input that never comes — hanging the hook until the host's - // timeout and stalling the IDE. Callers fall back to injecting a stdout - // binding hint when this returns null, so degrade to that instead. - // The decline stays synchronous — this runs on the hook path, where loading - // the prompt module only to say no is work nobody asked for. - if (!isInteractive()) return null; - const { askQuestion } = await import('./utils/prompt.js'); - return askQuestion(prompt, ''); -} - -async function promptForProjectBinding( - workspacePath: string, - projects: LocalAgentProject[], -): Promise { - if (projects.length === 0) return null; - - log.debug(`local-agent: workspace not bound: ${workspacePath}`); - const answer = await askViaTty('是否绑定到一个项目?[y/N] '); - if (!answer || answer.toLowerCase() !== 'y') return null; - - if (projects.length === 1) return projects[0]; - - log.info('可用项目:'); - projects.forEach((project, index) => { - const desc = project.description ? ` - ${project.description}` : ''; - log.info(` ${index + 1}. ${project.name}${desc} [id=${project.id}]`); - }); - - const selection = await askViaTty(`选择项目编号(1-${projects.length},0 跳过): `); - if (selection === null || selection === '0') return null; - const index = selection ? Number.parseInt(selection, 10) : 0; - if (Number.isNaN(index) || index < 1 || index > projects.length) return null; - return projects[index - 1]; -} - -/** - * Persist a ClawPro binding decision for the current checkout. - * - * Binding is a per-project decision, so it is recorded on the `projectAnchor` - * (the main checkout, shared by a repo and all of its git worktrees — issue - * #374 / #387). It is ALSO stamped on the current `workspaceRoot` so this - * checkout is reported with the project_id immediately and its resources land - * in the current worktree (#387's workspaceRoot model — every AI tool discovers - * resources by scanning up from the launch dir, never via git-common-dir). For a - * plain repo the two anchors coincide and this writes a single entry. Falls back - * to `resolvedPath` when `cwd` is not inside a git repo. - * - * Existing fields (e.g. a stamped `ideType`) on any touched entry are preserved. - */ -async function persistWorkspaceBinding( - config: LocalAgentConfig, - cwd: string | undefined, - resolvedPath: string, - projectId: number, - projectName: string, -): Promise { - const anchors = await resolveAnchors(cwd); - const keys = new Set([resolvedPath]); - if (anchors) { - keys.add(anchors.projectAnchor); - keys.add(anchors.workspaceRoot); - } - const boundAt = new Date().toISOString(); - for (const key of keys) { - config.workspaceBindings[key] = { - ...(config.workspaceBindings[key] ?? {}), - projectId, - projectName, - boundAt, - }; - } - await saveLocalAgentConfig(config); -} - -/** - * If the current checkout is an unbound git worktree whose main checkout - * (`projectAnchor`) is already bound or skipped, copy that decision onto the - * current `workspaceRoot` and report success — so a repo is never re-prompted - * for binding once per new worktree (a `--skip` on the main checkout silences - * all of them too). Returns true when the worktree inherited a binding. - */ -async function inheritWorktreeBinding( - config: LocalAgentConfig, - cwd: string | undefined, - resolvedPath: string, -): Promise { - const anchors = await resolveAnchors(cwd); - if (!anchors || anchors.projectAnchor === anchors.workspaceRoot) return false; - const anchorBinding = config.workspaceBindings[anchors.projectAnchor]; - if (!anchorBinding) return false; - config.workspaceBindings[resolvedPath] = { - ...(config.workspaceBindings[resolvedPath] ?? {}), - projectId: anchorBinding.projectId, - projectName: anchorBinding.projectName, - boundAt: new Date().toISOString(), - }; - await saveLocalAgentConfig(config); - return true; -} - -export async function bindWorkspaceToProject( - workspacePath: string, - projectId?: number, -): Promise { - const config = await loadLocalAgentConfig(); - if (!config) { - throw new Error('HTTP local agent is not initialized. Run `teamai init --http --token ` first.'); - } - - const projects = await fetchUserProjects(config); - const project = projectId - ? projects.find((item) => item.id === projectId) - : await promptForProjectBinding(workspacePath, projects); - if (!project) return null; - - const binding: WorkspaceBinding = { - projectId: project.id, - projectName: project.name, - boundAt: new Date().toISOString(), - }; - // Record on the projectAnchor (shared across the repo's worktrees) and the - // current workspaceRoot; workspacePath is already the resolved checkout root. - await persistWorkspaceBinding(config, workspacePath, workspacePath, project.id, project.name); - log.success(`已将工作区绑定到项目:${project.name} [id=${project.id}]`); - return binding; -} - -async function ensureWorkspaceBinding( - config: LocalAgentConfig, - workspacePath: string, - sessionId?: string, - cwd?: string, -): Promise { - if (config.workspaceBindings[workspacePath]) return; - // A worktree inherits its main checkout's binding/skip decision — never prompt. - if (await inheritWorktreeBinding(config, cwd, workspacePath)) return; - - const markerKey = sessionId || `ppid-${process.ppid}`; - const hintMarker = path.join(os.tmpdir(), `teamai-bind-session-${markerKey}`); - if (fs.existsSync(hintMarker)) return; - try { fs.writeFileSync(hintMarker, ''); } catch {} - - let projects: LocalAgentProject[]; - try { - projects = await fetchUserProjects(config); - } catch (e) { - log.debug(`local-agent: failed to load user projects: ${(e as Error).message}`); - return; - } - - if (projects.length === 0) return; - - const project = await promptForProjectBinding(workspacePath, projects); - if (project) { - await persistWorkspaceBinding(config, cwd, workspacePath, project.id, project.name); - return; - } - - // TTY prompt failed (running as hook in background) — output context for AI to prompt user - const projectList = projects.map((p) => `${p.name} (id=${p.id})`).join(', '); - const context = [ - '[teamai] 当前工作区尚未绑定项目,无法接收项目下发的 skills/rules/claudemd 资源。', - `可用项目: ${projectList}`, - `请提示用户运行: teamai bind-project`, - `或指定项目: teamai bind-project --project-id `, - ].join('\n'); - const hookOutput = JSON.stringify({ - hookSpecificOutput: { - hookEventName: 'SessionStart', - additionalContext: context, - }, - }); - process.stdout.write(hookOutput + '\n'); -} - -/** - * The organization-binding prompt (TTY prompt + injected hook context) is - * on by default. Disable it explicitly with `TEAMAI_BIND_PROMPT_ENABLED=0` - * (or `false`). The manual `teamai bind-project` command is always available - * regardless. - */ -function isBindPromptEnabled(): boolean { - const flag = process.env.TEAMAI_BIND_PROMPT_ENABLED; - if (flag === undefined) return true; - const normalized = flag.toLowerCase(); - return normalized !== '0' && normalized !== 'false'; -} - -/** - * ClawPro project binding only backs CodeBuddy/WorkBuddy (the ClawPro-native - * agents); the prompt is noise for every other host (Claude, Cursor, Codex, …), - * which drove the poor UX. Gate the whole prompt — both the SessionStart TTY - * prompt and the UserPromptSubmit hint — on the current tool being a buddy - * agent. Reuses `modelAgentKind` so tool-name variants like `codebuddy-internal` - * still match (a raw Set would miss them). - */ -function isBindPromptTool(tool: string | undefined): boolean { - const kind = modelAgentKind(tool); - return kind === 'codebuddy' || kind === 'workbuddy'; -} - -async function emitBindingHint( - config: LocalAgentConfig, - workspacePath: string, - sessionId?: string, - cwd?: string, -): Promise { - if (config.workspaceBindings[workspacePath]) return; - // A worktree inherits its main checkout's binding/skip decision — never hint. - if (await inheritWorktreeBinding(config, cwd, workspacePath)) return; - - // Only hint once per session — use a temp marker file keyed by sessionId - const markerKey = sessionId || `ppid-${process.ppid}`; - const hintMarker = path.join(os.tmpdir(), `teamai-bind-hint-${markerKey}`); - if (fs.existsSync(hintMarker)) return; - try { fs.writeFileSync(hintMarker, ''); } catch {} - - let projects: LocalAgentProject[]; - try { - projects = await fetchUserProjects(config); - } catch { - return; - } - if (projects.length === 0) return; - - const skipNum = projects.length + 1; - const choices = projects.map((p, i) => `${i + 1}. 绑定到「${p.name}」项目`); - const actions = projects.map((p, i) => `- 用户回复 ${i + 1} → 执行 \`teamai bind-project --project-id ${p.id}\`,完成后回复"绑定完成,请开新会话生效"`); - const context = [ - '[ClawPro项目 绑定提示] 你必须原样展示下面的选项列表,不得修改编号或内容。等用户回复数字后执行对应命令。', - '', - '---', - '当前工作区未绑定ClawPro项目。绑定后可同步团队 skills/rules。请回复数字选择:', - '', - ...choices, - `${skipNum}. 不绑定,以后也不再提示`, - '---', - '', - '执行规则(不要展示给用户):', - ...actions, - `- 用户回复 ${skipNum} → 执行 \`teamai bind-project --skip\`,完成后回复"已跳过,以后不再提示"`, - ].join('\n'); - const hookOutput = JSON.stringify({ - hookSpecificOutput: { - hookEventName: 'UserPromptSubmit', - additionalContext: context, - }, - }); - process.stdout.write(hookOutput + '\n'); -} - -function isEphemeralTaskDir(dir: string): boolean { - const segments = dir.split(path.sep); - const wbIdx = segments.lastIndexOf('WorkBuddy'); - if (wbIdx < 0 || wbIdx >= segments.length - 1) return false; - return /^\d{4}-\d{2}-\d{2}/.test(segments[wbIdx + 1]); -} - -async function resolveWorkspacePath(cwd?: string): Promise { - if (!cwd) return undefined; - const absolute = path.resolve(cwd); - if (isEphemeralTaskDir(absolute)) return undefined; - try { - const { stdout } = await execFileAsync('git', ['-C', absolute, 'rev-parse', '--show-toplevel']); - const root = stdout.trim(); - return await canonicalizeWorkspacePath(root || absolute); - } catch { - return await canonicalizeWorkspacePath(absolute); - } -} - -interface ReportedResource { - slug: string; - version?: string; - display_name?: string; - source: string; -} - -/** - * Resolve a resource's source by looking it up in the local-agent manifest: - * slugs recorded there were installed via HTTP distribution (`enterprise`); - * everything else present only on disk is treated as `local`. - */ -function resolveSource(slug: string, manifestSlugs: Set): string { - return manifestSlugs.has(slug) ? 'enterprise' : 'local'; -} - -/** - * Scan a tool's on-disk skills directory. Each sub-directory containing a - * SKILL.md is one installed skill; slug/version/display_name come from its - * front-matter (falling back to the directory name). - */ -async function scanSkillsFromDisk( - skillsDir: string, - manifestSlugs: Set, -): Promise { - if (!(await pathExists(skillsDir))) return []; - const dirs = (await listDirs(skillsDir)).filter((name) => !name.startsWith('.') && !name.startsWith('_')); - const results: ReportedResource[] = []; - for (const dir of dirs) { - const skillMd = path.join(skillsDir, dir, 'SKILL.md'); - if (!(await pathExists(skillMd))) continue; - const fm = await readFrontmatter(skillMd); - const slug = typeof fm.name === 'string' && fm.name ? fm.name : dir; - const version = fm.version != null ? String(fm.version) : undefined; - results.push({ - slug, - version, - display_name: slug, - source: resolveSource(slug, manifestSlugs), - }); - } - return results.sort((a, b) => a.slug.localeCompare(b.slug)); -} - -/** - * Scan a tool's on-disk rules directory. Every `.md` file (recursively) is one - * installed rule; the slug is its path relative to the rules dir without the - * `.md` extension. - */ -async function scanRulesFromDisk( - rulesDir: string, - manifestSlugs: Set, -): Promise { - if (!(await pathExists(rulesDir))) return []; - // Cursor stores rules as `.mdc`, every other tool as `.md`; match by stem so - // a Cursor agent still reports its installed rules. - const files = await listFilesRecursive(rulesDir); - const results: ReportedResource[] = []; - const seen = new Set(); - for (const file of files) { - const slug = ruleStemFromFilename(file); - if (slug === null) continue; - if (seen.has(slug)) continue; // Same rule under both extensions - seen.add(slug); - // Skip CLI built-in / legacy rules (e.g. teamai-recall) so they are not - // reported as user-installed resources — mirrors the pull/uninstall filter. - if (EXCLUDED_RULE_NAMES.has(path.basename(slug)) || EXCLUDED_RULE_NAMES.has(slug)) continue; - results.push({ - slug, - display_name: slug, - source: resolveSource(slug, manifestSlugs), - }); - } - return results.sort((a, b) => a.slug.localeCompare(b.slug)); -} - -/** Collect every skill/rule slug recorded across all manifest scopes. - * For skills, also includes dir_name (the on-disk SKILL.md name) so that - * scanSkillsFromDisk — which uses SKILL.md name as the reported slug — - * correctly resolves source as 'enterprise' even when dir_name ≠ slug. - */ -function collectManifestSlugs(manifest: LocalAgentManifest): { skills: Set; rules: Set } { - const skills = new Set(); - const rules = new Set(); - for (const scope of Object.values(manifest.scopes)) { - for (const [slug, entry] of Object.entries(scope.skills ?? {})) { - skills.add(slug); - if (entry.dir_name) skills.add(entry.dir_name); - } - for (const slug of Object.keys(scope.rules ?? {})) rules.add(slug); - } - return { skills, rules }; -} - -/** - * Scan the managed-mcp manifest for a given scope and return MCP servers as - * ReportedResource entries. Only servers tracked in managed-mcp.json (i.e. - * installed via HTTP distribution) are reported with source = 'enterprise'. - * - * Results are scoped to the current `tool` so a report never leaks another - * tool's MCP inventory. The manifest is keyed by the same key the installer - * writes under (see `installMcpServer`): `tool` at user scope, `${tool}:project` - * at project scope. `tool` is the raw hook-context value (not run through - * normalizeAgentType), matching how the installer keys the manifest. - */ -async function scanMcpFromManifest( - scope: 'user' | 'project', - tool: string, - projectRoot?: string, -): Promise { - const { resolveDataHomeForScope } = await import('./config.js'); - const dataHome = await resolveDataHomeForScope(scope, projectRoot); - - // Project scope reads THIS worktree's own manifest file (per-worktree under the - // partition; migrates legacy shared records on first read). User scope reads the - // single global file. Either way every record in the loaded file belongs to this - // scope, so no key filtering is needed. - let manifest: ManagedMcpManifest; - if (scope === 'project' && projectRoot) { - const { loadProjectMcpManifest } = await import('./utils/mcp-manifest.js'); - ({ manifest } = await loadProjectMcpManifest(dataHome, projectRoot)); - } else { - manifest = (await readJson(managedMcpManifestPath(dataHome))) ?? {}; - } - - const manifestKey = `${tool}${scope === 'project' ? ':project' : ''}`; - const records = manifest[manifestKey]; - if (!Array.isArray(records)) return []; - - const seen = new Set(); - const results: ReportedResource[] = []; - for (const rec of records) { - if (!rec.name || seen.has(rec.name)) continue; - seen.add(rec.name); - results.push({ slug: rec.name, source: 'enterprise' }); - } - return results.sort((a, b) => a.slug.localeCompare(b.slug)); -} - -interface ReportedModel { - provider: string; - model_id: string; - name?: string; - source: string; -} - -/** - * Scan the models a tool can currently use, as configured on disk. The server - * requires both `provider` and `model_id`, so entries that cannot supply them - * are dropped rather than reported as incomplete. `source` is derived from the - * model manifest, mirroring how skills/rules classify enterprise vs local. - * - * Only CodeBuddy, WorkBuddy, and Claude keep a discoverable model config; - * every other tool reports nothing. User-owned models are omitted: the - * backend cannot resolve them, so only entries still matching a TeamAI - * delivery are reported. - */ -function buddyModelsPath(agentKind: BuddyAgentKind, workspacePath?: string): string { - return workspacePath - ? path.join(workspacePath, '.codebuddy', 'models.json') - : path.join(getUserHome(), `.${agentKind}`, 'models.json'); -} - -function modelConfigDisplayPath(filePath: string): string { - if (filePath.endsWith(`${path.sep}.codebuddy${path.sep}models.json`)) { - return '.codebuddy/models.json'; - } - if (filePath.endsWith(`${path.sep}.workbuddy${path.sep}models.json`)) { - return '~/.workbuddy/models.json'; - } - return path.basename(filePath); -} - -async function scanModelsFromDisk(tool: string, workspacePath?: string): Promise { - const manifest = (await readJson(getModelManifestPath())) ?? {}; - const agentKind = modelAgentKind(tool); - - if (agentKind === 'codebuddy' || agentKind === 'workbuddy') { - const scopeManifest = workspacePath ? manifest.workspaceModels?.[workspacePath] : manifest; - const providers = scopeManifest?.providersByAgent?.[agentKind] - ?? (!workspacePath && agentKind !== 'workbuddy' ? manifest.providers : undefined) - ?? {}; - const raw = await readJson(buddyModelsPath(agentKind, workspacePath)); - const entries = Array.isArray(raw) - ? raw - : (Array.isArray((raw as { models?: unknown } | null)?.models) - ? (raw as { models: unknown[] }).models - : []); - const owned = (agentKind === 'codebuddy' - ? scopeManifest?.codebuddy - : scopeManifest?.workbuddy) ?? {}; - const results: ReportedModel[] = []; - for (const entry of entries) { - if (typeof entry !== 'object' || entry === null) continue; - const { id, vendor, name } = entry as Record; - if (typeof id !== 'string' || !id) continue; - if (typeof vendor !== 'string' || !vendor) continue; - // CodeBuddy / WorkBuddy may normalize a model entry by adding capability - // metadata. The manifest's model id is the durable proof that TeamAI - // delivered it; requiring an exact object hash would hide such entries. - if (owned[id] === undefined || providers[id] !== vendor) continue; - results.push({ - provider: vendor, - model_id: id, - ...(typeof name === 'string' && name ? { name } : {}), - source: 'enterprise', - }); - } - return results; - } - - if (agentKind === 'claude' && !workspacePath) { - const providers = manifest.providersByAgent?.claude ?? manifest.providers ?? {}; - const settings = await readJson<{ env?: unknown }>( - path.join(await claudeUserRoot(), 'settings.json'), - ); - const env = settings?.env; - if (typeof env !== 'object' || env === null || Array.isArray(env)) return []; - const { ANTHROPIC_CUSTOM_MODEL_OPTION: modelId, ANTHROPIC_CUSTOM_MODEL_OPTION_NAME: name } = - env as Record; - if (typeof modelId !== 'string' || !modelId) return []; - const managed = manifest.claudeEnv?.ANTHROPIC_CUSTOM_MODEL_OPTION; - if (managed === undefined || entryHash(modelId) !== managed) return []; - const provider = providers[modelId]; - if (!provider) return []; - return [{ - provider, - model_id: modelId, - ...(typeof name === 'string' && name ? { name } : {}), - source: 'enterprise', - }]; - } - - return []; -} - -/** - * Remove workspace bindings whose directory no longer exists on disk. - * - * Workspace bindings are only ever added, never removed, so a deleted - * project directory would otherwise be reported forever and the server - * (full-sync snapshot) could never drop it. This prunes such stale - * entries in place. Applies to skipped ('__skipped__', projectId 0) - * entries too — a deleted directory should not leave a permanent sentinel. - * - * @param config - Loaded local-agent config; its workspaceBindings map is mutated in place. - * @returns True if at least one binding was removed. - */ -export async function pruneDeadWorkspaceBindings(config: LocalAgentConfig): Promise { - let changed = false; - for (const workspacePath of Object.keys(config.workspaceBindings)) { - try { - await fs.promises.stat(workspacePath); - } catch (error) { - // Only prune when the directory is confirmed gone (ENOENT). Transient - // failures — permission errors, unreachable network mounts — must NOT - // delete a still-valid binding, or the server's full-sync snapshot - // would drop that workspace's resources. - if ((error as NodeJS.ErrnoException).code === 'ENOENT') { - delete config.workspaceBindings[workspacePath]; - changed = true; - } else { - log.debug( - `local-agent: keeping workspace binding ${workspacePath} despite stat error: ${(error as Error).message}`, - ); - } - } - } - return changed; -} - -/** - * Stamps the workspace binding's owning tool when the hook fires from that tool's own process. - * Because hooks are invoked from within the tool's process, cwd === binding.path is the - * authoritative signal that this binding belongs to the triggering tool. - * - * Returns true if the binding was modified (caller should persist config), false otherwise. - */ -export function stampWorkspaceTool( - config: LocalAgentConfig, - currentPath: string | null | undefined, - tool: string, -): boolean { - if (!currentPath) return false; - const binding = config.workspaceBindings[currentPath]; - if (!binding) return false; - const normalized = normalizeAgentType(tool); - if (binding.ideType === normalized) return false; - binding.ideType = normalized; - return true; -} - -/** - * Select the workspace paths that belong to the current tool for reporting. - * - * A binding belongs to the current tool when its stamped ideType matches, or — - * for the not-yet-attributed current cwd — when it is the workspace the hook - * fired from. An empty/absent ideType on a non-cwd binding is treated as - * unattributed and excluded (it self-heals once its owning tool reports from it). - */ -function selectToolWorkspaces( - config: LocalAgentConfig, - currentPath: string | null | undefined, - currentTool: string, -): string[] { - const paths = new Set(Object.keys(config.workspaceBindings)); - if (currentPath) paths.add(currentPath); - return Array.from(paths).filter((wsPath) => { - const b = config.workspaceBindings[wsPath]; - const wsTool = (b?.ideType || undefined) ?? (wsPath === currentPath ? currentTool : undefined); - return wsTool === currentTool; - }); -} - -export async function buildReportPayload( - config: LocalAgentConfig, - context: LocalAgentContext, -): Promise> { - const manifest = await loadManifest(); - - // Resource discovery scans the tool's on-disk skills/rules directories rather - // than the manifest, so locally-installed resources (not just HTTP-distributed - // ones) are reported. `source` is derived from the manifest: slugs recorded - // there are `enterprise`, the rest `local`. - const tool = context.tool ?? 'workbuddy'; - const teamConfig = createLocalAgentTeamConfig(config.endpoint); - const manifestSlugs = collectManifestSlugs(manifest); - - // Resolve paths through the same user-scope seam the installers use: tools - // that relocate their user customization root (copilot via $COPILOT_HOME) or - // lay user scope out differently from project scope declare a `userScope` - // block. Reading the raw toolPaths map against $HOME would scan the project - // layout under the wrong base — e.g. ~/.github/skills for copilot, a path - // teamai never writes to — and silently report nothing. - const scanScope = async (workspacePath?: string): Promise<{ skills: ReportedResource[]; rules: ReportedResource[] }> => { - const scope: LocalAgentScope = workspacePath ? 'project' : 'user'; - const localConfig = await createResourceLocalConfig(config, scope, workspacePath ?? getUserHome(), workspacePath); - const toolPath = scopedToolPaths(teamConfig, localConfig)[tool]; - if (!toolPath) return { skills: [], rules: [] }; - const baseDir = resolveToolBaseDir(tool, localConfig); - const skills = toolPath.skills - ? await scanSkillsFromDisk(path.join(baseDir, toolPath.skills), manifestSlugs.skills) - : []; - const rules = toolPath.rules - ? await scanRulesFromDisk(path.join(baseDir, toolPath.rules), manifestSlugs.rules) - : []; - return { skills, rules }; - }; - - const userScope = await scanScope(); - - const userLevel: Record = { group_id: config.userGroupId }; - if (userScope.skills.length > 0) userLevel.skills = userScope.skills; - if (userScope.rules.length > 0) userLevel.rules = userScope.rules; - const userMcps = await scanMcpFromManifest('user', tool); - if (userMcps.length > 0) userLevel.mcps = userMcps; - // Omitted when empty for the same full-sync reason as skills/rules: the - // server treats a present array as a snapshot, so [] would wipe the models. - const userModels = await scanModelsFromDisk(tool); - if (userModels.length > 0) userLevel.models = userModels; - - const payload: Record = { - agent_type: normalizeAgentType(tool), - agent_version: await getAgentVersion(tool), - local_agent_id: resolveLocalAgentId(context), - host_name: os.hostname(), - os: os.platform(), - started_at: config.createdAt, - last_status: context.status ?? 'running', - // Instance-level skills/rules are a phase-1 legacy concept. They are - // deliberately omitted (not sent as []): the server treats present arrays - // as a full-sync snapshot ("消失即删"), so an empty array would wipe any - // instance-level resources. Omitting the field leaves them untouched. - user_level: userLevel, - }; - - const currentPath = await resolveWorkspacePath(context.cwd); - const currentTool = normalizeAgentType(tool); - const targetPaths = selectToolWorkspaces(config, currentPath, currentTool); - if (targetPaths.length > 0) { - const workspaceResults = await Promise.all( - targetPaths.map(async (wsPath) => { - const wsScope = await scanScope(wsPath); - const wsBinding = config.workspaceBindings[wsPath]; - const workspace: Record = { - path: wsPath, - name: path.basename(wsPath), - ide_type: currentTool, - project_id: wsBinding?.projectId, - }; - if (wsScope.skills.length > 0) workspace.skills = wsScope.skills; - if (wsScope.rules.length > 0) workspace.rules = wsScope.rules; - const wsMcps = await scanMcpFromManifest('project', tool, wsPath); - if (wsMcps.length > 0) workspace.mcps = wsMcps; - const wsModels = await scanModelsFromDisk(tool, wsPath); - if (wsModels.length > 0) workspace.models = wsModels; - return workspace; - }), - ); - payload.workspaces = workspaceResults; - } - - return payload; -} - -export async function buildSyncPayload( - config: LocalAgentConfig, - context: LocalAgentContext, -): Promise> { - const payload: Record = { - agent_type: normalizeAgentType(context.tool ?? 'workbuddy'), - local_agent_id: resolveLocalAgentId(context), - status: context.status ?? 'running', - }; - const currentPath = await resolveWorkspacePath(context.cwd); - const currentTool = normalizeAgentType(context.tool ?? 'workbuddy'); - const targetPaths = selectToolWorkspaces(config, currentPath, currentTool); - if (targetPaths.length > 0) { - payload.workspaces = targetPaths.map((wsPath) => { - const wsBinding = config.workspaceBindings[wsPath]; - return { - path: wsPath, - name: path.basename(wsPath), - ide_type: currentTool, - project_id: wsBinding?.projectId, - }; - }); - } - return payload; -} - -function commandKind(command: LocalAgentCommand): CommandResourceKind | null { - // Unified cmds[] carries handle_type; legacy commands[] carries rule_type. - // Both map a prompt rule to the claudemd resource kind. - if (command.rule_type === 'prompt' || command.handle_type === 'prompt') return 'claudemd'; - if (command.handle_type === 'rule') return 'rule'; - if (command.handle_type === 'hook') return null; // defensive; skipped before dispatch - const type = command.type ?? ''; - if (type.endsWith('_skill') || type === '') return 'skill'; - if (type.endsWith('_claudemd') || type.endsWith('_claude_md')) return 'claudemd'; - if (type.endsWith('_rule')) return 'rule'; - return null; -} - -function commandAction(command: LocalAgentCommand): 'install' | 'uninstall' | null { - const type = command.type ?? ''; - if (type === '') return 'install'; - if (type.startsWith('install_')) return 'install'; - if (type.startsWith('uninstall_')) return 'uninstall'; - return null; -} - -/** - * Reject slugs that could escape the resource directory. Slugs come from - * backend sync commands and are used directly in filesystem paths, so a value - * like `../../.ssh/authorized_keys` would otherwise write outside the repo. - */ -function validateSlug(slug: string): string { - if ( - !slug || - slug.includes('/') || - slug.includes('\\') || - slug.includes('..') || - path.isAbsolute(slug) - ) { - throw new Error(`Invalid resource slug: ${slug}`); - } - return slug; -} - -function commandSlug(command: LocalAgentCommand, kind: CommandResourceKind): string { - const slug = - kind === 'skill' ? command.skill_slug : - kind === 'rule' ? command.rule_slug : - (command.claudemd_slug ?? command.rule_slug); - const resolved = slug ?? command.resource_slug ?? command.slug ?? command.name; - if (!resolved) { - throw new Error(`Missing ${kind} slug`); - } - return validateSlug(resolved); -} - -function commandVersion(command: LocalAgentCommand, kind: CommandResourceKind): string | undefined { - return ( - kind === 'skill' ? command.skill_version : - kind === 'rule' ? command.rule_version : - (command.claudemd_version ?? command.rule_version) - ) ?? command.resource_version ?? command.version; -} - -/** - * Normalize a backend-sent scope string to an internal LocalAgentScope. - * - * The backend emits `user` / `workspace` (see clawpro local-agent-api.md); - * the deprecated `instance` is no longer sent. Internally project-level - * resources use the `project` scope, so `workspace` maps to `project`. - * Any unrecognized value falls back to `user` (global install). - * - * This only maps the scope; presence of `workspace_path` for project scope - * is validated by the caller (executeCommand throws if it is missing). - */ -function normalizeScope(raw?: string): LocalAgentScope { - if (raw === 'workspace' || raw === 'project') return 'project'; - if (raw !== undefined && raw !== 'user' && raw !== 'instance') { - log.debug(`local-agent: unknown scope "${raw}", defaulting to user`); - } - return 'user'; -} - -function manifestKind(kind: CommandResourceKind): ResourceKind { - return kind === 'skill' ? 'skills' : kind === 'rule' ? 'rules' : 'claudemd'; -} - -/** Only http(s) downloads are allowed — reject file:, ftp:, gopher:, etc. */ -function assertHttpUrl(rawUrl: string): URL { - let parsed: URL; - try { - parsed = new URL(rawUrl); - } catch { - throw new Error(`Invalid download URL: ${rawUrl}`); - } - if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { - throw new Error(`Unsupported download URL scheme: ${parsed.protocol}`); - } - return parsed; -} - -/** - * Fetch a resource by URL. download_url comes from backend sync commands, so it - * is treated as untrusted: only http(s) is honoured (no file:// / local-path - * copy, which would be arbitrary local file read), and redirects are followed - * manually so every hop's scheme is re-validated instead of blindly trusting - * whatever Location the server returns. - */ -async function downloadResource(downloadUrl: string): Promise { - const tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'teamai-local-agent-')); - const filePath = path.join(tmpDir, 'resource'); - - let current = assertHttpUrl(downloadUrl); - let response: Response; - const maxRedirects = 5; - // One timeout budget for the whole download (all redirect hops combined), so a - // chain of slow redirects cannot exceed the intended bound. - const signal = AbortSignal.timeout(activeFetchTimeoutMs); - for (let hop = 0; ; hop++) { - response = await fetch(current, { redirect: 'manual', signal }); - if (response.status >= 300 && response.status < 400) { - const location = response.headers.get('location'); - if (!location) break; - if (hop >= maxRedirects) { - throw new Error(`Download failed: too many redirects (${downloadUrl})`); - } - current = assertHttpUrl(new URL(location, current).toString()); - continue; - } - break; - } - - if (!response.ok) { - throw new Error(`Download failed: ${response.status} ${response.statusText}`); - } - const buffer = Buffer.from(await response.arrayBuffer()); - await fs.promises.writeFile(filePath, buffer); - return filePath; -} - -const ZIP_MAGIC = Buffer.from([0x50, 0x4b, 0x03, 0x04]); - -async function isZipFile(filePath: string): Promise { - const fd = await fs.promises.open(filePath, 'r'); - try { - const buf = Buffer.alloc(4); - await fd.read(buf, 0, 4, 0); - return buf.equals(ZIP_MAGIC); - } finally { - await fd.close(); - } -} - -async function resolveMarkdownFromDownload(downloadedPath: string, slug: string): Promise { - if (await isZipFile(downloadedPath)) { - const extractDir = await extractZip(downloadedPath); - return findMarkdownFile(extractDir, slug); - } - return downloadedPath; -} - -async function extractZip(zipPath: string): Promise { - const extractDir = path.join(path.dirname(zipPath), 'extracted'); - await ensureDir(extractDir); - await execFileAsync('unzip', ['-q', zipPath, '-d', extractDir]); - return extractDir; -} - -async function findFirst( - dir: string, - predicate: (absolutePath: string, name: string) => Promise, -): Promise { - const entries = await fs.promises.readdir(dir, { withFileTypes: true }); - for (const entry of entries) { - const absolute = path.join(dir, entry.name); - if (await predicate(absolute, entry.name)) return absolute; - if (entry.isDirectory()) { - const nested = await findFirst(absolute, predicate); - if (nested) return nested; - } - } - return null; -} - -async function findSkillRoot(extractDir: string): Promise { - if (await pathExists(path.join(extractDir, 'SKILL.md'))) return extractDir; - const skillMd = await findFirst(extractDir, async (absolute, name) => name === 'SKILL.md' && (await pathExists(absolute))); - if (!skillMd) throw new Error('Downloaded skill package does not contain SKILL.md'); - return path.dirname(skillMd); -} - -async function findMarkdownFile(extractDir: string, preferredName: string): Promise { - const preferred = await findFirst( - extractDir, - async (_absolute, name) => name === `${preferredName}.md` || name === preferredName, - ); - if (preferred) return preferred; - - const firstMd = await findFirst(extractDir, async (_absolute, name) => name.endsWith('.md')); - if (!firstMd) throw new Error('Downloaded package does not contain a markdown file'); - return firstMd; -} - -async function readFrontmatter(filePath: string): Promise> { - const content = await readFileSafe(filePath); - if (!content) return {}; - return parseFrontmatter(content).data; -} - -/** - * Decide the on-disk directory name for a skill. The SKILL.md `name:` field is - * the source of truth for how the skill is identified by the AI tool, so use it - * when it differs from the server-provided slug (matching the git-path behaviour - * in skill-command.ts / #144). Falls back to the slug when the name is missing, - * empty, equal to the slug, or fails path-safety validation. - */ -async function resolveSkillDirName(skillRoot: string, slug: string): Promise { - const fm = await readFrontmatter(path.join(skillRoot, 'SKILL.md')); - const name = typeof fm.name === 'string' ? fm.name.trim() : ''; - if (!name || name === slug) return slug; - try { - assertSafeResourceName(name); - return name; - } catch { - log.debug(`[local-agent] keeping slug "${slug}" as skill dir (SKILL.md name "${name}" failed safety check)`); - return slug; - } -} - -async function installDownloadedResource(input: { - config: LocalAgentConfig; - command: LocalAgentCommand; - kind: CommandResourceKind; - slug: string; - scope: LocalAgentScope; - workspacePath?: string; - tool?: string; -}): Promise { - if (!input.command.download_url) { - throw new Error(`Missing download_url for ${input.command.type ?? 'install_skill'}`); - } - - const repoPath = await getResourceRepoPath(input.scope, input.workspacePath); - if (input.scope === 'project' && input.workspacePath - && repoPath.startsWith(path.join(input.workspacePath, '.teamai') + path.sep)) { - // Only gitignore when the cache actually lands inside the workspace (a legacy, - // un-migrated install). A partitioned install keeps it under ~/.teamai, so - // there is nothing in the workspace to ignore. - await ensureProjectGitignore(input.workspacePath); - } - await ensureDir(repoPath); - - const downloadedPath = await downloadResource(input.command.download_url); - try { - const fullTeamConfig = createLocalAgentTeamConfig(input.config.endpoint); - const tool = input.tool ?? 'workbuddy'; - const toolPath = fullTeamConfig.toolPaths[tool]; - if (!toolPath) { - throw new Error(`Unknown tool "${tool}": no toolPaths entry found`); - } - const teamConfig = { ...fullTeamConfig, toolPaths: { [tool]: toolPath } }; - const localConfig = await createResourceLocalConfig(input.config, input.scope, repoPath, input.workspacePath); - // Ensure the tool root directory exists before dispatch so isToolInstalled - // gate does not skip the resource when the workspace is freshly bound. - // Restricted to project scope: user-scope installs use $HOME as baseDir and - // should continue to rely on isToolInstalled as the gate. - if (localConfig.scope === 'project') { - try { - const baseDir = resolveBaseDir(localConfig); - const resourceToolPath = - input.kind === 'skill' ? toolPath.skills : - input.kind === 'rule' ? toolPath.rules : - // Default branch covers the 'claudemd' kind; if a new CommandResourceKind - // is added, revisit this mapping so it doesn't silently fall through to claudemd. - toolPath.claudemd; - if (resourceToolPath && resourceToolPath.includes('/')) { - const rootSegment = resourceToolPath.split('/')[0]; - await ensureDir(path.join(baseDir, rootSegment)); - } - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - if (msg.includes('resolveBaseDir')) { - log.warn(`Cannot resolve base dir to pre-create tool root: ${msg}`); - } else { - log.debug(`Failed to pre-create tool root directory: ${msg}`); - } - } - } - const now = new Date().toISOString(); - let displayName = input.command.display_name ?? input.slug; - // On-disk skill directory name (SKILL.md name when it differs from slug). - // Stays the slug for rules/claudemd. Recorded in the manifest so uninstall - // can find the directory by slug. - let skillDirName = input.slug; - - if (input.kind === 'skill') { - const extractDir = await extractZip(downloadedPath); - const skillRoot = await findSkillRoot(extractDir); - skillDirName = await resolveSkillDirName(skillRoot, input.slug); - const dest = path.join(repoPath, 'skills', skillDirName); - await remove(dest); - await fse.copy(skillRoot, dest, { overwrite: true }); - const fm = await readFrontmatter(path.join(dest, 'SKILL.md')); - displayName = typeof fm.name === 'string' ? fm.name : displayName; - await new SkillsHandler().pullItem({ - name: skillDirName, - type: 'skills', - sourcePath: dest, - relativePath: `skills/${skillDirName}`, - }, teamConfig, localConfig); - } else if (input.kind === 'rule') { - const ruleFile = await resolveMarkdownFromDownload(downloadedPath, input.slug); - const dest = path.join(repoPath, 'rules', `${input.slug}.md`); - await fse.ensureDir(path.dirname(dest)); - await fse.copyFile(ruleFile, dest); - await new RulesHandler().pullAllRules(teamConfig, localConfig); - } else { - const mdFile = await resolveMarkdownFromDownload(downloadedPath, input.slug); - const dest = path.join(repoPath, 'claudemd', `${input.slug}.md`); - await fse.ensureDir(path.dirname(dest)); - await fse.copyFile(mdFile, dest); - await syncClaudemd(teamConfig, localConfig, repoPath, input.workspacePath); - } - - const version = commandVersion(input.command, input.kind); - const manifest = await loadManifest(); - const scopeManifest = getManifestScope(manifest, input.scope, input.workspacePath); - scopeManifest[manifestKind(input.kind)][input.slug] = { - slug: input.slug, - version, - display_name: displayName, - source: 'enterprise', - installed_at: now, - ...(input.kind === 'skill' && skillDirName !== input.slug ? { dir_name: skillDirName } : {}), - }; - await saveManifest(manifest); - return version; - } finally { - await remove(path.dirname(downloadedPath)); - } -} - -async function uninstallResource(input: { - config: LocalAgentConfig; - kind: CommandResourceKind; - slug: string; - scope: LocalAgentScope; - workspacePath?: string; - tool?: string; -}): Promise { - const repoPath = await getResourceRepoPath(input.scope, input.workspacePath); - const fullTeamConfig = createLocalAgentTeamConfig(input.config.endpoint); - const tool = input.tool ?? 'workbuddy'; - const toolPath = fullTeamConfig.toolPaths[tool]; - if (!toolPath) { - throw new Error(`Unknown tool "${tool}": no toolPaths entry found`); - } - const teamConfig = { ...fullTeamConfig, toolPaths: { [tool]: toolPath } }; - const localConfig = await createResourceLocalConfig(input.config, input.scope, repoPath, input.workspacePath); - const manifest = await loadManifest(); - const scopeManifest = getManifestScope(manifest, input.scope, input.workspacePath); - - if (input.kind === 'skill') { - // The directory was created under the SKILL.md name (recorded as dir_name); - // remove by that name, falling back to the slug for older installs. - const dirName = scopeManifest.skills[input.slug]?.dir_name ?? input.slug; - await new SkillsHandler().removeItem(dirName, teamConfig, localConfig); - } else if (input.kind === 'rule') { - await new RulesHandler().removeItem(input.slug, teamConfig, localConfig); - } else { - await remove(path.join(repoPath, 'claudemd', `${input.slug}.md`)); - await syncClaudemd(teamConfig, localConfig, repoPath, input.workspacePath); - } - - delete scopeManifest[manifestKind(input.kind)][input.slug]; - await saveManifest(manifest); -} - -async function resolveHermesUserBaseDir(): Promise { - try { - const envWs = process.env.TEAMAI_HERMES_WORKSPACE; - if (envWs && path.isAbsolute(envWs)) return envWs; - const cfg = await readJson(getConfigPath()); - const bindings = cfg?.workspaceBindings; - if (bindings && typeof bindings === 'object') { - const entries = Object.entries(bindings) - .filter(([p, v]) => path.isAbsolute(p) && v?.ideType === 'hermes') - .sort((a, b) => (b[1].boundAt ?? '').localeCompare(a[1].boundAt ?? '')); - for (const [p] of entries) { - if (await pathExists(path.join(p, '.hermes'))) return p; - } - } - } catch { /* fall through */ } - return undefined; -} - -async function syncClaudemd( - teamConfig: TeamaiConfig, - localConfig: LocalConfig, - repoPath: string, - workspacePath?: string, -): Promise { - const claudemdDir = path.join(repoPath, 'claudemd'); - const files = (await pathExists(claudemdDir)) - ? (await fse.readdir(claudemdDir)).filter((file) => file.endsWith('.md')).sort() - : []; - const contents: string[] = []; - for (const file of files) { - const content = await readFileSafe(path.join(claudemdDir, file)); - if (content) contents.push(content); - } - const block = compileClaudemdBlock(contents); - let syncedAny = false; - - for (const [tool, toolPath] of Object.entries(scopedToolPaths(teamConfig, localConfig))) { - if (!toolPath.claudemd) continue; - - let baseDir = resolveToolBaseDir(tool, localConfig); - let resolvedAbsPath: string | null = null; - - if (tool === 'openclaw' && localConfig.scope !== 'project') { - const openclawWs = await resolveOpenclawWorkspaceDir(workspacePath); - if (openclawWs) { - resolvedAbsPath = path.join(openclawWs, path.basename(toolPath.claudemd)); - } - } else if (tool === 'hermes' && localConfig.scope !== 'project') { - const hermesBase = workspacePath ?? await resolveHermesUserBaseDir(); - if (hermesBase) { - baseDir = hermesBase; - log.debug(`local-agent: hermes user-scope baseDir resolved to ${baseDir}`); - } - } - - const toolInstalled = resolvedAbsPath - ? await pathExists(resolvedAbsPath) - : tool === COPILOT_TOOL_ID && localConfig.scope === 'user' - ? await isToolInstalledForConfig(tool, toolPath.claudemd, localConfig) - : toolPath.claudemd.includes('/') - ? await ResourceHandler.isToolInstalled(toolPath.claudemd, baseDir) - : await pathExists(path.join(baseDir, `.${tool}`)); - if (!toolInstalled) { - log.debug(`Skipped CLAUDE.md sync for ${tool}: target not found`); - continue; - } - - const claudeMdPath = resolvedAbsPath ?? path.join(baseDir, toolPath.claudemd); - try { - if (block) { - await injectClaudeMdSection(claudeMdPath, TEAMAI_CLAUDEMD_START, TEAMAI_CLAUDEMD_END, block); - log.debug(`local-agent: synced CLAUDE.md instructions to ${tool}`); - syncedAny = true; - } else { - await removeClaudeMdSection(claudeMdPath, TEAMAI_CLAUDEMD_START, TEAMAI_CLAUDEMD_END); - log.debug(`local-agent: removed CLAUDE.md instructions from ${tool}`); - syncedAny = true; - } - } catch (e) { - log.warn(`Failed to sync CLAUDE.md instructions to ${tool}: ${(e as Error).message}`); - } - } - - if (files.length > 0 && !syncedAny) { - throw new Error('CLAUDE.md sync landed on no tool: every configured target was skipped'); - } -} - -async function ackCommand( - config: LocalAgentConfig, - tag: string, - command: LocalAgentCommand, - status: 'success' | 'failed', - version?: string, - error?: string, -): Promise { - await localAgentFetch(config, tag, 'ack', { - method: 'POST', - body: JSON.stringify({ - id: command.id, - type: command.type ?? '', - status, - error: error ?? '', - version, - }), - }); -} - -function requireModelString( - value: unknown, - field: keyof Pick, -): string { - if (typeof value !== 'string' || !value.trim()) { - throw new Error(`apply_model_config: ${field} must be a non-empty string`); - } - return value.trim(); -} - -/** CodeBuddy maxOutputTokens when the backend omits max_tokens or sends 0 (Go zero value). */ -const DEFAULT_MAX_TOKENS = 4096; - -function optionalPositiveInteger(value: unknown, field: 'max_tokens' | 'context_window'): number | undefined { - if (value === undefined || value === null || value === '') return undefined; - const normalized = typeof value === 'string' && /^\d+$/.test(value) - ? Number(value) - : value; - if (!Number.isSafeInteger(normalized) || (normalized as number) < 0) { - throw new Error(`apply_model_config: ${field} must be a positive integer`); - } - // 0 is the Go zero value for an unset int, not a real output/context cap. - if ((normalized as number) === 0) return undefined; - return normalized as number; -} - -function parseDeliveredModels(raw: string | undefined): { models: DeliveredModel[]; fullSnapshot: boolean } { - if (!raw) throw new Error('apply_model_config: missing cmd'); - let parsed: unknown; - try { - parsed = JSON.parse(raw); - } catch { - throw new Error('apply_model_config: cmd must be valid JSON'); - } - const fullSnapshot = ( - typeof parsed === 'object' && - parsed !== null && - 'models' in parsed - ); - const values = fullSnapshot ? (parsed as { models?: unknown }).models : [parsed]; - if (!Array.isArray(values)) { - throw new Error('apply_model_config: models must be an array'); - } - - const seen = new Set(); - const models = values.map((value) => { - if (typeof value !== 'object' || value === null || Array.isArray(value)) { - throw new Error('apply_model_config: each model must be an object'); - } - const input = value as Record; - const modelId = requireModelString(input.model_id, 'model_id'); - if (modelId === '__proto__' || modelId === 'prototype' || modelId === 'constructor') { - throw new Error(`apply_model_config: reserved model_id "${modelId}"`); - } - const model: DeliveredModel = { - provider: requireModelString(input.provider, 'provider'), - model_id: modelId, - name: requireModelString(input.name, 'name'), - base_url: requireModelString(input.base_url, 'base_url'), - api_key: requireModelString(input.api_key, 'api_key'), - max_tokens: optionalPositiveInteger(input.max_tokens, 'max_tokens') ?? DEFAULT_MAX_TOKENS, - context_window: optionalPositiveInteger(input.context_window, 'context_window'), - }; - let parsedUrl: URL; - try { - parsedUrl = new URL(model.base_url); - } catch { - throw new Error('apply_model_config: base_url must be a valid URL'); - } - if (parsedUrl.protocol !== 'http:' && parsedUrl.protocol !== 'https:') { - throw new Error('apply_model_config: base_url must use http or https'); - } - if (seen.has(model.model_id)) { - throw new Error(`apply_model_config: duplicate model_id "${model.model_id}"`); - } - seen.add(model.model_id); - return model; - }); - return { models, fullSnapshot }; -} - -function buddyModelEntry(model: DeliveredModel): Record { - const baseUrl = model.base_url.replace(/\/+$/, ''); - return { - id: model.model_id, - name: model.name, - vendor: model.provider, - apiKey: model.api_key, - ...(model.context_window === undefined ? {} : { maxInputTokens: model.context_window }), - ...(model.max_tokens === undefined ? {} : { maxOutputTokens: model.max_tokens }), - url: baseUrl.endsWith('/chat/completions') ? baseUrl : `${baseUrl}/chat/completions`, - supportsToolCall: true, - }; -} - -async function readJsonObject(filePath: string): Promise> { - const source = await readFileSafe(filePath); - if (source === null) return {}; - try { - const parsed = JSON.parse(source); - if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { - throw new Error('root must be an object'); - } - return parsed as Record; - } catch (error) { - throw new Error( - `apply_model_config: cannot parse ${modelConfigDisplayPath(filePath)}: ${(error as Error).message}`, - ); - } -} - -/** Atomically update a model dotfile without replacing a user-managed symlink. */ -async function writeModelJson(filePath: string, data: unknown): Promise { - let targetPath = filePath; - try { - if ((await fs.promises.lstat(filePath)).isSymbolicLink()) { - targetPath = await fs.promises.realpath(filePath); - } - } catch (error) { - if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; - } - // Set the temp file's mode before the atomic rename. A post-rename chmod - // would introduce a symlink-following TOCTOU window. - await writeJsonAtomic(targetPath, data, { mode: 0o600 }); -} - -async function ensureWorkspaceModelGitignore(workspacePath: string): Promise { - const gitignorePath = path.join(workspacePath, '.codebuddy', '.gitignore'); - const existing = await readFileSafe(gitignorePath); - if (existing === null) { - await writeFile(gitignorePath, '# Local model credentials\nmodels.json\n'); - return; - } - if (existing.split(/\r?\n/).some((line) => line.trim() === 'models.json')) return; - await writeFile(gitignorePath, `${existing.trimEnd()}\nmodels.json\n`); -} - -async function readBuddyModelEntries( - filePath: string, -): Promise<{ existing: unknown[]; doc?: Record }> { - const source = await readFileSafe(filePath); - // The current WorkBuddy / CodeBuddy documentation uses an object wrapper. - // Product releases also accept the legacy top-level array, so preserve that - // shape when a user already has one instead of forcing a migration. - if (source === null) return { existing: [], doc: {} }; - try { - const parsed = JSON.parse(source); - if (Array.isArray(parsed)) return { existing: parsed }; - if (typeof parsed !== 'object' || parsed === null) { - throw new Error('root must be an object or array'); - } - const doc = parsed as Record; - const existing = doc.models === undefined ? [] : doc.models; - if (!Array.isArray(existing)) { - throw new Error('models must be an array'); - } - return { existing, doc }; - } catch (error) { - throw new Error( - `apply_model_config: cannot parse ${modelConfigDisplayPath(filePath)}: ${(error as Error).message}`, - ); - } -} - -async function reconcileBuddyModels( - models: DeliveredModel[], - fullSnapshot: boolean, - scopeManifest: BuddyModelManifest, - agentKind: BuddyAgentKind, - workspacePath?: string, -): Promise { - const targetFile = buddyModelsPath(agentKind, workspacePath); - const { existing, doc } = await readBuddyModelEntries(targetFile); - const previouslyManaged = (agentKind === 'codebuddy' - ? scopeManifest.codebuddy - : scopeManifest.workbuddy) ?? {}; - const nextManaged: Record = fullSnapshot ? {} : { ...previouslyManaged }; - const incomingIds = new Set(models.map((model) => model.model_id)); - const removedManaged = new Set(); - const preserved: unknown[] = []; - const occupiedIds = new Set(); - for (const entry of existing) { - const id = typeof entry === 'object' && entry !== null && typeof (entry as { id?: unknown }).id === 'string' - ? (entry as { id: string }).id - : undefined; - if (id && previouslyManaged[id] && entryHash(entry) === previouslyManaged[id]) { - if (fullSnapshot || incomingIds.has(id)) { - removedManaged.add(id); - continue; - } - preserved.push(entry); - occupiedIds.add(id); - continue; - } - preserved.push(entry); - if (id) occupiedIds.add(id); - if (id && previouslyManaged[id]) delete nextManaged[id]; - } - - for (const model of models) { - if (occupiedIds.has(model.model_id)) continue; - const entry = buddyModelEntry(model); - preserved.push(entry); - nextManaged[model.model_id] = entryHash(entry); - } - - if (workspacePath) await ensureWorkspaceModelGitignore(workspacePath); - if (doc) { - doc.models = preserved; - if (Array.isArray(doc.availableModels) && doc.availableModels.length > 0) { - const available = doc.availableModels.filter( - (id): id is string => typeof id === 'string' && !removedManaged.has(id), - ); - for (const id of Object.keys(nextManaged)) { - if (!available.includes(id)) available.push(id); - } - doc.availableModels = available; - } - await writeModelJson(targetFile, doc); - } else { - await writeModelJson(targetFile, preserved); - } - if (agentKind === 'codebuddy') scopeManifest.codebuddy = nextManaged; - else scopeManifest.workbuddy = nextManaged; -} - -function claudeEnvForModel(model: DeliveredModel): Record { - const baseUrl = model.base_url.replace(/\/+$/, '').replace(/\/v1$/, ''); - return { - ANTHROPIC_BASE_URL: baseUrl, - ANTHROPIC_AUTH_TOKEN: model.api_key, - ANTHROPIC_CUSTOM_MODEL_OPTION: model.model_id, - ANTHROPIC_CUSTOM_MODEL_OPTION_NAME: model.name, - }; -} - -/** - * Drop the gateway env and model profile the agent delivered into `claudeRoot`, - * and forget them in the manifest. For `teamai init` moving the Claude root: - * the credentials would otherwise stay in a profile nothing syncs any more. - * No-op when the agent never delivered a model. - */ -export async function releaseClaudeModelConfig(claudeRoot: string): Promise { - const manifest = (await readJson(getModelManifestPath())) ?? {}; - if (Object.keys(manifest.claudeEnv ?? {}).length === 0) return; - await reconcileClaudeModels([], manifest, claudeRoot); - await writeJsonAtomic(getModelManifestPath(), manifest); - log.info(`Removed the delivered Claude model config from ${claudeRoot}`); -} - -async function reconcileClaudeModels( - models: DeliveredModel[], - manifest: ModelConfigManifest, - claudeRoot?: string, -): Promise { - claudeRoot ??= await claudeUserRoot(); - const settingsPath = path.join(claudeRoot, 'settings.json'); - const profilePath = path.join(claudeRoot, 'teamai-models.json'); - const previousHashes = manifest.claudeEnv ?? {}; - const settings = await readJsonObject(settingsPath); - const rawEnv = settings.env === undefined ? {} : settings.env; - if (typeof rawEnv !== 'object' || rawEnv === null || Array.isArray(rawEnv)) { - throw new Error(`apply_model_config: env must be an object in ${settingsPath}`); - } - const env = { ...(rawEnv as Record) }; - - if (models.length === 0) { - const canRemoveGateway = Object.entries(previousHashes).every( - ([key, hash]) => entryHash(env[key]) === hash, - ); - if (canRemoveGateway && Object.keys(previousHashes).length > 0) { - for (const key of Object.keys(previousHashes)) delete env[key]; - settings.env = env; - await writeModelJson(settingsPath, settings); - } - await remove(profilePath); - manifest.claudeEnv = {}; - return; - } - - // Claude supports one active custom gateway in settings. The first model - // seeds that gateway; other candidates remain discoverable from its - // /v1/models endpoint when the gateway implements model discovery. - const desired = claudeEnvForModel(models[0]); - await writeModelJson(profilePath, { env: desired }); - - // Any of these keys, if the user already set them, means they have their own - // Claude gateway/model config we must not silently take over. Beyond the keys - // we write, this also covers auth the gateway swap would break - // (ANTHROPIC_API_KEY, ANTHROPIC_CUSTOM_HEADERS) and the user's model choice - // (ANTHROPIC_DEFAULT_{OPUS,SONNET,HAIKU}_MODEL). - const conflictKeys = new Set([ - ...Object.keys(desired), - 'ANTHROPIC_API_KEY', - 'ANTHROPIC_CUSTOM_HEADERS', - 'ANTHROPIC_DEFAULT_OPUS_MODEL', - 'ANTHROPIC_DEFAULT_SONNET_MODEL', - 'ANTHROPIC_DEFAULT_HAIKU_MODEL', - ]); - // A value is TeamAI-managed if it matches what we recorded last time or the - // value currently in settings.json (settings.json is our own output, so a - // process.env var equal to it is Claude re-injecting settings.json.env into - // the hook, not a user's independent shell config). - const isManagedValue = (key: string, value: unknown): boolean => ( - (previousHashes[key] !== undefined && entryHash(value) === previousHashes[key]) || - (env[key] !== undefined && entryHash(value) === entryHash(env[key])) - ); - // The guard must see config the user set outside settings.json too. Users who - // run Claude via shell `export ANTHROPIC_*` keep no gateway in settings.json, - // so a settings-only check reads env[key] === undefined and wrongly seizes the - // slot — settings.json then outranks the shell env and breaks their setup. - // But Claude injects settings.json.env into the hook's own environment, so we - // must NOT treat our own re-injected managed values as a user conflict — doing - // so would block every follow-up sync and strand the user on stale config. - const userOwnsInShell = (key: string): boolean => { - const value = process.env[key]; - if (typeof value !== 'string' || value.trim() === '') return false; - return !isManagedValue(key, value); - }; - const shellConflicts = [...conflictKeys].filter(userOwnsInShell); - if (shellConflicts.length > 0) { - // The user has their own gateway/model config in the shell. Skip the write, - // but keep manifest.claudeEnv intact: this is not the user editing our - // managed settings.json entry, so we must stay able to reconcile once the - // shell config goes away. - await appendErrorLog({ - apply_model_config: 'skipped claude gateway: user owns conflicting shell env', - conflicts: shellConflicts, - }); - return; - } - - const canManage = [...conflictKeys].every((key) => ( - env[key] === undefined || - (previousHashes[key] !== undefined && entryHash(env[key]) === previousHashes[key]) - )); - if (!canManage) { - manifest.claudeEnv = {}; - return; - } - - for (const [key, hash] of Object.entries(previousHashes)) { - if (entryHash(env[key]) === hash) delete env[key]; - } - Object.assign(env, desired); - settings.env = env; - await writeModelJson(settingsPath, settings); - manifest.claudeEnv = Object.fromEntries( - Object.entries(desired).map(([key, value]) => [key, entryHash(value)]), - ); -} - -async function applyModelConfig( - config: LocalAgentConfig, - command: LocalAgentCommand, - context: LocalAgentContext, -): Promise { - const { models, fullSnapshot } = parseDeliveredModels(command.cmd); - const manifest = (await readJson(getModelManifestPath())) ?? {}; - const agentKind = modelAgentKind(context.tool); - if (!agentKind) { - throw new Error(`apply_model_config: unsupported agent "${context.tool ?? ''}"`); - } - - const scope = normalizeScope(command.scope); - const workspacePath = scope === 'project' - ? await resolveWorkspacePath(command.workspace_path ?? context.cwd) - : undefined; - if (scope === 'project' && !workspacePath) { - throw new Error('apply_model_config: workspace command is missing workspace_path'); - } - if (workspacePath && config.workspaceBindings[workspacePath] === undefined) { - throw new Error( - `apply_model_config: workspace "${path.basename(workspacePath)}" is not a registered binding`, - ); - } - if (agentKind === 'claude' && workspacePath) { - throw new Error('apply_model_config: workspace scope is unsupported for claude'); - } - if (!workspacePath) { - // An explicit profile switch takes precedence over server delivery. Keep - // both the Agent config and delivery manifest intact for a later restore. - const { isModelProfileManaged } = await import('./models/switch.js'); - if (await isModelProfileManaged(agentKind)) return; - } - - let scopeManifest: BuddyModelManifest = manifest; - if (workspacePath) { - manifest.workspaceModels ??= {}; - manifest.workspaceModels[workspacePath] ??= {}; - scopeManifest = manifest.workspaceModels[workspacePath]; - } - const previousProviders = scopeManifest.providersByAgent?.[agentKind] - ?? (!workspacePath && agentKind !== 'workbuddy' ? manifest.providers : undefined) - ?? {}; - const providers = { - ...(fullSnapshot ? {} : previousProviders), - ...Object.fromEntries(models.map((model) => [model.model_id, model.provider])), - }; - scopeManifest.providersByAgent = { - ...scopeManifest.providersByAgent, - [agentKind]: providers, - }; - if (agentKind === 'claude') { - await reconcileClaudeModels(models, manifest); - } else { - await reconcileBuddyModels(models, fullSnapshot, scopeManifest, agentKind, workspacePath); - } - await writeJsonAtomic(getModelManifestPath(), manifest); -} - -/** - * Tokenize a restricted `teamai` command string into an argv array. - * - * Supports single and double quotes so arguments containing spaces survive - * (e.g. `--name "a b"`). No variable expansion, no globbing; shell - * metacharacters like `;`, `|`, `&`, `$`, `(`, `)` are treated as literals. - * Throws when the string is empty, has an unterminated quote, or its first - * token is not exactly `teamai` — so a backend can never launch anything but - * a teamai subcommand. - */ -export function parseTeamaiCmd(raw: string): string[] { - const argv: string[] = []; - let current = ''; - let quote: '"' | "'" | null = null; - let hasToken = false; - for (const char of raw) { - if (quote) { - if (char === quote) { - quote = null; - } else { - current += char; - } - continue; - } - if (char === '"' || char === "'") { - quote = char; - hasToken = true; - continue; - } - if (char === ' ' || char === '\t' || char === '\n' || char === '\r') { - if (hasToken) { - argv.push(current); - current = ''; - hasToken = false; - } - continue; - } - current += char; - hasToken = true; - } - if (quote) { - throw new Error('Unterminated quote in cmd'); - } - if (hasToken) { - argv.push(current); - } - if (argv.length === 0) { - throw new Error('Empty cmd'); - } - if (argv[0] !== 'teamai') { - throw new Error(`Rejected cmd: only "teamai" subcommands are allowed, got "${argv[0]}"`); - } - return argv; -} - -/** - * Resolve the teamai entry script to run a pushed cmd. Prefers the current - * process entry (`process.argv[1]`) so the running teamai is reused, and - * falls back to resolving `dist/index.js` from this bundle when argv[1] is - * unavailable (some sandboxed hook launchers). Returns null when neither - * resolves. - */ -function resolveCmdEntry(): string | null { - const argvEntry = process.argv[1]; - if (argvEntry) { - return argvEntry; - } - return resolveTeamaiEntryScript(); -} - -/** - * Execute an `uninstall_teamai` command's `cmd` string pushed via sync. Runs a - * teamai subcommand once with the current Node binary (`process.execPath`) and - * the resolved entry script — no shell, so there is no metacharacter injection - * and no PATH dependency (works inside sandboxes with a bundled Node). The - * whole `process.env` is forwarded so bundled-node runtime variables survive. - * - * Throws (which the caller acks as `failed`) when remote cmd is disabled, the - * cmd is missing/rejected, the entry cannot be resolved, or the subprocess - * exits non-zero or times out. Returns undefined on success (no version to - * report for a cmd). - */ -async function runCmdCommand( - command: LocalAgentCommand, - context: LocalAgentContext, -): Promise { - if (process.env.TEAMAI_DISABLE_REMOTE_CMD === '1') { - throw new Error('remote cmd disabled by client'); - } - if (!command.cmd) { - throw new Error('cmd command is missing the "cmd" field'); - } - const argv = parseTeamaiCmd(command.cmd); - const entry = resolveCmdEntry(); - if (!entry) { - throw new Error('Cannot resolve teamai entry script to run cmd'); - } - const tag = localAgentTag(context); - try { - const { stdout } = await execFileAsync( - process.execPath, - [entry, ...argv.slice(1)], - { timeout: 120_000, env: process.env, maxBuffer: 4 * 1024 * 1024 }, - ); - const summary = stdout.trim().split('\n').slice(0, 3).join(' | '); - log.debug(`${tag} cmd OK: ${command.cmd}${summary ? ` — ${summary}` : ''}`); - return undefined; - } catch (e) { - const err = e as { stderr?: string; message?: string; killed?: boolean; code?: string }; - const detail = (err.stderr?.trim() || err.message || 'unknown error') - .split('\n') - .slice(0, 3) - .join(' | ') - .slice(0, 200); - // `killed` is also set on maxBuffer overflow, so disambiguate before labeling. - const prefix = err.code === 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER' - ? 'cmd output too large' - : err.killed - ? 'cmd timed out' - : 'cmd failed'; - throw new Error(`${prefix}: ${detail}`); - } -} - -/** - * Execute an install_hook_rule / uninstall_hook_rule sync command (issue #238): - * write or remove a single HTTP-source agent hook in the CURRENT tool's settings, - * tracked in the agent-hook manifest. Each tool family has its own hook format: - * claude/codex use settings.json, hermes uses config.yaml, openclaw-family uses - * HOOK.md + handler.ts. cursor is rejected. Throws on validation failure so - * the caller acks 'failed' with the message. - * - * Gated by the same TEAMAI_DISABLE_REMOTE_CMD kill-switch as runCmdCommand: an - * agent hook writes a backend-supplied command that the tool auto-runs on session - * events, so the client's single remote-command opt-out disables this surface too. - */ -async function runHookRuleCommand( - config: LocalAgentConfig, - command: LocalAgentCommand, - context: LocalAgentContext, -): Promise { - if (process.env.TEAMAI_DISABLE_REMOTE_CMD === '1') { - throw new Error('remote cmd disabled by client'); - } - const tool = context.tool; - if (!tool) { - throw new Error('install_hook_rule: missing current tool in context'); - } - if (!isAgentHookSupportedTool(tool)) { - throw new Error(`unsupported tool: ${tool}`); - } - const slug = command.slug; - if (!slug) { - throw new Error(`${command.type}: missing slug`); - } - const manifest = await loadAgentHookManifest(); - - if (command.type === 'uninstall_hook_rule') { - const rec = manifest[slug]; - if (rec) { - if (rec.tool === 'hermes') { - const { removeHermesAgentHook } = await import('./hermes-hooks.js'); - await removeHermesAgentHook({ slug, event: rec.event, command: rec.command }); - } else if (OPENCLAW_TOOLS.has(rec.tool)) { - const { removeOpenClawAgentHook } = await import('./openclaw-hooks.js'); - await removeOpenClawAgentHook({ slug, tool: rec.tool }); - } else if (rec.tool === 'opencode') { - const { removeOpencodeAgentHook } = await import('./opencode-hooks.js'); - await removeOpencodeAgentHook({ slug, baseDir: getUserHome(), scope: 'user' }); - } else if (rec.tool === 'pi') { - const { removePiAgentHook } = await import('./pi-hooks.js'); - await removePiAgentHook(slug); - } else { - const settingsPath = await resolveToolSettingsPath(config, rec.tool); - await removeAgentHook(settingsPath, rec.tool, { slug, command: rec.command }); - } - delete manifest[slug]; - await saveAgentHookManifest(manifest); - } - return undefined; - } - - // install_hook_rule - const event = command.event; - const cmd = command.cmd; - if (!event || !cmd) { - throw new Error('install_hook_rule: missing event or cmd'); - } - if (!isAgentHookEvent(event)) { - throw new Error(`unsupported event: ${event}`); - } - const timeout = command.timeout ?? 10; - const matcher = command.matcher; // may be undefined → applyAgentHook defaults to '*' - - // If this slug was previously installed, remove the old entry first so re-install - // never leaves a stale hook behind. This must run even when the tool is unchanged: - // applyAgentHook only replaces within the new event (claude) or by the new command - // (codex), so a same-tool re-install that changes the event or command would - // otherwise orphan the old entry. removeAgentHook scans all events by slug (claude) - // and matches prior.command (codex), covering both cases. - const prior = manifest[slug]; - if (prior) { - try { - if (prior.tool === 'hermes') { - const { removeHermesAgentHook } = await import('./hermes-hooks.js'); - await removeHermesAgentHook({ slug, event: prior.event, command: prior.command }); - } else if (OPENCLAW_TOOLS.has(prior.tool)) { - const { removeOpenClawAgentHook } = await import('./openclaw-hooks.js'); - await removeOpenClawAgentHook({ slug, tool: prior.tool }); - } else if (prior.tool === 'opencode') { - const { removeOpencodeAgentHook } = await import('./opencode-hooks.js'); - await removeOpencodeAgentHook({ slug, baseDir: getUserHome(), scope: 'user' }); - } else if (prior.tool === 'pi') { - const { removePiAgentHook } = await import('./pi-hooks.js'); - await removePiAgentHook(slug); - } else { - const priorPath = await resolveToolSettingsPath(config, prior.tool); - await removeAgentHook(priorPath, prior.tool, { slug, command: prior.command }); - } - } catch (e) { - log.debug(`agent hook [${slug}] prior cleanup failed: ${(e as Error).message}`); - } - } - - if (tool === 'hermes') { - const { applyHermesAgentHook } = await import('./hermes-hooks.js'); - await applyHermesAgentHook({ slug, event, command: cmd, matcher, timeout }); - } else if (OPENCLAW_TOOLS.has(tool)) { - const { applyOpenClawAgentHook } = await import('./openclaw-hooks.js'); - await applyOpenClawAgentHook({ slug, event, command: cmd, tool, matcher, timeout }); - } else if (tool === 'opencode') { - // OpenCode loads plugins from ~/.config/opencode/plugin (user scope). - const { applyOpencodeAgentHook } = await import('./opencode-hooks.js'); - await applyOpencodeAgentHook({ slug, event, command: cmd, baseDir: getUserHome(), scope: 'user', matcher }); - } else if (tool === 'pi') { - const { applyPiAgentHook } = await import('./pi-hooks.js'); - await applyPiAgentHook({ slug, event, command: cmd, matcher, timeout }); - } else { - const settingsPath = await resolveToolSettingsPath(config, tool); - await applyAgentHook(settingsPath, tool, { slug, event, command: cmd, matcher, timeout }); - } - manifest[slug] = { tool, event, command: cmd, matcher, timeout }; - await saveAgentHookManifest(manifest); - return undefined; -} - -// ─── MCP server install / uninstall (HTTP distribution) ───── - -const VALID_MCP_TRANSPORTS = new Set(['stdio', 'http', 'sse']); - -function mcpConfigToDef(slug: string, cfg: NonNullable): McpServerDef { - if (!VALID_MCP_TRANSPORTS.has(cfg.transport)) { - throw new Error(`install_mcp: unsupported transport "${cfg.transport}" for server "${slug}"`); - } - return { - name: slug, - transport: cfg.transport as McpTransport, - command: cfg.command, - args: cfg.args, - url: cfg.url, - headers: cfg.headers, - env: cfg.env, - timeout: cfg.timeout, - requires: cfg.requires, - }; -} - -function updateManifestRecord( - manifest: ManagedMcpManifest, - key: string, - name: string, - hash: string, -): void { - const records = manifest[key] ?? []; - const idx = records.findIndex((r: ManagedMcpRecord) => r.name === name); - if (idx >= 0) { - records[idx] = { name, hash }; - } else { - records.push({ name, hash }); - } - manifest[key] = records; -} - -async function installMcpServer( - config: LocalAgentConfig, - command: LocalAgentCommand, - tool: string, - slug: string, - scope: LocalAgentScope, - workspacePath?: string, -): Promise { - if (!command.mcp_config) { - throw new Error('install_mcp: missing mcp_config'); - } - - const def = mcpConfigToDef(slug, command.mcp_config); - const fullTeamConfig = createLocalAgentTeamConfig(config.endpoint); - // Resolved through the scope seam, so the user-scope MCP file follows a root - // the member relocated (`toolRoots`) the way `teamai pull` writes it. Project - // scope returns `mcpProject` unchanged — it belongs to the workspace. - const localConfig = await createResourceLocalConfig(config, scope, getUserHome(), workspacePath); - const toolPath = scopedToolPaths(fullTeamConfig, localConfig)[tool]; - if (!toolPath) { - throw new Error(`install_mcp: unknown tool "${tool}"`); - } - - const projectScope = scope === 'project'; - const mcpRel = projectScope ? toolPath.mcpProject : toolPath.mcp; - if (!mcpRel) { - throw new Error(`install_mcp: tool "${tool}" has no MCP config path for scope "${scope}"`); - } - - const format = detectMcpFormat(tool); - if (!format) { - throw new Error(`install_mcp: tool "${tool}" has no known MCP format`); - } - if (!supportsTransport(format, def.transport)) { - throw new Error(`install_mcp: tool "${tool}" does not support ${def.transport} transport`); - } - - const baseDir = resolveToolBaseDir(tool, localConfig); - const targetFile = path.join(baseDir, mcpRel); - - const { resolveDataHomeForScope } = await import('./config.js'); - const dataHome = await resolveDataHomeForScope(projectScope ? 'project' : 'user', projectScope ? workspacePath : undefined); - // Project scope uses THIS worktree's own manifest file (per-worktree under the - // partition; migrates legacy shared records on first read). User scope uses the - // single global file. The ownership key needs no workspace segment. - let manifestPath: string; - let manifest: ManagedMcpManifest; - if (projectScope && workspacePath) { - const { loadProjectMcpManifest } = await import('./utils/mcp-manifest.js'); - ({ manifestPath, manifest } = await loadProjectMcpManifest(dataHome, workspacePath)); - } else { - manifestPath = managedMcpManifestPath(dataHome); - manifest = (await readJson(manifestPath)) ?? {}; - } - const manifestKey = managedMcpManifestKey(tool, projectScope); - const owned = manifest[manifestKey] ?? []; - const ownedNames = new Set(owned.map((r: ManagedMcpRecord) => r.name)); - - if (format === 'codex') { - const block = renderCodexBlock(def); - const hash = entryHash(block); - let source = (await readFileSafe(targetFile)) ?? ''; - const present = new Set(codexServerNames(source)); - if (present.has(slug) && !ownedNames.has(slug)) { - throw new Error(`install_mcp: server "${slug}" exists in ${tool} config and is not managed by teamai`); - } - updateManifestRecord(manifest, manifestKey, slug, hash); - await writeJsonAtomic(manifestPath, manifest); - source = spliceCodexBlock(source, slug, block); - await writeCodexAtomic(targetFile, source); - } else { - const entry = renderJsonEntry(format, def); - const serverKey = MCP_SERVER_KEY[format]; - const hash = entryHash(entry); - const allowBare = format === 'copilot' && projectScope; - const doc = await readJsonDoc(targetFile, serverKey, allowBare); - if (!doc) { - throw new Error(`install_mcp: cannot parse ${targetFile}`); - } - if (doc.servers[slug] !== undefined && !ownedNames.has(slug)) { - throw new Error(`install_mcp: server "${slug}" exists in ${tool} config and is not managed by teamai`); - } - updateManifestRecord(manifest, manifestKey, slug, hash); - await writeJsonAtomic(manifestPath, manifest); - doc.servers[slug] = entry; - await writeJsonDoc(targetFile, serverKey, doc); - } - log.debug(`local-agent: installed MCP server "${slug}" for ${tool} (scope=${scope})`); - return command.version; -} - -async function uninstallMcpServer( - config: LocalAgentConfig, - tool: string, - slug: string, - scope: LocalAgentScope, - workspacePath?: string, -): Promise { - const fullTeamConfig = createLocalAgentTeamConfig(config.endpoint); - // Removal has to look where the install wrote: same scope seam, same root. - const localConfig = await createResourceLocalConfig(config, scope, getUserHome(), workspacePath); - const toolPath = scopedToolPaths(fullTeamConfig, localConfig)[tool]; - if (!toolPath) return; - - const projectScope = scope === 'project'; - const mcpRel = projectScope ? toolPath.mcpProject : toolPath.mcp; - if (!mcpRel) return; - - const format = detectMcpFormat(tool); - if (!format) return; - - const baseDir = resolveToolBaseDir(tool, localConfig); - const targetFile = path.join(baseDir, mcpRel); - - const { resolveDataHomeForScope } = await import('./config.js'); - const dataHome = await resolveDataHomeForScope(projectScope ? 'project' : 'user', projectScope ? workspacePath : undefined); - // Project scope uses THIS worktree's own manifest file (per-worktree under the - // partition; migrates legacy shared records on first read). User scope uses the - // single global file. The ownership key needs no workspace segment. - let manifestPath: string; - let manifest: ManagedMcpManifest; - if (projectScope && workspacePath) { - const { loadProjectMcpManifest } = await import('./utils/mcp-manifest.js'); - ({ manifestPath, manifest } = await loadProjectMcpManifest(dataHome, workspacePath)); - } else { - manifestPath = managedMcpManifestPath(dataHome); - manifest = (await readJson(manifestPath)) ?? {}; - } - const manifestKey = managedMcpManifestKey(tool, projectScope); - const owned = manifest[manifestKey] ?? []; - const ownedNames = new Set(owned.map((r: ManagedMcpRecord) => r.name)); - - if (!ownedNames.has(slug)) return; - - manifest[manifestKey] = owned.filter((r: ManagedMcpRecord) => r.name !== slug); - if ((manifest[manifestKey] as ManagedMcpRecord[]).length === 0) delete manifest[manifestKey]; - await writeJsonAtomic(manifestPath, manifest); - - if (format === 'codex') { - let source = (await readFileSafe(targetFile)) ?? ''; - source = spliceCodexBlock(source, slug, null); - await writeCodexAtomic(targetFile, source); - } else { - const serverKey = MCP_SERVER_KEY[format]; - const allowBare = format === 'copilot' && projectScope; - const doc = await readJsonDoc(targetFile, serverKey, allowBare); - if (doc && doc.servers[slug] !== undefined) { - delete doc.servers[slug]; - await writeJsonDoc(targetFile, serverKey, doc); - } - } - log.debug(`local-agent: uninstalled MCP server "${slug}" from ${tool} (scope=${scope})`); -} - -async function runMcpCommand( - config: LocalAgentConfig, - command: LocalAgentCommand, - context: LocalAgentContext, -): Promise { - const tool = context.tool; - if (!tool) { - throw new Error(`${command.type}: cannot determine current tool`); - } - const slug = command.slug; - if (!slug) { - throw new Error(`${command.type}: missing slug`); - } - assertSafeResourceName(slug); - - const scope = normalizeScope(command.scope); - const workspacePath = scope === 'project' - ? await resolveWorkspacePath(command.workspace_path ?? context.cwd) - : undefined; - if (scope === 'project' && !workspacePath) { - throw new Error(`${command.type}: workspace command is missing workspace_path`); - } - - if (command.type === 'install_mcp') { - return installMcpServer(config, command, tool, slug, scope, workspacePath); - } - - await uninstallMcpServer(config, tool, slug, scope, workspacePath); - return command.version; -} - -async function executeCommand( - config: LocalAgentConfig, - command: LocalAgentCommand, - context: LocalAgentContext, -): Promise { - if (command.type === 'apply_model_config') { - await applyModelConfig(config, command, context); - return; - } - // uninstall_teamai (clawpro three-phase: cmd = "teamai uninstall --force - // --agent ") executes its `cmd` string as a restricted teamai subcommand. - if (command.type === 'uninstall_teamai') { - return runCmdCommand(command, context); - } - if (command.type === 'install_hook_rule' || command.type === 'uninstall_hook_rule') { - return runHookRuleCommand(config, command, context); - } - if (command.type === 'install_mcp' || command.type === 'uninstall_mcp') { - return runMcpCommand(config, command, context); - } - const kind = commandKind(command); - const action = commandAction(command); - if (!kind || !action) { - throw new Error(`Unsupported command type: ${command.type ?? ''}`); - } - - const scope = normalizeScope(command.scope); - const workspacePath = scope === 'project' - ? await resolveWorkspacePath(command.workspace_path ?? context.cwd) - : undefined; - if (scope === 'project' && !workspacePath) { - throw new Error('Project command is missing workspace_path'); - } - - const slug = commandSlug(command, kind); - const tool = context.tool; - if (action === 'install') { - return installDownloadedResource({ config, command, kind, slug, scope, workspacePath, tool }); - } - - await uninstallResource({ config, kind, slug, scope, workspacePath, tool }); - return commandVersion(command, kind); -} - -async function processCommands( - config: LocalAgentConfig, - commands: LocalAgentCommand[], - context: LocalAgentContext, -): Promise { - const tag = localAgentTag(context); - let modelConfigApplied = false; - for (const command of commands) { - // Keep these special types aligned with executeCommand's direct branches. - // Resource commands are recognized generically by commandKind/action; - // everything else is a future protocol extension and must be skipped. - if (isUnimplementedCommand(command) || ( - command.type !== 'apply_model_config' && - command.type !== 'uninstall_teamai' && - command.type !== 'install_hook_rule' && - command.type !== 'uninstall_hook_rule' && - command.type !== 'install_mcp' && - command.type !== 'uninstall_mcp' && - (!commandKind(command) || !commandAction(command)) - )) { - log.debug(`${tag} skipping unimplemented command ${command.id} (${command.type})`); - continue; - } - try { - const version = await executeCommand(config, command, context); - await ackCommand(config, tag, command, 'success', version); - if (command.type === 'apply_model_config') modelConfigApplied = true; - log.debug(`${tag} command ${command.id} (${command.type ?? ''}) succeeded`); - // Uninstall succeeded — skip remaining commands; the hook process exits naturally. - if (command.type === 'uninstall_teamai') { - log.debug(`${tag} uninstall_teamai completed — remaining commands skipped`); - return modelConfigApplied; - } - } catch (e) { - const error = (e as Error).message; - log.error(`${tag} command ${command.id} failed: ${error}`); - try { - await ackCommand(config, tag, command, 'failed', undefined, error); - } catch (ackError) { - log.debug(`${tag} failed to ack command ${command.id}: ${(ackError as Error).message}`); - } - } - } - return modelConfigApplied; -} - -export async function reportAndSyncLocalAgent(context: LocalAgentContext): Promise { - const config = await loadLocalAgentConfig(); - if (!config) return false; - - // Binding prompt is injected via stdout hook context (not HTTP), so it must run - // even inside the CloudStudio sandbox — the sandbox guard below only skips the - // HTTP report/sync that would produce a duplicate card. Resolve the workspace - // only when the prompt is enabled AND the host is a buddy agent, so every other - // path (disabled flag, or a non-buddy tool like Claude/Cursor/Codex) forks no - // git process. - if (isBindPromptEnabled() && isBindPromptTool(context.tool)) { - const workspacePath = await resolveWorkspacePath(context.cwd); - if (workspacePath) { - const sid = context.event?.sessionId; - if (context.event?.type === 'session_start') { - await ensureWorkspaceBinding(config, workspacePath, sid, context.cwd); - } - if (context.event?.type === 'prompt_submit') { - await emitBindingHint(config, workspacePath, sid, context.cwd); - } - } - } - - // CloudStudio sandbox reports a duplicate agent card (different machine id - // than the host), so we skip the report POST here. Sync + command execution - // must still run so sandboxed agents can receive pushed cmds (e.g. uninstall); - // sync produces no card, so there is no duplicate risk. - // TEAMAI_ALLOW_SANDBOX_REPORT=1 restores the report too (backward compatible). - const skipReport = isCloudStudioSandbox() && process.env.TEAMAI_ALLOW_SANDBOX_REPORT !== '1'; - if (skipReport) { - log.debug( - '[local-agent] CloudStudio sandbox detected; skipping HTTP report ' + - '(sync still runs; set TEAMAI_ALLOW_SANDBOX_REPORT=1 to report too)', - ); - } - - const tag = localAgentTag(context); - log.debug(`${tag} run: endpoint=${config.endpoint}`); - - // Report-side bookkeeping (plugin reconcile + binding prune + tool stamp) is - // tied to the report path and must stay skipped inside the CloudStudio sandbox, - // exactly as before this branch stopped returning early. In particular, - // pruneDeadWorkspaceBindings would wrongly drop host bindings whose paths are - // not mounted in the container. Only sync + command execution run when - // skipReport is set. - if (!skipReport) { - if (context.event?.type === 'session_start') { - await maybeReconcilePlugins(context); - } - - const pruned = await pruneDeadWorkspaceBindings(config); - // Resolve the current workspace independently here rather than reusing an - // earlier local, so tool attribution does not depend on the binding-prompt - // block above keeping a `workspacePath` in scope. - const currentPath = await resolveWorkspacePath(context.cwd); - const stamped = stampWorkspaceTool(config, currentPath, context.tool ?? 'workbuddy'); - if (pruned || stamped) { - await saveLocalAgentConfig(config); - } - } - - try { - if (!skipReport) { - const reportPayload = await buildReportPayload(config, context); - await localAgentFetch(config, tag, 'report', { - method: 'POST', - body: JSON.stringify(reportPayload), - }); - log.debug(`${tag} report OK`); - } - - const syncPayload = await buildSyncPayload(config, context); - const syncResponse = await localAgentFetch<{ - ok?: boolean; - cmds?: LocalAgentCommand[]; - commands?: LocalAgentCommand[]; - }>( - config, - tag, - 'sync', - { method: 'POST', body: JSON.stringify(syncPayload) }, - { redactResponseLog: true }, - ); - // Prefer the unified cmds[] (source of truth). Fall back to the legacy - // commands[] for older backends that do not yet emit cmds. An empty cmds[] - // is treated as "cmds not available" and falls back too — the backend sends - // identical data in both arrays, so this only affects old backends where - // cmds is genuinely absent/empty while commands still carries the work. - // TODO(jiahe, cmds-migration): drop the `commands` fallback once the backend - // guarantees `cmds` on all sync responses (clawpro iwiki ch.7). - const cmds = syncResponse.cmds; - const commands = cmds && cmds.length > 0 ? cmds : (syncResponse.commands ?? []); - if (commands.length > 0) { - log.debug(`${tag} sync returned ${commands.length} command(s): ${commands.map((c) => `${c.type}#${c.id}`).join(', ')}`); - const modelConfigApplied = await processCommands(config, commands, context); - if (modelConfigApplied && !skipReport) { - const reportPayload = await buildReportPayload(config, context); - await localAgentFetch(config, tag, 'report', { - method: 'POST', - body: JSON.stringify(reportPayload), - }); - log.debug(`${tag} model config report OK`); - } - } - log.debug(`${tag} sync OK (${commands.length} command(s))`); - } catch (e) { - const error = (e as Error).message; - log.error(`${tag} sync FAILED: ${error}`); - await appendErrorLog({ error, context }); - } - - return true; -} - -function statusFromEvent(event?: DashboardEvent): string { - if (!event) return 'running'; - if (event.type === 'stop' || event.type === 'process_exit') return 'stopped'; - return 'running'; -} - -/** - * Hook-handler adapter: run local-agent report/sync (incl. workspace binding - * prompts) from within the unified hook dispatcher. Accepts pre-parsed STDIN - * data so the dispatcher reads STDIN only once. - */ -export async function reportAndSyncFromHook( - stdin: Record, - tool: string, -): Promise { - const raw = JSON.stringify(stdin); - const event = await parseHookEvent(raw, tool); - // parseHookEvent resolves cwd via resolveHookCwd too, so event?.cwd would be - // identical here — resolve once and fall back to process.cwd(). - const cwd = resolveHookCwd(stdin) ?? process.cwd(); - - // SessionStart and UserPromptSubmit run this handler in the *foreground*, where - // it blocks the host IDE's hook (UserPromptSubmit cap = 10s). Narrow the - // per-fetch timeout so a slow/unreachable endpoint fails fast and the handler - // returns before the host aborts the hook. Stop / PostToolUse run detached in - // the background, so they keep the full interactive timeout to complete real - // resource syncs/downloads. - const isForegroundEvent = event?.type === 'session_start' || event?.type === 'prompt_submit'; - activeFetchTimeoutMs = isForegroundEvent - ? LOCAL_AGENT_HOOK_FETCH_TIMEOUT_MS - : LOCAL_AGENT_FETCH_TIMEOUT_MS; - try { - await reportAndSyncLocalAgent({ - cwd, - tool, - status: statusFromEvent(event ?? undefined), - event: event ?? undefined, - }); - return null; - } finally { - activeFetchTimeoutMs = LOCAL_AGENT_FETCH_TIMEOUT_MS; - } -} - -/** - * Persist the API token as a credential file with owner-only (0o600) - * permissions. chmod after write so an already-existing token file (whose perms - * mode-on-create would not touch) is also tightened. - */ -export async function writeTokenFile(tokenPath: string, token: string): Promise { - await fs.promises.writeFile(tokenPath, token + '\n', { mode: 0o600 }); - await fs.promises.chmod(tokenPath, 0o600); -} - -export async function initLocalAgentHttp(options: { - endpoint: string; - token?: string; - force?: boolean; - filterAgents?: string[]; -}): Promise { - const endpoint = normalizeEndpoint(options.endpoint); - if (!endpoint) { - throw new Error('HTTP endpoint is required.'); - } - - const existing = await loadLocalAgentConfig(); - if (existing && !options.force) { - throw new Error('HTTP local agent is already initialized. Re-run with --force to overwrite.'); - } - - const config: LocalAgentConfig = { - endpoint, - token: options.token, - createdAt: existing?.createdAt ?? new Date().toISOString(), - workspaceBindings: existing?.workspaceBindings ?? {}, - userGroupId: existing?.userGroupId, - userGroupName: existing?.userGroupName, - }; - - await ensureDir(getLocalAgentHome()); - await saveLocalAgentConfig(config); - if (options.token) { - await writeTokenFile(getTokenPath(), options.token); - } - - const teamConfig = createLocalAgentTeamConfig(endpoint); - // The local agent is always user-scope and always rooted at HOME, so resolve - // the user-scope paths (Qoder CN's user config lives under ~/.qoder-cn). - await injectHooksToAllTools( - scopedToolPaths(teamConfig, { scope: 'user', toolRoots: await memberToolRoots() }), - getUserHome(), - options.filterAgents, - ); - log.success(`HTTP local agent initialized at ${getConfigPath()}`); -} - -export async function pullLocalAgentForCwd(context?: LocalAgentContext): Promise { - return reportAndSyncLocalAgent({ - cwd: context?.cwd ?? process.cwd(), - tool: context?.tool ?? 'workbuddy', - status: context?.status ?? 'running', - event: context?.event, - }); -} - -/** Summary of the configured HTTP local-agent bypass, for `teamai source list`. */ -export interface LocalAgentSummary { - endpoint: string; - boundProjects: Array<{ path: string; projectName?: string; projectId: number }>; - resourceCounts: { skills: number; rules: number; claudemd: number }; -} - -/** - * Describe the configured HTTP local-agent bypass (report/sync/ack), or null when - * none is configured. Used by `teamai source list` to show the HTTP side channel - * alongside git cross-team sources. - */ -export async function describeLocalAgent(): Promise { - const config = await loadLocalAgentConfig(); - if (!config) return null; - - const boundProjects = Object.entries(config.workspaceBindings) - .filter(([, b]) => b.projectId !== 0) - .map(([workspacePath, b]) => ({ path: workspacePath, projectName: b.projectName, projectId: b.projectId })); - - const manifest = await loadManifest(); - const counts = { skills: 0, rules: 0, claudemd: 0 }; - for (const scope of Object.values(manifest.scopes)) { - counts.skills += Object.keys(scope.skills ?? {}).length; - counts.rules += Object.keys(scope.rules ?? {}).length; - counts.claudemd += Object.keys(scope.claudemd ?? {}).length; - } - - return { endpoint: config.endpoint, boundProjects, resourceCounts: counts }; -} - -/** Parse a manifest scope key back into (scope, workspacePath). */ -function parseScopeKey(key: string): { scope: LocalAgentScope; workspacePath?: string } { - if (key.startsWith('project:')) { - return { scope: 'project', workspacePath: key.slice('project:'.length) || undefined }; - } - return { scope: key === 'instance' ? 'instance' : 'user' }; -} - -/** - * Run each installed plugin's uninstall_cmd (stop daemons, deregister autostart, - * remove packages) using the persisted plugin manifest. No-op when no HTTP source - * is configured. - * - * Best-effort: failures are logged, never thrown, so teardown of the rest of teamai - * is never blocked. Must run BEFORE ~/.teamai is deleted — it reads the plugin - * manifest and endpoint config from ~/.teamai/local-agent/. - */ -export async function teardownLocalAgentPlugins(): Promise { - try { - const config = await loadLocalAgentConfig(); - if (!config) return; - await teardownAllPlugins(buildReconcileDeps(config, '[local-agent] [uninstall]')); - } catch (e) { - log.warn(`[local-agent] plugin teardown failed: ${e instanceof Error ? e.message : String(e)}`); - } -} - -/** - * Remove every HTTP-source agent hook recorded in the agent-hook manifest from - * each tool's settings, then clear the manifest. Best-effort; used by - * `source remove-http` and `teamai uninstall` teardown (issue #238). Safe to call - * when no config / no manifest exists. - */ -export async function removeAllAgentHooks(): Promise { - const config = await loadLocalAgentConfig(); - if (!config) return; - const manifest = await loadAgentHookManifest(); - const slugs = Object.keys(manifest); - if (slugs.length === 0) return; - for (const slug of slugs) { - const rec = manifest[slug]; - try { - if (rec.tool === 'hermes') { - const { removeHermesAgentHook } = await import('./hermes-hooks.js'); - await removeHermesAgentHook({ slug, event: rec.event, command: rec.command }); - } else if (OPENCLAW_TOOLS.has(rec.tool)) { - const { removeOpenClawAgentHook } = await import('./openclaw-hooks.js'); - await removeOpenClawAgentHook({ slug, tool: rec.tool }); - } else if (rec.tool === 'opencode') { - const { removeOpencodeAgentHook } = await import('./opencode-hooks.js'); - await removeOpencodeAgentHook({ slug, baseDir: getUserHome(), scope: 'user' }); - } else if (rec.tool === 'pi') { - const { removePiAgentHook } = await import('./pi-hooks.js'); - await removePiAgentHook(slug); - } else { - const settingsPath = await resolveToolSettingsPath(config, rec.tool); - await removeAgentHook(settingsPath, rec.tool, { slug, command: rec.command }); - } - } catch (e) { - log.debug(`agent hook [${slug}] teardown failed: ${(e as Error).message}`); - } - } - await saveAgentHookManifest({}); -} - -/** - * Tear down the HTTP local-agent bypass: uninstall every resource recorded in the - * manifest (skills/rules/claudemd, across all scopes) from the AI tool dirs, then - * remove the whole ~/.teamai/local-agent/ directory (config + manifest). - * - * Best-effort per resource: a single failed uninstall is logged and skipped so a - * stale entry cannot block the teardown. - */ -export async function removeLocalAgentHttp(): Promise { - const config = await loadLocalAgentConfig(); - if (!config) { - log.info('No HTTP source configured — nothing to remove.'); - return; - } - - // Tear down installed plugins before removing teamai's local-agent state. - try { - await teardownAllPlugins(buildReconcileDeps(config, '[local-agent] [uninstall]')); - } catch (e) { log.warn(`[local-agent] plugin teardown failed: ${(e as Error).message}`); } - - const kinds: CommandResourceKind[] = ['skill', 'rule', 'claudemd']; - const manifest = await loadManifest(); - for (const [key, scopeManifest] of Object.entries(manifest.scopes)) { - const { scope, workspacePath } = parseScopeKey(key); - for (const kind of kinds) { - for (const slug of Object.keys(scopeManifest[manifestKind(kind)] ?? {})) { - try { - await uninstallResource({ config, kind, slug, scope, workspacePath }); - } catch (e) { - log.debug(`local-agent: failed to uninstall ${kind} "${slug}": ${(e as Error).message}`); - } - } - } - } - - await removeAllAgentHooks(); - await remove(getLocalAgentHome()); - log.success('HTTP source removed (resources uninstalled, config cleared).'); -} - -export async function bindCurrentProject(options?: { projectId?: number; skip?: boolean; cwd?: string }): Promise { - const workspacePath = await resolveWorkspacePath(options?.cwd ?? process.cwd()); - if (!workspacePath) { - throw new Error('Cannot resolve current workspace path.'); - } - if (options?.skip) { - const config = await loadLocalAgentConfig(); - if (!config) { - throw new Error('Local agent not initialized. Run `teamai init --http` first.'); - } - // Skip the whole project (main checkout + all its worktrees), not just this - // one checkout, so sibling worktrees are not re-prompted. - await persistWorkspaceBinding(config, options?.cwd ?? process.cwd(), workspacePath, 0, '__skipped__'); - log.info(`已跳过绑定,以后不再提示此工作区。`); - return; - } - const binding = await bindWorkspaceToProject(workspacePath, options?.projectId); - if (!binding) { - log.info('未绑定项目。'); - } -} +export * from './providers/http/adapters/clawpro/client.js'; diff --git a/src/plugin-lifecycle.ts b/src/plugin-lifecycle.ts index b91f03e13..4d99ce1bc 100644 --- a/src/plugin-lifecycle.ts +++ b/src/plugin-lifecycle.ts @@ -214,10 +214,14 @@ export async function reconcilePlugins( * Tear down all locally-tracked plugins. * * Called when uninstalling teamai itself. Each plugin is handled independently; - * a failure only warns and continues. + * a failure only warns and continues. Returns true when every plugin was torn + * down, false when any uninstall_cmd failed — so a caller removing a provider + * can keep its state (plugins.json) for a retry instead of deleting the record + * of a plugin still on the system (issue #404, review #4). */ -export async function teardownAllPlugins(deps: ReconcileDeps): Promise { +export async function teardownAllPlugins(deps: ReconcileDeps): Promise { const plugins = await deps.readPlugins(); + let allTornDown = true; for (const [slug, state] of Object.entries(plugins)) { try { deps.log.debug(`plugin ${slug}: tearing down`); @@ -227,9 +231,11 @@ export async function teardownAllPlugins(deps: ReconcileDeps): Promise { }); deps.log.debug(`plugin ${slug}: torn down`); } catch (err) { + allTornDown = false; deps.log.warn(`plugin ${slug} teardown failed: ${(err as Error).message}`); } } + return allTornDown; } const PLUGIN_CMD_FIELDS = new Set([ diff --git a/src/provider-command.ts b/src/provider-command.ts new file mode 100644 index 000000000..f61a5f576 --- /dev/null +++ b/src/provider-command.ts @@ -0,0 +1,304 @@ +// ─── `teamai provider` commands ────────────────────────── +// +// Manage named HTTP resource providers (issue #404, phase 2). Git provider +// management (`provider add git`, `set-primary`) and cross-provider write-target +// selection belong to a later phase and are intentionally not exposed here. + +import path from 'node:path'; +import { log } from './utils/logger.js'; +import { getUserHome } from './utils/home.js'; +import { + listHttpProviderConfigs, + getHttpProviderConfig, + readHttpProviderHomeConfig, + upsertHttpProviderConfig, + writeHttpProviderHomeConfig, + removeHttpProviderConfig, + removeHttpProviderState, + migrateLegacyHttpProvider, + legacySingletonActive, + assertValidProviderName, +} from './providers/http/store.js'; +import { + availableHttpAdapters, + getHttpAdapter, + createHttpResourceProvider, +} from './providers/http/registry.js'; +import { syncResourceProviders } from './providers/resource-registry.js'; +import type { HttpProviderConfig } from './providers/types.js'; + +/** + * Default priority stamped on a provider's config. Reserved for the later + * multi-provider arbitration phase; with a single provider it has no effect and + * is deliberately not exposed as a CLI flag (issue #404, review P2). + */ +const DEFAULT_PROVIDER_PRIORITY = 50; + +/** + * Run `fn` while holding the machine-level provider lock, so the single-provider + * check-and-write in `provider add` and `migrate-legacy` cannot interleave and + * both pass the empty/one-provider gate (review #6/P3). Callers still enforce + * the gate; the lock only makes the check-then-act atomic across processes. + */ +async function withProviderLock(fn: () => Promise): Promise { + const { acquireLock, releaseLock } = await import('./update.js'); + const lockPath = path.join(getUserHome(), '.teamai', 'providers', '.add.lock'); + if (!(await acquireLock(lockPath))) { + log.error('Another `teamai provider` operation is in progress. Try again in a moment.'); + process.exit(1); + } + try { + return await fn(); + } finally { + await releaseLock(lockPath); + } +} + +interface AddHttpOptions { + name: string; + adapter?: string; + token?: string; +} + +/** `teamai provider add http --name --adapter --token` */ +export async function providerAddHttp(endpoint: string, opts: AddHttpOptions): Promise { + if (!opts.name) { + log.error('A provider name is required: --name '); + process.exit(1); + } + // Validate the name here (not just deep in the store) so an invalid name is a + // clean error + exit, never an uncaught stack trace. + try { + assertValidProviderName(opts.name); + } catch (e) { + log.error((e as Error).message); + process.exit(1); + } + + const adapter = opts.adapter ?? 'clawpro'; + // Fail early on an unknown adapter rather than after persisting config. + if (!availableHttpAdapters().includes(adapter)) { + log.error(`Unknown HTTP adapter "${adapter}". Available: ${availableHttpAdapters().join(', ')}`); + process.exit(1); + } + + const config: HttpProviderConfig = { + name: opts.name, + adapter, + endpoint: endpoint.trim().replace(/\/+$/, ''), + // priority is a data-model field reserved for the later arbitration phase; + // with a single provider it has no effect, so it is not user-configurable. + priority: DEFAULT_PROVIDER_PRIORITY, + }; + + // Serialize the whole check-and-write under a machine-level lock so two + // concurrent `provider add` / `migrate-legacy` runs cannot both see an empty + // registry and each create a provider (the single-provider gate below is + // otherwise a racy check-then-act). The lock also covers init + publish so a + // rollback cannot interleave with another add. + await withProviderLock(async () => { + if (await getHttpProviderConfig(opts.name)) { + log.error(`Provider "${opts.name}" already exists. Remove it first or choose another name.`); + process.exit(1); + } + + // Single-provider gate (issue #404, phase 2). Running two HTTP providers + // concurrently is unsafe until the ownership ledger (phase 4) arbitrates + // same-name resources across providers — otherwise one provider's uninstall + // deletes files another provider installed, and serial hook sync can exceed + // the foreground budget. Until then, allow exactly one HTTP provider (plus + // the legacy singleton, which double-track dispatch already handles). + const existing = await listHttpProviderConfigs(); + if (existing.length > 0) { + log.error( + `An HTTP provider ("${existing[0].name}") is already configured. Multiple HTTP ` + + 'providers need cross-provider ownership arbitration (issue #404 phase 4) and ' + + 'are not supported yet. Remove the existing one with `teamai provider remove ' + + `${existing[0].name}\` first.`, + ); + process.exit(1); + } + if (await legacySingletonActive()) { + log.error( + 'A legacy HTTP local agent is already configured. Migrate it with ' + + '`teamai provider migrate-legacy --name ` instead of adding a second ' + + 'HTTP provider (multiple providers need issue #404 phase 4).', + ); + process.exit(1); + } + + // Initialize the backend BEFORE publishing the registry record, so a failed + // init (bad token, unwritable dir, hook injection failure) never leaves a + // registered-but-broken provider that later hook dispatches keep loading. + // Publish the registry record only after init succeeds; on any failure run a + // FULL teardown so nothing init already did — including the hooks it injected + // into the tools' settings — is left behind, then start clean on retry. + const backend = getHttpAdapter(adapter); + try { + if (backend.initialize) { + await backend.initialize(config, opts.token); + } + await upsertHttpProviderConfig(config); + } catch (e) { + // Roll back what init wrote. Only delete the provider state when teardown + // fully succeeded — if teardown could not remove everything (e.g. an + // injected hook is locked), KEEP the state + manifest so the leftover can + // be cleaned up on a retry rather than orphaned with its ownership record + // destroyed (issue #404, review #5). teardown throws on partial failure. + let teardownOk = true; + try { + await backend.teardown(config); + } catch (teardownErr) { + teardownOk = false; + log.warn(`Rollback teardown for "${config.name}" hit an error: ${(teardownErr as Error).message}`); + } + if (teardownOk) { + await removeHttpProviderState(config.name); + await removeHttpProviderConfig(config.name); + } else { + // Drop the registry entry so hook dispatch won't load a broken provider, + // but keep the state home for a retriable `provider remove`. Persist the + // self-describing provider.json into the home so `provider remove` can + // recover this config even without a registry entry (review #3) — init + // may have failed before upsert wrote it. + await removeHttpProviderConfig(config.name); + await writeHttpProviderHomeConfig(config); + log.warn( + `Kept partial state for "${config.name}" (teardown incomplete); ` + + `run \`teamai provider remove ${config.name}\` after resolving the issue.`, + ); + } + log.error(`Failed to add provider "${config.name}": ${(e as Error).message}`); + process.exit(1); + } + }); + + log.success(`Added HTTP provider "${config.name}" (${config.adapter}) → ${config.endpoint}`); +} + +/** `teamai provider list` */ +export async function providerList(): Promise { + const configs = await listHttpProviderConfigs(); + if (configs.length === 0) { + log.info('No HTTP providers configured. Add one with `teamai provider add http --name `.'); + return; + } + log.info('HTTP providers:'); + for (const c of [...configs].sort((a, b) => b.priority - a.priority || a.name.localeCompare(b.name))) { + log.info(` ${c.name} [${c.adapter}] priority=${c.priority}`); + log.info(` ${c.endpoint}`); + } +} + +/** `teamai provider sync` */ +export async function providerSync(): Promise { + const configs = await listHttpProviderConfigs(); + if (configs.length === 0) { + log.info('No HTTP providers configured.'); + return; + } + const providers = configs.map(createHttpResourceProvider); + const results = await syncResourceProviders(providers, { trigger: 'manual' }); + for (const r of results) { + if (r.ok) log.success(` ${r.provider}: ok${r.changed ? ' (changed)' : ''}`); + else log.error(` ${r.provider}: ${r.message ?? 'failed'}`); + } + // Exit non-zero if any provider failed, so scripts/CI can detect it. + if (results.some((r) => !r.ok)) process.exit(1); +} + +/** `teamai provider remove ` */ +export async function providerRemove(name: string): Promise { + await withProviderLock(async () => { + // Resolve case-insensitively and then use the provider's OWN canonical name + // for every subsequent op. On a case-insensitive filesystem `Foo` and `foo` + // share a state dir, so acting on the raw input would delete one provider's + // state while leaving the other's registry record (review #3). + const registered = (await listHttpProviderConfigs()).find( + (p) => p.name === name || p.name.toLowerCase() === name.toLowerCase(), + ); + // A failed/interrupted `provider add` may have left a state home whose + // registry record was dropped so dispatch would not load a broken provider. + // Recover its config from its own home so cleanup can still finish. + const config = registered ?? (await readHttpProviderHomeConfig(name)); + if (!config) { + log.error(`No HTTP provider named "${name}".`); + process.exit(1); + } + const canonical = config.name; + + // Deactivate FIRST (drop the registry entry) so a concurrent session hook + // cannot re-install resources during teardown, and so a later write failure + // can never leave the registry pointing at deleted state (review #4). The + // state home is preserved until teardown confirms a clean removal. + await removeHttpProviderConfig(canonical); + + const provider = createHttpResourceProvider(config); + try { + await provider.teardown(); + } catch (e) { + // Registry entry is already gone (dispatch won't load it); keep the state + // home so cleanup can be retried once the issue is resolved. + log.error( + `Could not fully remove provider "${canonical}": ${(e as Error).message} ` + + `Kept its state for a retry — re-run \`teamai provider remove ${canonical}\` ` + + 'after resolving the issue.', + ); + process.exit(1); + } + await removeHttpProviderState(canonical); + log.success(`Removed HTTP provider "${canonical}".`); + }); +} + +interface MigrateLegacyOptions { + name: string; +} + +/** `teamai provider migrate-legacy --name` */ +export async function providerMigrateLegacy(opts: MigrateLegacyOptions): Promise { + if (!opts.name) { + log.error('A provider name is required: --name '); + process.exit(1); + } + try { + assertValidProviderName(opts.name); + } catch (e) { + log.error((e as Error).message); + process.exit(1); + } + // Hold the same lock as `provider add` so the gate below and the migration + // cannot interleave with a concurrent add/migrate (review P3). + const config = await withProviderLock(async () => { + // Single-provider gate (issue #404 phase 2): a legacy migration must not + // create a SECOND provider alongside an existing one. Only a provider with a + // DIFFERENT name is a foreign second provider — an entry under this same + // target name is either an already-finished migration (idempotent re-run) or + // one interrupted after the registry write but before the marker, both of + // which the store function resolves. Gating on "any entry" here would break + // that idempotency/resume (a re-run would error instead of no-op), so only + // reject a differently-named provider. + const foreign = (await listHttpProviderConfigs()).filter((p) => p.name !== opts.name); + if (foreign.length > 0) { + log.error( + `A named HTTP provider ("${foreign[0].name}") already exists; migrating the legacy ` + + 'singleton would create a second one, which is not supported yet (issue #404 phase 4).', + ); + process.exit(1); + } + return migrateLegacyHttpProvider({ name: opts.name }); + }); + if (!config) { + log.info('No legacy HTTP local agent to migrate (or it was already migrated).'); + return; + } + log.success( + `Migrated legacy HTTP local agent to provider "${config.name}" ` + + '(the old ~/.teamai/local-agent/ has been removed).', + ); +} + +/** Adapter names this build supports, for CLI help. */ +export function providerAdapters(): string[] { + return availableHttpAdapters(); +} diff --git a/src/providers/http/adapters/clawpro/client.ts b/src/providers/http/adapters/clawpro/client.ts new file mode 100644 index 000000000..c6f13beb0 --- /dev/null +++ b/src/providers/http/adapters/clawpro/client.ts @@ -0,0 +1,3743 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; +import { AsyncLocalStorage } from 'node:async_hooks'; +import fse from 'fs-extra'; +import { log } from '../../../../utils/logger.js'; +import { detachChild } from '../../../../utils/exec.js'; +import { parseFrontmatter } from '../../../../utils/frontmatter.js'; +import { + ensureDir, + listDirs, + listFilesRecursive, + pathExists, + readFileSafe, + readJson, + remove, + writeFile, + writeJson, + writeJsonAtomic, +} from '../../../../utils/fs.js'; +import { isToolInstalledForConfig, ResourceHandler } from '../../../../resources/base.js'; +import { RulesHandler, SkillsHandler } from '../../../../resources/index.js'; +import { injectHooksToAllTools, reconcileHooksToAllTools, applyAgentHook, removeAgentHook, isAgentHookSupportedTool, isAgentHookEvent, OPENCLAW_TOOLS } from '../../../../hooks.js'; +import { parseHookEvent } from '../../../../dashboard-collector.js'; +import { resolveHookCwd } from '../../../../utils/hook-cwd.js'; +import { isInteractive } from '../../../../utils/prompt.js'; +import { getAgentVersion } from '../../../../agent-version.js'; +import { getMachineId, deriveLocalAgentId } from '../../../../machine-id.js'; +import { EXCLUDED_RULE_NAMES } from '../../../../builtin-rules.js'; +import { ruleStemFromFilename } from '../../../../resources/rule-format.js'; +import { resolveTeamaiEntryScript } from '../../../../builtin-hooks.js'; +import { resolveOpenclawWorkspaceDir } from '../../../../openclaw-hooks.js'; +import { assertSafeResourceName } from '../../../../utils/path-safety.js'; +import { + detectMcpFormat, + supportsTransport, + renderJsonEntry, + renderCodexBlock, + entryHash, + MCP_SERVER_KEY, +} from '../../../../resources/mcp-format.js'; +import { + readJsonDoc, + writeJsonDoc, + writeCodexAtomic, + spliceCodexBlock, + codexServerNames, +} from '../../../../mcp-reconcile.js'; +import { normalizeAgentType } from '../../../../utils/tool-names.js'; +import { logHttpRequest, logHttpResponse } from '../../../../utils/http-log.js'; +import { injectClaudeMdSection, removeClaudeMdSection } from '../../../../utils/claudemd.js'; +import { reconcilePlugins, teardownAllPlugins, parseGetConfig, substituteVars, unresolvedPlaceholders, type ReconcileDeps, type PluginState } from '../../../../plugin-lifecycle.js'; +import { + resolveBaseDir, + resolveToolBaseDir, + scopedToolPaths, + applyToolRoots, + resolveToolRootDir, + CLAUDE_TOOL_ID, + DEFAULT_CLAUDE_ROOT, + COPILOT_TOOL_ID, + getTokenPath, + getManagedHooksPath, + TEAMAI_CLAUDEMD_START, + TEAMAI_CLAUDEMD_END, + TeamaiConfigSchema, + managedMcpManifestPath, + managedMcpManifestKey, + managedMcpWorkspaceId, + type DashboardEvent, + type LocalConfig, + type ManagedMcpManifest, + type ManagedMcpRecord, + type McpServerDef, + type McpTransport, + type Scope, + type TeamaiConfig, +} from '../../../../types.js'; +import { getUserHome } from '../../../../utils/home.js'; +import { resolveAnchors } from '../../../../utils/git.js'; + +const execFileAsync = promisify(execFile); + +const LOCAL_AGENT_DIR = 'local-agent'; +const CONFIG_FILE = 'config.json'; +const MANIFEST_FILE = 'manifest.json'; +const MODEL_MANIFEST_FILE = 'model-manifest.json'; +const REPORTER_ERROR_LOG = 'reporter/errors.jsonl'; + +/** + * Abort timeout for local-agent network calls. + * + * Prevents a fetch from hanging indefinitely when the endpoint is unreachable, + * which would otherwise keep a socket pending on the event loop and stall the + * hook subprocess until the host IDE's default hook timeout fires. + */ +const LOCAL_AGENT_FETCH_TIMEOUT_MS = 15_000; + +/** + * Per-fetch timeout to use while running inside a *foreground* hook. Foreground + * hooks block the host IDE and must finish under its per-event hook timeout + * (UserPromptSubmit/PostToolUse = 10s). Kept under 5s — and safely below the + * foreground handler's dispatch budget (LOCAL_AGENT_FG_TIMEOUT_MS = 4.5s) — so a + * slow/unreachable endpoint fails fast and the whole handler returns before the + * host aborts it. Healthy endpoints answer in well under a second, so this is + * invisible in normal use and never degrades the experience. + */ +const LOCAL_AGENT_HOOK_FETCH_TIMEOUT_MS = 3_000; + +/** Active per-fetch timeout; overridden to the hook value inside foreground hooks. */ +let activeFetchTimeoutMs = LOCAL_AGENT_FETCH_TIMEOUT_MS; + +type LocalAgentScope = 'instance' | 'user' | 'project'; +type ResourceKind = 'skills' | 'rules' | 'claudemd'; +type CommandResourceKind = 'skill' | 'rule' | 'claudemd'; + +// Command types recognized but not yet implemented by this reporter. Skipped +// silently (see isUnimplementedCommand) so the suffix logic in commandKind() +// cannot misfire (e.g. uninstall_hook_rule ends in _rule and would otherwise be +// treated as a destructive rule uninstall). uninstall_teamai is NOT here — it +// carries a `cmd` and is executed by runCmdCommand (see executeCommand), so the +// local agent actually uninstalls itself and acks. +// install_hook_rule / uninstall_hook_rule are now implemented (see runHookRuleCommand) and are NOT skipped. +const UNIMPLEMENTED_COMMAND_TYPES = new Set([]); + +/** Hook commands this reporter implements (see runHookRuleCommand). Excluded from + * the handle_type==='hook' skip so they dispatch instead of being silently dropped. */ +const IMPLEMENTED_HOOK_COMMAND_TYPES = new Set(['install_hook_rule', 'uninstall_hook_rule']); + +interface WorkspaceBinding { + projectId: number; + projectName?: string; + boundAt: string; + /** Normalized owning tool (via normalizeAgentType). Optional for back-compat with existing config.json; + * absent means "not yet attributed". */ + ideType?: string; +} + +export interface LocalAgentConfig { + endpoint: string; + token?: string; + /** + * @deprecated No longer the id source. local_agent_id is now derived at + * runtime per detected tool via resolveLocalAgentId(). Kept optional so + * older config.json files still load without a rewrite. + */ + localAgentId?: string; + createdAt: string; + userGroupId?: number; + userGroupName?: string; + workspaceBindings: Record; + /** + * Optional per-endpoint path overrides. Maps a logical route name to a custom + * path so a backend that does not use the default `/api/local-agent/*` layout + * can be pointed at its own routes. Unspecified routes fall back to DEFAULT_ROUTES. + * Example: { "getConfig": "/api/plugins/config", "sync": "/v2/agent/sync" } + */ + routes?: Partial>; +} + +/** + * Logical names for every backend endpoint the local agent talks to, mapped to + * their default paths. A deployment can override any of these via config.routes + * (see LocalAgentConfig.routes) without touching call sites. + */ +export const DEFAULT_ROUTES = { + projects: '/api/projects/mine', + report: '/api/local-agent/report', + sync: '/api/local-agent/sync', + ack: '/api/local-agent/commands/ack', + getConfig: '/api/local-agent/get-config', +} as const; + +export type RouteName = keyof typeof DEFAULT_ROUTES; + +interface LocalAgentProject { + id: number; + name: string; + description?: string; +} + +interface ManifestResource { + slug: string; + version?: string; + display_name?: string; + source?: string; + installed_at: string; + /** + * Actual on-disk directory name for skills. Equals the SKILL.md `name:` when + * it differs from the server slug, else the slug. Used at uninstall time to + * locate the directory by slug (the manifest key stays the slug). + */ + dir_name?: string; +} + +interface ManifestScope { + skills: Record; + rules: Record; + claudemd: Record; +} + +interface LocalAgentManifest { + scopes: Record; +} + +interface LocalAgentCommand { + id: number; + type?: string; + scope?: string; + workspace_path?: string; + download_url?: string; + skill_slug?: string; + skill_version?: string; + rule_slug?: string; + rule_version?: string; + rule_type?: string; + handle_type?: string; + claudemd_slug?: string; + claudemd_version?: string; + resource_slug?: string; + resource_version?: string; + slug?: string; + name?: string; + version?: string; + display_name?: string; + cmd?: string; + event?: string; + matcher?: string; + timeout?: number; + mcp_config?: { + transport: string; + url?: string; + headers?: Record; + command?: string; + args?: string[]; + env?: Record; + timeout?: number; + requires?: string[]; + }; +} + +interface DeliveredModel { + provider: string; + model_id: string; + name: string; + base_url: string; + api_key: string; + max_tokens?: number; + context_window?: number; +} + +interface BuddyModelManifest { + codebuddy?: Record; + workbuddy?: Record; + providersByAgent?: Record>; +} + +interface ModelConfigManifest extends BuddyModelManifest { + claudeEnv?: Record; + /** + * model_id → provider for every model this reporter has applied. Claude + * stores its gateway as plain ANTHROPIC_* env vars that carry no provider, + * so this is the only way to report back the provider the server sent. + */ + providers?: Record; + workspaceModels?: Record; +} + +type ModelAgentKind = 'codebuddy' | 'workbuddy' | 'claude'; +type BuddyAgentKind = 'codebuddy' | 'workbuddy'; + +function modelAgentKind(tool: string | undefined): ModelAgentKind | undefined { + const normalized = normalizeAgentType(tool ?? ''); + if (normalized === 'codebuddy' || normalized === 'codebuddy-internal') return 'codebuddy'; + if (normalized === 'workbuddy') return 'workbuddy'; + if (normalized === 'claude') return 'claude'; + return undefined; +} + +/** + * Whether a sync command is recognized-but-unimplemented and must be skipped + * before dispatch. Matches both the known unimplemented type strings and any + * hook command (handle_type === 'hook'), so a future hook `type` outside + * UNIMPLEMENTED_COMMAND_TYPES still skips silently instead of falling through + * to commandKind() and being acked as a failure. + */ +function isUnimplementedCommand(command: LocalAgentCommand): boolean { + const type = command.type ?? ''; + if (IMPLEMENTED_HOOK_COMMAND_TYPES.has(type)) return false; + return UNIMPLEMENTED_COMMAND_TYPES.has(type) || command.handle_type === 'hook'; +} + +interface LocalAgentContext { + cwd?: string; + tool?: string; + status?: string; + event?: DashboardEvent; +} + +/** + * Execution context for a *named* HTTP provider (issue #404). When a provider + * runs inside `withHttpProvider`, its state (config, manifests, credentials, + * caches) is isolated to the provider's own directory instead of the legacy + * global `~/.teamai/local-agent/` singleton. Absent context = legacy singleton, + * so every pre-#404 code path (and unmigrated installs) behaves exactly as + * before. + */ +export interface HttpProviderExecutionContext { + /** Provider name (state-directory segment). */ + name: string; + /** Absolute state home for this provider, e.g. ~/.teamai/providers/http/. */ + home: string; + /** Absolute credential-file path, kept outside `home` and out of config. */ + credentialPath: string; +} + +const httpProviderContext = new AsyncLocalStorage(); + +/** Run `op` with a named HTTP provider's state isolated to its own directory. */ +export function withHttpProvider( + ctx: HttpProviderExecutionContext, + op: () => Promise, +): Promise { + return httpProviderContext.run(ctx, op); +} + +/** The active named-provider context, or undefined for the legacy singleton. */ +export function currentHttpProvider(): HttpProviderExecutionContext | undefined { + return httpProviderContext.getStore(); +} + +function getTeamaiHomePath(): string { + return path.join(getUserHome(), '.teamai'); +} + +function getLocalAgentHome(): string { + // A named provider redirects all state to its own directory; without a + // context we fall back to the legacy global singleton location. + return httpProviderContext.getStore()?.home ?? path.join(getTeamaiHomePath(), LOCAL_AGENT_DIR); +} + +function getConfigPath(): string { + return path.join(getLocalAgentHome(), CONFIG_FILE); +} + +function getManifestPath(): string { + return path.join(getLocalAgentHome(), MANIFEST_FILE); +} + +function getModelManifestPath(): string { + return path.join(getLocalAgentHome(), MODEL_MANIFEST_FILE); +} + +function getErrorLogPath(): string { + // Error log stays under the provider home when named, so a provider's error + // stream is isolated too; legacy singleton keeps its historical HOME location. + const ctx = httpProviderContext.getStore(); + if (ctx) return path.join(ctx.home, REPORTER_ERROR_LOG); + return path.join(getTeamaiHomePath(), REPORTER_ERROR_LOG); +} + +function compileClaudemdBlock(contents: string[]): string | null { + const parts = contents.map((content) => content.trim()).filter(Boolean); + if (parts.length === 0) return null; + return [ + TEAMAI_CLAUDEMD_START, + '', + '', + parts.join('\n\n'), + '', + TEAMAI_CLAUDEMD_END, + ].join('\n'); +} + +function normalizeEndpoint(endpoint: string): string { + return endpoint.trim().replace(/\/+$/, ''); +} + +/** Normalize a route override so it is a leading-slash path (endpoint has no trailing slash). */ +function normalizeRoute(route: string): string { + const trimmed = route.trim(); + return trimmed.startsWith('/') ? trimmed : `/${trimmed}`; +} + +/** + * Resolve a logical route name to its path, applying config.routes overrides + * over DEFAULT_ROUTES. A blank/whitespace override is ignored (falls back to default). + */ +export function resolveRoute(config: Pick, name: RouteName): string { + const override = config.routes?.[name]; + if (override && override.trim()) return normalizeRoute(override); + return DEFAULT_ROUTES[name]; +} + +/** + * Resolve the per-tool install directory that seeds the local_agent_id hash. + * + * This must match the historical status-report口径 — `~/.` — so that a + * machine upgrading from the status-report era keeps the same id instead of + * drifting. It is derived from the same toolPaths map buildReportPayload uses: + * `~/` (e.g. `.codebuddy/skills` → `~/.codebuddy`). Unknown + * tools fall back to `~/.`, still deterministic and distinct per tool. + * Note: install_path only feeds the local hash — it never leaves the machine. + */ +function resolveAgentInstallPath(agentType: string): string { + const home = getUserHome(); + const skillsRel = createLocalAgentTeamConfig('').toolPaths[agentType]?.skills; + const rel = skillsRel ? path.dirname(skillsRel) : `.${agentType}`; + return path.join(home, rel); +} + +/** + * Resolve the local_agent_id for the current invocation. + * + * Deterministic per (detected tool + machine + install dir) — same tool on the + * same machine always yields the same id, so the backend sees a stable agent + * instead of a fresh random one every hook fire. The tool is auto-detected from + * the hook's --tool flag (context.tool); different tools (claude / codebuddy / + * workbuddy) get different ids because agent_type AND the per-tool install dir + * (~/.) both feed the hash. install_path uses the tool's own dir (not the + * teamai home) to stay byte-for-byte identical to the historical status-report + * derivation, avoiding an id change on upgrade. TEAMAI_LOCAL_AGENT_ID still + * overrides for explicit pinning. + */ +function resolveLocalAgentId(context: LocalAgentContext): string { + const envOverride = process.env.TEAMAI_LOCAL_AGENT_ID; + if (envOverride) return envOverride; + const agentType = context.tool ?? 'workbuddy'; + return deriveLocalAgentId(agentType, getMachineId(), resolveAgentInstallPath(agentType)); +} + +/** + * Detect whether we are running inside a CloudStudio container sandbox. + * + * WorkBuddy can spawn a CloudStudio Linux container that runs its own teamai + * hooks. That container has a different machine_id than the macOS host, so it + * derives a second local_agent_id and reports a duplicate agent card. Both + * signals below are absent on a normal Linux user machine, so this never + * suppresses reporting for legitimate standalone Linux users. + */ +function isCloudStudioSandbox(): boolean { + if (process.env.X_IDE_IS_CLOUDSTUDIO === 'TRUE') return true; + try { + return fs.existsSync('/var/run/cloudstudio'); + } catch { + return false; + } +} + +/** + * Build the unified log tag for local-agent debug output: `[] []` — + * the last 6 chars of the derived agent id plus the agent name (tool), so every + * line (HTTP request/response, report/sync, command ack) reads the same way. + */ +function localAgentTag(context: LocalAgentContext): string { + const tool = context.tool ?? 'workbuddy'; + return `[${resolveLocalAgentId(context).slice(-6)}] [${tool}]`; +} + +function scopeKey(scope: LocalAgentScope, workspacePath?: string): string { + return scope === 'project' ? `project:${workspacePath ?? ''}` : scope; +} + +function emptyManifestScope(): ManifestScope { + return { skills: {}, rules: {}, claudemd: {} }; +} + +async function loadManifest(): Promise { + const manifest = await readJson(getManifestPath()); + return manifest ?? { scopes: {} }; +} + +async function saveManifest(manifest: LocalAgentManifest): Promise { + await writeJson(getManifestPath(), manifest); +} + +/** One HTTP-source agent hook recorded locally so teardown can find & remove it + * across all formats (codex has no in-file marker, so its command is stored). */ +interface AgentHookRecord { + tool: string; + event: string; + command: string; + matcher?: string; + timeout?: number; +} + +/** slug → record. Kept separate from the resource manifest and from the team + * managed-hooks.json so a team pull never treats agent hooks as stale. */ +type AgentHookManifest = Record; + +function getAgentHookManifestPath(): string { + return path.join(getLocalAgentHome(), 'agent-hooks.json'); +} + +async function loadAgentHookManifest(): Promise { + const data = await readJson(getAgentHookManifestPath()); + return data && typeof data === 'object' ? data : {}; +} + +async function saveAgentHookManifest(manifest: AgentHookManifest): Promise { + await writeJsonAtomic(getAgentHookManifestPath(), manifest); +} + +/** + * The member's per-machine tool roots, from the teamai config that governs this + * directory: the project one when there is one, else the user-scope one. + * + * The local agent carries no LocalConfig — it addresses tool roots under $HOME + * directly — but it writes the same files `teamai pull` does, so a root the + * member relocated (CLAUDE_CONFIG_DIR, recorded by `teamai init`) has to reach + * them too. No config, or no entry, leaves the paths exactly as they were. + */ +async function memberToolRoots(workspacePath?: string): Promise | undefined> { + const { resolveMemberToolRoots } = await import('../../../../config.js'); + return resolveMemberToolRoots(workspacePath ?? process.cwd()); +} + +/** Claude Code's user root on this machine, honoring a relocated CLAUDE_CONFIG_DIR. */ +async function claudeUserRoot(): Promise { + return resolveToolRootDir(CLAUDE_TOOL_ID, DEFAULT_CLAUDE_ROOT, await memberToolRoots()); +} + +/** Resolve the current tool's settings file absolute path (user scope, $HOME base). */ +async function resolveToolSettingsPath(config: LocalAgentConfig, tool: string): Promise { + const teamConfig = createLocalAgentTeamConfig(config.endpoint); + const toolPath = applyToolRoots(teamConfig.toolPaths, await memberToolRoots())[tool]; + if (!toolPath?.settings) { + throw new Error(`unsupported tool: ${tool} (no settings path)`); + } + return path.join(getUserHome(), toolPath.settings); +} + +function getPluginStatePath(): string { + return path.join(getLocalAgentHome(), 'plugins.json'); +} + +async function readPluginState(): Promise> { + return (await readJson>(getPluginStatePath())) ?? {}; +} + +/** + * Atomically mutate the plugin-state file under an exclusive lock. If the lock + * cannot be acquired within the timeout, throws (the caller skips this cycle + * rather than writing without the lock — reconcile is throttled, so skipping is safe). + */ +async function withPluginStateLock(mutate: (m: Record) => void): Promise { + const statePath = getPluginStatePath(); + const lockPath = `${statePath}.lock`; + await ensureDir(path.dirname(lockPath)); + const deadline = Date.now() + 5000; + let acquired = false; + while (Date.now() <= deadline) { + try { const fd = await fs.promises.open(lockPath, 'wx'); await fd.close(); acquired = true; break; } + catch (e) { + if ((e as { code?: string }).code !== 'EEXIST') throw e; + try { + const st = await fs.promises.stat(lockPath); + if (Date.now() - st.mtimeMs > 30_000) { await fs.promises.rm(lockPath, { force: true }); continue; } + } catch { /* lock vanished */ } + await new Promise((r) => setTimeout(r, 50)); + } + } + if (!acquired) throw new Error('could not acquire plugin-state lock'); + try { + const m = await readPluginState(); + mutate(m); + await writeJson(statePath, m); + } finally { + await fs.promises.rm(lockPath, { force: true }); + } +} + +function getManifestScope( + manifest: LocalAgentManifest, + scope: LocalAgentScope, + workspacePath?: string, +): ManifestScope { + const key = scopeKey(scope, workspacePath); + manifest.scopes[key] ??= emptyManifestScope(); + return manifest.scopes[key]; +} + +/** + * Canonicalize a workspace path to its physical on-disk form via realpath. + * On case-insensitive filesystems (macOS) this collapses casing variants of the + * same physical directory to one identity; it also resolves symlinks. Falls back + * to the resolved absolute path when the target does not exist (dead binding) or + * realpath fails for any other reason. + */ +async function canonicalizeWorkspacePath(value: string): Promise { + const absolute = path.resolve(value); + try { + return await fs.promises.realpath(absolute); + } catch { + return absolute; + } +} + +function mergeWorkspaceBindings( + existing: WorkspaceBinding | undefined, + incoming: WorkspaceBinding, + canonicalKey: string, +): WorkspaceBinding { + if (!existing) return incoming; + // pick base = whichever has a non-zero projectId; prefer existing on tie + const existingReal = existing.projectId !== 0; + const incomingReal = incoming.projectId !== 0; + if (existingReal && incomingReal && existing.projectId !== incoming.projectId) { + log.warn( + `local-agent: workspace ${canonicalKey} has conflicting project bindings ` + + `(${existing.projectId} vs ${incoming.projectId}); keeping ${existing.projectId}`, + ); + } + const base = existingReal || !incomingReal ? { ...existing } : { ...incoming }; + // A physical workspace tracks one owning tool (same single-owner model as + // stampWorkspaceTool). Only backfill ideType when the base lacks one; if two + // aliases were stamped by different tools, the base's ideType wins — the + // other tool re-stamps itself on its next report from the canonical path. + if (!base.ideType) base.ideType = existing.ideType ?? incoming.ideType; + return base; +} + +export async function loadLocalAgentConfig(): Promise { + const providerCtx = httpProviderContext.getStore(); + const fileConfig = await readJson(getConfigPath()); + if (fileConfig?.endpoint) { + // Named providers keep the credential in a separate file outside config + // (issue #404); fall back to any inline token for the legacy singleton. + const token = providerCtx + ? (await readCredentialFile(providerCtx.credentialPath)) ?? fileConfig.token + : fileConfig.token; + const config = { + ...fileConfig, + token, + endpoint: normalizeEndpoint(fileConfig.endpoint), + workspaceBindings: fileConfig.workspaceBindings ?? {}, + }; + // Migrate: clear legacy group-based bindings (groupId without projectId) + const removedLegacyPaths: string[] = []; + for (const [wsPath, binding] of Object.entries(config.workspaceBindings)) { + if ('groupId' in binding && !('projectId' in (binding as Record))) { + delete config.workspaceBindings[wsPath]; + removedLegacyPaths.push(wsPath); + } + } + if (removedLegacyPaths.length > 0) { + log.warn( + `Removed ${removedLegacyPaths.length} legacy group-based workspace binding(s); ` + + `you will be prompted to re-bind on the next session.`, + ); + try { + await saveLocalAgentConfig(config); + } catch (e) { + log.debug(`local-agent: failed to persist binding cleanup: ${(e as Error).message}`); + } + } + // Migrate: canonicalize binding keys to their physical on-disk path so + // case-only / symlink aliases of the same workspace collapse to one entry. + const migrated: Record = {}; + let migrationChanged = false; + for (const [wsPath, binding] of Object.entries(config.workspaceBindings)) { + const canonicalKey = await canonicalizeWorkspacePath(wsPath); + if (canonicalKey !== wsPath) migrationChanged = true; + if (migrated[canonicalKey]) migrationChanged = true; + migrated[canonicalKey] = mergeWorkspaceBindings(migrated[canonicalKey], binding, canonicalKey); + } + config.workspaceBindings = migrated; + if (migrationChanged) { + await saveLocalAgentConfig(config); + } + return config; + } + + // Backfill: if config.json is missing but a legacy ~/.teamai/config.yaml has + // an HTTP team repo, auto-create config.json so v0.17.x upgraders keep capability. + const { loadLocalConfig } = await import('../../../../config.js'); + const { resolveApiKey } = await import('../../../../api-key.js'); + const legacy = await loadLocalConfig(); + if (legacy?.repo?.kind === 'http' && legacy.repo.url) { + const endpoint = normalizeEndpoint(legacy.repo.url); + const token = resolveApiKey() ?? undefined; + const backfilled: LocalAgentConfig = { + endpoint, + token, + createdAt: new Date().toISOString(), + workspaceBindings: {}, + }; + try { + await saveLocalAgentConfig(backfilled); + log.debug('local-agent: backfilled config.json from legacy ~/.teamai/config.yaml (http repo)'); + } catch (e) { + log.debug(`local-agent: backfill persist failed, using in-memory config: ${(e as Error).message}`); + } + return backfilled; + } + + const envEndpoint = + process.env.TEAMAI_HTTP_ENDPOINT ?? + process.env.TEAMAI_ENDPOINT ?? + process.env.TEAMAI_API_BASE_URL; + if (!envEndpoint) return null; + + return { + endpoint: normalizeEndpoint(envEndpoint), + token: process.env.TEAMAI_API_TOKEN ?? process.env.TEAMAI_TOKEN, + createdAt: new Date().toISOString(), + workspaceBindings: {}, + }; +} + +/** Read a 0600 credential file's token, trimming the trailing newline. Missing → undefined. */ +async function readCredentialFile(credentialPath: string): Promise { + const raw = await readFileSafe(credentialPath); + const token = raw?.trim(); + return token ? token : undefined; +} + +async function saveLocalAgentConfig(config: LocalAgentConfig): Promise { + const providerCtx = httpProviderContext.getStore(); + const persisted = { + ...config, + endpoint: normalizeEndpoint(config.endpoint), + workspaceBindings: config.workspaceBindings ?? {}, + }; + // A named provider stores its credential in a separate 0600 file, never in + // config.json — strip any in-memory token before persisting so migration + // saves (binding cleanup / key canonicalization) cannot leak it. + if (providerCtx) delete persisted.token; + await writeJsonAtomic(getConfigPath(), persisted); +} + +function createLocalAgentTeamConfig(endpoint: string): TeamaiConfig { + return TeamaiConfigSchema.parse({ + team: 'local-agent', + repo: endpoint, + description: 'HTTP local agent resource cache', + }); +} + +async function createResourceLocalConfig( + config: LocalAgentConfig, + scope: LocalAgentScope, + repoPath: string, + workspacePath?: string, +): Promise { + const projectScope = scope === 'project'; + return { + repo: { localPath: repoPath, remote: config.endpoint }, + username: os.userInfo().username, + scope: projectScope ? 'project' : 'user', + projectRoot: projectScope ? workspacePath : undefined, + additionalRoles: [], + // User-scope paths resolve under $HOME here, so a tool the member relocated + // must be addressed at its recorded root — the same one `teamai pull` uses. + ...(projectScope ? {} : { toolRoots: await memberToolRoots(workspacePath) }), + }; +} + +async function getResourceRepoPath(scope: LocalAgentScope, workspacePath?: string): Promise { + if (scope === 'project' && workspacePath) { + // Project resource cache is A1 (per-project) AND per-worktree: the resource + // cache (claudemd/skills/rules fragments) is what each worktree installs + // independently, and syncClaudemd merges EVERY file in this dir. The partition + // data home is shared by all linked worktrees, so the cache must live in a + // per-worktree subdir — otherwise worktree B's CLAUDE.md would merge in + // worktree A's instructions. Mirror managed-mcp's per-worktree layout. + const { resolveDataHomeForScope } = await import('../../../../config.js'); + const dataHome = await resolveDataHomeForScope('project', workspacePath); + return path.join(dataHome, 'workspaces', managedMcpWorkspaceId(workspacePath), LOCAL_AGENT_DIR, 'resources'); + } + return path.join(getLocalAgentHome(), 'resources', scope); +} + +async function ensureProjectGitignore(workspacePath: string): Promise { + const teamaiDir = path.join(workspacePath, '.teamai'); + await ensureDir(teamaiDir); + const gitignorePath = path.join(teamaiDir, '.gitignore'); + const existing = await readFileSafe(gitignorePath); + if (!existing) { + await writeFile(gitignorePath, ['# teamai local state', 'local-agent/', ''].join('\n')); + return; + } + if (!existing.split('\n').some((line) => line.trim() === 'local-agent/')) { + await writeFile(gitignorePath, existing.trimEnd() + '\nlocal-agent/\n'); + } +} + +function authHeaders(config: LocalAgentConfig, json = true): Record { + const headers: Record = {}; + if (json) headers['Content-Type'] = 'application/json'; + if (config.token) { + headers.Authorization = `Bearer ${config.token}`; + headers['X-API-Token'] = config.token; + } + return headers; +} + +async function localAgentFetch( + config: LocalAgentConfig, + tag: string, + route: RouteName, + init?: RequestInit, + opts?: { redactResponseLog?: boolean }, +): Promise { + const method = init?.method ?? 'GET'; + const url = `${config.endpoint}${resolveRoute(config, route)}`; + const headers: Record = { + ...authHeaders(config, init?.body !== undefined), + ...((init?.headers as Record | undefined) ?? {}), + }; + logHttpRequest(tag, method, url, headers, init?.body); + + const response = await fetch(url, { + ...init, + headers, + signal: init?.signal ?? AbortSignal.timeout(activeFetchTimeoutMs), + }); + const text = await response.text(); + let body: unknown = null; + if (text.trim()) { + try { + body = JSON.parse(text); + } catch { + body = text; + } + } + logHttpResponse(tag, method, url, response.status, response.statusText, opts?.redactResponseLog ? '' : body); + if (!response.ok) { + const message = typeof body === 'object' && body && 'error' in body + ? String((body as { error: unknown }).error) + : text || `${response.status} ${response.statusText}`; + throw new Error(message); + } + return body as T; +} + +async function appendErrorLog(entry: unknown): Promise { + try { + await ensureDir(path.dirname(getErrorLogPath())); + await fs.promises.appendFile( + getErrorLogPath(), + JSON.stringify({ at: new Date().toISOString(), entry }) + '\n', + 'utf-8', + ); + } catch { + // Best-effort; hook execution must not fail on I/O. + } +} + +export async function fetchUserProjects(config: LocalAgentConfig): Promise { + const response = await localAgentFetch<{ ok?: boolean; projects?: LocalAgentProject[] }>( + config, + localAgentTag({}), + 'projects', + { method: 'GET' }, + ); + return response.projects ?? []; +} + +/** Mask secret values (CLI flags / key=value / bearer tokens) so they don't reach logs. */ +function redactSecrets(s: string): string { + // Secret-bearing identifiers, matched case-insensitively in flag and key=value forms. + const names = 'secret[_-]?(?:key|id)|api[_-]?key|access[_-]?token|token|password|passwd|pwd'; + return s + .replace(new RegExp(`(--(?:${names})[= ]+)\\S+`, 'gi'), '$1***') + .replace(new RegExp(`((?:${names})"?\\s*[:=]\\s*"?)[^"\\s,}]+`, 'gi'), '$1***') + .replace(/(bearer\s+)[\w.\-]+/gi, '$1***'); +} + +/** + * Execute a shell command string with a timeout. + * + * Completion is gated on the process 'exit' event, NOT 'close': a setup command that + * daemonizes and leaves the inherited stderr pipe open in a background process would never + * emit 'close', producing a false timeout even though the command itself finished. + * Rejects on non-zero exit, termination by signal, or timeout. + */ +export async function execPluginCommand(cmd: string, timeoutMs: number): Promise { + const { spawn } = await import('node:child_process'); + await new Promise((resolve, reject) => { + const child = process.platform === 'win32' + ? spawn('cmd', ['/c', cmd], { windowsHide: true, stdio: ['ignore', 'ignore', 'pipe'] }) + : spawn('bash', ['-lc', cmd], { stdio: ['ignore', 'ignore', 'pipe'] }); + let stderr = ''; + let settled = false; + let timer: ReturnType; + child.stderr?.on('data', (d) => { stderr += d.toString(); if (stderr.length > 8192) stderr = stderr.slice(-8192); }); + const finish = (fn: () => void): void => { + if (settled) return; + settled = true; + clearTimeout(timer); + detachChild(child); + fn(); + }; + timer = setTimeout(() => { + child.kill('SIGKILL'); + finish(() => reject(new Error(`command timed out after ${timeoutMs}ms`))); + }, timeoutMs); + child.on('error', (e) => finish(() => reject(e))); + child.on('exit', (code, signal) => + finish(() => { + if (signal) return reject(new Error(`command killed by ${signal}`)); + if (code === 0) return resolve(); + const tail = stderr ? ' :: ' + redactSecrets(stderr.slice(0, 200).trim()) : ''; + reject(new Error(`command failed (exit ${code})${tail}`)); + }), + ); + }); +} + +/** + * Fetch backend plugin config. + * Route = 'getConfig' (default path /api/local-agent/get-config, overridable via config.routes). + * localAgentFetch builds `url = config.endpoint + resolveRoute(...)`, matching report/sync pattern. + */ +async function fetchPluginConfig(config: LocalAgentConfig, tag: string): Promise { + return localAgentFetch(config, tag, 'getConfig', { method: 'GET' }, { redactResponseLog: true }); +} + +const PLUGIN_PULL_INTERVAL_MS = 12 * 60 * 60 * 1000; +const PLUGIN_FAIL_BACKOFF_MS = 60 * 60 * 1000; + +function getPluginPullStatePath(): string { + return path.join(getLocalAgentHome(), 'plugin-pull.json'); +} + +function buildReconcileDeps(config: LocalAgentConfig, tag: string): ReconcileDeps { + return { + readPlugins: () => readPluginState(), + mutatePlugins: (fn) => withPluginStateLock(fn), + execCommand: (cmd, t) => execPluginCommand(cmd, t), + now: () => Date.now(), + log: { + debug: (msg) => log.debug(`${tag} ${msg}`), + // The reconcile worker runs detached (stdio: 'ignore'), so console-only log.warn + // output is discarded. Mirror warnings to debug.log so failures are traceable. + warn: (msg) => { + log.warn(`${tag} ${msg}`); + log.debug(`${tag} WARN: ${msg}`); + }, + }, + }; +} + +/** On session start, throttle-check and spawn a detached worker for plugin reconcile. Never blocks. */ +async function maybeReconcilePlugins(context: LocalAgentContext): Promise { + try { + const state = (await readJson<{ lastPullAt?: number; lastFailAt?: number }>(getPluginPullStatePath())) ?? {}; + const now = Date.now(); + if (state.lastPullAt && now - state.lastPullAt < PLUGIN_PULL_INTERVAL_MS) return; + if (state.lastFailAt && now - state.lastFailAt < PLUGIN_FAIL_BACKOFF_MS) return; + const tool = context.tool ?? 'workbuddy'; + const localAgentId = `${tool}-${resolveLocalAgentId(context)}`; + const { spawn } = await import('node:child_process'); + if (!process.argv[1]) { log.debug('[local-agent] plugin reconcile: no CLI entrypoint (argv[1]), skipping'); return; } + // AsyncLocalStorage context does NOT cross the process boundary, so a named + // provider's identity must be passed explicitly and re-established in the + // worker — otherwise the detached process reads the legacy dir and a named + // provider's backend plugins are never installed/updated (issue #404). + const providerCtx = httpProviderContext.getStore(); + const child = spawn(process.execPath, [process.argv[1], 'source', 'reconcile-plugins'], + { detached: true, windowsHide: true, stdio: 'ignore', env: { + ...process.env, + TEAMAI_PLUGIN_LOCAL_AGENT_ID: localAgentId, + ...(providerCtx ? { TEAMAI_HTTP_PROVIDER_NAME: providerCtx.name } : {}), + } }); + child.unref(); + } catch (e) { log.debug(`[local-agent] plugin reconcile spawn skipped: ${(e as Error).message}`); } +} + +/** Detached worker: pull get-config and reconcile plugins once, guarded by a reconcile lock. */ +export async function runPluginReconcileWorker(): Promise { + // Re-establish the named-provider context the spawning process passed via env + // (AsyncLocalStorage does not cross process boundaries). Without this the + // worker would read the legacy dir and never reconcile a named provider's + // plugins. No env var → legacy singleton, exactly as before. + const providerName = process.env.TEAMAI_HTTP_PROVIDER_NAME?.trim(); + if (providerName && !httpProviderContext.getStore()) { + const { httpProviderExecutionContext } = await import('../../store.js'); + return withHttpProvider(httpProviderExecutionContext(providerName), () => runPluginReconcileWorker()); + } + const config = await loadLocalAgentConfig(); + if (!config) return; + const lockPath = path.join(getLocalAgentHome(), 'plugin-reconcile.lock'); + await ensureDir(path.dirname(lockPath)); + let acquired = false; + try { + try { + const fd = await fs.promises.open(lockPath, 'wx'); + await fd.close(); + acquired = true; + } catch (e) { + if ((e as { code?: string }).code !== 'EEXIST') throw e; + try { + const st = await fs.promises.stat(lockPath); + if (Date.now() - st.mtimeMs > 30 * 60 * 1000) { + await fs.promises.rm(lockPath, { force: true }); + const fd = await fs.promises.open(lockPath, 'wx'); + await fd.close(); + acquired = true; + } + } catch { /* ignore */ } + if (!acquired) return; + } + const tag = '[local-agent] [plugin-reconcile]'; + const statePath = getPluginPullStatePath(); + try { + const resp = await fetchPluginConfig(config, tag); + const { vars, plugins } = parseGetConfig(resp); + const declaredSlugs = plugins.length ? ` [${plugins.map((p) => p.slug).join(', ')}]` : ''; + log.debug(`${tag} get-config: ${plugins.length} plugin(s) declared${declaredSlugs}`); + const laid = process.env.TEAMAI_PLUGIN_LOCAL_AGENT_ID; + if (!laid) log.debug(tag + ' no local_agent_id in env; plugins needing it will be skipped'); + const allVars = { ...vars, ...(laid ? { local_agent_id: laid } : {}) }; + const resolved: typeof plugins = []; + for (const p of plugins) { + const rp = { + ...p, + installCmd: substituteVars(p.installCmd, allVars), + updateCmd: p.updateCmd ? substituteVars(p.updateCmd, allVars) : undefined, + uninstallCmd: substituteVars(p.uninstallCmd, allVars), + runCmd: substituteVars(p.runCmd, allVars), + }; + const missing = [...new Set([ + ...unresolvedPlaceholders(rp.installCmd), + ...unresolvedPlaceholders(rp.runCmd), + ...unresolvedPlaceholders(rp.uninstallCmd), + ...(rp.updateCmd ? unresolvedPlaceholders(rp.updateCmd) : []), + ])]; + if (missing.length) { + log.warn(`${tag} plugin ${p.slug}: unresolved placeholders [${missing.join(',')}], skipping`); + log.debug(`${tag} WARN: plugin ${p.slug}: unresolved placeholders [${missing.join(',')}], skipping`); + continue; + } + resolved.push(rp); + } + await reconcilePlugins(resolved, buildReconcileDeps(config, tag)); + log.debug(`${tag} reconcile complete (${resolved.length} plugin(s) processed)`); + await writeJson(statePath, { lastPullAt: Date.now() }); + } catch (e) { + const prev = (await readJson<{ lastPullAt?: number; lastFailAt?: number }>(statePath)) ?? {}; + await writeJson(statePath, { ...prev, lastFailAt: Date.now() }); + log.debug(`${tag} reconcile failed: ${(e as Error).message}`); + } + } finally { + if (acquired) await fs.promises.rm(lockPath, { force: true }); + } +} + +async function askViaTty(prompt: string): Promise { + // Only prompt on a real interactive terminal (e.g. the user running + // `teamai bind-project` directly). In non-interactive contexts such as an + // IDE-invoked hook, stdin is piped; opening /dev/tty there succeeds when the + // host GUI keeps a controlling terminal, and readline then blocks forever + // waiting for input that never comes — hanging the hook until the host's + // timeout and stalling the IDE. Callers fall back to injecting a stdout + // binding hint when this returns null, so degrade to that instead. + // The decline stays synchronous — this runs on the hook path, where loading + // the prompt module only to say no is work nobody asked for. + if (!isInteractive()) return null; + const { askQuestion } = await import('../../../../utils/prompt.js'); + return askQuestion(prompt, ''); +} + +async function promptForProjectBinding( + workspacePath: string, + projects: LocalAgentProject[], +): Promise { + if (projects.length === 0) return null; + + log.debug(`local-agent: workspace not bound: ${workspacePath}`); + const answer = await askViaTty('是否绑定到一个项目?[y/N] '); + if (!answer || answer.toLowerCase() !== 'y') return null; + + if (projects.length === 1) return projects[0]; + + log.info('可用项目:'); + projects.forEach((project, index) => { + const desc = project.description ? ` - ${project.description}` : ''; + log.info(` ${index + 1}. ${project.name}${desc} [id=${project.id}]`); + }); + + const selection = await askViaTty(`选择项目编号(1-${projects.length},0 跳过): `); + if (selection === null || selection === '0') return null; + const index = selection ? Number.parseInt(selection, 10) : 0; + if (Number.isNaN(index) || index < 1 || index > projects.length) return null; + return projects[index - 1]; +} + +/** + * Persist a ClawPro binding decision for the current checkout. + * + * Binding is a per-project decision, so it is recorded on the `projectAnchor` + * (the main checkout, shared by a repo and all of its git worktrees — issue + * #374 / #387). It is ALSO stamped on the current `workspaceRoot` so this + * checkout is reported with the project_id immediately and its resources land + * in the current worktree (#387's workspaceRoot model — every AI tool discovers + * resources by scanning up from the launch dir, never via git-common-dir). For a + * plain repo the two anchors coincide and this writes a single entry. Falls back + * to `resolvedPath` when `cwd` is not inside a git repo. + * + * Existing fields (e.g. a stamped `ideType`) on any touched entry are preserved. + */ +async function persistWorkspaceBinding( + config: LocalAgentConfig, + cwd: string | undefined, + resolvedPath: string, + projectId: number, + projectName: string, +): Promise { + const anchors = await resolveAnchors(cwd); + const keys = new Set([resolvedPath]); + if (anchors) { + keys.add(anchors.projectAnchor); + keys.add(anchors.workspaceRoot); + } + const boundAt = new Date().toISOString(); + for (const key of keys) { + config.workspaceBindings[key] = { + ...(config.workspaceBindings[key] ?? {}), + projectId, + projectName, + boundAt, + }; + } + await saveLocalAgentConfig(config); +} + +/** + * If the current checkout is an unbound git worktree whose main checkout + * (`projectAnchor`) is already bound or skipped, copy that decision onto the + * current `workspaceRoot` and report success — so a repo is never re-prompted + * for binding once per new worktree (a `--skip` on the main checkout silences + * all of them too). Returns true when the worktree inherited a binding. + */ +async function inheritWorktreeBinding( + config: LocalAgentConfig, + cwd: string | undefined, + resolvedPath: string, +): Promise { + const anchors = await resolveAnchors(cwd); + if (!anchors || anchors.projectAnchor === anchors.workspaceRoot) return false; + const anchorBinding = config.workspaceBindings[anchors.projectAnchor]; + if (!anchorBinding) return false; + config.workspaceBindings[resolvedPath] = { + ...(config.workspaceBindings[resolvedPath] ?? {}), + projectId: anchorBinding.projectId, + projectName: anchorBinding.projectName, + boundAt: new Date().toISOString(), + }; + await saveLocalAgentConfig(config); + return true; +} + +export async function bindWorkspaceToProject( + workspacePath: string, + projectId?: number, +): Promise { + const config = await loadLocalAgentConfig(); + if (!config) { + throw new Error('HTTP local agent is not initialized. Run `teamai init --http --token ` first.'); + } + + const projects = await fetchUserProjects(config); + const project = projectId + ? projects.find((item) => item.id === projectId) + : await promptForProjectBinding(workspacePath, projects); + if (!project) return null; + + const binding: WorkspaceBinding = { + projectId: project.id, + projectName: project.name, + boundAt: new Date().toISOString(), + }; + // Record on the projectAnchor (shared across the repo's worktrees) and the + // current workspaceRoot; workspacePath is already the resolved checkout root. + await persistWorkspaceBinding(config, workspacePath, workspacePath, project.id, project.name); + log.success(`已将工作区绑定到项目:${project.name} [id=${project.id}]`); + return binding; +} + +async function ensureWorkspaceBinding( + config: LocalAgentConfig, + workspacePath: string, + sessionId?: string, + cwd?: string, +): Promise { + if (config.workspaceBindings[workspacePath]) return; + // A worktree inherits its main checkout's binding/skip decision — never prompt. + if (await inheritWorktreeBinding(config, cwd, workspacePath)) return; + + const markerKey = sessionId || `ppid-${process.ppid}`; + const hintMarker = path.join(os.tmpdir(), `teamai-bind-session-${markerKey}`); + if (fs.existsSync(hintMarker)) return; + try { fs.writeFileSync(hintMarker, ''); } catch {} + + let projects: LocalAgentProject[]; + try { + projects = await fetchUserProjects(config); + } catch (e) { + log.debug(`local-agent: failed to load user projects: ${(e as Error).message}`); + return; + } + + if (projects.length === 0) return; + + const project = await promptForProjectBinding(workspacePath, projects); + if (project) { + await persistWorkspaceBinding(config, cwd, workspacePath, project.id, project.name); + return; + } + + // TTY prompt failed (running as hook in background) — output context for AI to prompt user + const projectList = projects.map((p) => `${p.name} (id=${p.id})`).join(', '); + const context = [ + '[teamai] 当前工作区尚未绑定项目,无法接收项目下发的 skills/rules/claudemd 资源。', + `可用项目: ${projectList}`, + `请提示用户运行: teamai bind-project`, + `或指定项目: teamai bind-project --project-id `, + ].join('\n'); + const hookOutput = JSON.stringify({ + hookSpecificOutput: { + hookEventName: 'SessionStart', + additionalContext: context, + }, + }); + process.stdout.write(hookOutput + '\n'); +} + +/** + * The organization-binding prompt (TTY prompt + injected hook context) is + * on by default. Disable it explicitly with `TEAMAI_BIND_PROMPT_ENABLED=0` + * (or `false`). The manual `teamai bind-project` command is always available + * regardless. + */ +function isBindPromptEnabled(): boolean { + const flag = process.env.TEAMAI_BIND_PROMPT_ENABLED; + if (flag === undefined) return true; + const normalized = flag.toLowerCase(); + return normalized !== '0' && normalized !== 'false'; +} + +/** + * ClawPro project binding only backs CodeBuddy/WorkBuddy (the ClawPro-native + * agents); the prompt is noise for every other host (Claude, Cursor, Codex, …), + * which drove the poor UX. Gate the whole prompt — both the SessionStart TTY + * prompt and the UserPromptSubmit hint — on the current tool being a buddy + * agent. Reuses `modelAgentKind` so tool-name variants like `codebuddy-internal` + * still match (a raw Set would miss them). + */ +function isBindPromptTool(tool: string | undefined): boolean { + const kind = modelAgentKind(tool); + return kind === 'codebuddy' || kind === 'workbuddy'; +} + +async function emitBindingHint( + config: LocalAgentConfig, + workspacePath: string, + sessionId?: string, + cwd?: string, +): Promise { + if (config.workspaceBindings[workspacePath]) return; + // A worktree inherits its main checkout's binding/skip decision — never hint. + if (await inheritWorktreeBinding(config, cwd, workspacePath)) return; + + // Only hint once per session — use a temp marker file keyed by sessionId + const markerKey = sessionId || `ppid-${process.ppid}`; + const hintMarker = path.join(os.tmpdir(), `teamai-bind-hint-${markerKey}`); + if (fs.existsSync(hintMarker)) return; + try { fs.writeFileSync(hintMarker, ''); } catch {} + + let projects: LocalAgentProject[]; + try { + projects = await fetchUserProjects(config); + } catch { + return; + } + if (projects.length === 0) return; + + const skipNum = projects.length + 1; + const choices = projects.map((p, i) => `${i + 1}. 绑定到「${p.name}」项目`); + const actions = projects.map((p, i) => `- 用户回复 ${i + 1} → 执行 \`teamai bind-project --project-id ${p.id}\`,完成后回复"绑定完成,请开新会话生效"`); + const context = [ + '[ClawPro项目 绑定提示] 你必须原样展示下面的选项列表,不得修改编号或内容。等用户回复数字后执行对应命令。', + '', + '---', + '当前工作区未绑定ClawPro项目。绑定后可同步团队 skills/rules。请回复数字选择:', + '', + ...choices, + `${skipNum}. 不绑定,以后也不再提示`, + '---', + '', + '执行规则(不要展示给用户):', + ...actions, + `- 用户回复 ${skipNum} → 执行 \`teamai bind-project --skip\`,完成后回复"已跳过,以后不再提示"`, + ].join('\n'); + const hookOutput = JSON.stringify({ + hookSpecificOutput: { + hookEventName: 'UserPromptSubmit', + additionalContext: context, + }, + }); + process.stdout.write(hookOutput + '\n'); +} + +function isEphemeralTaskDir(dir: string): boolean { + const segments = dir.split(path.sep); + const wbIdx = segments.lastIndexOf('WorkBuddy'); + if (wbIdx < 0 || wbIdx >= segments.length - 1) return false; + return /^\d{4}-\d{2}-\d{2}/.test(segments[wbIdx + 1]); +} + +async function resolveWorkspacePath(cwd?: string): Promise { + if (!cwd) return undefined; + const absolute = path.resolve(cwd); + if (isEphemeralTaskDir(absolute)) return undefined; + try { + const { stdout } = await execFileAsync('git', ['-C', absolute, 'rev-parse', '--show-toplevel']); + const root = stdout.trim(); + return await canonicalizeWorkspacePath(root || absolute); + } catch { + return await canonicalizeWorkspacePath(absolute); + } +} + +interface ReportedResource { + slug: string; + version?: string; + display_name?: string; + source: string; +} + +/** + * Resolve a resource's source by looking it up in the local-agent manifest: + * slugs recorded there were installed via HTTP distribution (`enterprise`); + * everything else present only on disk is treated as `local`. + */ +function resolveSource(slug: string, manifestSlugs: Set): string { + return manifestSlugs.has(slug) ? 'enterprise' : 'local'; +} + +/** + * Scan a tool's on-disk skills directory. Each sub-directory containing a + * SKILL.md is one installed skill; slug/version/display_name come from its + * front-matter (falling back to the directory name). + */ +async function scanSkillsFromDisk( + skillsDir: string, + manifestSlugs: Set, +): Promise { + if (!(await pathExists(skillsDir))) return []; + const dirs = (await listDirs(skillsDir)).filter((name) => !name.startsWith('.') && !name.startsWith('_')); + const results: ReportedResource[] = []; + for (const dir of dirs) { + const skillMd = path.join(skillsDir, dir, 'SKILL.md'); + if (!(await pathExists(skillMd))) continue; + const fm = await readFrontmatter(skillMd); + const slug = typeof fm.name === 'string' && fm.name ? fm.name : dir; + const version = fm.version != null ? String(fm.version) : undefined; + results.push({ + slug, + version, + display_name: slug, + source: resolveSource(slug, manifestSlugs), + }); + } + return results.sort((a, b) => a.slug.localeCompare(b.slug)); +} + +/** + * Scan a tool's on-disk rules directory. Every `.md` file (recursively) is one + * installed rule; the slug is its path relative to the rules dir without the + * `.md` extension. + */ +async function scanRulesFromDisk( + rulesDir: string, + manifestSlugs: Set, +): Promise { + if (!(await pathExists(rulesDir))) return []; + // Cursor stores rules as `.mdc`, every other tool as `.md`; match by stem so + // a Cursor agent still reports its installed rules. + const files = await listFilesRecursive(rulesDir); + const results: ReportedResource[] = []; + const seen = new Set(); + for (const file of files) { + const slug = ruleStemFromFilename(file); + if (slug === null) continue; + if (seen.has(slug)) continue; // Same rule under both extensions + seen.add(slug); + // Skip CLI built-in / legacy rules (e.g. teamai-recall) so they are not + // reported as user-installed resources — mirrors the pull/uninstall filter. + if (EXCLUDED_RULE_NAMES.has(path.basename(slug)) || EXCLUDED_RULE_NAMES.has(slug)) continue; + results.push({ + slug, + display_name: slug, + source: resolveSource(slug, manifestSlugs), + }); + } + return results.sort((a, b) => a.slug.localeCompare(b.slug)); +} + +/** Collect every skill/rule slug recorded across all manifest scopes. + * For skills, also includes dir_name (the on-disk SKILL.md name) so that + * scanSkillsFromDisk — which uses SKILL.md name as the reported slug — + * correctly resolves source as 'enterprise' even when dir_name ≠ slug. + */ +function collectManifestSlugs(manifest: LocalAgentManifest): { skills: Set; rules: Set } { + const skills = new Set(); + const rules = new Set(); + for (const scope of Object.values(manifest.scopes)) { + for (const [slug, entry] of Object.entries(scope.skills ?? {})) { + skills.add(slug); + if (entry.dir_name) skills.add(entry.dir_name); + } + for (const slug of Object.keys(scope.rules ?? {})) rules.add(slug); + } + return { skills, rules }; +} + +/** + * Scan the managed-mcp manifest for a given scope and return MCP servers as + * ReportedResource entries. Only servers tracked in managed-mcp.json (i.e. + * installed via HTTP distribution) are reported with source = 'enterprise'. + * + * Results are scoped to the current `tool` so a report never leaks another + * tool's MCP inventory. The manifest is keyed by the same key the installer + * writes under (see `installMcpServer`): `tool` at user scope, `${tool}:project` + * at project scope. `tool` is the raw hook-context value (not run through + * normalizeAgentType), matching how the installer keys the manifest. + */ +async function scanMcpFromManifest( + scope: 'user' | 'project', + tool: string, + projectRoot?: string, +): Promise { + const { resolveDataHomeForScope } = await import('../../../../config.js'); + const dataHome = await resolveDataHomeForScope(scope, projectRoot); + + // Project scope reads THIS worktree's own manifest file (per-worktree under the + // partition; migrates legacy shared records on first read). User scope reads the + // single global file. Either way every record in the loaded file belongs to this + // scope, so no key filtering is needed. + let manifest: ManagedMcpManifest; + if (scope === 'project' && projectRoot) { + const { loadProjectMcpManifest } = await import('../../../../utils/mcp-manifest.js'); + ({ manifest } = await loadProjectMcpManifest(dataHome, projectRoot)); + } else { + manifest = (await readJson(managedMcpManifestPath(dataHome))) ?? {}; + } + + const manifestKey = `${tool}${scope === 'project' ? ':project' : ''}`; + const records = manifest[manifestKey]; + if (!Array.isArray(records)) return []; + + const seen = new Set(); + const results: ReportedResource[] = []; + for (const rec of records) { + if (!rec.name || seen.has(rec.name)) continue; + seen.add(rec.name); + results.push({ slug: rec.name, source: 'enterprise' }); + } + return results.sort((a, b) => a.slug.localeCompare(b.slug)); +} + +interface ReportedModel { + provider: string; + model_id: string; + name?: string; + source: string; +} + +/** + * Scan the models a tool can currently use, as configured on disk. The server + * requires both `provider` and `model_id`, so entries that cannot supply them + * are dropped rather than reported as incomplete. `source` is derived from the + * model manifest, mirroring how skills/rules classify enterprise vs local. + * + * Only CodeBuddy, WorkBuddy, and Claude keep a discoverable model config; + * every other tool reports nothing. User-owned models are omitted: the + * backend cannot resolve them, so only entries still matching a TeamAI + * delivery are reported. + */ +function buddyModelsPath(agentKind: BuddyAgentKind, workspacePath?: string): string { + return workspacePath + ? path.join(workspacePath, '.codebuddy', 'models.json') + : path.join(getUserHome(), `.${agentKind}`, 'models.json'); +} + +function modelConfigDisplayPath(filePath: string): string { + if (filePath.endsWith(`${path.sep}.codebuddy${path.sep}models.json`)) { + return '.codebuddy/models.json'; + } + if (filePath.endsWith(`${path.sep}.workbuddy${path.sep}models.json`)) { + return '~/.workbuddy/models.json'; + } + return path.basename(filePath); +} + +async function scanModelsFromDisk(tool: string, workspacePath?: string): Promise { + const manifest = (await readJson(getModelManifestPath())) ?? {}; + const agentKind = modelAgentKind(tool); + + if (agentKind === 'codebuddy' || agentKind === 'workbuddy') { + const scopeManifest = workspacePath ? manifest.workspaceModels?.[workspacePath] : manifest; + const providers = scopeManifest?.providersByAgent?.[agentKind] + ?? (!workspacePath && agentKind !== 'workbuddy' ? manifest.providers : undefined) + ?? {}; + const raw = await readJson(buddyModelsPath(agentKind, workspacePath)); + const entries = Array.isArray(raw) + ? raw + : (Array.isArray((raw as { models?: unknown } | null)?.models) + ? (raw as { models: unknown[] }).models + : []); + const owned = (agentKind === 'codebuddy' + ? scopeManifest?.codebuddy + : scopeManifest?.workbuddy) ?? {}; + const results: ReportedModel[] = []; + for (const entry of entries) { + if (typeof entry !== 'object' || entry === null) continue; + const { id, vendor, name } = entry as Record; + if (typeof id !== 'string' || !id) continue; + if (typeof vendor !== 'string' || !vendor) continue; + // CodeBuddy / WorkBuddy may normalize a model entry by adding capability + // metadata. The manifest's model id is the durable proof that TeamAI + // delivered it; requiring an exact object hash would hide such entries. + if (owned[id] === undefined || providers[id] !== vendor) continue; + results.push({ + provider: vendor, + model_id: id, + ...(typeof name === 'string' && name ? { name } : {}), + source: 'enterprise', + }); + } + return results; + } + + if (agentKind === 'claude' && !workspacePath) { + const providers = manifest.providersByAgent?.claude ?? manifest.providers ?? {}; + const settings = await readJson<{ env?: unknown }>( + path.join(await claudeUserRoot(), 'settings.json'), + ); + const env = settings?.env; + if (typeof env !== 'object' || env === null || Array.isArray(env)) return []; + const { ANTHROPIC_CUSTOM_MODEL_OPTION: modelId, ANTHROPIC_CUSTOM_MODEL_OPTION_NAME: name } = + env as Record; + if (typeof modelId !== 'string' || !modelId) return []; + const managed = manifest.claudeEnv?.ANTHROPIC_CUSTOM_MODEL_OPTION; + if (managed === undefined || entryHash(modelId) !== managed) return []; + const provider = providers[modelId]; + if (!provider) return []; + return [{ + provider, + model_id: modelId, + ...(typeof name === 'string' && name ? { name } : {}), + source: 'enterprise', + }]; + } + + return []; +} + +/** + * Remove workspace bindings whose directory no longer exists on disk. + * + * Workspace bindings are only ever added, never removed, so a deleted + * project directory would otherwise be reported forever and the server + * (full-sync snapshot) could never drop it. This prunes such stale + * entries in place. Applies to skipped ('__skipped__', projectId 0) + * entries too — a deleted directory should not leave a permanent sentinel. + * + * @param config - Loaded local-agent config; its workspaceBindings map is mutated in place. + * @returns True if at least one binding was removed. + */ +export async function pruneDeadWorkspaceBindings(config: LocalAgentConfig): Promise { + let changed = false; + for (const workspacePath of Object.keys(config.workspaceBindings)) { + try { + await fs.promises.stat(workspacePath); + } catch (error) { + // Only prune when the directory is confirmed gone (ENOENT). Transient + // failures — permission errors, unreachable network mounts — must NOT + // delete a still-valid binding, or the server's full-sync snapshot + // would drop that workspace's resources. + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + delete config.workspaceBindings[workspacePath]; + changed = true; + } else { + log.debug( + `local-agent: keeping workspace binding ${workspacePath} despite stat error: ${(error as Error).message}`, + ); + } + } + } + return changed; +} + +/** + * Stamps the workspace binding's owning tool when the hook fires from that tool's own process. + * Because hooks are invoked from within the tool's process, cwd === binding.path is the + * authoritative signal that this binding belongs to the triggering tool. + * + * Returns true if the binding was modified (caller should persist config), false otherwise. + */ +export function stampWorkspaceTool( + config: LocalAgentConfig, + currentPath: string | null | undefined, + tool: string, +): boolean { + if (!currentPath) return false; + const binding = config.workspaceBindings[currentPath]; + if (!binding) return false; + const normalized = normalizeAgentType(tool); + if (binding.ideType === normalized) return false; + binding.ideType = normalized; + return true; +} + +/** + * Select the workspace paths that belong to the current tool for reporting. + * + * A binding belongs to the current tool when its stamped ideType matches, or — + * for the not-yet-attributed current cwd — when it is the workspace the hook + * fired from. An empty/absent ideType on a non-cwd binding is treated as + * unattributed and excluded (it self-heals once its owning tool reports from it). + */ +function selectToolWorkspaces( + config: LocalAgentConfig, + currentPath: string | null | undefined, + currentTool: string, +): string[] { + const paths = new Set(Object.keys(config.workspaceBindings)); + if (currentPath) paths.add(currentPath); + return Array.from(paths).filter((wsPath) => { + const b = config.workspaceBindings[wsPath]; + const wsTool = (b?.ideType || undefined) ?? (wsPath === currentPath ? currentTool : undefined); + return wsTool === currentTool; + }); +} + +export async function buildReportPayload( + config: LocalAgentConfig, + context: LocalAgentContext, +): Promise> { + const manifest = await loadManifest(); + + // Resource discovery scans the tool's on-disk skills/rules directories rather + // than the manifest, so locally-installed resources (not just HTTP-distributed + // ones) are reported. `source` is derived from the manifest: slugs recorded + // there are `enterprise`, the rest `local`. + const tool = context.tool ?? 'workbuddy'; + const teamConfig = createLocalAgentTeamConfig(config.endpoint); + const manifestSlugs = collectManifestSlugs(manifest); + + // Resolve paths through the same user-scope seam the installers use: tools + // that relocate their user customization root (copilot via $COPILOT_HOME) or + // lay user scope out differently from project scope declare a `userScope` + // block. Reading the raw toolPaths map against $HOME would scan the project + // layout under the wrong base — e.g. ~/.github/skills for copilot, a path + // teamai never writes to — and silently report nothing. + const scanScope = async (workspacePath?: string): Promise<{ skills: ReportedResource[]; rules: ReportedResource[] }> => { + const scope: LocalAgentScope = workspacePath ? 'project' : 'user'; + const localConfig = await createResourceLocalConfig(config, scope, workspacePath ?? getUserHome(), workspacePath); + const toolPath = scopedToolPaths(teamConfig, localConfig)[tool]; + if (!toolPath) return { skills: [], rules: [] }; + const baseDir = resolveToolBaseDir(tool, localConfig); + const skills = toolPath.skills + ? await scanSkillsFromDisk(path.join(baseDir, toolPath.skills), manifestSlugs.skills) + : []; + const rules = toolPath.rules + ? await scanRulesFromDisk(path.join(baseDir, toolPath.rules), manifestSlugs.rules) + : []; + return { skills, rules }; + }; + + const userScope = await scanScope(); + + const userLevel: Record = { group_id: config.userGroupId }; + if (userScope.skills.length > 0) userLevel.skills = userScope.skills; + if (userScope.rules.length > 0) userLevel.rules = userScope.rules; + const userMcps = await scanMcpFromManifest('user', tool); + if (userMcps.length > 0) userLevel.mcps = userMcps; + // Omitted when empty for the same full-sync reason as skills/rules: the + // server treats a present array as a snapshot, so [] would wipe the models. + const userModels = await scanModelsFromDisk(tool); + if (userModels.length > 0) userLevel.models = userModels; + + const payload: Record = { + agent_type: normalizeAgentType(tool), + agent_version: await getAgentVersion(tool), + local_agent_id: resolveLocalAgentId(context), + host_name: os.hostname(), + os: os.platform(), + started_at: config.createdAt, + last_status: context.status ?? 'running', + // Instance-level skills/rules are a phase-1 legacy concept. They are + // deliberately omitted (not sent as []): the server treats present arrays + // as a full-sync snapshot ("消失即删"), so an empty array would wipe any + // instance-level resources. Omitting the field leaves them untouched. + user_level: userLevel, + }; + + const currentPath = await resolveWorkspacePath(context.cwd); + const currentTool = normalizeAgentType(tool); + const targetPaths = selectToolWorkspaces(config, currentPath, currentTool); + if (targetPaths.length > 0) { + const workspaceResults = await Promise.all( + targetPaths.map(async (wsPath) => { + const wsScope = await scanScope(wsPath); + const wsBinding = config.workspaceBindings[wsPath]; + const workspace: Record = { + path: wsPath, + name: path.basename(wsPath), + ide_type: currentTool, + project_id: wsBinding?.projectId, + }; + if (wsScope.skills.length > 0) workspace.skills = wsScope.skills; + if (wsScope.rules.length > 0) workspace.rules = wsScope.rules; + const wsMcps = await scanMcpFromManifest('project', tool, wsPath); + if (wsMcps.length > 0) workspace.mcps = wsMcps; + const wsModels = await scanModelsFromDisk(tool, wsPath); + if (wsModels.length > 0) workspace.models = wsModels; + return workspace; + }), + ); + payload.workspaces = workspaceResults; + } + + return payload; +} + +export async function buildSyncPayload( + config: LocalAgentConfig, + context: LocalAgentContext, +): Promise> { + const payload: Record = { + agent_type: normalizeAgentType(context.tool ?? 'workbuddy'), + local_agent_id: resolveLocalAgentId(context), + status: context.status ?? 'running', + }; + const currentPath = await resolveWorkspacePath(context.cwd); + const currentTool = normalizeAgentType(context.tool ?? 'workbuddy'); + const targetPaths = selectToolWorkspaces(config, currentPath, currentTool); + if (targetPaths.length > 0) { + payload.workspaces = targetPaths.map((wsPath) => { + const wsBinding = config.workspaceBindings[wsPath]; + return { + path: wsPath, + name: path.basename(wsPath), + ide_type: currentTool, + project_id: wsBinding?.projectId, + }; + }); + } + return payload; +} + +function commandKind(command: LocalAgentCommand): CommandResourceKind | null { + // Unified cmds[] carries handle_type; legacy commands[] carries rule_type. + // Both map a prompt rule to the claudemd resource kind. + if (command.rule_type === 'prompt' || command.handle_type === 'prompt') return 'claudemd'; + if (command.handle_type === 'rule') return 'rule'; + if (command.handle_type === 'hook') return null; // defensive; skipped before dispatch + const type = command.type ?? ''; + if (type.endsWith('_skill') || type === '') return 'skill'; + if (type.endsWith('_claudemd') || type.endsWith('_claude_md')) return 'claudemd'; + if (type.endsWith('_rule')) return 'rule'; + return null; +} + +function commandAction(command: LocalAgentCommand): 'install' | 'uninstall' | null { + const type = command.type ?? ''; + if (type === '') return 'install'; + if (type.startsWith('install_')) return 'install'; + if (type.startsWith('uninstall_')) return 'uninstall'; + return null; +} + +/** + * Reject slugs that could escape the resource directory. Slugs come from + * backend sync commands and are used directly in filesystem paths, so a value + * like `../../.ssh/authorized_keys` would otherwise write outside the repo. + */ +function validateSlug(slug: string): string { + if ( + !slug || + slug.includes('/') || + slug.includes('\\') || + slug.includes('..') || + path.isAbsolute(slug) + ) { + throw new Error(`Invalid resource slug: ${slug}`); + } + return slug; +} + +function commandSlug(command: LocalAgentCommand, kind: CommandResourceKind): string { + const slug = + kind === 'skill' ? command.skill_slug : + kind === 'rule' ? command.rule_slug : + (command.claudemd_slug ?? command.rule_slug); + const resolved = slug ?? command.resource_slug ?? command.slug ?? command.name; + if (!resolved) { + throw new Error(`Missing ${kind} slug`); + } + return validateSlug(resolved); +} + +function commandVersion(command: LocalAgentCommand, kind: CommandResourceKind): string | undefined { + return ( + kind === 'skill' ? command.skill_version : + kind === 'rule' ? command.rule_version : + (command.claudemd_version ?? command.rule_version) + ) ?? command.resource_version ?? command.version; +} + +/** + * Normalize a backend-sent scope string to an internal LocalAgentScope. + * + * The backend emits `user` / `workspace` (see clawpro local-agent-api.md); + * the deprecated `instance` is no longer sent. Internally project-level + * resources use the `project` scope, so `workspace` maps to `project`. + * Any unrecognized value falls back to `user` (global install). + * + * This only maps the scope; presence of `workspace_path` for project scope + * is validated by the caller (executeCommand throws if it is missing). + */ +function normalizeScope(raw?: string): LocalAgentScope { + if (raw === 'workspace' || raw === 'project') return 'project'; + if (raw !== undefined && raw !== 'user' && raw !== 'instance') { + log.debug(`local-agent: unknown scope "${raw}", defaulting to user`); + } + return 'user'; +} + +function manifestKind(kind: CommandResourceKind): ResourceKind { + return kind === 'skill' ? 'skills' : kind === 'rule' ? 'rules' : 'claudemd'; +} + +/** Only http(s) downloads are allowed — reject file:, ftp:, gopher:, etc. */ +function assertHttpUrl(rawUrl: string): URL { + let parsed: URL; + try { + parsed = new URL(rawUrl); + } catch { + throw new Error(`Invalid download URL: ${rawUrl}`); + } + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { + throw new Error(`Unsupported download URL scheme: ${parsed.protocol}`); + } + return parsed; +} + +/** + * Fetch a resource by URL. download_url comes from backend sync commands, so it + * is treated as untrusted: only http(s) is honoured (no file:// / local-path + * copy, which would be arbitrary local file read), and redirects are followed + * manually so every hop's scheme is re-validated instead of blindly trusting + * whatever Location the server returns. + */ +async function downloadResource(downloadUrl: string): Promise { + const tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'teamai-local-agent-')); + const filePath = path.join(tmpDir, 'resource'); + + let current = assertHttpUrl(downloadUrl); + let response: Response; + const maxRedirects = 5; + // One timeout budget for the whole download (all redirect hops combined), so a + // chain of slow redirects cannot exceed the intended bound. + const signal = AbortSignal.timeout(activeFetchTimeoutMs); + for (let hop = 0; ; hop++) { + response = await fetch(current, { redirect: 'manual', signal }); + if (response.status >= 300 && response.status < 400) { + const location = response.headers.get('location'); + if (!location) break; + if (hop >= maxRedirects) { + throw new Error(`Download failed: too many redirects (${downloadUrl})`); + } + current = assertHttpUrl(new URL(location, current).toString()); + continue; + } + break; + } + + if (!response.ok) { + throw new Error(`Download failed: ${response.status} ${response.statusText}`); + } + const buffer = Buffer.from(await response.arrayBuffer()); + await fs.promises.writeFile(filePath, buffer); + return filePath; +} + +const ZIP_MAGIC = Buffer.from([0x50, 0x4b, 0x03, 0x04]); + +async function isZipFile(filePath: string): Promise { + const fd = await fs.promises.open(filePath, 'r'); + try { + const buf = Buffer.alloc(4); + await fd.read(buf, 0, 4, 0); + return buf.equals(ZIP_MAGIC); + } finally { + await fd.close(); + } +} + +async function resolveMarkdownFromDownload(downloadedPath: string, slug: string): Promise { + if (await isZipFile(downloadedPath)) { + const extractDir = await extractZip(downloadedPath); + return findMarkdownFile(extractDir, slug); + } + return downloadedPath; +} + +async function extractZip(zipPath: string): Promise { + const extractDir = path.join(path.dirname(zipPath), 'extracted'); + await ensureDir(extractDir); + await execFileAsync('unzip', ['-q', zipPath, '-d', extractDir]); + return extractDir; +} + +async function findFirst( + dir: string, + predicate: (absolutePath: string, name: string) => Promise, +): Promise { + const entries = await fs.promises.readdir(dir, { withFileTypes: true }); + for (const entry of entries) { + const absolute = path.join(dir, entry.name); + if (await predicate(absolute, entry.name)) return absolute; + if (entry.isDirectory()) { + const nested = await findFirst(absolute, predicate); + if (nested) return nested; + } + } + return null; +} + +async function findSkillRoot(extractDir: string): Promise { + if (await pathExists(path.join(extractDir, 'SKILL.md'))) return extractDir; + const skillMd = await findFirst(extractDir, async (absolute, name) => name === 'SKILL.md' && (await pathExists(absolute))); + if (!skillMd) throw new Error('Downloaded skill package does not contain SKILL.md'); + return path.dirname(skillMd); +} + +async function findMarkdownFile(extractDir: string, preferredName: string): Promise { + const preferred = await findFirst( + extractDir, + async (_absolute, name) => name === `${preferredName}.md` || name === preferredName, + ); + if (preferred) return preferred; + + const firstMd = await findFirst(extractDir, async (_absolute, name) => name.endsWith('.md')); + if (!firstMd) throw new Error('Downloaded package does not contain a markdown file'); + return firstMd; +} + +async function readFrontmatter(filePath: string): Promise> { + const content = await readFileSafe(filePath); + if (!content) return {}; + return parseFrontmatter(content).data; +} + +/** + * Decide the on-disk directory name for a skill. The SKILL.md `name:` field is + * the source of truth for how the skill is identified by the AI tool, so use it + * when it differs from the server-provided slug (matching the git-path behaviour + * in skill-command.ts / #144). Falls back to the slug when the name is missing, + * empty, equal to the slug, or fails path-safety validation. + */ +async function resolveSkillDirName(skillRoot: string, slug: string): Promise { + const fm = await readFrontmatter(path.join(skillRoot, 'SKILL.md')); + const name = typeof fm.name === 'string' ? fm.name.trim() : ''; + if (!name || name === slug) return slug; + try { + assertSafeResourceName(name); + return name; + } catch { + log.debug(`[local-agent] keeping slug "${slug}" as skill dir (SKILL.md name "${name}" failed safety check)`); + return slug; + } +} + +async function installDownloadedResource(input: { + config: LocalAgentConfig; + command: LocalAgentCommand; + kind: CommandResourceKind; + slug: string; + scope: LocalAgentScope; + workspacePath?: string; + tool?: string; +}): Promise { + if (!input.command.download_url) { + throw new Error(`Missing download_url for ${input.command.type ?? 'install_skill'}`); + } + + const repoPath = await getResourceRepoPath(input.scope, input.workspacePath); + if (input.scope === 'project' && input.workspacePath + && repoPath.startsWith(path.join(input.workspacePath, '.teamai') + path.sep)) { + // Only gitignore when the cache actually lands inside the workspace (a legacy, + // un-migrated install). A partitioned install keeps it under ~/.teamai, so + // there is nothing in the workspace to ignore. + await ensureProjectGitignore(input.workspacePath); + } + await ensureDir(repoPath); + + const downloadedPath = await downloadResource(input.command.download_url); + try { + const fullTeamConfig = createLocalAgentTeamConfig(input.config.endpoint); + const tool = input.tool ?? 'workbuddy'; + const toolPath = fullTeamConfig.toolPaths[tool]; + if (!toolPath) { + throw new Error(`Unknown tool "${tool}": no toolPaths entry found`); + } + const teamConfig = { ...fullTeamConfig, toolPaths: { [tool]: toolPath } }; + const localConfig = await createResourceLocalConfig(input.config, input.scope, repoPath, input.workspacePath); + // Ensure the tool root directory exists before dispatch so isToolInstalled + // gate does not skip the resource when the workspace is freshly bound. + // Restricted to project scope: user-scope installs use $HOME as baseDir and + // should continue to rely on isToolInstalled as the gate. + if (localConfig.scope === 'project') { + try { + const baseDir = resolveBaseDir(localConfig); + const resourceToolPath = + input.kind === 'skill' ? toolPath.skills : + input.kind === 'rule' ? toolPath.rules : + // Default branch covers the 'claudemd' kind; if a new CommandResourceKind + // is added, revisit this mapping so it doesn't silently fall through to claudemd. + toolPath.claudemd; + if (resourceToolPath && resourceToolPath.includes('/')) { + const rootSegment = resourceToolPath.split('/')[0]; + await ensureDir(path.join(baseDir, rootSegment)); + } + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + if (msg.includes('resolveBaseDir')) { + log.warn(`Cannot resolve base dir to pre-create tool root: ${msg}`); + } else { + log.debug(`Failed to pre-create tool root directory: ${msg}`); + } + } + } + const now = new Date().toISOString(); + let displayName = input.command.display_name ?? input.slug; + // On-disk skill directory name (SKILL.md name when it differs from slug). + // Stays the slug for rules/claudemd. Recorded in the manifest so uninstall + // can find the directory by slug. + let skillDirName = input.slug; + + if (input.kind === 'skill') { + const extractDir = await extractZip(downloadedPath); + const skillRoot = await findSkillRoot(extractDir); + skillDirName = await resolveSkillDirName(skillRoot, input.slug); + const dest = path.join(repoPath, 'skills', skillDirName); + await remove(dest); + await fse.copy(skillRoot, dest, { overwrite: true }); + const fm = await readFrontmatter(path.join(dest, 'SKILL.md')); + displayName = typeof fm.name === 'string' ? fm.name : displayName; + await new SkillsHandler().pullItem({ + name: skillDirName, + type: 'skills', + sourcePath: dest, + relativePath: `skills/${skillDirName}`, + }, teamConfig, localConfig); + } else if (input.kind === 'rule') { + const ruleFile = await resolveMarkdownFromDownload(downloadedPath, input.slug); + const dest = path.join(repoPath, 'rules', `${input.slug}.md`); + await fse.ensureDir(path.dirname(dest)); + await fse.copyFile(ruleFile, dest); + await new RulesHandler().pullAllRules(teamConfig, localConfig); + } else { + const mdFile = await resolveMarkdownFromDownload(downloadedPath, input.slug); + const dest = path.join(repoPath, 'claudemd', `${input.slug}.md`); + await fse.ensureDir(path.dirname(dest)); + await fse.copyFile(mdFile, dest); + await syncClaudemd(teamConfig, localConfig, repoPath, input.workspacePath); + } + + const version = commandVersion(input.command, input.kind); + const manifest = await loadManifest(); + const scopeManifest = getManifestScope(manifest, input.scope, input.workspacePath); + scopeManifest[manifestKind(input.kind)][input.slug] = { + slug: input.slug, + version, + display_name: displayName, + source: 'enterprise', + installed_at: now, + ...(input.kind === 'skill' && skillDirName !== input.slug ? { dir_name: skillDirName } : {}), + }; + await saveManifest(manifest); + return version; + } finally { + await remove(path.dirname(downloadedPath)); + } +} + +async function uninstallResource(input: { + config: LocalAgentConfig; + kind: CommandResourceKind; + slug: string; + scope: LocalAgentScope; + workspacePath?: string; + tool?: string; +}): Promise { + const repoPath = await getResourceRepoPath(input.scope, input.workspacePath); + const fullTeamConfig = createLocalAgentTeamConfig(input.config.endpoint); + const tool = input.tool ?? 'workbuddy'; + const toolPath = fullTeamConfig.toolPaths[tool]; + if (!toolPath) { + throw new Error(`Unknown tool "${tool}": no toolPaths entry found`); + } + const teamConfig = { ...fullTeamConfig, toolPaths: { [tool]: toolPath } }; + const localConfig = await createResourceLocalConfig(input.config, input.scope, repoPath, input.workspacePath); + const manifest = await loadManifest(); + const scopeManifest = getManifestScope(manifest, input.scope, input.workspacePath); + + if (input.kind === 'skill') { + // The directory was created under the SKILL.md name (recorded as dir_name); + // remove by that name, falling back to the slug for older installs. + const dirName = scopeManifest.skills[input.slug]?.dir_name ?? input.slug; + await new SkillsHandler().removeItem(dirName, teamConfig, localConfig); + } else if (input.kind === 'rule') { + await new RulesHandler().removeItem(input.slug, teamConfig, localConfig); + } else { + await remove(path.join(repoPath, 'claudemd', `${input.slug}.md`)); + await syncClaudemd(teamConfig, localConfig, repoPath, input.workspacePath); + } + + delete scopeManifest[manifestKind(input.kind)][input.slug]; + await saveManifest(manifest); +} + +async function resolveHermesUserBaseDir(): Promise { + try { + const envWs = process.env.TEAMAI_HERMES_WORKSPACE; + if (envWs && path.isAbsolute(envWs)) return envWs; + const cfg = await readJson(getConfigPath()); + const bindings = cfg?.workspaceBindings; + if (bindings && typeof bindings === 'object') { + const entries = Object.entries(bindings) + .filter(([p, v]) => path.isAbsolute(p) && v?.ideType === 'hermes') + .sort((a, b) => (b[1].boundAt ?? '').localeCompare(a[1].boundAt ?? '')); + for (const [p] of entries) { + if (await pathExists(path.join(p, '.hermes'))) return p; + } + } + } catch { /* fall through */ } + return undefined; +} + +async function syncClaudemd( + teamConfig: TeamaiConfig, + localConfig: LocalConfig, + repoPath: string, + workspacePath?: string, +): Promise { + const claudemdDir = path.join(repoPath, 'claudemd'); + const files = (await pathExists(claudemdDir)) + ? (await fse.readdir(claudemdDir)).filter((file) => file.endsWith('.md')).sort() + : []; + const contents: string[] = []; + for (const file of files) { + const content = await readFileSafe(path.join(claudemdDir, file)); + if (content) contents.push(content); + } + const block = compileClaudemdBlock(contents); + let syncedAny = false; + + for (const [tool, toolPath] of Object.entries(scopedToolPaths(teamConfig, localConfig))) { + if (!toolPath.claudemd) continue; + + let baseDir = resolveToolBaseDir(tool, localConfig); + let resolvedAbsPath: string | null = null; + + if (tool === 'openclaw' && localConfig.scope !== 'project') { + const openclawWs = await resolveOpenclawWorkspaceDir(workspacePath); + if (openclawWs) { + resolvedAbsPath = path.join(openclawWs, path.basename(toolPath.claudemd)); + } + } else if (tool === 'hermes' && localConfig.scope !== 'project') { + const hermesBase = workspacePath ?? await resolveHermesUserBaseDir(); + if (hermesBase) { + baseDir = hermesBase; + log.debug(`local-agent: hermes user-scope baseDir resolved to ${baseDir}`); + } + } + + const toolInstalled = resolvedAbsPath + ? await pathExists(resolvedAbsPath) + : tool === COPILOT_TOOL_ID && localConfig.scope === 'user' + ? await isToolInstalledForConfig(tool, toolPath.claudemd, localConfig) + : toolPath.claudemd.includes('/') + ? await ResourceHandler.isToolInstalled(toolPath.claudemd, baseDir) + : await pathExists(path.join(baseDir, `.${tool}`)); + if (!toolInstalled) { + log.debug(`Skipped CLAUDE.md sync for ${tool}: target not found`); + continue; + } + + const claudeMdPath = resolvedAbsPath ?? path.join(baseDir, toolPath.claudemd); + try { + if (block) { + await injectClaudeMdSection(claudeMdPath, TEAMAI_CLAUDEMD_START, TEAMAI_CLAUDEMD_END, block); + log.debug(`local-agent: synced CLAUDE.md instructions to ${tool}`); + syncedAny = true; + } else { + await removeClaudeMdSection(claudeMdPath, TEAMAI_CLAUDEMD_START, TEAMAI_CLAUDEMD_END); + log.debug(`local-agent: removed CLAUDE.md instructions from ${tool}`); + syncedAny = true; + } + } catch (e) { + log.warn(`Failed to sync CLAUDE.md instructions to ${tool}: ${(e as Error).message}`); + } + } + + if (files.length > 0 && !syncedAny) { + throw new Error('CLAUDE.md sync landed on no tool: every configured target was skipped'); + } +} + +async function ackCommand( + config: LocalAgentConfig, + tag: string, + command: LocalAgentCommand, + status: 'success' | 'failed', + version?: string, + error?: string, +): Promise { + await localAgentFetch(config, tag, 'ack', { + method: 'POST', + body: JSON.stringify({ + id: command.id, + type: command.type ?? '', + status, + error: error ?? '', + version, + }), + }); +} + +function requireModelString( + value: unknown, + field: keyof Pick, +): string { + if (typeof value !== 'string' || !value.trim()) { + throw new Error(`apply_model_config: ${field} must be a non-empty string`); + } + return value.trim(); +} + +/** CodeBuddy maxOutputTokens when the backend omits max_tokens or sends 0 (Go zero value). */ +const DEFAULT_MAX_TOKENS = 4096; + +function optionalPositiveInteger(value: unknown, field: 'max_tokens' | 'context_window'): number | undefined { + if (value === undefined || value === null || value === '') return undefined; + const normalized = typeof value === 'string' && /^\d+$/.test(value) + ? Number(value) + : value; + if (!Number.isSafeInteger(normalized) || (normalized as number) < 0) { + throw new Error(`apply_model_config: ${field} must be a positive integer`); + } + // 0 is the Go zero value for an unset int, not a real output/context cap. + if ((normalized as number) === 0) return undefined; + return normalized as number; +} + +function parseDeliveredModels(raw: string | undefined): { models: DeliveredModel[]; fullSnapshot: boolean } { + if (!raw) throw new Error('apply_model_config: missing cmd'); + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + throw new Error('apply_model_config: cmd must be valid JSON'); + } + const fullSnapshot = ( + typeof parsed === 'object' && + parsed !== null && + 'models' in parsed + ); + const values = fullSnapshot ? (parsed as { models?: unknown }).models : [parsed]; + if (!Array.isArray(values)) { + throw new Error('apply_model_config: models must be an array'); + } + + const seen = new Set(); + const models = values.map((value) => { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + throw new Error('apply_model_config: each model must be an object'); + } + const input = value as Record; + const modelId = requireModelString(input.model_id, 'model_id'); + if (modelId === '__proto__' || modelId === 'prototype' || modelId === 'constructor') { + throw new Error(`apply_model_config: reserved model_id "${modelId}"`); + } + const model: DeliveredModel = { + provider: requireModelString(input.provider, 'provider'), + model_id: modelId, + name: requireModelString(input.name, 'name'), + base_url: requireModelString(input.base_url, 'base_url'), + api_key: requireModelString(input.api_key, 'api_key'), + max_tokens: optionalPositiveInteger(input.max_tokens, 'max_tokens') ?? DEFAULT_MAX_TOKENS, + context_window: optionalPositiveInteger(input.context_window, 'context_window'), + }; + let parsedUrl: URL; + try { + parsedUrl = new URL(model.base_url); + } catch { + throw new Error('apply_model_config: base_url must be a valid URL'); + } + if (parsedUrl.protocol !== 'http:' && parsedUrl.protocol !== 'https:') { + throw new Error('apply_model_config: base_url must use http or https'); + } + if (seen.has(model.model_id)) { + throw new Error(`apply_model_config: duplicate model_id "${model.model_id}"`); + } + seen.add(model.model_id); + return model; + }); + return { models, fullSnapshot }; +} + +function buddyModelEntry(model: DeliveredModel): Record { + const baseUrl = model.base_url.replace(/\/+$/, ''); + return { + id: model.model_id, + name: model.name, + vendor: model.provider, + apiKey: model.api_key, + ...(model.context_window === undefined ? {} : { maxInputTokens: model.context_window }), + ...(model.max_tokens === undefined ? {} : { maxOutputTokens: model.max_tokens }), + url: baseUrl.endsWith('/chat/completions') ? baseUrl : `${baseUrl}/chat/completions`, + supportsToolCall: true, + }; +} + +async function readJsonObject(filePath: string): Promise> { + const source = await readFileSafe(filePath); + if (source === null) return {}; + try { + const parsed = JSON.parse(source); + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { + throw new Error('root must be an object'); + } + return parsed as Record; + } catch (error) { + throw new Error( + `apply_model_config: cannot parse ${modelConfigDisplayPath(filePath)}: ${(error as Error).message}`, + ); + } +} + +/** Atomically update a model dotfile without replacing a user-managed symlink. */ +async function writeModelJson(filePath: string, data: unknown): Promise { + let targetPath = filePath; + try { + if ((await fs.promises.lstat(filePath)).isSymbolicLink()) { + targetPath = await fs.promises.realpath(filePath); + } + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; + } + // Set the temp file's mode before the atomic rename. A post-rename chmod + // would introduce a symlink-following TOCTOU window. + await writeJsonAtomic(targetPath, data, { mode: 0o600 }); +} + +async function ensureWorkspaceModelGitignore(workspacePath: string): Promise { + const gitignorePath = path.join(workspacePath, '.codebuddy', '.gitignore'); + const existing = await readFileSafe(gitignorePath); + if (existing === null) { + await writeFile(gitignorePath, '# Local model credentials\nmodels.json\n'); + return; + } + if (existing.split(/\r?\n/).some((line) => line.trim() === 'models.json')) return; + await writeFile(gitignorePath, `${existing.trimEnd()}\nmodels.json\n`); +} + +async function readBuddyModelEntries( + filePath: string, +): Promise<{ existing: unknown[]; doc?: Record }> { + const source = await readFileSafe(filePath); + // The current WorkBuddy / CodeBuddy documentation uses an object wrapper. + // Product releases also accept the legacy top-level array, so preserve that + // shape when a user already has one instead of forcing a migration. + if (source === null) return { existing: [], doc: {} }; + try { + const parsed = JSON.parse(source); + if (Array.isArray(parsed)) return { existing: parsed }; + if (typeof parsed !== 'object' || parsed === null) { + throw new Error('root must be an object or array'); + } + const doc = parsed as Record; + const existing = doc.models === undefined ? [] : doc.models; + if (!Array.isArray(existing)) { + throw new Error('models must be an array'); + } + return { existing, doc }; + } catch (error) { + throw new Error( + `apply_model_config: cannot parse ${modelConfigDisplayPath(filePath)}: ${(error as Error).message}`, + ); + } +} + +async function reconcileBuddyModels( + models: DeliveredModel[], + fullSnapshot: boolean, + scopeManifest: BuddyModelManifest, + agentKind: BuddyAgentKind, + workspacePath?: string, +): Promise { + const targetFile = buddyModelsPath(agentKind, workspacePath); + const { existing, doc } = await readBuddyModelEntries(targetFile); + const previouslyManaged = (agentKind === 'codebuddy' + ? scopeManifest.codebuddy + : scopeManifest.workbuddy) ?? {}; + const nextManaged: Record = fullSnapshot ? {} : { ...previouslyManaged }; + const incomingIds = new Set(models.map((model) => model.model_id)); + const removedManaged = new Set(); + const preserved: unknown[] = []; + const occupiedIds = new Set(); + for (const entry of existing) { + const id = typeof entry === 'object' && entry !== null && typeof (entry as { id?: unknown }).id === 'string' + ? (entry as { id: string }).id + : undefined; + if (id && previouslyManaged[id] && entryHash(entry) === previouslyManaged[id]) { + if (fullSnapshot || incomingIds.has(id)) { + removedManaged.add(id); + continue; + } + preserved.push(entry); + occupiedIds.add(id); + continue; + } + preserved.push(entry); + if (id) occupiedIds.add(id); + if (id && previouslyManaged[id]) delete nextManaged[id]; + } + + for (const model of models) { + if (occupiedIds.has(model.model_id)) continue; + const entry = buddyModelEntry(model); + preserved.push(entry); + nextManaged[model.model_id] = entryHash(entry); + } + + if (workspacePath) await ensureWorkspaceModelGitignore(workspacePath); + if (doc) { + doc.models = preserved; + if (Array.isArray(doc.availableModels) && doc.availableModels.length > 0) { + const available = doc.availableModels.filter( + (id): id is string => typeof id === 'string' && !removedManaged.has(id), + ); + for (const id of Object.keys(nextManaged)) { + if (!available.includes(id)) available.push(id); + } + doc.availableModels = available; + } + await writeModelJson(targetFile, doc); + } else { + await writeModelJson(targetFile, preserved); + } + if (agentKind === 'codebuddy') scopeManifest.codebuddy = nextManaged; + else scopeManifest.workbuddy = nextManaged; +} + +function claudeEnvForModel(model: DeliveredModel): Record { + const baseUrl = model.base_url.replace(/\/+$/, '').replace(/\/v1$/, ''); + return { + ANTHROPIC_BASE_URL: baseUrl, + ANTHROPIC_AUTH_TOKEN: model.api_key, + ANTHROPIC_CUSTOM_MODEL_OPTION: model.model_id, + ANTHROPIC_CUSTOM_MODEL_OPTION_NAME: model.name, + }; +} + +/** + * Drop the gateway env and model profile the agent delivered into `claudeRoot`, + * and forget them in the manifest. For `teamai init` moving the Claude root: + * the credentials would otherwise stay in a profile nothing syncs any more. + * No-op when the agent never delivered a model. + */ +export async function releaseClaudeModelConfig(claudeRoot: string): Promise { + const manifest = (await readJson(getModelManifestPath())) ?? {}; + if (Object.keys(manifest.claudeEnv ?? {}).length === 0) return; + await reconcileClaudeModels([], manifest, claudeRoot); + await writeJsonAtomic(getModelManifestPath(), manifest); + log.info(`Removed the delivered Claude model config from ${claudeRoot}`); +} + +async function reconcileClaudeModels( + models: DeliveredModel[], + manifest: ModelConfigManifest, + claudeRoot?: string, +): Promise { + claudeRoot ??= await claudeUserRoot(); + const settingsPath = path.join(claudeRoot, 'settings.json'); + const profilePath = path.join(claudeRoot, 'teamai-models.json'); + const previousHashes = manifest.claudeEnv ?? {}; + const settings = await readJsonObject(settingsPath); + const rawEnv = settings.env === undefined ? {} : settings.env; + if (typeof rawEnv !== 'object' || rawEnv === null || Array.isArray(rawEnv)) { + throw new Error(`apply_model_config: env must be an object in ${settingsPath}`); + } + const env = { ...(rawEnv as Record) }; + + if (models.length === 0) { + const canRemoveGateway = Object.entries(previousHashes).every( + ([key, hash]) => entryHash(env[key]) === hash, + ); + if (canRemoveGateway && Object.keys(previousHashes).length > 0) { + for (const key of Object.keys(previousHashes)) delete env[key]; + settings.env = env; + await writeModelJson(settingsPath, settings); + } + await remove(profilePath); + manifest.claudeEnv = {}; + return; + } + + // Claude supports one active custom gateway in settings. The first model + // seeds that gateway; other candidates remain discoverable from its + // /v1/models endpoint when the gateway implements model discovery. + const desired = claudeEnvForModel(models[0]); + await writeModelJson(profilePath, { env: desired }); + + // Any of these keys, if the user already set them, means they have their own + // Claude gateway/model config we must not silently take over. Beyond the keys + // we write, this also covers auth the gateway swap would break + // (ANTHROPIC_API_KEY, ANTHROPIC_CUSTOM_HEADERS) and the user's model choice + // (ANTHROPIC_DEFAULT_{OPUS,SONNET,HAIKU}_MODEL). + const conflictKeys = new Set([ + ...Object.keys(desired), + 'ANTHROPIC_API_KEY', + 'ANTHROPIC_CUSTOM_HEADERS', + 'ANTHROPIC_DEFAULT_OPUS_MODEL', + 'ANTHROPIC_DEFAULT_SONNET_MODEL', + 'ANTHROPIC_DEFAULT_HAIKU_MODEL', + ]); + // A value is TeamAI-managed if it matches what we recorded last time or the + // value currently in settings.json (settings.json is our own output, so a + // process.env var equal to it is Claude re-injecting settings.json.env into + // the hook, not a user's independent shell config). + const isManagedValue = (key: string, value: unknown): boolean => ( + (previousHashes[key] !== undefined && entryHash(value) === previousHashes[key]) || + (env[key] !== undefined && entryHash(value) === entryHash(env[key])) + ); + // The guard must see config the user set outside settings.json too. Users who + // run Claude via shell `export ANTHROPIC_*` keep no gateway in settings.json, + // so a settings-only check reads env[key] === undefined and wrongly seizes the + // slot — settings.json then outranks the shell env and breaks their setup. + // But Claude injects settings.json.env into the hook's own environment, so we + // must NOT treat our own re-injected managed values as a user conflict — doing + // so would block every follow-up sync and strand the user on stale config. + const userOwnsInShell = (key: string): boolean => { + const value = process.env[key]; + if (typeof value !== 'string' || value.trim() === '') return false; + return !isManagedValue(key, value); + }; + const shellConflicts = [...conflictKeys].filter(userOwnsInShell); + if (shellConflicts.length > 0) { + // The user has their own gateway/model config in the shell. Skip the write, + // but keep manifest.claudeEnv intact: this is not the user editing our + // managed settings.json entry, so we must stay able to reconcile once the + // shell config goes away. + await appendErrorLog({ + apply_model_config: 'skipped claude gateway: user owns conflicting shell env', + conflicts: shellConflicts, + }); + return; + } + + const canManage = [...conflictKeys].every((key) => ( + env[key] === undefined || + (previousHashes[key] !== undefined && entryHash(env[key]) === previousHashes[key]) + )); + if (!canManage) { + manifest.claudeEnv = {}; + return; + } + + for (const [key, hash] of Object.entries(previousHashes)) { + if (entryHash(env[key]) === hash) delete env[key]; + } + Object.assign(env, desired); + settings.env = env; + await writeModelJson(settingsPath, settings); + manifest.claudeEnv = Object.fromEntries( + Object.entries(desired).map(([key, value]) => [key, entryHash(value)]), + ); +} + +async function applyModelConfig( + config: LocalAgentConfig, + command: LocalAgentCommand, + context: LocalAgentContext, +): Promise { + const { models, fullSnapshot } = parseDeliveredModels(command.cmd); + const manifest = (await readJson(getModelManifestPath())) ?? {}; + const agentKind = modelAgentKind(context.tool); + if (!agentKind) { + throw new Error(`apply_model_config: unsupported agent "${context.tool ?? ''}"`); + } + + const scope = normalizeScope(command.scope); + const workspacePath = scope === 'project' + ? await resolveWorkspacePath(command.workspace_path ?? context.cwd) + : undefined; + if (scope === 'project' && !workspacePath) { + throw new Error('apply_model_config: workspace command is missing workspace_path'); + } + if (workspacePath && config.workspaceBindings[workspacePath] === undefined) { + throw new Error( + `apply_model_config: workspace "${path.basename(workspacePath)}" is not a registered binding`, + ); + } + if (agentKind === 'claude' && workspacePath) { + throw new Error('apply_model_config: workspace scope is unsupported for claude'); + } + if (!workspacePath) { + // An explicit profile switch takes precedence over server delivery. Keep + // both the Agent config and delivery manifest intact for a later restore. + const { isModelProfileManaged } = await import('../../../../models/switch.js'); + if (await isModelProfileManaged(agentKind)) return; + } + + let scopeManifest: BuddyModelManifest = manifest; + if (workspacePath) { + manifest.workspaceModels ??= {}; + manifest.workspaceModels[workspacePath] ??= {}; + scopeManifest = manifest.workspaceModels[workspacePath]; + } + const previousProviders = scopeManifest.providersByAgent?.[agentKind] + ?? (!workspacePath && agentKind !== 'workbuddy' ? manifest.providers : undefined) + ?? {}; + const providers = { + ...(fullSnapshot ? {} : previousProviders), + ...Object.fromEntries(models.map((model) => [model.model_id, model.provider])), + }; + scopeManifest.providersByAgent = { + ...scopeManifest.providersByAgent, + [agentKind]: providers, + }; + if (agentKind === 'claude') { + await reconcileClaudeModels(models, manifest); + } else { + await reconcileBuddyModels(models, fullSnapshot, scopeManifest, agentKind, workspacePath); + } + await writeJsonAtomic(getModelManifestPath(), manifest); +} + +/** + * Tokenize a restricted `teamai` command string into an argv array. + * + * Supports single and double quotes so arguments containing spaces survive + * (e.g. `--name "a b"`). No variable expansion, no globbing; shell + * metacharacters like `;`, `|`, `&`, `$`, `(`, `)` are treated as literals. + * Throws when the string is empty, has an unterminated quote, or its first + * token is not exactly `teamai` — so a backend can never launch anything but + * a teamai subcommand. + */ +export function parseTeamaiCmd(raw: string): string[] { + const argv: string[] = []; + let current = ''; + let quote: '"' | "'" | null = null; + let hasToken = false; + for (const char of raw) { + if (quote) { + if (char === quote) { + quote = null; + } else { + current += char; + } + continue; + } + if (char === '"' || char === "'") { + quote = char; + hasToken = true; + continue; + } + if (char === ' ' || char === '\t' || char === '\n' || char === '\r') { + if (hasToken) { + argv.push(current); + current = ''; + hasToken = false; + } + continue; + } + current += char; + hasToken = true; + } + if (quote) { + throw new Error('Unterminated quote in cmd'); + } + if (hasToken) { + argv.push(current); + } + if (argv.length === 0) { + throw new Error('Empty cmd'); + } + if (argv[0] !== 'teamai') { + throw new Error(`Rejected cmd: only "teamai" subcommands are allowed, got "${argv[0]}"`); + } + return argv; +} + +/** + * Resolve the teamai entry script to run a pushed cmd. Prefers the current + * process entry (`process.argv[1]`) so the running teamai is reused, and + * falls back to resolving `dist/index.js` from this bundle when argv[1] is + * unavailable (some sandboxed hook launchers). Returns null when neither + * resolves. + */ +function resolveCmdEntry(): string | null { + const argvEntry = process.argv[1]; + if (argvEntry) { + return argvEntry; + } + return resolveTeamaiEntryScript(); +} + +/** + * Execute an `uninstall_teamai` command's `cmd` string pushed via sync. Runs a + * teamai subcommand once with the current Node binary (`process.execPath`) and + * the resolved entry script — no shell, so there is no metacharacter injection + * and no PATH dependency (works inside sandboxes with a bundled Node). The + * whole `process.env` is forwarded so bundled-node runtime variables survive. + * + * Throws (which the caller acks as `failed`) when remote cmd is disabled, the + * cmd is missing/rejected, the entry cannot be resolved, or the subprocess + * exits non-zero or times out. Returns undefined on success (no version to + * report for a cmd). + */ +async function runCmdCommand( + command: LocalAgentCommand, + context: LocalAgentContext, +): Promise { + if (process.env.TEAMAI_DISABLE_REMOTE_CMD === '1') { + throw new Error('remote cmd disabled by client'); + } + if (!command.cmd) { + throw new Error('cmd command is missing the "cmd" field'); + } + const argv = parseTeamaiCmd(command.cmd); + const entry = resolveCmdEntry(); + if (!entry) { + throw new Error('Cannot resolve teamai entry script to run cmd'); + } + const tag = localAgentTag(context); + try { + const { stdout } = await execFileAsync( + process.execPath, + [entry, ...argv.slice(1)], + { timeout: 120_000, env: process.env, maxBuffer: 4 * 1024 * 1024 }, + ); + const summary = stdout.trim().split('\n').slice(0, 3).join(' | '); + log.debug(`${tag} cmd OK: ${command.cmd}${summary ? ` — ${summary}` : ''}`); + return undefined; + } catch (e) { + const err = e as { stderr?: string; message?: string; killed?: boolean; code?: string }; + const detail = (err.stderr?.trim() || err.message || 'unknown error') + .split('\n') + .slice(0, 3) + .join(' | ') + .slice(0, 200); + // `killed` is also set on maxBuffer overflow, so disambiguate before labeling. + const prefix = err.code === 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER' + ? 'cmd output too large' + : err.killed + ? 'cmd timed out' + : 'cmd failed'; + throw new Error(`${prefix}: ${detail}`); + } +} + +/** + * Execute an install_hook_rule / uninstall_hook_rule sync command (issue #238): + * write or remove a single HTTP-source agent hook in the CURRENT tool's settings, + * tracked in the agent-hook manifest. Each tool family has its own hook format: + * claude/codex use settings.json, hermes uses config.yaml, openclaw-family uses + * HOOK.md + handler.ts. cursor is rejected. Throws on validation failure so + * the caller acks 'failed' with the message. + * + * Gated by the same TEAMAI_DISABLE_REMOTE_CMD kill-switch as runCmdCommand: an + * agent hook writes a backend-supplied command that the tool auto-runs on session + * events, so the client's single remote-command opt-out disables this surface too. + */ +async function runHookRuleCommand( + config: LocalAgentConfig, + command: LocalAgentCommand, + context: LocalAgentContext, +): Promise { + if (process.env.TEAMAI_DISABLE_REMOTE_CMD === '1') { + throw new Error('remote cmd disabled by client'); + } + const tool = context.tool; + if (!tool) { + throw new Error('install_hook_rule: missing current tool in context'); + } + if (!isAgentHookSupportedTool(tool)) { + throw new Error(`unsupported tool: ${tool}`); + } + const slug = command.slug; + if (!slug) { + throw new Error(`${command.type}: missing slug`); + } + const manifest = await loadAgentHookManifest(); + + if (command.type === 'uninstall_hook_rule') { + const rec = manifest[slug]; + if (rec) { + if (rec.tool === 'hermes') { + const { removeHermesAgentHook } = await import('../../../../hermes-hooks.js'); + await removeHermesAgentHook({ slug, event: rec.event, command: rec.command }); + } else if (OPENCLAW_TOOLS.has(rec.tool)) { + const { removeOpenClawAgentHook } = await import('../../../../openclaw-hooks.js'); + await removeOpenClawAgentHook({ slug, tool: rec.tool }); + } else if (rec.tool === 'opencode') { + const { removeOpencodeAgentHook } = await import('../../../../opencode-hooks.js'); + await removeOpencodeAgentHook({ slug, baseDir: getUserHome(), scope: 'user' }); + } else if (rec.tool === 'pi') { + const { removePiAgentHook } = await import('../../../../pi-hooks.js'); + await removePiAgentHook(slug); + } else { + const settingsPath = await resolveToolSettingsPath(config, rec.tool); + await removeAgentHook(settingsPath, rec.tool, { slug, command: rec.command }); + } + delete manifest[slug]; + await saveAgentHookManifest(manifest); + } + return undefined; + } + + // install_hook_rule + const event = command.event; + const cmd = command.cmd; + if (!event || !cmd) { + throw new Error('install_hook_rule: missing event or cmd'); + } + if (!isAgentHookEvent(event)) { + throw new Error(`unsupported event: ${event}`); + } + const timeout = command.timeout ?? 10; + const matcher = command.matcher; // may be undefined → applyAgentHook defaults to '*' + + // If this slug was previously installed, remove the old entry first so re-install + // never leaves a stale hook behind. This must run even when the tool is unchanged: + // applyAgentHook only replaces within the new event (claude) or by the new command + // (codex), so a same-tool re-install that changes the event or command would + // otherwise orphan the old entry. removeAgentHook scans all events by slug (claude) + // and matches prior.command (codex), covering both cases. + const prior = manifest[slug]; + if (prior) { + try { + if (prior.tool === 'hermes') { + const { removeHermesAgentHook } = await import('../../../../hermes-hooks.js'); + await removeHermesAgentHook({ slug, event: prior.event, command: prior.command }); + } else if (OPENCLAW_TOOLS.has(prior.tool)) { + const { removeOpenClawAgentHook } = await import('../../../../openclaw-hooks.js'); + await removeOpenClawAgentHook({ slug, tool: prior.tool }); + } else if (prior.tool === 'opencode') { + const { removeOpencodeAgentHook } = await import('../../../../opencode-hooks.js'); + await removeOpencodeAgentHook({ slug, baseDir: getUserHome(), scope: 'user' }); + } else if (prior.tool === 'pi') { + const { removePiAgentHook } = await import('../../../../pi-hooks.js'); + await removePiAgentHook(slug); + } else { + const priorPath = await resolveToolSettingsPath(config, prior.tool); + await removeAgentHook(priorPath, prior.tool, { slug, command: prior.command }); + } + } catch (e) { + log.debug(`agent hook [${slug}] prior cleanup failed: ${(e as Error).message}`); + } + } + + if (tool === 'hermes') { + const { applyHermesAgentHook } = await import('../../../../hermes-hooks.js'); + await applyHermesAgentHook({ slug, event, command: cmd, matcher, timeout }); + } else if (OPENCLAW_TOOLS.has(tool)) { + const { applyOpenClawAgentHook } = await import('../../../../openclaw-hooks.js'); + await applyOpenClawAgentHook({ slug, event, command: cmd, tool, matcher, timeout }); + } else if (tool === 'opencode') { + // OpenCode loads plugins from ~/.config/opencode/plugin (user scope). + const { applyOpencodeAgentHook } = await import('../../../../opencode-hooks.js'); + await applyOpencodeAgentHook({ slug, event, command: cmd, baseDir: getUserHome(), scope: 'user', matcher }); + } else if (tool === 'pi') { + const { applyPiAgentHook } = await import('../../../../pi-hooks.js'); + await applyPiAgentHook({ slug, event, command: cmd, matcher, timeout }); + } else { + const settingsPath = await resolveToolSettingsPath(config, tool); + await applyAgentHook(settingsPath, tool, { slug, event, command: cmd, matcher, timeout }); + } + manifest[slug] = { tool, event, command: cmd, matcher, timeout }; + await saveAgentHookManifest(manifest); + return undefined; +} + +// ─── MCP server install / uninstall (HTTP distribution) ───── + +const VALID_MCP_TRANSPORTS = new Set(['stdio', 'http', 'sse']); + +function mcpConfigToDef(slug: string, cfg: NonNullable): McpServerDef { + if (!VALID_MCP_TRANSPORTS.has(cfg.transport)) { + throw new Error(`install_mcp: unsupported transport "${cfg.transport}" for server "${slug}"`); + } + return { + name: slug, + transport: cfg.transport as McpTransport, + command: cfg.command, + args: cfg.args, + url: cfg.url, + headers: cfg.headers, + env: cfg.env, + timeout: cfg.timeout, + requires: cfg.requires, + }; +} + +function updateManifestRecord( + manifest: ManagedMcpManifest, + key: string, + name: string, + hash: string, +): void { + const records = manifest[key] ?? []; + const idx = records.findIndex((r: ManagedMcpRecord) => r.name === name); + if (idx >= 0) { + records[idx] = { name, hash }; + } else { + records.push({ name, hash }); + } + manifest[key] = records; +} + +async function installMcpServer( + config: LocalAgentConfig, + command: LocalAgentCommand, + tool: string, + slug: string, + scope: LocalAgentScope, + workspacePath?: string, +): Promise { + if (!command.mcp_config) { + throw new Error('install_mcp: missing mcp_config'); + } + + const def = mcpConfigToDef(slug, command.mcp_config); + const fullTeamConfig = createLocalAgentTeamConfig(config.endpoint); + // Resolved through the scope seam, so the user-scope MCP file follows a root + // the member relocated (`toolRoots`) the way `teamai pull` writes it. Project + // scope returns `mcpProject` unchanged — it belongs to the workspace. + const localConfig = await createResourceLocalConfig(config, scope, getUserHome(), workspacePath); + const toolPath = scopedToolPaths(fullTeamConfig, localConfig)[tool]; + if (!toolPath) { + throw new Error(`install_mcp: unknown tool "${tool}"`); + } + + const projectScope = scope === 'project'; + const mcpRel = projectScope ? toolPath.mcpProject : toolPath.mcp; + if (!mcpRel) { + throw new Error(`install_mcp: tool "${tool}" has no MCP config path for scope "${scope}"`); + } + + const format = detectMcpFormat(tool); + if (!format) { + throw new Error(`install_mcp: tool "${tool}" has no known MCP format`); + } + if (!supportsTransport(format, def.transport)) { + throw new Error(`install_mcp: tool "${tool}" does not support ${def.transport} transport`); + } + + const baseDir = resolveToolBaseDir(tool, localConfig); + const targetFile = path.join(baseDir, mcpRel); + + const { resolveDataHomeForScope } = await import('../../../../config.js'); + const dataHome = await resolveDataHomeForScope(projectScope ? 'project' : 'user', projectScope ? workspacePath : undefined); + // Project scope uses THIS worktree's own manifest file (per-worktree under the + // partition; migrates legacy shared records on first read). User scope uses the + // single global file. The ownership key needs no workspace segment. + let manifestPath: string; + let manifest: ManagedMcpManifest; + if (projectScope && workspacePath) { + const { loadProjectMcpManifest } = await import('../../../../utils/mcp-manifest.js'); + ({ manifestPath, manifest } = await loadProjectMcpManifest(dataHome, workspacePath)); + } else { + manifestPath = managedMcpManifestPath(dataHome); + manifest = (await readJson(manifestPath)) ?? {}; + } + const manifestKey = managedMcpManifestKey(tool, projectScope); + const owned = manifest[manifestKey] ?? []; + const ownedNames = new Set(owned.map((r: ManagedMcpRecord) => r.name)); + + if (format === 'codex') { + const block = renderCodexBlock(def); + const hash = entryHash(block); + let source = (await readFileSafe(targetFile)) ?? ''; + const present = new Set(codexServerNames(source)); + if (present.has(slug) && !ownedNames.has(slug)) { + throw new Error(`install_mcp: server "${slug}" exists in ${tool} config and is not managed by teamai`); + } + updateManifestRecord(manifest, manifestKey, slug, hash); + await writeJsonAtomic(manifestPath, manifest); + source = spliceCodexBlock(source, slug, block); + await writeCodexAtomic(targetFile, source); + } else { + const entry = renderJsonEntry(format, def); + const serverKey = MCP_SERVER_KEY[format]; + const hash = entryHash(entry); + const allowBare = format === 'copilot' && projectScope; + const doc = await readJsonDoc(targetFile, serverKey, allowBare); + if (!doc) { + throw new Error(`install_mcp: cannot parse ${targetFile}`); + } + if (doc.servers[slug] !== undefined && !ownedNames.has(slug)) { + throw new Error(`install_mcp: server "${slug}" exists in ${tool} config and is not managed by teamai`); + } + updateManifestRecord(manifest, manifestKey, slug, hash); + await writeJsonAtomic(manifestPath, manifest); + doc.servers[slug] = entry; + await writeJsonDoc(targetFile, serverKey, doc); + } + log.debug(`local-agent: installed MCP server "${slug}" for ${tool} (scope=${scope})`); + return command.version; +} + +async function uninstallMcpServer( + config: LocalAgentConfig, + tool: string, + slug: string, + scope: LocalAgentScope, + workspacePath?: string, +): Promise { + const fullTeamConfig = createLocalAgentTeamConfig(config.endpoint); + // Removal has to look where the install wrote: same scope seam, same root. + const localConfig = await createResourceLocalConfig(config, scope, getUserHome(), workspacePath); + const toolPath = scopedToolPaths(fullTeamConfig, localConfig)[tool]; + if (!toolPath) return; + + const projectScope = scope === 'project'; + const mcpRel = projectScope ? toolPath.mcpProject : toolPath.mcp; + if (!mcpRel) return; + + const format = detectMcpFormat(tool); + if (!format) return; + + const baseDir = resolveToolBaseDir(tool, localConfig); + const targetFile = path.join(baseDir, mcpRel); + + const { resolveDataHomeForScope } = await import('../../../../config.js'); + const dataHome = await resolveDataHomeForScope(projectScope ? 'project' : 'user', projectScope ? workspacePath : undefined); + // Project scope uses THIS worktree's own manifest file (per-worktree under the + // partition; migrates legacy shared records on first read). User scope uses the + // single global file. The ownership key needs no workspace segment. + let manifestPath: string; + let manifest: ManagedMcpManifest; + if (projectScope && workspacePath) { + const { loadProjectMcpManifest } = await import('../../../../utils/mcp-manifest.js'); + ({ manifestPath, manifest } = await loadProjectMcpManifest(dataHome, workspacePath)); + } else { + manifestPath = managedMcpManifestPath(dataHome); + manifest = (await readJson(manifestPath)) ?? {}; + } + const manifestKey = managedMcpManifestKey(tool, projectScope); + const owned = manifest[manifestKey] ?? []; + const ownedNames = new Set(owned.map((r: ManagedMcpRecord) => r.name)); + + if (!ownedNames.has(slug)) return; + + manifest[manifestKey] = owned.filter((r: ManagedMcpRecord) => r.name !== slug); + if ((manifest[manifestKey] as ManagedMcpRecord[]).length === 0) delete manifest[manifestKey]; + await writeJsonAtomic(manifestPath, manifest); + + if (format === 'codex') { + let source = (await readFileSafe(targetFile)) ?? ''; + source = spliceCodexBlock(source, slug, null); + await writeCodexAtomic(targetFile, source); + } else { + const serverKey = MCP_SERVER_KEY[format]; + const allowBare = format === 'copilot' && projectScope; + const doc = await readJsonDoc(targetFile, serverKey, allowBare); + if (doc && doc.servers[slug] !== undefined) { + delete doc.servers[slug]; + await writeJsonDoc(targetFile, serverKey, doc); + } + } + log.debug(`local-agent: uninstalled MCP server "${slug}" from ${tool} (scope=${scope})`); +} + +async function runMcpCommand( + config: LocalAgentConfig, + command: LocalAgentCommand, + context: LocalAgentContext, +): Promise { + const tool = context.tool; + if (!tool) { + throw new Error(`${command.type}: cannot determine current tool`); + } + const slug = command.slug; + if (!slug) { + throw new Error(`${command.type}: missing slug`); + } + assertSafeResourceName(slug); + + const scope = normalizeScope(command.scope); + const workspacePath = scope === 'project' + ? await resolveWorkspacePath(command.workspace_path ?? context.cwd) + : undefined; + if (scope === 'project' && !workspacePath) { + throw new Error(`${command.type}: workspace command is missing workspace_path`); + } + + if (command.type === 'install_mcp') { + return installMcpServer(config, command, tool, slug, scope, workspacePath); + } + + await uninstallMcpServer(config, tool, slug, scope, workspacePath); + return command.version; +} + +async function executeCommand( + config: LocalAgentConfig, + command: LocalAgentCommand, + context: LocalAgentContext, +): Promise { + if (command.type === 'apply_model_config') { + await applyModelConfig(config, command, context); + return; + } + // uninstall_teamai (clawpro three-phase: cmd = "teamai uninstall --force + // --agent ") executes its `cmd` string as a restricted teamai subcommand. + if (command.type === 'uninstall_teamai') { + return runCmdCommand(command, context); + } + if (command.type === 'install_hook_rule' || command.type === 'uninstall_hook_rule') { + return runHookRuleCommand(config, command, context); + } + if (command.type === 'install_mcp' || command.type === 'uninstall_mcp') { + return runMcpCommand(config, command, context); + } + const kind = commandKind(command); + const action = commandAction(command); + if (!kind || !action) { + throw new Error(`Unsupported command type: ${command.type ?? ''}`); + } + + const scope = normalizeScope(command.scope); + const workspacePath = scope === 'project' + ? await resolveWorkspacePath(command.workspace_path ?? context.cwd) + : undefined; + if (scope === 'project' && !workspacePath) { + throw new Error('Project command is missing workspace_path'); + } + + const slug = commandSlug(command, kind); + const tool = context.tool; + if (action === 'install') { + return installDownloadedResource({ config, command, kind, slug, scope, workspacePath, tool }); + } + + await uninstallResource({ config, kind, slug, scope, workspacePath, tool }); + return commandVersion(command, kind); +} + +async function processCommands( + config: LocalAgentConfig, + commands: LocalAgentCommand[], + context: LocalAgentContext, + outcome?: SyncOutcome, +): Promise { + const tag = localAgentTag(context); + let modelConfigApplied = false; + for (const command of commands) { + // Keep these special types aligned with executeCommand's direct branches. + // Resource commands are recognized generically by commandKind/action; + // everything else is a future protocol extension and must be skipped. + if (isUnimplementedCommand(command) || ( + command.type !== 'apply_model_config' && + command.type !== 'uninstall_teamai' && + command.type !== 'install_hook_rule' && + command.type !== 'uninstall_hook_rule' && + command.type !== 'install_mcp' && + command.type !== 'uninstall_mcp' && + (!commandKind(command) || !commandAction(command)) + )) { + log.debug(`${tag} skipping unimplemented command ${command.id} (${command.type})`); + continue; + } + try { + const version = await executeCommand(config, command, context); + await ackCommand(config, tag, command, 'success', version); + if (command.type === 'apply_model_config') modelConfigApplied = true; + log.debug(`${tag} command ${command.id} (${command.type ?? ''}) succeeded`); + // Uninstall succeeded — skip remaining commands; the hook process exits naturally. + if (command.type === 'uninstall_teamai') { + log.debug(`${tag} uninstall_teamai completed — remaining commands skipped`); + return modelConfigApplied; + } + } catch (e) { + const error = (e as Error).message; + log.error(`${tag} command ${command.id} failed: ${error}`); + // A failed command is isolated (the session continues), but the sync as a + // whole did not fully succeed — surface it so `provider sync` reports the + // failure instead of a false success (does not throw / abort the loop). + if (outcome && !outcome.failed) { + outcome.failed = true; + outcome.error = `command ${command.id} (${command.type ?? ''}) failed: ${error}`; + } + try { + await ackCommand(config, tag, command, 'failed', undefined, error); + } catch (ackError) { + log.debug(`${tag} failed to ack command ${command.id}: ${(ackError as Error).message}`); + } + } + } + return modelConfigApplied; +} + +/** + * Out-parameter for report/sync outcome. The boolean return of + * `reportAndSyncLocalAgent` means "config present and ran", not "succeeded" + * (many callers/tests rely on that), so a failed report/sync is reported here + * instead: `failed` is set true with the error when the network/command phase + * throws. Legacy callers omit it and are unaffected; the HTTP provider adapter + * passes one so `provider sync` never prints a backend failure as success. + */ +export interface SyncOutcome { + failed?: boolean; + error?: string; +} + +export async function reportAndSyncLocalAgent( + context: LocalAgentContext, + outcome?: SyncOutcome, +): Promise { + const config = await loadLocalAgentConfig(); + if (!config) return false; + + // Binding prompt is injected via stdout hook context (not HTTP), so it must run + // even inside the CloudStudio sandbox — the sandbox guard below only skips the + // HTTP report/sync that would produce a duplicate card. Resolve the workspace + // only when the prompt is enabled AND the host is a buddy agent, so every other + // path (disabled flag, or a non-buddy tool like Claude/Cursor/Codex) forks no + // git process. + if (isBindPromptEnabled() && isBindPromptTool(context.tool)) { + const workspacePath = await resolveWorkspacePath(context.cwd); + if (workspacePath) { + const sid = context.event?.sessionId; + if (context.event?.type === 'session_start') { + await ensureWorkspaceBinding(config, workspacePath, sid, context.cwd); + } + if (context.event?.type === 'prompt_submit') { + await emitBindingHint(config, workspacePath, sid, context.cwd); + } + } + } + + // CloudStudio sandbox reports a duplicate agent card (different machine id + // than the host), so we skip the report POST here. Sync + command execution + // must still run so sandboxed agents can receive pushed cmds (e.g. uninstall); + // sync produces no card, so there is no duplicate risk. + // TEAMAI_ALLOW_SANDBOX_REPORT=1 restores the report too (backward compatible). + const skipReport = isCloudStudioSandbox() && process.env.TEAMAI_ALLOW_SANDBOX_REPORT !== '1'; + if (skipReport) { + log.debug( + '[local-agent] CloudStudio sandbox detected; skipping HTTP report ' + + '(sync still runs; set TEAMAI_ALLOW_SANDBOX_REPORT=1 to report too)', + ); + } + + const tag = localAgentTag(context); + log.debug(`${tag} run: endpoint=${config.endpoint}`); + + // Report-side bookkeeping (plugin reconcile + binding prune + tool stamp) is + // tied to the report path and must stay skipped inside the CloudStudio sandbox, + // exactly as before this branch stopped returning early. In particular, + // pruneDeadWorkspaceBindings would wrongly drop host bindings whose paths are + // not mounted in the container. Only sync + command execution run when + // skipReport is set. + if (!skipReport) { + if (context.event?.type === 'session_start') { + await maybeReconcilePlugins(context); + } + + const pruned = await pruneDeadWorkspaceBindings(config); + // Resolve the current workspace independently here rather than reusing an + // earlier local, so tool attribution does not depend on the binding-prompt + // block above keeping a `workspacePath` in scope. + const currentPath = await resolveWorkspacePath(context.cwd); + const stamped = stampWorkspaceTool(config, currentPath, context.tool ?? 'workbuddy'); + if (pruned || stamped) { + await saveLocalAgentConfig(config); + } + } + + try { + if (!skipReport) { + const reportPayload = await buildReportPayload(config, context); + await localAgentFetch(config, tag, 'report', { + method: 'POST', + body: JSON.stringify(reportPayload), + }); + log.debug(`${tag} report OK`); + } + + const syncPayload = await buildSyncPayload(config, context); + const syncResponse = await localAgentFetch<{ + ok?: boolean; + cmds?: LocalAgentCommand[]; + commands?: LocalAgentCommand[]; + }>( + config, + tag, + 'sync', + { method: 'POST', body: JSON.stringify(syncPayload) }, + { redactResponseLog: true }, + ); + // Prefer the unified cmds[] (source of truth). Fall back to the legacy + // commands[] for older backends that do not yet emit cmds. An empty cmds[] + // is treated as "cmds not available" and falls back too — the backend sends + // identical data in both arrays, so this only affects old backends where + // cmds is genuinely absent/empty while commands still carries the work. + // TODO(jiahe, cmds-migration): drop the `commands` fallback once the backend + // guarantees `cmds` on all sync responses (clawpro iwiki ch.7). + const cmds = syncResponse.cmds; + const commands = cmds && cmds.length > 0 ? cmds : (syncResponse.commands ?? []); + if (commands.length > 0) { + log.debug(`${tag} sync returned ${commands.length} command(s): ${commands.map((c) => `${c.type}#${c.id}`).join(', ')}`); + const modelConfigApplied = await processCommands(config, commands, context, outcome); + if (modelConfigApplied && !skipReport) { + const reportPayload = await buildReportPayload(config, context); + await localAgentFetch(config, tag, 'report', { + method: 'POST', + body: JSON.stringify(reportPayload), + }); + log.debug(`${tag} model config report OK`); + } + } + log.debug(`${tag} sync OK (${commands.length} command(s))`); + } catch (e) { + const error = (e as Error).message; + log.error(`${tag} sync FAILED: ${error}`); + await appendErrorLog({ error, context }); + if (outcome) { + outcome.failed = true; + outcome.error = error; + } + } + + return true; +} + +function statusFromEvent(event?: DashboardEvent): string { + if (!event) return 'running'; + if (event.type === 'stop' || event.type === 'process_exit') return 'stopped'; + return 'running'; +} + +/** + * Hook-handler adapter: run local-agent report/sync (incl. workspace binding + * prompts) from within the unified hook dispatcher. Accepts pre-parsed STDIN + * data so the dispatcher reads STDIN only once. + */ +export async function reportAndSyncFromHook( + stdin: Record, + tool: string, + outcome?: SyncOutcome, +): Promise { + const raw = JSON.stringify(stdin); + const event = await parseHookEvent(raw, tool); + // parseHookEvent resolves cwd via resolveHookCwd too, so event?.cwd would be + // identical here — resolve once and fall back to process.cwd(). + const cwd = resolveHookCwd(stdin) ?? process.cwd(); + + // SessionStart and UserPromptSubmit run this handler in the *foreground*, where + // it blocks the host IDE's hook (UserPromptSubmit cap = 10s). Narrow the + // per-fetch timeout so a slow/unreachable endpoint fails fast and the handler + // returns before the host aborts the hook. Stop / PostToolUse run detached in + // the background, so they keep the full interactive timeout to complete real + // resource syncs/downloads. + const isForegroundEvent = event?.type === 'session_start' || event?.type === 'prompt_submit'; + activeFetchTimeoutMs = isForegroundEvent + ? LOCAL_AGENT_HOOK_FETCH_TIMEOUT_MS + : LOCAL_AGENT_FETCH_TIMEOUT_MS; + try { + await reportAndSyncLocalAgent({ + cwd, + tool, + status: statusFromEvent(event ?? undefined), + event: event ?? undefined, + }, outcome); + return null; + } finally { + activeFetchTimeoutMs = LOCAL_AGENT_FETCH_TIMEOUT_MS; + } +} + +/** + * Persist the API token as a credential file with owner-only (0o600) + * permissions. chmod after write so an already-existing token file (whose perms + * mode-on-create would not touch) is also tightened. + */ +export async function writeTokenFile(tokenPath: string, token: string): Promise { + await fs.promises.writeFile(tokenPath, token + '\n', { mode: 0o600 }); + await fs.promises.chmod(tokenPath, 0o600); +} + +export async function initLocalAgentHttp(options: { + endpoint: string; + token?: string; + force?: boolean; + filterAgents?: string[]; +}): Promise { + const endpoint = normalizeEndpoint(options.endpoint); + if (!endpoint) { + throw new Error('HTTP endpoint is required.'); + } + + const existing = await loadLocalAgentConfig(); + if (existing && !options.force) { + throw new Error('HTTP local agent is already initialized. Re-run with --force to overwrite.'); + } + + const config: LocalAgentConfig = { + endpoint, + token: options.token, + createdAt: existing?.createdAt ?? new Date().toISOString(), + workspaceBindings: existing?.workspaceBindings ?? {}, + userGroupId: existing?.userGroupId, + userGroupName: existing?.userGroupName, + }; + + await ensureDir(getLocalAgentHome()); + await saveLocalAgentConfig(config); + // Migration now DELETES the legacy dir (no marker), so a freshly (re)written + // legacy config here is active again purely by its presence — nothing to + // clear. (Named-provider init writes under the provider home, not here.) + if (options.token) { + // Named providers keep the credential in their own 0600 file; the legacy + // singleton keeps its historical ~/.teamai/token location. + const providerCtx = httpProviderContext.getStore(); + const credentialPath = providerCtx?.credentialPath ?? getTokenPath(); + await ensureDir(path.dirname(credentialPath)); + await writeTokenFile(credentialPath, options.token); + } + + const teamConfig = createLocalAgentTeamConfig(endpoint); + // The local agent is always user-scope and always rooted at HOME, so resolve + // the user-scope paths (Qoder CN's user config lives under ~/.qoder-cn). + const hookResult = await injectHooksToAllTools( + scopedToolPaths(teamConfig, { scope: 'user', toolRoots: await memberToolRoots() }), + getUserHome(), + options.filterAgents, + ); + // injectHooksToAllTools is best-effort (one tool failing warns and continues). + // For a named provider that is a fresh, explicit `provider add`, a run where + // hooks were attempted but NONE succeeded means the provider would deliver + // nothing — surface that as a failure so the caller rolls back instead of + // reporting a false success. The legacy singleton keeps its lenient behavior. + if (httpProviderContext.getStore()) { + if (hookResult.attempted > 0 && hookResult.succeeded === 0) { + throw new Error( + 'Failed to inject the teamai hook into any detected tool; the provider would deliver nothing.', + ); + } + } else { + log.success(`HTTP provider initialized at ${getConfigPath()}`); + } +} + +export async function pullLocalAgentForCwd( + context?: LocalAgentContext, + outcome?: SyncOutcome, +): Promise { + return reportAndSyncLocalAgent({ + cwd: context?.cwd ?? process.cwd(), + tool: context?.tool ?? 'workbuddy', + status: context?.status ?? 'running', + event: context?.event, + }, outcome); +} + +/** Summary of the configured HTTP local-agent bypass, for `teamai source list`. */ +export interface LocalAgentSummary { + endpoint: string; + boundProjects: Array<{ path: string; projectName?: string; projectId: number }>; + resourceCounts: { skills: number; rules: number; claudemd: number }; +} + +/** + * Describe the configured HTTP local-agent bypass (report/sync/ack), or null when + * none is configured. Used by `teamai source list` to show the HTTP side channel + * alongside git cross-team sources. + */ +export async function describeLocalAgent(): Promise { + // Migration deletes the legacy dir outright (no rollback snapshot), so a + // migrated singleton simply no longer exists here and loadLocalAgentConfig + // returns null — nothing special to filter for `source list`. + const config = await loadLocalAgentConfig(); + if (!config) return null; + + const boundProjects = Object.entries(config.workspaceBindings) + .filter(([, b]) => b.projectId !== 0) + .map(([workspacePath, b]) => ({ path: workspacePath, projectName: b.projectName, projectId: b.projectId })); + + const manifest = await loadManifest(); + const counts = { skills: 0, rules: 0, claudemd: 0 }; + for (const scope of Object.values(manifest.scopes)) { + counts.skills += Object.keys(scope.skills ?? {}).length; + counts.rules += Object.keys(scope.rules ?? {}).length; + counts.claudemd += Object.keys(scope.claudemd ?? {}).length; + } + + return { endpoint: config.endpoint, boundProjects, resourceCounts: counts }; +} + +/** Parse a manifest scope key back into (scope, workspacePath). */ +function parseScopeKey(key: string): { scope: LocalAgentScope; workspacePath?: string } { + if (key.startsWith('project:')) { + return { scope: 'project', workspacePath: key.slice('project:'.length) || undefined }; + } + return { scope: key === 'instance' ? 'instance' : 'user' }; +} + +/** + * Run each installed plugin's uninstall_cmd (stop daemons, deregister autostart, + * remove packages) using the persisted plugin manifest. No-op when no HTTP source + * is configured. + * + * Best-effort: failures are logged, never thrown, so teardown of the rest of teamai + * is never blocked. Must run BEFORE ~/.teamai is deleted — it reads the plugin + * manifest and endpoint config from ~/.teamai/local-agent/. + */ +export async function teardownLocalAgentPlugins(): Promise { + try { + const config = await loadLocalAgentConfig(); + if (!config) return; + await teardownAllPlugins(buildReconcileDeps(config, '[local-agent] [uninstall]')); + } catch (e) { + log.warn(`[local-agent] plugin teardown failed: ${e instanceof Error ? e.message : String(e)}`); + } +} + +/** + * Remove every HTTP-source agent hook recorded in the agent-hook manifest from + * each tool's settings, then clear the manifest. Best-effort; used by + * `source remove-http` and `teamai uninstall` teardown (issue #238). Safe to call + * when no config / no manifest exists. + * + * Returns true when every recorded hook was removed. When some removals failed + * the manifest is KEPT (not cleared) and false is returned, so a caller tearing + * a provider down can preserve state for a retry instead of orphaning the hooks + * whose ownership record it would otherwise destroy (issue #404, review #5). + */ +export async function removeAllAgentHooks(): Promise { + const config = await loadLocalAgentConfig(); + if (!config) return true; + const manifest = await loadAgentHookManifest(); + const slugs = Object.keys(manifest); + if (slugs.length === 0) return true; + let allRemoved = true; + for (const slug of slugs) { + const rec = manifest[slug]; + try { + if (rec.tool === 'hermes') { + const { removeHermesAgentHook } = await import('../../../../hermes-hooks.js'); + await removeHermesAgentHook({ slug, event: rec.event, command: rec.command }); + } else if (OPENCLAW_TOOLS.has(rec.tool)) { + const { removeOpenClawAgentHook } = await import('../../../../openclaw-hooks.js'); + await removeOpenClawAgentHook({ slug, tool: rec.tool }); + } else if (rec.tool === 'opencode') { + const { removeOpencodeAgentHook } = await import('../../../../opencode-hooks.js'); + await removeOpencodeAgentHook({ slug, baseDir: getUserHome(), scope: 'user' }); + } else if (rec.tool === 'pi') { + const { removePiAgentHook } = await import('../../../../pi-hooks.js'); + await removePiAgentHook(slug); + } else { + const settingsPath = await resolveToolSettingsPath(config, rec.tool); + await removeAgentHook(settingsPath, rec.tool, { slug, command: rec.command }); + } + } catch (e) { + allRemoved = false; + log.warn(`agent hook [${slug}] teardown failed: ${(e as Error).message}`); + } + } + // Only clear the manifest when everything was removed; keeping it on failure + // lets a retry find and clean the leftover hooks. + if (allRemoved) await saveAgentHookManifest({}); + return allRemoved; +} + +/** + * Whether any OTHER teamai install still relies on the shared built-in hooks: + * a user-scope config, OR any project-scope partition config. Used to decide + * whether a named provider's teardown may remove the built-in dispatch hooks — + * it must not when a git/self install of ANY scope still needs them. Checking + * only the user scope would miss a project-only Git TeamAI install. + */ +async function hasOtherTeamaiInstall(): Promise { + const { loadLocalConfig, detectProjectConfig } = await import('../../../../config.js'); + if (await loadLocalConfig()) return true; + // A project-scope install governing the current directory — covers both the + // partitioned form (~/.teamai/projects//) and a legacy in-tree + // /.teamai/config.yaml, which the partition enumeration below misses. + try { + if (await detectProjectConfig(process.cwd())) return true; + } catch { + // unreadable project config → fall through to partition scan + } + // Enumerate project-scope partitions (~/.teamai/projects//config.yaml) + // so an install for a DIFFERENT project on this machine is also detected. + try { + const { projectsRootDir } = await import('../../../../utils/partition.js'); + const root = projectsRootDir(); + for (const slug of await listDirs(root)) { + if (await pathExists(path.join(root, slug, 'config.yaml'))) return true; + } + } catch { + // No partitions dir / unreadable → treat as no other install. + } + return false; +} + +/** + * Tear down the HTTP local-agent bypass: uninstall every resource recorded in the + * manifest (skills/rules/claudemd, across all scopes) from the AI tool dirs, then + * remove the whole ~/.teamai/local-agent/ directory (config + manifest). + * + * Best-effort per resource: a single failed uninstall is logged and skipped so a + * stale entry cannot block the teardown. If any resource uninstall failed, the + * provider home + manifest are KEPT (not deleted) so cleanup can be retried + * (review #6) — a destroyed manifest would orphan the leftover resources. + */ +export async function removeLocalAgentHttp(): Promise { + const config = await loadLocalAgentConfig(); + if (!config) { + log.info('No HTTP source configured — nothing to remove.'); + return; + } + + // Any teardown step that fails to fully clean up sets this — the state home + // (config + manifest + plugins.json) is then KEPT so a retry can finish the + // job. Deleting it after a partial teardown would orphan the leftover + // resources/hooks/plugins and destroy the ownership records needed to find + // them (issue #404, reviews #5/#6). + let uninstallFailed = false; + + // Tear down installed plugins before removing teamai's local-agent state. + // teardownAllPlugins swallows each plugin's error internally and reports an + // aggregate success flag, so check the return value (a thrown error would be + // an unexpected failure of the teardown machinery itself). + try { + if (!await teardownAllPlugins(buildReconcileDeps(config, '[local-agent] [uninstall]'))) { + uninstallFailed = true; + } + } catch (e) { + uninstallFailed = true; + log.warn(`[local-agent] plugin teardown failed: ${(e as Error).message}`); + } + + const kinds: CommandResourceKind[] = ['skill', 'rule', 'claudemd']; + const manifest = await loadManifest(); + for (const [key, scopeManifest] of Object.entries(manifest.scopes)) { + const { scope, workspacePath } = parseScopeKey(key); + for (const kind of kinds) { + for (const slug of Object.keys(scopeManifest[manifestKind(kind)] ?? {})) { + try { + await uninstallResource({ config, kind, slug, scope, workspacePath }); + } catch (e) { + uninstallFailed = true; + log.warn(`local-agent: failed to uninstall ${kind} "${slug}": ${(e as Error).message}`); + } + } + } + } + + if (!await removeAllAgentHooks()) uninstallFailed = true; + + const providerCtx = httpProviderContext.getStore(); + // The built-in teamai dispatch hooks are SHARED infrastructure: the same + // entries serve every teamai install (git/self/other providers), and with no + // config for a tool's cwd every handler no-ops gracefully. Removing them on a + // single `provider remove` is therefore asymmetric — a leftover hook is + // harmless, but an erroneous removal breaks a coexisting install, and whether + // another install exists cannot be determined reliably (a legacy in-tree + // /.teamai can live anywhere on disk; review #5/#7). So we do NOT + // strip the built-in hooks here. `teamai uninstall` — the "remove everything" + // path — owns full built-in-hook removal via its own enumeration. + // + // Only when this provider is being torn down AS PART OF `teamai uninstall` + // (TEAMAI_UNINSTALL=1) and no other install remains do we also clear them, so + // a provider added standalone still gets its hooks removed at uninstall time. + if (providerCtx && process.env.TEAMAI_UNINSTALL === '1' && !(await hasOtherTeamaiInstall())) { + try { + const teamConfig = createLocalAgentTeamConfig(config.endpoint); + await reconcileHooksToAllTools( + scopedToolPaths(teamConfig, { scope: 'user', toolRoots: await memberToolRoots() }), + getUserHome(), + [], + getManagedHooksPath('user'), + { removeAll: true }, + ); + } catch (e) { + uninstallFailed = true; + log.warn(`[local-agent] built-in hook removal failed: ${(e as Error).message}`); + } + } + + // If ANY teardown step failed (locked file, permission denied), KEEP the state + // home and manifest so cleanup can be retried — deleting them here would + // orphan the leftover resources/hooks/plugins permanently (reviews #5/#6). + if (uninstallFailed) { + throw new Error( + 'Some resources/hooks/plugins could not be removed; kept the provider state so cleanup ' + + 'can be retried. Re-run once the underlying issue (locked file / permissions) is resolved.', + ); + } + + await remove(getLocalAgentHome()); + // A named provider's credential lives outside its state home; remove it too. + if (providerCtx) await remove(providerCtx.credentialPath); + log.success('HTTP source removed (resources uninstalled, config cleared).'); +} + +export async function bindCurrentProject(options?: { projectId?: number; skip?: boolean; cwd?: string }): Promise { + const workspacePath = await resolveWorkspacePath(options?.cwd ?? process.cwd()); + if (!workspacePath) { + throw new Error('Cannot resolve current workspace path.'); + } + if (options?.skip) { + const config = await loadLocalAgentConfig(); + if (!config) { + throw new Error('Local agent not initialized. Run `teamai init --http` first.'); + } + // Skip the whole project (main checkout + all its worktrees), not just this + // one checkout, so sibling worktrees are not re-prompted. + await persistWorkspaceBinding(config, options?.cwd ?? process.cwd(), workspacePath, 0, '__skipped__'); + log.info(`已跳过绑定,以后不再提示此工作区。`); + return; + } + const binding = await bindWorkspaceToProject(workspacePath, options?.projectId); + if (!binding) { + log.info('未绑定项目。'); + } +} diff --git a/src/providers/http/adapters/clawpro/index.ts b/src/providers/http/adapters/clawpro/index.ts new file mode 100644 index 000000000..afe3d9c8c --- /dev/null +++ b/src/providers/http/adapters/clawpro/index.ts @@ -0,0 +1,78 @@ +// ─── ClawPro HTTP backend adapter ──────────────────────── +// +// Speaks the ClawPro report/sync/ack wire format. It is a thin translation +// layer over ./client.ts (relocated from the former src/local-agent.ts): each +// HttpBackendAdapter method runs the corresponding client entry point inside the +// provider's isolated execution context, so a named provider's state lands in +// its own directory. The wire format — routes, `local_agent_id`, payload shapes +// — is unchanged; see issue #404. + +import type { + HttpBackendAdapter, + HttpProviderConfig, + ProviderResult, + ProviderSummary, + SyncContext, +} from '../../../types.js'; +import { + withHttpProvider, + initLocalAgentHttp, + reportAndSyncFromHook, + pullLocalAgentForCwd, + describeLocalAgent, + removeLocalAgentHttp, + type SyncOutcome, +} from './client.js'; +import { httpProviderExecutionContext } from '../../store.js'; + +export class ClawProAdapter implements HttpBackendAdapter { + readonly name = 'clawpro'; + + async initialize(config: HttpProviderConfig, token?: string): Promise { + await withHttpProvider(httpProviderExecutionContext(config.name), () => + initLocalAgentHttp({ endpoint: config.endpoint, token, force: true }), + ); + } + + async sync(config: HttpProviderConfig, context: SyncContext): Promise { + return withHttpProvider(httpProviderExecutionContext(config.name), async () => { + // reportAndSyncLocalAgent swallows network/command errors (so one bad + // backend never crashes a hook); it signals them through this outcome + // instead, so we report an honest ok rather than always true. + const outcome: SyncOutcome = {}; + let hookOutput: string | null = null; + if (context.trigger === 'hook' && context.stdin) { + hookOutput = await reportAndSyncFromHook(context.stdin, context.tool ?? 'workbuddy', outcome); + } else { + await pullLocalAgentForCwd({ cwd: context.cwd, tool: context.tool }, outcome); + } + return { + provider: config.name, + ok: !outcome.failed, + changed: false, + ...(outcome.error ? { message: outcome.error } : {}), + ...(hookOutput ? { hookOutput } : {}), + }; + }); + } + + async describe(config: HttpProviderConfig): Promise { + const summary = await withHttpProvider(httpProviderExecutionContext(config.name), () => + describeLocalAgent(), + ); + return { + name: config.name, + type: 'http', + priority: config.priority, + capabilities: { pull: true, push: false, report: true, commands: true }, + endpoint: summary?.endpoint ?? config.endpoint, + adapter: this.name, + }; + } + + async teardown(config: HttpProviderConfig): Promise { + await withHttpProvider(httpProviderExecutionContext(config.name), () => + removeLocalAgentHttp(), + ); + } +} diff --git a/src/providers/http/provider.ts b/src/providers/http/provider.ts new file mode 100644 index 000000000..a92a4dbdd --- /dev/null +++ b/src/providers/http/provider.ts @@ -0,0 +1,53 @@ +// ─── HTTP resource provider ────────────────────────────── +// +// A ResourceProvider backed by an HTTP backend. It owns the common shape +// (capabilities, name, type) and delegates every operation to a protocol +// HttpBackendAdapter, which handles that backend's wire format. HTTP backends +// deliver and report but are never a push target. See issue #404. + +import type { + ResourceProvider, + ProviderCapabilities, + ProviderResult, + ProviderSummary, + SyncContext, + HttpBackendAdapter, + HttpProviderConfig, +} from '../types.js'; + +const HTTP_CAPABILITIES: ProviderCapabilities = { + pull: true, + push: false, + report: true, + commands: true, +}; + +export class HttpResourceProvider implements ResourceProvider { + readonly type = 'http' as const; + readonly capabilities = HTTP_CAPABILITIES; + + constructor( + private readonly config: HttpProviderConfig, + private readonly adapter: HttpBackendAdapter, + ) {} + + get name(): string { + return this.config.name; + } + + get priority(): number { + return this.config.priority; + } + + sync(context: SyncContext): Promise { + return this.adapter.sync(this.config, context); + } + + describe(): Promise { + return this.adapter.describe(this.config); + } + + teardown(): Promise { + return this.adapter.teardown(this.config); + } +} diff --git a/src/providers/http/registry.ts b/src/providers/http/registry.ts new file mode 100644 index 000000000..917b54ee3 --- /dev/null +++ b/src/providers/http/registry.ts @@ -0,0 +1,43 @@ +// ─── HTTP adapter registry ─────────────────────────────── +// +// Maps a protocol adapter name (e.g. 'clawpro') to its implementation and +// builds HttpResourceProviders from persisted config. Deleting an adapter +// directory removes it here without touching the generic HTTP provider — the +// point of the ClawPro extraction in issue #404. + +import type { HttpBackendAdapter, HttpProviderConfig } from '../types.js'; +import { HttpResourceProvider } from './provider.js'; +import { ClawProAdapter } from './adapters/clawpro/index.js'; +import { listHttpProviderConfigs } from './store.js'; + +/** Adapter factories, keyed by adapter name. */ +const ADAPTERS: Record HttpBackendAdapter> = { + clawpro: () => new ClawProAdapter(), +}; + +/** Names of the HTTP protocol adapters this build supports. */ +export function availableHttpAdapters(): string[] { + return Object.keys(ADAPTERS); +} + +/** Instantiate an HTTP backend adapter by name. Throws for an unknown adapter. */ +export function getHttpAdapter(name: string): HttpBackendAdapter { + const factory = ADAPTERS[name]; + if (!factory) { + throw new Error( + `Unknown HTTP adapter "${name}". Available: ${availableHttpAdapters().join(', ')}`, + ); + } + return factory(); +} + +/** Build a provider from one config. */ +export function createHttpResourceProvider(config: HttpProviderConfig): HttpResourceProvider { + return new HttpResourceProvider(config, getHttpAdapter(config.adapter)); +} + +/** Load every configured HTTP provider from the store. */ +export async function loadHttpResourceProviders(): Promise { + const configs = await listHttpProviderConfigs(); + return configs.map(createHttpResourceProvider); +} diff --git a/src/providers/http/store.ts b/src/providers/http/store.ts new file mode 100644 index 000000000..e2ce1515e --- /dev/null +++ b/src/providers/http/store.ts @@ -0,0 +1,313 @@ +// ─── HTTP provider store ───────────────────────────────── +// +// On-disk layout for named HTTP providers (issue #404). Each provider's state +// is isolated so credentials, manifests, bindings, plugin state and caches of +// one backend never touch another's: +// +// ~/.teamai/providers/http// state home (config.json, manifest, …) +// ~/.teamai/credentials/ credential (0600), outside config +// ~/.teamai/providers/settings.json registry (which providers exist) +// +// The legacy single HTTP backend at ~/.teamai/local-agent/ keeps working +// untouched; `migrateLegacyHttpProvider` promotes it to a named provider with +// an atomic copy + rollback snapshot, and leaves a marker so the old singleton +// stops claiming the backend. + +import path from 'node:path'; +import fse from 'fs-extra'; +import { getUserHome } from '../../utils/home.js'; +import { + ensureDir, + pathExists, + readJson, + remove, + writeJsonAtomic, +} from '../../utils/fs.js'; +import { readFileSafe } from '../../utils/fs.js'; +import { writeTokenFile } from './adapters/clawpro/client.js'; +import type { HttpProviderExecutionContext } from './adapters/clawpro/client.js'; +import type { HttpProviderConfig } from '../types.js'; + +const PROVIDERS_DIR = 'providers'; +const HTTP_DIR = 'http'; +const CREDENTIALS_DIR = 'credentials'; +const REGISTRY_FILE = 'settings.json'; +const PROVIDER_CONFIG_FILE = 'provider.json'; +const LEGACY_DIR = 'local-agent'; + +function teamaiHome(): string { + return path.join(getUserHome(), '.teamai'); +} + +function httpProvidersRoot(): string { + return path.join(teamaiHome(), PROVIDERS_DIR, HTTP_DIR); +} + +/** State home for one named HTTP provider. */ +export function httpProviderHome(name: string): string { + return path.join(httpProvidersRoot(), name); +} + +/** Credential-file path for one named HTTP provider (0600, outside config). */ +export function httpProviderCredentialPath(name: string): string { + return path.join(teamaiHome(), CREDENTIALS_DIR, name); +} + +/** Build the execution context a named provider run needs (see withHttpProvider). */ +export function httpProviderExecutionContext(name: string): HttpProviderExecutionContext { + return { + name, + home: httpProviderHome(name), + credentialPath: httpProviderCredentialPath(name), + }; +} + +function registryPath(): string { + return path.join(teamaiHome(), PROVIDERS_DIR, REGISTRY_FILE); +} + +interface ProviderRegistryFile { + http?: HttpProviderConfig[]; +} + +/** + * Windows reserved device names (case-insensitive). Windows forbids these both + * bare and with any extension (`CON`, `CON.txt`, `LPT1.foo` all resolve to the + * device), so match an optional `.` suffix too. + */ +const WINDOWS_RESERVED_NAME = /^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/i; + +/** + * A provider name becomes a single path segment for its state dir and + * credential file, so it must be safe on every platform. Beyond the character + * set, reject anything that could collide with another name on a + * case-insensitive filesystem (`Foo` vs `foo`), a name ending in `.` or a + * space (Windows strips them, so `name.` and `name` would share a path), and + * Windows reserved device names (`CON`, `COM1`, …). + */ +export function assertValidProviderName(name: string): void { + if (!/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(name)) { + throw new Error( + `Invalid provider name "${name}". Use letters, digits, '.', '_' or '-', starting alphanumeric.`, + ); + } + if (name.endsWith('.')) { + throw new Error(`Invalid provider name "${name}": must not end with '.'.`); + } + if (WINDOWS_RESERVED_NAME.test(name)) { + throw new Error(`Invalid provider name "${name}": reserved device name.`); + } +} + +/** + * Reject a name that collides with an existing provider on a case-insensitive + * filesystem (`Foo` when `foo` exists). Same-name replacement is allowed (that + * is an intentional upsert), so only a *different* name with the same lowercase + * form is a conflict. Call before writing a NEW provider. + */ +async function assertNameNotCaseColliding(name: string): Promise { + const lower = name.toLowerCase(); + const clash = (await listHttpProviderConfigs()).find( + (p) => p.name !== name && p.name.toLowerCase() === lower, + ); + if (clash) { + throw new Error( + `Provider name "${name}" collides with existing "${clash.name}" on a ` + + 'case-insensitive filesystem. Choose a distinct name.', + ); + } +} + +/** Read the registry of configured HTTP providers (empty when none). */ +export async function listHttpProviderConfigs(): Promise { + const file = await readJson(registryPath()); + return file?.http ?? []; +} + +/** Look up one HTTP provider's config by name. */ +export async function getHttpProviderConfig(name: string): Promise { + return (await listHttpProviderConfigs()).find((p) => p.name === name); +} + +/** + * Read a provider's self-describing config from its own state home + * (`~/.teamai/providers/http//provider.json`), independent of the + * registry. Used to recover a provider whose registry entry was dropped after a + * failed add/remove so `provider remove` can still find and finish its cleanup + * (issue #404, review #3). Returns undefined when the home has no config. + */ +export async function readHttpProviderHomeConfig(name: string): Promise { + try { + assertValidProviderName(name); + } catch { + return undefined; + } + const cfg = await readJson( + path.join(httpProviderHome(name), PROVIDER_CONFIG_FILE), + ); + return cfg?.name ? cfg : undefined; +} + +/** + * Persist a provider's self-describing config into its own state home WITHOUT + * touching the registry. Used by a failed `provider add` that keeps partial + * state for a retriable `provider remove`, so the recovery read above can find + * the config even though the registry entry was dropped (issue #404, review #3). + */ +export async function writeHttpProviderHomeConfig(config: HttpProviderConfig): Promise { + assertValidProviderName(config.name); + await ensureDir(httpProviderHome(config.name)); + await writeJsonAtomic(path.join(httpProviderHome(config.name), PROVIDER_CONFIG_FILE), config); +} + +/** Add or replace an HTTP provider in the registry, persisting its config. */ +export async function upsertHttpProviderConfig(config: HttpProviderConfig): Promise { + assertValidProviderName(config.name); + await assertNameNotCaseColliding(config.name); + const existing = await listHttpProviderConfigs(); + const next = existing.filter((p) => p.name !== config.name); + next.push(config); + next.sort((a, b) => a.name.localeCompare(b.name)); + await ensureDir(path.dirname(registryPath())); + await writeJsonAtomic(registryPath(), { http: next }); + + // Also persist the per-provider config into its own home, so the state + // directory is self-describing and survives a registry rebuild. + await ensureDir(httpProviderHome(config.name)); + await writeJsonAtomic(path.join(httpProviderHome(config.name), PROVIDER_CONFIG_FILE), config); +} + +/** Remove an HTTP provider from the registry. Does not delete its state (teardown does). */ +export async function removeHttpProviderConfig(name: string): Promise { + const existing = await listHttpProviderConfigs(); + const next = existing.filter((p) => p.name !== name); + if (next.length === existing.length) return false; + await ensureDir(path.dirname(registryPath())); + await writeJsonAtomic(registryPath(), { http: next }); + return true; +} + +/** Delete a named provider's state home and credential file. */ +export async function removeHttpProviderState(name: string): Promise { + await remove(httpProviderHome(name)); + await remove(httpProviderCredentialPath(name)); +} + +// ─── Legacy singleton migration ────────────────────────── + +function legacyHome(): string { + return path.join(teamaiHome(), LEGACY_DIR); +} + +/** + * True when a legacy ~/.teamai/local-agent/ singleton owns the backend — i.e. + * its config.json exists. Migration DELETES this dir (no marker/snapshot), so + * its mere presence is the "active" signal and a re-written config after a + * migrate+remove is active again with no extra bookkeeping. + */ +export async function legacySingletonActive(): Promise { + return pathExists(path.join(legacyHome(), 'config.json')); +} + +interface LegacyConfigShape { + endpoint?: string; + token?: string; + priority?: number; +} + +/** + * Promote the legacy singleton to a named HTTP provider. Copies the legacy + * state home into the provider's home via a staging directory + atomic rename, + * moves the credential to the isolated 0600 file, publishes the provider, and + * then DELETES the legacy dir. No rollback snapshot is kept: retaining it caused + * a whole class of "snapshot revival" / "double uninstall" bugs (a later + * `source add-http` reviving stale bindings/manifest, `teamai uninstall` + * running a plugin uninstall_cmd twice — issue #404 reviews #6/#7), and the + * legacy dir's own disappearance is the migrated signal (no marker needed). + * + * Idempotent: once the legacy dir is gone, a second call returns null. + * + * @returns the provider config it registered, or null when there was no legacy + * singleton to migrate. + */ +export async function migrateLegacyHttpProvider(options: { + name: string; + adapter?: string; + priority?: number; +}): Promise { + assertValidProviderName(options.name); + const legacyConfigPath = path.join(legacyHome(), 'config.json'); + const legacy = await readJson(legacyConfigPath); + if (!legacy?.endpoint) return null; // nothing (or already migrated → dir gone) + + const config: HttpProviderConfig = { + name: options.name, + adapter: options.adapter ?? 'clawpro', + endpoint: legacy.endpoint, + priority: options.priority ?? legacy.priority ?? 50, + }; + + // Retriability: the legacy dir is deleted LAST, so reaching this point means + // any previous attempt was interrupted before it finished — the legacy backend + // is still authoritative. Partial state a previous attempt left (a registry + // entry, a half-copied home) carries no unique data and belongs to THIS same + // migration, so we discard and rebuild it rather than failing. A registry + // entry for this name whose endpoint DIFFERS is a foreign `provider add` + // conflict — reject that. + const home = httpProviderHome(options.name); + const existingEntry = await getHttpProviderConfig(options.name); + if (existingEntry && existingEntry.endpoint !== config.endpoint) { + throw new Error(`Provider "${options.name}" already exists; choose another name.`); + } + await remove(home); + + // Extract the credential first. Prefer the legacy inline token, then the + // legacy ~/.teamai/token file. + const token = + legacy.token ?? + (await readFileSafe(path.join(teamaiHome(), 'token')))?.trim() ?? + undefined; + + // Stage a full copy, then atomically move it into place so an interrupted + // migration never leaves a half-populated provider home. + const staging = `${home}.migrating`; + await remove(staging); + await ensureDir(path.dirname(home)); + await fse.copy(legacyHome(), staging); + // Redact the inline token from the staged config.json BEFORE publishing the + // directory — the credential belongs only in the isolated 0600 file, and the + // published home must never contain it, not even in the crash window between + // move and a later cleanup (issue #404, review #3). + const stagedConfigPath = path.join(staging, 'config.json'); + const stagedConfig = await readJson>(stagedConfigPath); + if (stagedConfig && 'token' in stagedConfig) { + delete stagedConfig.token; + await writeJsonAtomic(stagedConfigPath, stagedConfig); + } + // Write the isolated credential before publishing the home, so the credential + // exists the moment the provider becomes visible. + if (token) { + await ensureDir(path.dirname(httpProviderCredentialPath(options.name))); + await writeTokenFile(httpProviderCredentialPath(options.name), token); + } + await fse.move(staging, home); + + await upsertHttpProviderConfig(config); + + // Delete the legacy dir LAST. A crash before this re-runs the migration (the + // legacy config is still there); after it, the legacy singleton no longer + // exists so there is nothing to revive or double-uninstall. + await remove(legacyHome()); + + // Also remove the shared legacy ~/.teamai/token if it was the credential + // source — the token now lives only in the isolated 0600 credential file, so + // leaving the old plaintext copy would strand it after `provider remove` + // (issue #404, review P2). Only delete when it actually matched what we moved. + if (token && !legacy.token) { + const legacyTokenPath = path.join(teamaiHome(), 'token'); + const legacyTokenValue = (await readFileSafe(legacyTokenPath))?.trim(); + if (legacyTokenValue === token) await remove(legacyTokenPath); + } + + return config; +} diff --git a/src/providers/index.ts b/src/providers/index.ts index 3830fa1c4..8ccc4e060 100644 --- a/src/providers/index.ts +++ b/src/providers/index.ts @@ -1,6 +1,19 @@ export type { GitProvider, RepoInfo, PrCreateOptions } from './types.js'; export { RepoNotFoundError, OrganizationNotFoundError, RepoCreatePermissionError } from './types.js'; export { getProvider, getProviderFromUrl, detectProvider, detectProviderForInit } from './registry.js'; + +// ─── Resource delivery providers (issue #404) ──────────── +export type { + ResourceProvider, + ResourceProviderType, + ProviderCapabilities, + ProviderSummary, + ProviderResult, + SyncContext, + HttpBackendAdapter, + HttpProviderConfig, +} from './types.js'; +export { syncResourceProviders } from './resource-registry.js'; export { TGitProvider } from './tgit/index.js'; export { GitHubProvider } from './github/index.js'; export { GitLabProvider } from './gitlab/index.js'; diff --git a/src/providers/resource-registry.ts b/src/providers/resource-registry.ts new file mode 100644 index 000000000..bde66bf69 --- /dev/null +++ b/src/providers/resource-registry.ts @@ -0,0 +1,47 @@ +// ─── Resource provider sync ────────────────────────────── +// +// Drives a failure-isolated sync across the mounted resource providers. This is +// the sync-mechanism layer (git / http); it is distinct from the git-host +// registry in ./registry.ts, which maps a repo URL to a GitProvider. +// +// See issue #404. Multi-provider ownership arbitration, primary-write selection +// and cross-provider failover are a later phase and intentionally live outside +// this file. + +import type { ResourceProvider, SyncContext, ProviderResult } from './types.js'; +import { log } from '../utils/logger.js'; + +/** + * Sync every provider that can pull or report, isolating failures: one + * provider throwing or timing out never aborts the others. Each provider's + * outcome — success or a caught error rendered as `{ ok: false }` — is + * returned so the caller can report per-provider status. + * + * Providers run lowest-priority first so that, once same-name arbitration + * lands, a higher-priority provider applied later wins the final on-disk state. + * Until then order only affects log sequencing. + */ +export async function syncResourceProviders( + providers: ResourceProvider[], + context: SyncContext, +): Promise { + const active = providers.filter( + (p) => p.capabilities.pull || p.capabilities.report, + ); + // Ascending priority: apply the winner last (see doc comment). + const ordered = [...active].sort( + (a, b) => a.priority - b.priority || a.name.localeCompare(b.name), + ); + + const results: ProviderResult[] = []; + for (const provider of ordered) { + try { + results.push(await provider.sync(context)); + } catch (e) { + const message = (e as Error).message; + log.debug(`[providers] "${provider.name}" sync failed (isolated): ${message}`); + results.push({ provider: provider.name, ok: false, changed: false, message }); + } + } + return results; +} diff --git a/src/providers/types.ts b/src/providers/types.ts index e67b68adb..c97fe9281 100644 --- a/src/providers/types.ts +++ b/src/providers/types.ts @@ -206,3 +206,133 @@ export class RepoCreatePermissionError extends Error { this.createUrl = createUrl; } } + +// ─── Resource delivery providers ───────────────────────── +// +// A higher-level abstraction than GitProvider (above). GitProvider adapts a +// *git host* (github/tgit/gitlab/…); a ResourceProvider adapts a *resource +// sync mechanism* — either `git` (clone a team repo, the existing behavior) or +// `http` (talk to an HTTP backend such as ClawPro). Multiple named providers +// can be mounted side by side, each syncing independently and isolated from the +// others' failures. +// +// ResourceProvider +// ├── git (wraps the existing team-repo pull; GitProvider is used inside) +// └── http (HttpResourceProvider → HttpBackendAdapter) +// └── clawpro adapter +// +// See docs/designs/management-backend.md §8 and issue #404. The ownership +// ledger, priority arbitration and cross-provider failover it describes are a +// later phase; `priority` is carried here so those phases need no type change, +// but nothing consumes it for arbitration yet. + +/** Whether a provider syncs via a git repo or an HTTP backend. */ +export type ResourceProviderType = 'git' | 'http'; + +/** + * What a provider can do. Callers gate work on these instead of assuming every + * provider implements git clone / push / command execution. + * - `pull`: delivers resources into the local tool directories. + * - `push`: can be a write target for `teamai push` (git main only; HTTP + * backends and cross-team git sources are read-only → false). + * - `report`: sends usage/telemetry to a backend on hook dispatch. + * - `commands`: executes commands the backend pushes back (install/uninstall/…). + */ +export interface ProviderCapabilities { + pull: boolean; + push: boolean; + report: boolean; + commands: boolean; +} + +/** Why and where a sync was triggered. */ +export interface SyncContext { + /** Working directory the sync runs for (workspace attribution). */ + cwd?: string; + /** Host tool that triggered the sync (e.g. 'claude', 'codebuddy'). */ + tool?: string; + /** What initiated this sync. */ + trigger: 'hook' | 'pull' | 'manual'; + /** Raw hook STDIN payload, when trigger === 'hook'. */ + stdin?: Record; + /** Bypass any pull TTL / freshness cache. */ + force?: boolean; +} + +/** Outcome of a single provider's sync. */ +export interface ProviderResult { + /** Provider name this result is for. */ + provider: string; + /** Whether the sync completed without error. */ + ok: boolean; + /** Whether the sync changed anything on disk. */ + changed: boolean; + /** Human-readable status or error, for logs / CLI output. */ + message?: string; + /** Text a protocol adapter asks the host to emit on its hook stdout. */ + hookOutput?: string; +} + +/** Static description of a provider, for `teamai provider list` and diagnostics. */ +export interface ProviderSummary { + name: string; + type: ResourceProviderType; + priority: number; + capabilities: ProviderCapabilities; + /** HTTP backend endpoint, when type === 'http'. */ + endpoint?: string; + /** HTTP protocol adapter name, when type === 'http'. */ + adapter?: string; +} + +/** + * A mounted resource backend. Named uniquely within a registry. `sync` is the + * one hot-path method; `describe`/`teardown` support listing and removal. + */ +export interface ResourceProvider { + readonly name: string; + readonly type: ResourceProviderType; + /** + * Ordering hint for later multi-provider arbitration (higher wins). Carried + * now so the arbitration phase needs no interface change; not yet consumed + * for same-name resource conflict resolution. + */ + readonly priority: number; + readonly capabilities: ProviderCapabilities; + + /** Deliver resources / report usage for this trigger. */ + sync(context: SyncContext): Promise; + /** Return a static summary for listing and diagnostics. */ + describe(): Promise; + /** Remove this provider's local state (credentials, manifests, caches). */ + teardown(): Promise; +} + +/** Persisted configuration for one named HTTP provider. */ +export interface HttpProviderConfig { + /** Unique provider name (also the state-directory segment). */ + name: string; + /** Protocol adapter that speaks this backend's wire format (e.g. 'clawpro'). */ + adapter: string; + /** Backend base URL. */ + endpoint: string; + /** Arbitration hint (see ResourceProvider.priority). */ + priority: number; +} + +/** + * Translates one HTTP backend's wire format to the common ResourceProvider + * shape. The HTTP provider owns transport concerns generically; the adapter + * owns only protocol differences (payload/command shapes). + */ +export interface HttpBackendAdapter { + readonly name: string; + /** One-time setup for a newly added provider (e.g. persist a token). */ + initialize?(config: HttpProviderConfig, token?: string): Promise; + /** Run report/sync/command execution for this provider. */ + sync(config: HttpProviderConfig, context: SyncContext): Promise; + /** Static summary for listing. */ + describe(config: HttpProviderConfig): Promise; + /** Remove this provider's local state. */ + teardown(config: HttpProviderConfig): Promise; +} diff --git a/src/source.ts b/src/source.ts index ddf083d3b..1daa11442 100644 --- a/src/source.ts +++ b/src/source.ts @@ -302,6 +302,22 @@ export async function sourceAddHttp( return; } + // Single-provider gate (issue #404 phase 2): a named HTTP provider and this + // legacy singleton would both be dispatched each session, reintroducing the + // cross-provider resource-ownership and timeout problems the phase-2 gate + // exists to avoid. Refuse rather than create a second active HTTP backend. + const { listHttpProviderConfigs } = await import('./providers/http/store.js'); + const namedProviders = await listHttpProviderConfigs(); + if (namedProviders.length > 0) { + log.error( + `A named HTTP provider ("${namedProviders[0].name}") is already configured. ` + + 'Running it alongside a legacy HTTP source needs cross-provider ownership ' + + 'arbitration (issue #404 phase 4) and is not supported yet.', + ); + log.info(`Remove it first with \`teamai provider remove ${namedProviders[0].name}\`, or manage this endpoint via \`teamai provider add http\`.`); + return; + } + if (options.dryRun) { log.info(`[dry-run] Would add HTTP source ${trimmed}`); return; @@ -322,6 +338,18 @@ export async function sourceRemoveHttp(options: GlobalOptions): Promise { log.info('[dry-run] Would remove the HTTP source'); return; } + // When a named HTTP provider is configured and there is no legacy singleton, + // `source remove-http` is the wrong tool — the named provider owns delivery. + // Point the user at `provider remove` rather than no-op confusingly (#404). + const { legacySingletonActive, listHttpProviderConfigs } = await import('./providers/http/store.js'); + const namedProviders = await listHttpProviderConfigs(); + if (namedProviders.length > 0 && !(await legacySingletonActive())) { + log.error( + `An HTTP provider ("${namedProviders[0].name}") is configured (not a legacy HTTP source). ` + + `Remove it with \`teamai provider remove ${namedProviders[0].name}\`.`, + ); + return; + } const { removeLocalAgentHttp } = await import('./local-agent.js'); await removeLocalAgentHttp(); } diff --git a/src/uninstall.ts b/src/uninstall.ts index c5dfcdc64..97b68f201 100644 --- a/src/uninstall.ts +++ b/src/uninstall.ts @@ -853,17 +853,61 @@ function printSummary(plan: RemovalPlan, agentFilter?: string): void { // ─── Execution ───────────────────────────────────────── /** - * Stop and uninstall local-agent plugins (best-effort) before ~/.teamai is deleted. - * Dynamic import mirrors source.ts — keeps local-agent's heavy dependency graph out - * of uninstall's static import chain. + * Tear down HTTP resource backends before ~/.teamai is deleted (best-effort). + * Dynamic import mirrors source.ts — keeps local-agent's heavy dependency graph + * out of uninstall's static import chain. + * + * The legacy singleton only needs its plugins stopped: uninstall's own removal + * plan already reads the legacy manifest and strips its resources/hooks. But a + * named HTTP provider's resources are recorded in ITS OWN manifest under + * ~/.teamai/providers/http//, which the removal plan never reads — so its + * installed skills/rules/CLAUDE.md/agent-hooks would be orphaned in the tool + * dirs when ~/.teamai is deleted. Each named provider therefore gets a full + * teardown() (uninstall all manifest resources + remove agent hooks + stop + * plugins) inside its own isolated context (issue #404). */ -async function teardownPlugins(): Promise { +async function teardownPlugins(): Promise<{ incompleteProviders: string[] }> { + const incompleteProviders: string[] = []; try { const { teardownLocalAgentPlugins } = await import('./local-agent.js'); await teardownLocalAgentPlugins(); } catch (e) { log.warn(`plugin teardown failed: ${e instanceof Error ? e.message : String(e)}`); } + try { + const { listHttpProviderConfigs, httpProviderExecutionContext } = await import( + './providers/http/store.js' + ); + const { withHttpProvider, removeLocalAgentHttp } = await import('./local-agent.js'); + // Signal the provider teardown that this is the full-uninstall path, so it + // also removes the shared built-in dispatch hooks (a standalone + // `provider remove` deliberately leaves those to avoid breaking a + // coexisting install — issue #404 #5/#7). + const priorUninstallFlag = process.env.TEAMAI_UNINSTALL; + process.env.TEAMAI_UNINSTALL = '1'; + try { + for (const config of await listHttpProviderConfigs()) { + try { + await withHttpProvider(httpProviderExecutionContext(config.name), () => + removeLocalAgentHttp(), + ); + } catch (e) { + // removeLocalAgentHttp throws when it could not fully clean up (locked + // file / permission). ~/.teamai is about to be deleted, so its manifest + // will be gone — record the provider so we can warn the user that some + // external tool hooks/plugins may need manual cleanup (issue #404 #6). + incompleteProviders.push(config.name); + log.warn(`teardown for provider "${config.name}" failed: ${(e as Error).message}`); + } + } + } finally { + if (priorUninstallFlag === undefined) delete process.env.TEAMAI_UNINSTALL; + else process.env.TEAMAI_UNINSTALL = priorUninstallFlag; + } + } catch (e) { + log.warn(`HTTP provider teardown failed: ${e instanceof Error ? e.message : String(e)}`); + } + return { incompleteProviders }; } async function executeRemoval(plan: RemovalPlan): Promise { @@ -1082,12 +1126,26 @@ async function executeRemoval(plan: RemovalPlan): Promise { // (g) Remove ~/.teamai/ directory (last — earlier steps read from it) if (plan.teamaiHomeExists) { // Tear down plugins first: their manifest/config live under ~/.teamai/local-agent. - await teardownPlugins(); - try { - await remove(plan.teamaiHome); - log.success(`Removed ${plan.teamaiHome}/`); - } catch (e) { - log.warn(`Failed to remove ${plan.teamaiHome}: ${(e as Error).message}`); + const { incompleteProviders } = await teardownPlugins(); + if (incompleteProviders.length > 0) { + // A provider's teardown could not fully clean up (locked file / perms). + // ~/.teamai holds the ownership manifests needed to find and finish that + // cleanup, so do NOT delete it — that would strand the external + // hooks/plugins with no way to retry. Keep it and tell the user to + // resolve the issue and re-run, exactly like `provider remove` does. + log.warn( + `Kept ${plan.teamaiHome}/ because these HTTP providers did not fully tear down: ` + + `${incompleteProviders.join(', ')}. Their tool hooks/plugins may still be installed and ` + + 'their tracking manifests are needed to remove them. Resolve the underlying issue ' + + '(locked file / permissions) and run `teamai uninstall` again.', + ); + } else { + try { + await remove(plan.teamaiHome); + log.success(`Removed ${plan.teamaiHome}/`); + } catch (e) { + log.warn(`Failed to remove ${plan.teamaiHome}: ${(e as Error).message}`); + } } }