Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
2d4e559
feat: HTTP team repo + hooks-based agent status reporting (issue #1)
jeff-r2026 Jun 24, 2026
4ccb19d
fix: align local-agent reporter with updated backend contract
jeff-r2026 Jun 29, 2026
8bc1b2c
fix: wire WorkBuddy via settings.json Claude hooks (not OpenClaw)
jeff-r2026 Jun 29, 2026
83aaf68
fix: teamai uninstall also removes OpenClaw HOOK.md hooks
jeff-r2026 Jun 29, 2026
982cd71
feat: tolerate missing /repo in HTTP init (reporting-only mode)
jeff-r2026 Jun 29, 2026
4ec3b56
refactor: fold `teamai login` into `init --http --token` (remove logi…
jeff-r2026 Jun 29, 2026
ad80d5e
fix: skip git usage auto-report for HTTP consumers (no .git → noisy E…
jeff-r2026 Jun 29, 2026
e4abe76
feat: log skill-command execution in the reporter (observability)
jeff-r2026 Jun 29, 2026
1dd8238
chore: drop accidentally committed .teamai/domains.yaml (test artifact)
jeff-r2026 Jun 29, 2026
9f99188
feat: surface skill download URL + server error body in reporter logs
jeff-r2026 Jun 29, 2026
237bce8
feat: log every reporter run + report/sync/ack outcome
jeff-r2026 Jun 29, 2026
053736e
feat: skip team-repo built-in skills in HTTP reporting-only mode
jeff-r2026 Jun 29, 2026
1cdc98b
fix: accept flat skill zips (SKILL.md at root), not just <slug>/SKILL.md
jeff-r2026 Jun 29, 2026
bcbc17e
fix: use reconcileTeamHooksForConfig for HTTP init hook injection
jeff-r2026 Jun 30, 2026
0d27bae
docs: document git-free HTTP team repo + agent status reporting
jeff-r2026 Jun 30, 2026
a2263e7
docs: document the HTTP contract (endpoints, /repo schema, env knobs)
jeff-r2026 Jun 30, 2026
e1060b0
refactor(reporter): drop per-skill `source` tag and clawpro bookkeeping
jeff-r2026 Jun 30, 2026
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
55 changes: 55 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,61 @@ The CLI picks a provider automatically from the repo URL:
- `yourorg/yourrepo` or `https://github.com/yourorg/yourrepo` → GitHub
- `https://git.woa.com/yourteam/yourrepo` → TGit

### Read-only consumers (HTTP team repo, no git)

Some users or agents only need to *consume* a team's skills/rules — no git clone, no push. Onboard them over plain HTTP with just an API key:

```bash
teamai init --http https://your-team-host/api --token <api-key>
```

- **Read-only:** `push` / `contribute` / `remove` are disabled for HTTP repos.
- The API key is stored `0600` (never written to config, never committed); `TEAMAI_API_TOKEN` is also honored.
- If the team-repo endpoint (`/repo`) is not live yet, init falls back to **reporting-only mode** — hooks and status reporting are wired immediately, and skills/rules begin syncing automatically once the endpoint is available.

#### Agent status reporting

Once initialized, supported agents (CodeBuddy / WorkBuddy) report their installed-skill state on session start and pull down server-managed skill install / update / uninstall commands, driven by the existing hook dispatch (`session-start` → report + sync, `prompt-submit` → sync). Failed deliveries are buffered to an offline queue and retried next time.

> **Privacy.** The install path and machine id are only hashed *locally* to derive a stable `local_agent_id` — neither is ever uploaded.

<details>
<summary><b>HTTP contract</b> (for backend implementers) — what the <code>--http</code> endpoint must serve</summary>

The value you pass to `--http <baseUrl>` is the base; every endpoint is relative to it and authenticated with `Authorization: Bearer <api-key>`.

| Endpoint | Method | Purpose | Path |
|----------|--------|---------|------|
| `{baseUrl}/repo` | GET | Team-repo snapshot (skills + rules/docs) | **fixed** |
| `{baseUrl}/api/local-agent/report` | POST | Session start: upsert agent + installed skills | default, configurable |
| `{baseUrl}/api/local-agent/sync` | POST | Report status + return pending skill commands | default, configurable |
| `{baseUrl}/api/local-agent/commands/ack` | POST | Ack one command (`{ id, status, error }`) | default, configurable |

`GET /repo` returns JSON (a 404 or non-JSON 200 ⇒ the client enters reporting-only mode):

```json
{
"version": "<opaque cache key, e.g. a commit hash>",
"files": [{ "path": "rules/foo.md", "content": "..." }],
"commands":[{ "type": "install_skill", "skill_slug": "x", "skill_version": "1.0.0", "download_url": "https://signed-url/..." }]
}
```

- `files[]` are written verbatim into the local repo tree (path-traversal guarded); `commands[]` install/update/uninstall skills.
- A skill `download_url` is fetched **directly** — it carries its own signed auth in the query string, so no `Bearer` header is sent. It must resolve to a `.zip` whose root is either `<slug>/SKILL.md …` or a flat `SKILL.md …`.

**Fixed vs configurable.** The `/repo` path is fixed; the three reporter paths are defaults you can override. The JSON shapes above are the contract. Knobs (env vars):

| Variable | Effect |
|----------|--------|
| `TEAMAI_API_TOKEN` | API key (alternative to `--token`) |
| `TEAMAI_REPORT_ENDPOINT` | Reporter base URL (defaults to the `--http` URL) |
| `TEAMAI_REPORT_PATHS` | JSON `{ "report", "sync", "ack" }` to override the three reporter paths |
| `TEAMAI_REPORT_AGENTS` | Comma-separated agents that report (default `workbuddy,codebuddy`) |
| `TEAMAI_SKILL_DOWNLOAD_HOSTS` | Comma-separated host allowlist for skill `download_url` (empty = allow all) |

</details>

## Commands

| Command | Description |
Expand Down
55 changes: 55 additions & 0 deletions README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,61 @@ CLI 会根据用户传入的 repo URL 自动选择 provider:
- `yourorg/yourrepo` 或 `https://github.com/yourorg/yourrepo` → GitHub
- `https://git.woa.com/yourteam/yourrepo` → TGit

### 只读消费者(HTTP 团队仓库,免 git)

有些用户或 agent 只需要*消费*团队的 skills/rules——不需要 git clone,也不需要 push。用一个 API key 即可通过纯 HTTP 接入:

```bash
teamai init --http https://your-team-host/api --token <api-key>
```

- **只读**:HTTP 仓库下 `push` / `contribute` / `remove` 均被禁用。
- API key 以 `0600` 权限保存(不写入 config,也不会被提交);同时支持 `TEAMAI_API_TOKEN` 环境变量。
- 如果团队仓库端点(`/repo`)尚未上线,init 会回落到 **reporting-only 模式**——hooks 和状态上报立即生效,待端点可用后 skills/rules 会自动开始同步。

#### Agent 状态上报

初始化后,受支持的 agent(CodeBuddy / WorkBuddy)会在 session 启动时上报本地已安装 skill 的状态,并拉取服务端下发的 skill 安装 / 更新 / 卸载命令,全部挂在既有 hook dispatch 上(`session-start` → report + sync,`prompt-submit` → sync)。下发失败会进离线队列,下次重试。

> **隐私**:install path 和 machine id 仅在*本地*哈希以派生稳定的 `local_agent_id`,二者都不会上报。

<details>
<summary><b>HTTP 契约</b>(面向后端实现者)—— <code>--http</code> 端点需要提供哪些接口</summary>

`--http <baseUrl>` 传入的是基础地址,所有端点都相对于它,并统一用 `Authorization: Bearer <api-key>` 鉴权。

| 端点 | 方法 | 用途 | 路径 |
|------|------|------|------|
| `{baseUrl}/repo` | GET | 团队仓库快照(skills + rules/docs) | **固定** |
| `{baseUrl}/api/local-agent/report` | POST | session 启动:upsert agent + 已装 skill | 默认,可配置 |
| `{baseUrl}/api/local-agent/sync` | POST | 上报状态 + 返回待执行的 skill 命令 | 默认,可配置 |
| `{baseUrl}/api/local-agent/commands/ack` | POST | 回执单条命令(`{ id, status, error }`) | 默认,可配置 |

`GET /repo` 返回 JSON(返回 404 或非 JSON 的 200 ⇒ 客户端进入 reporting-only 模式):

```json
{
"version": "<不透明的缓存 key,例如 commit hash>",
"files": [{ "path": "rules/foo.md", "content": "..." }],
"commands":[{ "type": "install_skill", "skill_slug": "x", "skill_version": "1.0.0", "download_url": "https://signed-url/..." }]
}
```

- `files[]` 原样写入本地仓库树(带路径穿越防护);`commands[]` 负责 skill 的安装/更新/卸载。
- skill 的 `download_url` 是**直连**拉取——它在 query string 里自带签名鉴权,因此不附带 `Bearer` 头。它必须指向一个 `.zip`,其根目录为 `<slug>/SKILL.md …` 或扁平的 `SKILL.md …`。

**固定 vs 可配置**:`/repo` 路径固定;reporter 三个路径是可覆盖的默认值;上面的 JSON 结构是契约。可调项(环境变量):

| 变量 | 作用 |
|------|------|
| `TEAMAI_API_TOKEN` | API key(`--token` 的替代) |
| `TEAMAI_REPORT_ENDPOINT` | reporter 基础 URL(默认 = `--http` 地址) |
| `TEAMAI_REPORT_PATHS` | JSON `{ "report", "sync", "ack" }`,覆盖 reporter 三个路径 |
| `TEAMAI_REPORT_AGENTS` | 参与上报的 agent,逗号分隔(默认 `workbuddy,codebuddy`) |
| `TEAMAI_SKILL_DOWNLOAD_HOSTS` | skill `download_url` 的 host 白名单,逗号分隔(空 = 全部放行) |

</details>

## 命令

| 命令 | 说明 |
Expand Down
7 changes: 7 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@
"dependencies": {
"chalk": "^5.3.0",
"commander": "^12.1.0",
"fflate": "^0.8.3",
"fs-extra": "^11.2.0",
"gray-matter": "^4.0.3",
"ora": "^8.1.0",
Expand Down
122 changes: 122 additions & 0 deletions scripts/mock-teamai-server.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
#!/usr/bin/env node
/**
* Standalone local mock of the teamai HTTP backend — the three local-agent
* interfaces (report / sync / ack), the HTTP team-repo `/repo` endpoint, and a
* skill zip download endpoint. Use it to exercise `teamai init --http`,
* `teamai pull`, and the hooks-driven status reporter end to end on a dev box.
*
* Usage:
* node scripts/mock-teamai-server.mjs # port 8787, key "dev-key"
* PORT=9000 API_KEY=secret node scripts/mock-teamai-server.mjs
*
* To drive a status-reporter install, set SEED_INSTALL=<slug> so the first
* `sync` returns an install_skill command for that slug.
*
* Then point teamai at it:
* teamai login dev-key
* teamai init --http http://127.0.0.1:8787
* TEAMAI_REPORT_ENDPOINT=http://127.0.0.1:8787 TEAMAI_REPORT_AGENTS=codebuddy \
* teamai hook-dispatch session-start --tool codebuddy < /dev/null
*/

import http from 'node:http';
import { zipSync, strToU8 } from 'fflate';

const PORT = Number(process.env.PORT ?? 8787);
const API_KEY = process.env.API_KEY ?? 'dev-key';
const SEED_INSTALL = process.env.SEED_INSTALL ?? '';

let pendingCommands = SEED_INSTALL
? [
{
id: 1,
type: 'install_skill',
skill_slug: SEED_INSTALL,
skill_version: '1.0.0',
download_url: `http://127.0.0.1:${PORT}/download?slug=${SEED_INSTALL}&access_token=smh`,
},
]
: [];

function buildSkillZip(slug) {
return zipSync({
[`${slug}/SKILL.md`]: strToU8(`---\nname: ${slug}\nversion: 1.0.0\ndescription: mock skill\n---\nbody`),
});
}

async function readBody(req) {
const chunks = [];
for await (const c of req) chunks.push(c);
const raw = Buffer.concat(chunks).toString('utf-8');
return raw ? JSON.parse(raw) : {};
}

const server = http.createServer(async (req, res) => {
const url = new URL(req.url ?? '/', `http://localhost:${PORT}`);
const json = (status, body) => {
res.writeHead(status, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(body));
};

if (req.method === 'GET' && url.pathname === '/download') {
const slug = url.searchParams.get('slug') ?? 'skill';
res.writeHead(200, { 'Content-Type': 'application/zip' });
res.end(Buffer.from(buildSkillZip(slug)));
return;
}

if ((req.headers.authorization ?? '') !== `Bearer ${API_KEY}`) {
json(401, { error: 'unauthorized' });
return;
}

if (req.method === 'GET' && url.pathname === '/repo') {
json(200, {
version: 'v1',
files: [
{ path: 'teamai.yaml', content: 'team: mock\nrepo: http://mock\nsharing: {}\n' },
{ path: 'rules/common/demo.md', content: '# demo rule\n' },
],
commands: [
{
type: 'install_skill',
skill_slug: 'weather',
skill_version: '1.0.0',
download_url: `http://127.0.0.1:${PORT}/download?slug=weather&access_token=smh`,
},
],
});
return;
}

if (req.method === 'POST' && url.pathname === '/api/local-agent/report') {
const body = await readBody(req);
console.log('[report]', JSON.stringify(body));
json(200, { ok: true, instance_id: 'local-mock-abc123' });
return;
}

if (req.method === 'POST' && url.pathname === '/api/local-agent/sync') {
const body = await readBody(req);
console.log('[sync]', JSON.stringify(body));
const commands = pendingCommands;
pendingCommands = [];
json(200, { ok: true, commands });
return;
}

// ack: the command id now travels in the request body (id: int).
if (req.method === 'POST' && url.pathname === '/api/local-agent/commands/ack') {
const body = await readBody(req);
console.log(`[ack ${body.id}]`, JSON.stringify(body));
json(200, { ok: true });
return;
}

json(404, { error: 'not found' });
});

server.listen(PORT, '127.0.0.1', () => {
console.log(`mock teamai server on http://127.0.0.1:${PORT} (API_KEY=${API_KEY})`);
if (SEED_INSTALL) console.log(`seeded sync install_skill: ${SEED_INSTALL}`);
});
66 changes: 66 additions & 0 deletions src/__tests__/api-key.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import { resolveApiKey, saveApiKey, getApiKeyPath } from '../api-key.js';

let tmpDir: string;
let originalHome: string;
const ENV_KEYS = ['TEAMAI_API_TOKEN', 'TEAMAI_API_KEY'] as const;
let savedEnv: Record<string, string | undefined>;

beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-apikey-test-'));
originalHome = process.env.HOME ?? '';
process.env.HOME = tmpDir;
savedEnv = {};
for (const k of ENV_KEYS) {
savedEnv[k] = process.env[k];
delete process.env[k];
}
});

afterEach(() => {
process.env.HOME = originalHome;
for (const k of ENV_KEYS) {
if (savedEnv[k] === undefined) delete process.env[k];
else process.env[k] = savedEnv[k];
}
fs.rmSync(tmpDir, { recursive: true, force: true });
});

describe('resolveApiKey', () => {
it('returns null when nothing configured', () => {
expect(resolveApiKey()).toBeNull();
});

it('prefers TEAMAI_API_TOKEN over the file', async () => {
await saveApiKey('file-key');
process.env.TEAMAI_API_TOKEN = 'env-token';
expect(resolveApiKey()).toBe('env-token');
});

it('accepts TEAMAI_API_KEY as a legacy alias', () => {
process.env.TEAMAI_API_KEY = 'legacy-key';
expect(resolveApiKey()).toBe('legacy-key');
});

it('reads the file when no env var is set', async () => {
await saveApiKey(' on-disk-key ');
expect(resolveApiKey()).toBe('on-disk-key');
});
});

describe('saveApiKey', () => {
it('writes the key with 0600 permissions', async () => {
await saveApiKey('secret');
const p = getApiKeyPath();
expect(fs.existsSync(p)).toBe(true);
const mode = fs.statSync(p).mode & 0o777;
expect(mode).toBe(0o600);
});

it('rejects an empty key', async () => {
await expect(saveApiKey(' ')).rejects.toThrow(/must not be empty/);
});
});
Loading
Loading