Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 13 additions & 8 deletions docs/designs/management-backend.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
10 changes: 7 additions & 3 deletions docs/designs/management-backend.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) 通过项目和角色选择器决定资源命名空间。
Expand Down
26 changes: 26 additions & 0 deletions docs/usage-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <key>

# List, sync, remove
teamai provider list
teamai provider sync
teamai provider remove company
```

The provider's state lives under `~/.teamai/providers/http/<name>/`; its token is stored `0600` at `~/.teamai/credentials/<name>`, 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
Expand Down
26 changes: 26 additions & 0 deletions docs/usage-guide.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <key>

# 列出、同步、删除
teamai provider list
teamai provider sync
teamai provider remove company
```

该 provider 的状态存放在 `~/.teamai/providers/http/<name>/`;其 token 以 `0600` 权限单独存于 `~/.teamai/credentials/<name>`,绝不写入任何配置文件。每次 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。

---

## 命令参考
Expand Down
14 changes: 14 additions & 0 deletions skill-data/core/references/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,20 @@ Generated: do not edit by hand. Regenerate with
- `teamai source list` — List all configured sources
- `teamai source browse <name>` — 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 <endpoint>` — Add a named HTTP provider (e.g. a ClawPro backend)
- `--name <name>` — Unique name for this provider
- `--adapter <adapter>` — Protocol adapter (default: clawpro)
- `--token <key>` — 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 <name>` — 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>` — Name for the migrated provider

## update

- `teamai update` — Check for updates and upgrade teamai CLI
Expand Down
18 changes: 18 additions & 0 deletions skill-data/setup/references/join-member.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,24 @@ teamai init --http https://your-team-host/api --token <api-key>
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/<name>/`, with its
credential stored separately (0600) at `~/.teamai/credentials/<name>`, never in a
config file:

```bash
teamai provider add http https://your-team-host/api --name <name> --token <api-key>
teamai provider list
teamai provider remove <name>
```

To move an existing `init --http` singleton onto this model without losing state,
run `teamai provider migrate-legacy --name <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
Expand Down
10 changes: 10 additions & 0 deletions src/__tests__/hook-handlers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
25 changes: 25 additions & 0 deletions src/__tests__/hooks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof vi.fn>).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<typeof vi.fn>).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';
Expand Down
169 changes: 169 additions & 0 deletions src/__tests__/http-provider-multi.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof import('../providers/http/adapters/clawpro/client.js')>();
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<typeof import('../providers/http/adapters/clawpro/client.js')>();
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);
});
});
Loading
Loading