diff --git a/README.md b/README.md index 29a4d49..04cd841 100644 --- a/README.md +++ b/README.md @@ -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 +``` + +- **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. + +
+HTTP contract (for backend implementers) — what the --http endpoint must serve + +The value you pass to `--http ` is the base; every endpoint is relative to it and authenticated with `Authorization: Bearer `. + +| 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": "", + "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 `/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) | + +
+ ## Commands | Command | Description | diff --git a/README.zh-CN.md b/README.zh-CN.md index 6638048..22b7694 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -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 +``` + +- **只读**: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`,二者都不会上报。 + +
+HTTP 契约(面向后端实现者)—— --http 端点需要提供哪些接口 + +`--http ` 传入的是基础地址,所有端点都相对于它,并统一用 `Authorization: Bearer ` 鉴权。 + +| 端点 | 方法 | 用途 | 路径 | +|------|------|------|------| +| `{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`,其根目录为 `/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 白名单,逗号分隔(空 = 全部放行) | + +
+ ## 命令 | 命令 | 说明 | diff --git a/package-lock.json b/package-lock.json index f72644f..8a0e81b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,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", @@ -2190,6 +2191,12 @@ } } }, + "node_modules/fflate": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz", + "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==", + "license": "MIT" + }, "node_modules/figures": { "version": "3.2.0", "resolved": "https://mirrors.tencent.com/npm/figures/-/figures-3.2.0.tgz", diff --git a/package.json b/package.json index 302ca0b..f1990a2 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/mock-teamai-server.mjs b/scripts/mock-teamai-server.mjs new file mode 100644 index 0000000..4bbdb62 --- /dev/null +++ b/scripts/mock-teamai-server.mjs @@ -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= 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}`); +}); diff --git a/src/__tests__/api-key.test.ts b/src/__tests__/api-key.test.ts new file mode 100644 index 0000000..8616747 --- /dev/null +++ b/src/__tests__/api-key.test.ts @@ -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; + +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/); + }); +}); diff --git a/src/__tests__/helpers/mock-server.ts b/src/__tests__/helpers/mock-server.ts new file mode 100644 index 0000000..035cc02 --- /dev/null +++ b/src/__tests__/helpers/mock-server.ts @@ -0,0 +1,130 @@ +/** + * In-process mock of the teamai HTTP backend (the three local-agent interfaces + * report/sync/ack + the HTTP team-repo /repo endpoint + skill zip download). + * + * Used by the e2e tests and mirrors `scripts/mock-teamai-server.mjs` (the + * standalone runnable server the reviewer asked for). Bearer auth is enforced + * so the read-only-consumer / reporter auth paths are exercised. + */ + +import http from 'node:http'; +import type { AddressInfo } from 'node:net'; +import { zipSync, strToU8 } from 'fflate'; +import type { SkillCommand } from '../../skill-command.js'; +import type { RepoFile } from '../../source-http.js'; + +export interface MockServerConfig { + apiKey: string; + /** /repo response. */ + repo?: { version: string | null; files: RepoFile[]; commands: SkillCommand[] }; + /** Commands handed back by the next sync call, then cleared. */ + pendingCommands?: SkillCommand[]; + /** Slug → file map used to synthesize downloadable skill zips. */ + skillFiles?: Record>; +} + +export interface MockServerHandle { + url: string; + close: () => Promise; + reports: unknown[]; + syncs: unknown[]; + acks: Array<{ id: number; body: unknown }>; + /** Queue commands the next sync should return (download_url can use `url`). */ + seedCommands: (cmds: SkillCommand[]) => void; + /** Set the /repo response after start (download_url can use `url`). */ + seedRepo: (repo: NonNullable) => void; +} + +/** Build a valid skill zip (`/SKILL.md` + extra files). */ +export function buildSkillZip(slug: string, files: Record = {}): Uint8Array { + const entries: Record = { + [`${slug}/SKILL.md`]: strToU8(`---\nname: ${slug}\nversion: 1.0.0\ndescription: mock\n---\nbody`), + }; + for (const [rel, content] of Object.entries(files)) { + entries[`${slug}/${rel}`] = strToU8(content); + } + return zipSync(entries); +} + +async function readBody(req: http.IncomingMessage): Promise { + const chunks: Buffer[] = []; + for await (const c of req) chunks.push(c as Buffer); + const raw = Buffer.concat(chunks).toString('utf-8'); + return raw ? JSON.parse(raw) : {}; +} + +export async function startMockServer(config: MockServerConfig): Promise { + const handle: MockServerHandle = { + url: '', + close: async () => {}, + reports: [], + syncs: [], + acks: [], + seedCommands: (cmds) => { + config.pendingCommands = cmds; + }, + seedRepo: (repo) => { + config.repo = repo; + }, + }; + + const server = http.createServer(async (req, res) => { + const url = new URL(req.url ?? '/', 'http://localhost'); + const auth = req.headers.authorization ?? ''; + + const json = (status: number, body: unknown) => { + res.writeHead(status, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(body)); + }; + + // Skill zip download — SMH-style: no Bearer header, token in query. + if (req.method === 'GET' && url.pathname === '/download') { + const slug = url.searchParams.get('slug') ?? ''; + const zip = buildSkillZip(slug, config.skillFiles?.[slug]); + res.writeHead(200, { 'Content-Type': 'application/zip' }); + res.end(Buffer.from(zip)); + return; + } + + // Everything else requires Bearer auth. + if (auth !== `Bearer ${config.apiKey}`) { + json(401, { error: 'unauthorized' }); + return; + } + + if (req.method === 'GET' && url.pathname === '/repo') { + json(200, config.repo ?? { version: 'v1', files: [], commands: [] }); + return; + } + + if (req.method === 'POST' && url.pathname === '/api/local-agent/report') { + handle.reports.push(await readBody(req)); + json(200, { ok: true, instance_id: 'local-mock-abc123' }); + return; + } + + if (req.method === 'POST' && url.pathname === '/api/local-agent/sync') { + handle.syncs.push(await readBody(req)); + const commands = config.pendingCommands ?? []; + config.pendingCommands = []; // deliver once + 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)) as { id?: number }; + handle.acks.push({ id: body.id as number, body }); + json(200, { ok: true }); + return; + } + + json(404, { error: 'not found' }); + }); + + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const port = (server.address() as AddressInfo).port; + handle.url = `http://127.0.0.1:${port}`; + handle.close = () => new Promise((resolve) => server.close(() => resolve())); + return handle; +} diff --git a/src/__tests__/http-repo-integration.test.ts b/src/__tests__/http-repo-integration.test.ts new file mode 100644 index 0000000..16b9c09 --- /dev/null +++ b/src/__tests__/http-repo-integration.test.ts @@ -0,0 +1,108 @@ +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 YAML from 'yaml'; +import { startMockServer, type MockServerHandle } from './helpers/mock-server.js'; + +let tmpDir: string; +let originalHome: string; +let server: MockServerHandle | undefined; +const API_KEY = 'e2e-key'; +const ENV_KEYS = ['TEAMAI_API_TOKEN', 'TEAMAI_API_KEY', 'TEAMAI_REPORT_ENDPOINT']; +const saved: Record = {}; + +const TEAMAI_YAML = YAML.stringify({ + team: 'mock-http', + repo: 'http://mock', + toolPaths: { claude: { skills: '.claude/skills', settings: '.claude/settings.json' } }, +}); + +beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-http-e2e-')); + originalHome = process.env.HOME ?? ''; + process.env.HOME = tmpDir; + for (const k of ENV_KEYS) { + saved[k] = process.env[k]; + delete process.env[k]; + } +}); + +afterEach(async () => { + await server?.close(); + server = undefined; + vi.restoreAllMocks(); + process.env.HOME = originalHome; + for (const k of ENV_KEYS) { + if (saved[k] === undefined) delete process.env[k]; + else process.env[k] = saved[k]; + } + fs.rmSync(tmpDir, { recursive: true, force: true }); +}); + +function writeApiKey(): void { + fs.mkdirSync(path.join(tmpDir, '.teamai'), { recursive: true }); + fs.writeFileSync(path.join(tmpDir, '.teamai', 'apikey'), API_KEY); +} + +describe('teamai init --http (read-only onboarding)', () => { + it('materializes the repo and writes a kind:http local config', async () => { + writeApiKey(); + server = await startMockServer({ apiKey: API_KEY }); + server.seedRepo({ + version: 'v1', + files: [ + { path: 'teamai.yaml', content: TEAMAI_YAML }, + { path: 'rules/common/x.md', content: '# x\n' }, + ], + commands: [ + { + type: 'install_skill', + skill_slug: 'weather', + skill_version: '1.0.0', + download_url: `${server.url}/download?slug=weather&access_token=smh`, + }, + ], + }); + + const { init } = await import('../init.js'); + await init({ http: server.url, force: true }); + + // Local config written with kind:http and only the URL (no key). + const cfg = YAML.parse(fs.readFileSync(path.join(tmpDir, '.teamai', 'config.yaml'), 'utf-8')); + expect(cfg.repo.kind).toBe('http'); + expect(cfg.repo.url).toBe(server.url); + expect(JSON.stringify(cfg)).not.toContain(API_KEY); + + // Repo materialized like a clone. + const repoPath = path.join(tmpDir, '.teamai', 'team-repo'); + expect(fs.existsSync(path.join(repoPath, 'teamai.yaml'))).toBe(true); + expect(fs.existsSync(path.join(repoPath, 'rules', 'common', 'x.md'))).toBe(true); + expect(fs.existsSync(path.join(repoPath, 'skills', 'weather', 'SKILL.md'))).toBe(true); + }); +}); + +describe('read-only protection (http kind)', () => { + it('rejects teamai push', async () => { + writeApiKey(); + server = await startMockServer({ apiKey: API_KEY }); + server.seedRepo({ version: 'v1', files: [{ path: 'teamai.yaml', content: TEAMAI_YAML }], commands: [] }); + + const { init } = await import('../init.js'); + await init({ http: server.url, force: true }); + + const { push } = await import('../push.js'); + await expect(push({})).rejects.toThrow(/read-only HTTP source/); + }); +}); + +describe('hook registry wiring', () => { + it('registers status-report handlers on session-start and prompt-submit', async () => { + const { buildHandlerRegistry } = await import('../hook-handlers.js'); + const reg = buildHandlerRegistry(); + const names = (event: string) => + reg.filter((r) => r.event === event).map((r) => r.handler.name); + expect(names('session-start')).toContain('status-report-session'); + expect(names('prompt-submit')).toContain('status-report-message'); + }); +}); diff --git a/src/__tests__/machine-id.test.ts b/src/__tests__/machine-id.test.ts new file mode 100644 index 0000000..0d418e5 --- /dev/null +++ b/src/__tests__/machine-id.test.ts @@ -0,0 +1,54 @@ +import { describe, it, expect } from 'vitest'; +import { deriveLocalAgentId, deriveInstanceId, detectMachineId, getMachineId } from '../machine-id.js'; + +describe('deriveLocalAgentId', () => { + it('is stable for the same inputs', () => { + const a = deriveLocalAgentId('codebuddy', 'machine-xyz', '/home/u/.codebuddy'); + const b = deriveLocalAgentId('codebuddy', 'machine-xyz', '/home/u/.codebuddy'); + expect(a).toBe(b); + }); + + it('produces a 16-char hex id', () => { + const id = deriveLocalAgentId('workbuddy', 'm', '/home/u/.workbuddy'); + expect(id).toMatch(/^[0-9a-f]{16}$/); + }); + + it('differs by install_path (user vs project scope ⇒ independent instances)', () => { + const user = deriveLocalAgentId('codebuddy', 'm', '/home/u/.codebuddy'); + const project = deriveLocalAgentId('codebuddy', 'm', '/home/u/proj/.codebuddy'); + expect(user).not.toBe(project); + }); + + it('differs by agent_type and by machine_id', () => { + const base = deriveLocalAgentId('codebuddy', 'm1', '/p'); + expect(deriveLocalAgentId('workbuddy', 'm1', '/p')).not.toBe(base); + expect(deriveLocalAgentId('codebuddy', 'm2', '/p')).not.toBe(base); + }); + + it('never embeds the raw install_path in the id', () => { + const id = deriveLocalAgentId('codebuddy', 'm', '/home/secret-user/.codebuddy'); + expect(id).not.toContain('secret-user'); + expect(id).not.toContain('/'); + }); +}); + +describe('deriveInstanceId', () => { + it('formats as local--', () => { + const localAgentId = 'abcdef0123456789'; + expect(deriveInstanceId('workbuddy', localAgentId)).toBe('local-workbuddy-456789'); + }); +}); + +describe('detectMachineId', () => { + it('returns empty string for an unsupported/empty platform without throwing', () => { + // 'sunos' hits the linux branch which reads /etc/machine-id; in CI this may + // exist or not — either way it must be a string and must not throw. + expect(typeof detectMachineId('linux')).toBe('string'); + }); + + it('getMachineId caches and returns a string', () => { + expect(typeof getMachineId()).toBe('string'); + // Second call returns the cached value (same reference value). + expect(getMachineId()).toBe(getMachineId()); + }); +}); diff --git a/src/__tests__/openclaw-hooks.test.ts b/src/__tests__/openclaw-hooks.test.ts new file mode 100644 index 0000000..e9711de --- /dev/null +++ b/src/__tests__/openclaw-hooks.test.ts @@ -0,0 +1,54 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; +import os from 'node:os'; +import { injectOpenClawHooks, removeOpenClawHooks, OPENCLAW_HOOK_DIR } from '../openclaw-hooks.js'; + +let tmpDir: string; + +beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-openclaw-test-')); +}); + +afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); +}); + +describe('injectOpenClawHooks', () => { + it('writes HOOK.md + handler.ts under /teamai-status-report', async () => { + const hooksDir = path.join(tmpDir, 'hooks'); + await injectOpenClawHooks(hooksDir, 'openclaw'); + + const dir = path.join(hooksDir, OPENCLAW_HOOK_DIR); + const hookMd = fs.readFileSync(path.join(dir, 'HOOK.md'), 'utf-8'); + const handler = fs.readFileSync(path.join(dir, 'handler.ts'), 'utf-8'); + + expect(hookMd).toContain('events:'); + expect(hookMd).toContain('session:start'); + expect(hookMd).toContain('command:new'); + expect(handler).toContain('hook-dispatch'); + expect(handler).toContain('openclaw'); + // Maps OpenClaw events to teamai dispatch events. + expect(handler).toContain('session-start'); + expect(handler).toContain('prompt-submit'); + }); + + it('is idempotent (re-inject overwrites cleanly)', async () => { + const hooksDir = path.join(tmpDir, 'hooks'); + await injectOpenClawHooks(hooksDir, 'openclaw'); + await injectOpenClawHooks(hooksDir, 'openclaw'); + const dir = path.join(hooksDir, OPENCLAW_HOOK_DIR); + expect(fs.existsSync(path.join(dir, 'HOOK.md'))).toBe(true); + }); +}); + +describe('removeOpenClawHooks', () => { + it('removes the injected hook dir and is a no-op when absent', async () => { + const hooksDir = path.join(tmpDir, 'hooks'); + await injectOpenClawHooks(hooksDir, 'openclaw'); + await removeOpenClawHooks(hooksDir); + expect(fs.existsSync(path.join(hooksDir, OPENCLAW_HOOK_DIR))).toBe(false); + // second removal does not throw + await expect(removeOpenClawHooks(hooksDir)).resolves.toBeUndefined(); + }); +}); diff --git a/src/__tests__/skill-command.test.ts b/src/__tests__/skill-command.test.ts new file mode 100644 index 0000000..4b5e1ba --- /dev/null +++ b/src/__tests__/skill-command.test.ts @@ -0,0 +1,130 @@ +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 { zipSync, strToU8 } from 'fflate'; +import { installSkillZip, executeSkillCommand, type SkillCommand } from '../skill-command.js'; + +let tmpDir: string; +let skillsDir: string; + +beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-skillcmd-test-')); + skillsDir = path.join(tmpDir, 'skills'); + fs.mkdirSync(skillsDir, { recursive: true }); +}); + +afterEach(() => { + vi.restoreAllMocks(); + fs.rmSync(tmpDir, { recursive: true, force: true }); +}); + +/** Build a valid skill zip whose top level is `/`. */ +function makeSkillZip(slug: string, extraFiles: Record = {}): Uint8Array { + const files: Record = { + [`${slug}/SKILL.md`]: strToU8(`---\nname: ${slug}\ndescription: test skill\n---\nbody`), + }; + for (const [rel, content] of Object.entries(extraFiles)) { + files[`${slug}/${rel}`] = strToU8(content); + } + return zipSync(files); +} + +describe('installSkillZip', () => { + it('extracts the / subtree into targetSkillsDir//', async () => { + const zip = makeSkillZip('weather', { 'scripts/run.sh': 'echo hi' }); + await installSkillZip(zip, 'weather', skillsDir); + + expect(fs.existsSync(path.join(skillsDir, 'weather', 'SKILL.md'))).toBe(true); + expect(fs.readFileSync(path.join(skillsDir, 'weather', 'scripts', 'run.sh'), 'utf-8')).toBe('echo hi'); + }); + + it('rejects a package with no SKILL.md anywhere', async () => { + const zip = zipSync({ 'weather/README.md': strToU8('no skill md') }); + await expect(installSkillZip(zip, 'weather', skillsDir)).rejects.toThrow(/missing weather\/SKILL\.md/); + }); + + it('accepts a flat zip (SKILL.md at root) and installs into /', async () => { + // skillhub/clawpro package layout: files at the zip root, no wrapping dir. + const zip = zipSync({ + 'SKILL.md': strToU8('---\nname: find-skills\ndescription: test\n---\nbody'), + '_meta.json': strToU8('{"slug":"find-skills-skill"}'), + 'scripts/run.sh': strToU8('echo hi'), + }); + await installSkillZip(zip, 'find-skills-skill', skillsDir); + expect(fs.existsSync(path.join(skillsDir, 'find-skills-skill', 'SKILL.md'))).toBe(true); + expect(fs.readFileSync(path.join(skillsDir, 'find-skills-skill', 'scripts', 'run.sh'), 'utf-8')).toBe('echo hi'); + }); + + it('accepts a nested zip whose top-level dir name differs from the slug', async () => { + const zip = zipSync({ 'find-skills/SKILL.md': strToU8('---\nname: find-skills\n---\nbody') }); + await installSkillZip(zip, 'find-skills-skill', skillsDir); + expect(fs.existsSync(path.join(skillsDir, 'find-skills-skill', 'SKILL.md'))).toBe(true); + }); + + it('is overwrite-idempotent (re-install replaces prior content)', async () => { + await installSkillZip(makeSkillZip('weather', { 'old.txt': 'old' }), 'weather', skillsDir); + await installSkillZip(makeSkillZip('weather', { 'new.txt': 'new' }), 'weather', skillsDir); + expect(fs.existsSync(path.join(skillsDir, 'weather', 'old.txt'))).toBe(false); + expect(fs.existsSync(path.join(skillsDir, 'weather', 'new.txt'))).toBe(true); + }); + + it('rejects a path-traversal entry in the archive', async () => { + // Craft an archive with a malicious entry under the slug prefix. + const zip = zipSync({ + 'weather/SKILL.md': strToU8('---\nname: weather\n---'), + 'weather/../../escape.txt': strToU8('pwned'), + }); + await expect(installSkillZip(zip, 'weather', skillsDir)).rejects.toThrow(/path traversal/); + }); + + it('rejects an unsafe slug', async () => { + const zip = makeSkillZip('weather'); + await expect(installSkillZip(zip, '../evil', skillsDir)).rejects.toThrow(/Invalid resource name/); + }); +}); + +describe('executeSkillCommand', () => { + it('install_skill downloads, unzips and installs', async () => { + const zip = makeSkillZip('weather'); + vi.stubGlobal('fetch', vi.fn(async () => new Response(zip as unknown as BodyInit, { status: 200 }))); + + const cmd: SkillCommand = { + id: 1, + type: 'install_skill', + skill_slug: 'weather', + skill_version: '1.0.0', + download_url: 'https://smh.example.com/pkg.zip?access_token=x', + }; + await executeSkillCommand(cmd, skillsDir); + expect(fs.existsSync(path.join(skillsDir, 'weather', 'SKILL.md'))).toBe(true); + }); + + it('install_skill requires a download_url', async () => { + const cmd: SkillCommand = { type: 'install_skill', skill_slug: 'weather' }; + await expect(executeSkillCommand(cmd, skillsDir)).rejects.toThrow(/requires download_url/); + }); + + it('uninstall_skill removes the directory and is idempotent', async () => { + fs.mkdirSync(path.join(skillsDir, 'weather'), { recursive: true }); + fs.writeFileSync(path.join(skillsDir, 'weather', 'SKILL.md'), 'x'); + + await executeSkillCommand({ type: 'uninstall_skill', skill_slug: 'weather' }, skillsDir); + expect(fs.existsSync(path.join(skillsDir, 'weather'))).toBe(false); + + // Second uninstall on a missing dir is a no-op success. + await expect( + executeSkillCommand({ type: 'uninstall_skill', skill_slug: 'weather' }, skillsDir), + ).resolves.toBeUndefined(); + }); + + it('install_skill surfaces a non-200 download as an error (ack failed path)', async () => { + vi.stubGlobal('fetch', vi.fn(async () => new Response('nope', { status: 404 }))); + const cmd: SkillCommand = { + type: 'install_skill', + skill_slug: 'weather', + download_url: 'https://smh.example.com/missing.zip', + }; + await expect(executeSkillCommand(cmd, skillsDir)).rejects.toThrow(/HTTP 404/); + }); +}); diff --git a/src/__tests__/source-http.test.ts b/src/__tests__/source-http.test.ts new file mode 100644 index 0000000..0870ef5 --- /dev/null +++ b/src/__tests__/source-http.test.ts @@ -0,0 +1,134 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; +import os from 'node:os'; +import http from 'node:http'; +import type { AddressInfo } from 'node:net'; +import { fetchRepoSnapshot, materializeHttpRepo, RepoNotAvailableError } from '../source-http.js'; +import { startMockServer, type MockServerHandle } from './helpers/mock-server.js'; + +/** Spin up a one-off server that answers /repo with a fixed status + body. */ +async function startRawServer( + status: number, + body: string, + contentType = 'text/html', +): Promise<{ url: string; close: () => Promise }> { + const server = http.createServer((_req, res) => { + res.writeHead(status, { 'Content-Type': contentType }); + res.end(body); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const port = (server.address() as AddressInfo).port; + return { + url: `http://127.0.0.1:${port}`, + close: () => new Promise((resolve) => server.close(() => resolve())), + }; +} + +let tmpDir: string; +let server: MockServerHandle; +const API_KEY = 'test-key'; + +beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-http-test-')); +}); + +afterEach(async () => { + await server?.close(); + fs.rmSync(tmpDir, { recursive: true, force: true }); +}); + +describe('fetchRepoSnapshot', () => { + it('parses version / files / commands', async () => { + server = await startMockServer({ + apiKey: API_KEY, + repo: { + version: 'abc123', + files: [{ path: 'teamai.yaml', content: 'team: x\n' }], + commands: [{ type: 'install_skill', skill_slug: 'weather', skill_version: '1.0.0', download_url: '' }], + }, + }); + const snap = await fetchRepoSnapshot(server.url, API_KEY); + expect(snap.version).toBe('abc123'); + expect(snap.files).toHaveLength(1); + expect(snap.commands[0].skill_slug).toBe('weather'); + }); + + it('reports an auth failure on a bad key', async () => { + server = await startMockServer({ apiKey: API_KEY }); + await expect(fetchRepoSnapshot(server.url, 'wrong-key')).rejects.toThrow(/Authentication failed/); + }); + + it('throws RepoNotAvailableError on HTTP 404 (/repo not live yet)', async () => { + const raw = await startRawServer(404, 'not found'); + try { + await expect(fetchRepoSnapshot(raw.url, API_KEY)).rejects.toBeInstanceOf(RepoNotAvailableError); + } finally { + await raw.close(); + } + }); + + it('throws RepoNotAvailableError on a 200 non-JSON (SPA HTML) body', async () => { + const raw = await startRawServer(200, '', 'text/html'); + try { + await expect(fetchRepoSnapshot(raw.url, API_KEY)).rejects.toBeInstanceOf(RepoNotAvailableError); + } finally { + await raw.close(); + } + }); + + it('still throws a plain Error (not RepoNotAvailableError) on 500', async () => { + const raw = await startRawServer(500, 'boom'); + try { + const err = await fetchRepoSnapshot(raw.url, API_KEY).catch((e) => e); + expect(err).toBeInstanceOf(Error); + expect(err).not.toBeInstanceOf(RepoNotAvailableError); + } finally { + await raw.close(); + } + }); +}); + +describe('materializeHttpRepo', () => { + it('writes inlined files and installs skills via commands', async () => { + server = await startMockServer({ apiKey: API_KEY }); + // Seed /repo now that the server URL (download endpoint) is known. + server.seedRepo({ + version: 'v9', + files: [ + { path: 'teamai.yaml', content: 'team: mock\n' }, + { path: 'rules/common/demo.md', content: '# demo\n' }, + ], + commands: [ + { + type: 'install_skill', + skill_slug: 'weather', + skill_version: '1.0.0', + download_url: `${server.url}/download?slug=weather&access_token=smh`, + }, + ], + }); + + const localPath = path.join(tmpDir, 'team-repo'); + const version = await materializeHttpRepo(server.url, localPath, API_KEY); + + expect(version).toBe('v9'); + expect(fs.readFileSync(path.join(localPath, 'teamai.yaml'), 'utf-8')).toContain('team: mock'); + expect(fs.existsSync(path.join(localPath, 'rules', 'common', 'demo.md'))).toBe(true); + // Skill materialized into localPath/skills// via the shared executor. + expect(fs.existsSync(path.join(localPath, 'skills', 'weather', 'SKILL.md'))).toBe(true); + }); + + it('rejects a path-traversal file entry', async () => { + server = await startMockServer({ + apiKey: API_KEY, + repo: { + version: 'v1', + files: [{ path: '../escape.txt', content: 'pwned' }], + commands: [], + }, + }); + const localPath = path.join(tmpDir, 'team-repo'); + await expect(materializeHttpRepo(server.url, localPath, API_KEY)).rejects.toThrow(/path traversal/); + }); +}); diff --git a/src/__tests__/status-report.test.ts b/src/__tests__/status-report.test.ts new file mode 100644 index 0000000..f18ae69 --- /dev/null +++ b/src/__tests__/status-report.test.ts @@ -0,0 +1,191 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; +import os from 'node:os'; +import YAML from 'yaml'; +import { + resolveReportEndpoint, + resolveEndpoints, + getReportableAgents, + scanReportableSkills, + runStatusReport, +} from '../status-report.js'; +import { startMockServer, type MockServerHandle } from './helpers/mock-server.js'; +import type { LocalConfig } from '../types.js'; + +let tmpDir: string; +let originalHome: string; +let server: MockServerHandle | undefined; +const API_KEY = 'test-key'; +const SAVED_ENV: Record = {}; +const ENV_KEYS = ['TEAMAI_REPORT_ENDPOINT', 'TEAMAI_REPORT_AGENTS', 'TEAMAI_API_TOKEN', 'TEAMAI_API_KEY', 'TEAMAI_REPORT_PATHS']; + +function setupHome(): void { + // Local config (user scope, git-style repo — reporting endpoint comes from env). + const repoPath = path.join(tmpDir, '.teamai', 'team-repo'); + fs.mkdirSync(repoPath, { recursive: true }); + fs.writeFileSync( + path.join(repoPath, 'teamai.yaml'), + YAML.stringify({ team: 'mock', repo: 'http://x', toolPaths: { codebuddy: { skills: '.codebuddy/skills' } } }), + ); + const config = { + repo: { localPath: repoPath, remote: 'http://x' }, + username: 'tester', + scope: 'user', + additionalRoles: [], + }; + fs.mkdirSync(path.join(tmpDir, '.teamai'), { recursive: true }); + fs.writeFileSync(path.join(tmpDir, '.teamai', 'config.yaml'), YAML.stringify(config)); + fs.writeFileSync(path.join(tmpDir, '.teamai', 'apikey'), API_KEY); + + // A user-installed (local) skill. + const skillDir = path.join(tmpDir, '.codebuddy', 'skills', 'mylocal'); + fs.mkdirSync(skillDir, { recursive: true }); + fs.writeFileSync(path.join(skillDir, 'SKILL.md'), '---\nname: mylocal\nversion: 2.0.0\n---\nbody'); +} + +beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-report-test-')); + originalHome = process.env.HOME ?? ''; + process.env.HOME = tmpDir; + for (const k of ENV_KEYS) { + SAVED_ENV[k] = process.env[k]; + delete process.env[k]; + } +}); + +afterEach(async () => { + await server?.close(); + server = undefined; + process.env.HOME = originalHome; + for (const k of ENV_KEYS) { + if (SAVED_ENV[k] === undefined) delete process.env[k]; + else process.env[k] = SAVED_ENV[k]; + } + fs.rmSync(tmpDir, { recursive: true, force: true }); +}); + +// ─── pure helpers ─────────────────────────────────────── + +describe('resolveEndpoints', () => { + it('defaults to the clawpro contract paths (no v1)', () => { + const ep = resolveEndpoints(); + expect(ep.report).toBe('/api/local-agent/report'); + expect(ep.sync).toBe('/api/local-agent/sync'); + expect(ep.ack).toBe('/api/local-agent/commands/ack'); + }); + + it('honors a TEAMAI_REPORT_PATHS override (interface names not hard-coded)', () => { + process.env.TEAMAI_REPORT_PATHS = JSON.stringify({ + report: '/r', + sync: '/s', + ack: '/c/done', + }); + const ep = resolveEndpoints(); + expect(ep.report).toBe('/r'); + expect(ep.sync).toBe('/s'); + expect(ep.ack).toBe('/c/done'); + }); +}); + +describe('getReportableAgents', () => { + it('defaults to workbuddy + codebuddy only (phase 1)', () => { + const set = getReportableAgents(); + expect(set.has('codebuddy')).toBe(true); + expect(set.has('workbuddy')).toBe(true); + expect(set.has('claude')).toBe(false); + }); +}); + +describe('resolveReportEndpoint', () => { + it('uses repo.url for http kind', () => { + const cfg = { repo: { localPath: '/x', remote: 'u', kind: 'http', url: 'https://h.com/' } } as unknown as LocalConfig; + expect(resolveReportEndpoint(cfg)).toBe('https://h.com'); + }); + it('falls back to env for git kind, null when unset', () => { + const cfg = { repo: { localPath: '/x', remote: 'u' } } as unknown as LocalConfig; + expect(resolveReportEndpoint(cfg)).toBeNull(); + process.env.TEAMAI_REPORT_ENDPOINT = 'https://e.com/'; + expect(resolveReportEndpoint(cfg)).toBe('https://e.com'); + }); +}); + +describe('scanReportableSkills', () => { + it('lists installed skills from the agent skills dir', async () => { + setupHome(); + const skillsDir = path.join(tmpDir, '.codebuddy', 'skills'); + const skills = await scanReportableSkills(skillsDir); + expect(skills).toHaveLength(1); + expect(skills[0]).toMatchObject({ slug: 'mylocal', version: '2.0.0', display_name: 'mylocal' }); + expect(skills[0]).not.toHaveProperty('source'); + }); +}); + +// ─── e2e against the local mock server ────────────────── + +describe('runStatusReport (session phase)', () => { + it('reports installed skills (local) and syncs', async () => { + setupHome(); + server = await startMockServer({ apiKey: API_KEY }); + process.env.TEAMAI_REPORT_ENDPOINT = server.url; + + await runStatusReport({ stdin: {}, tool: 'codebuddy', phase: 'session' }); + + expect(server.reports).toHaveLength(1); + const report = server.reports[0] as { agent_type: string; skills: Array<{ slug: string }> }; + expect(report.agent_type).toBe('codebuddy'); + expect(report.skills.find((s) => s.slug === 'mylocal')).toBeTruthy(); + expect(JSON.stringify(report)).not.toContain('"source"'); + // No install_path / machine_id leaked in the payload (privacy boundary). + expect(JSON.stringify(report)).not.toContain('install_path'); + expect(JSON.stringify(report)).not.toContain('machine_id'); + expect(server.syncs).toHaveLength(1); + }); + + it('executes an install command from sync and acks success', async () => { + setupHome(); + server = await startMockServer({ apiKey: API_KEY }); + process.env.TEAMAI_REPORT_ENDPOINT = server.url; + // Seed the install command now that the server URL (and download endpoint) is known. + server.seedCommands([ + { + id: 1, + type: 'install_skill', + skill_slug: 'weather', + skill_version: '1.0.0', + download_url: `${server.url}/download?slug=weather&access_token=smh`, + }, + ]); + + await runStatusReport({ stdin: {}, tool: 'codebuddy', phase: 'session' }); + + // Skill installed into the agent skills dir. + expect(fs.existsSync(path.join(tmpDir, '.codebuddy', 'skills', 'weather', 'SKILL.md'))).toBe(true); + // Ack recorded as success. + expect(server.acks).toHaveLength(1); + expect(server.acks[0]).toMatchObject({ id: 1 }); + expect((server.acks[0].body as { id: number }).id).toBe(1); + expect((server.acks[0].body as { status: string }).status).toBe('success'); + }); +}); + +describe('runStatusReport offline resilience', () => { + it('does not throw and buffers when the endpoint is unreachable', async () => { + setupHome(); + process.env.TEAMAI_REPORT_ENDPOINT = 'http://127.0.0.1:1'; // nothing listening + + await expect(runStatusReport({ stdin: {}, tool: 'codebuddy', phase: 'session' })).resolves.toBeUndefined(); + + const queuePath = path.join(tmpDir, '.teamai', 'reporter', 'queue.jsonl'); + expect(fs.existsSync(queuePath)).toBe(true); + }); + + it('skips agents outside the reportable set', async () => { + setupHome(); + server = await startMockServer({ apiKey: API_KEY }); + process.env.TEAMAI_REPORT_ENDPOINT = server.url; + + await runStatusReport({ stdin: {}, tool: 'claude', phase: 'session' }); + expect(server.reports).toHaveLength(0); + }); +}); diff --git a/src/__tests__/uninstall.test.ts b/src/__tests__/uninstall.test.ts index 5f2330f..90be2f6 100644 --- a/src/__tests__/uninstall.test.ts +++ b/src/__tests__/uninstall.test.ts @@ -229,6 +229,38 @@ describe('uninstall', () => { expect(await fse.pathExists(teamaiHome)).toBe(false); }); + it('移除 OpenClaw 系 agent 的 HOOK.md 目录(无 settings 路径)', async () => { + const { homeDir, repoPath, teamaiHome } = await setupFixture(tmpDir); + vi.stubEnv('HOME', homeDir); + vi.stubEnv('SHELL', '/bin/zsh'); + + // Simulate an installed OpenClaw-family agent with teamai HOOK.md injected. + const ocHookDir = path.join(homeDir, '.openclaw', 'hooks', 'teamai-status-report'); + await fse.ensureDir(ocHookDir); + await fse.writeFile(path.join(ocHookDir, 'HOOK.md'), '---\nname: [teamai] status-report\n---\n'); + await fse.writeFile(path.join(ocHookDir, 'handler.ts'), '// teamai'); + + const teamConfig = makeTeamConfig({ + toolPaths: { + claude: { skills: '.claude/skills', rules: '.claude/rules', settings: '.claude/settings.json', claudemd: '.claude/CLAUDE.md' }, + openclaw: { skills: '.openclaw/skills', rules: '.openclaw/rules' }, // no settings → OpenClaw HOOK.md path + }, + sharing: { + skills: {}, + rules: { enforced: [] }, + docs: { localDir: `${teamaiHome}/docs` }, + env: { injectShellProfile: true }, + }, + }); + const localConfig = makeLocalConfig(homeDir, repoPath); + mockAutoDetectInit.mockResolvedValue({ localConfig, teamConfig }); + + await uninstall({ force: true }); + + // The OpenClaw HOOK.md dir must be removed (regression: previously leaked). + expect(await fse.pathExists(ocHookDir)).toBe(false); + }); + it('保留用户自建的 skills', async () => { const { homeDir, repoPath } = await setupFixture(tmpDir); vi.stubEnv('HOME', homeDir); diff --git a/src/api-key.ts b/src/api-key.ts new file mode 100644 index 0000000..895a687 --- /dev/null +++ b/src/api-key.ts @@ -0,0 +1,52 @@ +/** + * Shared Bearer credential resolution for the HTTP team repo (方案一) and the + * agent status reporter (方案二). One key both pulls skills and reports status. + * + * Resolution order (first non-empty wins): + * 1. env TEAMAI_API_TOKEN + * 2. env TEAMAI_API_KEY (legacy alias accepted for convenience) + * 3. ~/.teamai/apikey (written by `teamai init --http --token`) + * + * The key is NEVER stored in teamai.yaml / local config and NEVER reported in + * any payload. The on-disk file is created with 0600 permissions and is covered + * by the project-scope .gitignore. + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import { ensureDir } from './utils/fs.js'; + +/** Absolute path to the local API key file. */ +export function getApiKeyPath(): string { + return path.join(process.env.HOME ?? '', '.teamai', 'apikey'); +} + +/** + * Resolve the API key from env or the local file. Returns null when no key is + * configured (callers surface a friendly "pass --token to init" hint). + */ +export function resolveApiKey(): string | null { + const fromEnv = process.env.TEAMAI_API_TOKEN || process.env.TEAMAI_API_KEY; + if (fromEnv && fromEnv.trim()) return fromEnv.trim(); + + try { + const content = fs.readFileSync(getApiKeyPath(), 'utf-8').trim(); + if (content) return content; + } catch { + // no file — fall through to null + } + return null; +} + +/** + * Persist an API key to ~/.teamai/apikey with 0600 permissions. + */ +export async function saveApiKey(key: string): Promise { + const trimmed = key.trim(); + if (!trimmed) throw new Error('API key must not be empty'); + const keyPath = getApiKeyPath(); + await ensureDir(path.dirname(keyPath)); + fs.writeFileSync(keyPath, trimmed + '\n', { mode: 0o600 }); + // Re-assert mode in case the file already existed with looser perms. + fs.chmodSync(keyPath, 0o600); +} diff --git a/src/builtin-skills.ts b/src/builtin-skills.ts index a19000c..3bda01c 100644 --- a/src/builtin-skills.ts +++ b/src/builtin-skills.ts @@ -50,7 +50,15 @@ export const BUILTIN_SKILL_NAMES = new Set(['teamai-share-learnings', 'teamai-wi * - Built-in skills directory doesn't exist (dev environment without build) * - A tool's skills directory is not configured */ -export async function deployBuiltinSkills(teamConfig: TeamaiConfig, localConfig?: LocalConfig, options?: { skipWiki?: boolean }): Promise { +export async function deployBuiltinSkills(teamConfig: TeamaiConfig, localConfig?: LocalConfig, options?: { skipWiki?: boolean; reportingOnly?: boolean }): Promise { + // Reporting-only HTTP mode has no team repo to write to, so both built-in + // skills (teamai-share-learnings → shares to team repo; teamai-wiki → + // persists to team repo) are non-functional. Skip them entirely. + if (options?.reportingOnly) { + log.debug('Reporting-only mode (no team repo): skipping built-in skills (teamai-share-learnings, teamai-wiki)'); + return 0; + } + const builtinDir = getBuiltinSkillsDir(); if (!await pathExists(builtinDir)) { diff --git a/src/contribute.ts b/src/contribute.ts index 64b9cf3..56887f2 100644 --- a/src/contribute.ts +++ b/src/contribute.ts @@ -1,6 +1,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { requireInit, detectProjectConfig, loadTeamConfig, loadLocalConfigForScope } from './config.js'; +import { assertNotReadOnly } from './read-only.js'; import { pushRepoDirectly, pullRepo } from './utils/git.js'; import { ensureDir } from './utils/fs.js'; import { log, spinner } from './utils/logger.js'; @@ -86,6 +87,7 @@ export async function contribute( const projectConfig = await detectProjectConfig(); localConfig = projectConfig ?? (await requireInit()).localConfig; } + assertNotReadOnly(localConfig, 'teamai contribute'); const repoPath = localConfig.repo.localPath; const username = localConfig.username; diff --git a/src/hook-handlers.ts b/src/hook-handlers.ts index 7c86065..eeff383 100644 --- a/src/hook-handlers.ts +++ b/src/hook-handlers.ts @@ -40,6 +40,8 @@ const AUTO_RECALL_TIMEOUT_MS = 10_000; const TODOWRITE_HINT_TIMEOUT_MS = 5_000; /** MR-hint queries a remote MR/PR API — allow a network round-trip. */ const MR_HINT_TIMEOUT_MS = 10_000; +/** Status reporter does report/sync/ack + skill commands — allow network + download. */ +const STATUS_REPORT_TIMEOUT_MS = 30_000; // ─── Handler implementations ──────────────────────────── // @@ -204,6 +206,26 @@ const mrHintHandler: HookHandler = { }, }; +/** SessionStart: report + sync (→ commands → ack). Never blocks the agent. */ +const statusReportSessionHandler: HookHandler = { + name: 'status-report-session', + async execute(stdin, tool) { + const { runStatusReport } = await import('./status-report.js'); + await runStatusReport({ stdin, tool, phase: 'session' }); + return null; + }, +}; + +/** UserPromptSubmit: sync only (→ commands → ack). */ +const statusReportMessageHandler: HookHandler = { + name: 'status-report-message', + async execute(stdin, tool) { + const { runStatusReport } = await import('./status-report.js'); + await runStatusReport({ stdin, tool, phase: 'message' }); + return null; + }, +}; + // ─── Registry builder ─────────────────────────────────── /** @@ -216,6 +238,7 @@ export function buildHandlerRegistry(): HandlerRegistration[] { { event: 'session-start', matcher: '*', handler: pullHandler, timeoutMs: PULL_TIMEOUT_MS }, { event: 'session-start', matcher: '*', handler: dashboardReportHandler, timeoutMs: DASHBOARD_TIMEOUT_MS }, { event: 'session-start', matcher: '*', handler: mrHintHandler, timeoutMs: MR_HINT_TIMEOUT_MS }, + { event: 'session-start', matcher: '*', handler: statusReportSessionHandler, timeoutMs: STATUS_REPORT_TIMEOUT_MS }, // ─── Stop ───────────────────────────────────────── { event: 'stop', matcher: '*', handler: updateHandler, timeoutMs: UPDATE_TIMEOUT_MS }, @@ -236,5 +259,6 @@ export function buildHandlerRegistry(): HandlerRegistration[] { // ─── UserPromptSubmit ───────────────────────────── { event: 'prompt-submit', matcher: '*', handler: trackSlashHandler, timeoutMs: TRACK_TIMEOUT_MS }, { event: 'prompt-submit', matcher: '*', handler: dashboardReportHandler, timeoutMs: DASHBOARD_TIMEOUT_MS }, + { event: 'prompt-submit', matcher: '*', handler: statusReportMessageHandler, timeoutMs: STATUS_REPORT_TIMEOUT_MS }, ]; } diff --git a/src/hooks.ts b/src/hooks.ts index 3b3ada1..c500221 100644 --- a/src/hooks.ts +++ b/src/hooks.ts @@ -1,5 +1,5 @@ import path from 'node:path'; -import { readJson, writeJson, expandHome, ensureDir } from './utils/fs.js'; +import { readJson, writeJson, expandHome, ensureDir, pathExists } from './utils/fs.js'; import { log } from './utils/logger.js'; import { TEAMAI_HOOK_DESCRIPTION_PREFIX, TEAMAI_CUSTOM_HOOK_PREFIX, getManagedHooksPath, resolveBaseDir } from './types.js'; import type { HookDef, TeamaiConfig, LocalConfig } from './types.js'; @@ -7,6 +7,17 @@ import { builtinHookDefs, applyBuiltinOverride } from './builtin-hooks.js'; import type { BuiltinHookOverride } from './builtin-hooks.js'; import { resolveTeamHooks } from './resources/hooks.js'; +/** + * Lobster-family agents (OpenClaw engine) that use HOOK.md + handler.ts instead + * of settings.json (issue #1, 方案二 §四). + * + * WorkBuddy is intentionally NOT here: it reads Claude-format hooks from + * ~/.workbuddy/settings.json (verified on 5.2.0), so it routes through the + * settings-based injection path like codebuddy. The remaining claw variants + * stay on the OpenClaw HOOK.md path pending real-device confirmation. + */ +const OPENCLAW_TOOLS = new Set(['openclaw', 'qclaw', 'easyclaw', 'autoclaw']); + /** Subcommands expected in each tool settings file (for `teamai doctor`). */ export const TEAMAI_HOOK_SUBCOMMANDS = ['hook-dispatch'] as const; @@ -407,6 +418,18 @@ export async function injectHooksToAllTools(toolPaths: Record', 'Team repo (owner/repo or full URL)') + .option('--http ', 'Git-free HTTP team repo (read-only consumer; only needs an API key)') + .option('--token ', 'API key for HTTP team repo / status reporting (stored 0600, never committed). Also reads TEAMAI_API_TOKEN.') .option('--scope ', 'Scope: user (default) or project') .option('--role ', 'Primary role ID (e.g. hai_dev) for non-interactive setup') .option('--force', 'Overwrite existing config without confirmation') diff --git a/src/init.ts b/src/init.ts index 8cd5460..ba08716 100644 --- a/src/init.ts +++ b/src/init.ts @@ -106,7 +106,138 @@ export function validateScopeMatch(remoteScope: Scope | undefined, localScope: S } } -export async function init(options: GlobalOptions & { repo?: string; scope?: string; role?: string; force?: boolean }): Promise { +/** + * Git-free HTTP onboarding (issue #1, 方案一). A read-only consumer only needs + * an API key: no git auth, no clone, no member/reviewer push. The team repo is + * materialized from `GET {url}/repo` into the same on-disk layout a clone yields. + */ +export async function initHttp( + url: string, + options: GlobalOptions & { scope?: string; role?: string; force?: boolean; token?: string }, +): Promise { + const { resolveApiKey, saveApiKey, getApiKeyPath } = await import('./api-key.js'); + const { materializeHttpRepo, RepoNotAvailableError } = await import('./source-http.js'); + + log.info('Initializing teamai (HTTP read-only consumer)...'); + + // Step 0: scope + let scope: Scope = options.scope === 'project' ? 'project' : 'user'; + const projectRoot = scope === 'project' ? process.cwd() : undefined; + const teamaiHome = getTeamaiHome(scope, projectRoot); + log.info(`Scope: ${scope}${scope === 'project' ? ` (${projectRoot})` : ''}`); + + // Re-init guard + const existingConfigPath = getConfigPath(scope, projectRoot); + if (await pathExists(existingConfigPath) && !options.force) { + const confirmed = await askConfirmation(`teamai already initialized at ${existingConfigPath}. Overwrite? [y/N] `); + if (!confirmed) { + log.info('Aborted. Existing config is unchanged.'); + return; + } + } + + // Step 1: API key. Persist --token when given (one command sets endpoint+key), + // otherwise fall back to TEAMAI_API_TOKEN / an existing ~/.teamai/apikey. + if (options.token && options.token.trim()) { + await saveApiKey(options.token.trim()); + log.success(`API key saved to ${getApiKeyPath()}`); + } + const apiKey = resolveApiKey(); + if (!apiKey) { + log.error('No API key found. Pass --token to `teamai init --http`, or set TEAMAI_API_TOKEN.'); + process.exit(1); + } + + // Step 2: materialize repo from HTTP. If the endpoint doesn't serve /repo yet + // (404 / non-JSON), fall back to "reporting-only" mode: the endpoint + key are + // still configured so status reporting works now, and skills will sync once + // /repo comes online (no re-init needed). Auth/transport errors still abort. + const localPath = expandHome(path.join(teamaiHome, 'team-repo')); + const matSpin = spinner('Fetching team repo over HTTP...').start(); + let reportingOnly = false; + try { + await materializeHttpRepo(url, localPath, apiKey!); + matSpin.succeed('Team repo materialized'); + } catch (e) { + if (e instanceof RepoNotAvailableError) { + reportingOnly = true; + matSpin.warn('No /repo at this endpoint yet — configuring for status reporting only.'); + log.info('Skills/rules will sync automatically once /repo is available (no re-init needed).'); + } else { + matSpin.fail(`HTTP fetch failed: ${(e as Error).message}`); + process.exit(1); + } + } + + // Step 3: load teamai.yaml. In reporting-only mode the endpoint hasn't shipped + // one yet, so write a minimal local stub (default toolPaths) to drive hook + // injection + the reporter. A real /repo will overwrite it on the next pull. + if (reportingOnly) { + await ensureDir(localPath); + const stubPath = path.join(localPath, 'teamai.yaml'); + if (!(await pathExists(stubPath))) { + await writeFile(stubPath, YAML.stringify({ team: 'http-reporting', repo: url, sharing: {} })); + } + } + const teamConfig = await loadTeamConfig(localPath); + if (!teamConfig) { + log.error('Materialized repo has no valid teamai.yaml. Check the endpoint.'); + process.exit(1); + } + + // Step 4: save local config (kind: http; only the URL is stored, never the key) + const localConfig: LocalConfig = { + repo: { localPath, remote: url, kind: 'http', url }, + username: 'http-consumer', + scope, + projectRoot, + additionalRoles: [], + }; + try { + Object.assign(localConfig, await promptForRoleProfile(localPath, options.role)); + } catch (error) { + const msg = (error as Error).message; + if (!msg.includes('Roles manifest not found')) { + log.debug(`Role selection skipped: ${msg}`); + } + } + + await ensureDir(teamaiHome); + if (scope === 'project') { + await saveLocalConfigForScope(localConfig, scope, projectRoot); + } else { + await ensureDir(TEAMAI_HOME); + await saveLocalConfig(localConfig); + } + log.success(`Local config saved to ${teamaiHome}/config.yaml`); + + // Invalidate cache so the next pull does a full sync. + try { + const state = await loadStateForScope(scope, projectRoot); + state.lastPullRev = null; + await saveStateForScope(state, scope, projectRoot); + } catch { + // state may not exist yet + } + + // Step 5: inject hooks (built-in dispatch incl. the reporter) via the same + // authoritative path the git init uses, so HTTP consumers behave identically. + await reconcileTeamHooksForConfig(teamConfig, localConfig); + + if (reportingOnly) { + log.success('teamai initialized (HTTP, reporting-only — /repo not live yet)!'); + log.info('Status reporting is active now; skills/rules will sync automatically once /repo is available.'); + } else { + log.success('teamai initialized (HTTP read-only)!'); + log.info('Skills/rules will auto-sync on each session start. This team is read-only (no push).'); + } + closePrompt(); +} + +export async function init(options: GlobalOptions & { repo?: string; scope?: string; role?: string; force?: boolean; http?: string; token?: string }): Promise { + if (options.http) { + return initHttp(options.http, options); + } log.info('Initializing teamai...'); // Step 0: Determine scope (user or project) diff --git a/src/machine-id.ts b/src/machine-id.ts new file mode 100644 index 0000000..2265d27 --- /dev/null +++ b/src/machine-id.ts @@ -0,0 +1,108 @@ +/** + * Machine identity + local agent id derivation (Agent Status Reporting). + * + * Design contract (issue #1 / iWiki §5.2): + * local_agent_id = sha1(agent_type + machine_id + path_hash)[:16] + * path_hash = sha1(install_path)[:8] # only hashed locally, never reported + * instance_id = local-- + * + * Invariants: + * - install_path never leaves the machine — it only feeds the local hash (privacy boundary). + * - Pure derivation, no disk writes — same machine + install dir + agent_type ⇒ same id. + * - machine_id falls back to empty string when unavailable (no MAC fallback). + * + * Cross-platform machine_id sources (macOS / Windows are first-class): + * - macOS: IOPlatformUUID via `ioreg -rd1 -c IOPlatformExpertDevice` + * - Windows: MachineGuid via `reg query HKLM\SOFTWARE\Microsoft\Cryptography` + * - Linux: /etc/machine-id or /var/lib/dbus/machine-id + */ + +import crypto from 'node:crypto'; +import fs from 'node:fs'; +import { execFileSync } from 'node:child_process'; + +let cachedMachineId: string | null = null; + +/** + * Read this host's stable machine id. Result is cached for the process lifetime. + * + * Never throws — any failure (command missing, permission denied, unsupported + * platform) resolves to an empty string. The reporter must never assume bash + * exists; each platform branch calls native binaries directly via execFileSync. + */ +export function getMachineId(): string { + if (cachedMachineId !== null) return cachedMachineId; + cachedMachineId = detectMachineId(); + return cachedMachineId; +} + +/** @internal — exported for tests that need to bypass the cache. */ +export function detectMachineId(platform: NodeJS.Platform = process.platform): string { + try { + switch (platform) { + case 'darwin': + return readDarwinMachineId(); + case 'win32': + return readWindowsMachineId(); + default: + return readLinuxMachineId(); + } + } catch { + return ''; + } +} + +function readDarwinMachineId(): string { + const out = execFileSync('ioreg', ['-rd1', '-c', 'IOPlatformExpertDevice'], { + encoding: 'utf-8', + timeout: 3000, + }); + const match = out.match(/"IOPlatformUUID"\s*=\s*"([^"]+)"/); + return match ? match[1].trim() : ''; +} + +function readWindowsMachineId(): string { + // Use reg.exe directly — do not assume a POSIX shell is present on Windows. + const out = execFileSync( + 'reg', + ['query', 'HKLM\\SOFTWARE\\Microsoft\\Cryptography', '/v', 'MachineGuid'], + { encoding: 'utf-8', timeout: 3000 }, + ); + const match = out.match(/MachineGuid\s+REG_SZ\s+([^\s]+)/i); + return match ? match[1].trim() : ''; +} + +function readLinuxMachineId(): string { + for (const p of ['/etc/machine-id', '/var/lib/dbus/machine-id']) { + try { + const content = fs.readFileSync(p, 'utf-8').trim(); + if (content) return content; + } catch { + // try next source + } + } + return ''; +} + +function sha1Hex(input: string): string { + return crypto.createHash('sha1').update(input).digest('hex'); +} + +/** + * Derive the stable 16-hex local_agent_id. + * + * @param agentType Normalized agent id (e.g. "codebuddy", "workbuddy"). + * @param machineId Result of getMachineId() (may be empty string). + * @param installPath The agent resource root (e.g. ~/.codebuddy). Hashed locally only. + */ +export function deriveLocalAgentId(agentType: string, machineId: string, installPath: string): string { + const pathHash = sha1Hex(installPath).slice(0, 8); + return sha1Hex(`${agentType}${machineId}${pathHash}`).slice(0, 16); +} + +/** + * Derive the human-friendly instance id: local--. + */ +export function deriveInstanceId(agentType: string, localAgentId: string): string { + return `local-${agentType}-${localAgentId.slice(-6)}`; +} diff --git a/src/openclaw-hooks.ts b/src/openclaw-hooks.ts new file mode 100644 index 0000000..704dc4c --- /dev/null +++ b/src/openclaw-hooks.ts @@ -0,0 +1,94 @@ +/** + * OpenClaw / 龙虾-family hook injection (issue #1, 方案二 §四). + * + * NOTE: WorkBuddy was originally assumed to use this OpenClaw engine, but + * real-device verification (WorkBuddy 5.2.0) showed it embeds the CodeBuddy CLI + * engine and reads Claude-format hooks from ~/.workbuddy/settings.json instead. + * WorkBuddy is therefore wired via the settings-based path (see types.ts + * toolPaths.workbuddy.settings), NOT here. This adapter remains for the other + * claw variants (openclaw / qclaw / easyclaw / autoclaw) whose engine is still + * unconfirmed; it writes a `HOOK.md` (frontmatter `events:`) plus a `handler.ts` + * under `/teamai-status-report/`, both shelling out to the same + * `teamai hook-dispatch` entry point. + * + * Events: `session:start` → session-start dispatch (report + sync); + * `command:new` → prompt-submit dispatch (sync only). + */ + +import path from 'node:path'; +import { writeFile, ensureDir, pathExists, remove } from './utils/fs.js'; +import { log } from './utils/logger.js'; + +/** Sub-directory name under that holds the teamai OpenClaw hook. */ +export const OPENCLAW_HOOK_DIR = 'teamai-status-report'; + +/** Marker so we can recognize (and cleanly remove) our own hook. */ +const TEAMAI_MARKER = '[teamai]'; + +/** Map OpenClaw event → teamai dispatch event. */ +const EVENT_MAP: Record = { + 'session:start': 'session-start', + 'command:new': 'prompt-submit', +}; + +function buildHookMd(tool: string): string { + const events = Object.keys(EVENT_MAP); + return [ + '---', + `name: ${TEAMAI_MARKER} status-report`, + 'events:', + ...events.map((e) => ` - ${e}`), + `handler: ./handler.ts`, + '---', + '', + `${TEAMAI_MARKER} Reports agent status to the team backend (report/sync/ack) for tool \`${tool}\`.`, + 'Managed by teamai — do not edit by hand.', + '', + ].join('\n'); +} + +function buildHandlerTs(tool: string): string { + // Map each OpenClaw event to the corresponding teamai dispatch event and shell + // out. Failures are swallowed so the agent is never blocked. + const mapLiteral = JSON.stringify(EVENT_MAP); + return `// ${TEAMAI_MARKER} status-report handler — generated by teamai, do not edit. +import { spawn } from 'node:child_process'; + +const EVENT_MAP: Record = ${mapLiteral}; +const TOOL = ${JSON.stringify(tool)}; + +export default async function handler(ctx: { event?: string } = {}): Promise { + const dispatchEvent = ctx.event ? EVENT_MAP[ctx.event] : undefined; + if (!dispatchEvent) return; + try { + const child = spawn('teamai', ['hook-dispatch', dispatchEvent, '--tool', TOOL], { + stdio: ['inherit', 'ignore', 'ignore'], + }); + child.on('error', () => {}); + } catch { + // never block the agent + } +} +`; +} + +/** + * Inject (or refresh) the teamai OpenClaw hook into ``. + * Idempotent — rewrites the two files each time. + */ +export async function injectOpenClawHooks(hooksDir: string, tool = 'openclaw'): Promise { + const dir = path.join(hooksDir, OPENCLAW_HOOK_DIR); + await ensureDir(dir); + await writeFile(path.join(dir, 'HOOK.md'), buildHookMd(tool)); + await writeFile(path.join(dir, 'handler.ts'), buildHandlerTs(tool)); + log.success(`Injected teamai OpenClaw hook into ${dir}`); +} + +/** Remove the teamai OpenClaw hook from `` if present. */ +export async function removeOpenClawHooks(hooksDir: string): Promise { + const dir = path.join(hooksDir, OPENCLAW_HOOK_DIR); + if (await pathExists(dir)) { + await remove(dir); + log.success(`Removed teamai OpenClaw hook from ${dir}`); + } +} diff --git a/src/pull.ts b/src/pull.ts index 7f27746..dabe2a1 100644 --- a/src/pull.ts +++ b/src/pull.ts @@ -33,6 +33,56 @@ interface RolePullContext { inactiveSkillNames: Set; } +/** + * Refresh the local team-repo tree, abstracting the two backends. + * + * - git: `git pull` into localPath; version = current HEAD rev. + * - http: re-materialize `GET /repo` into localPath; version = server version. + * + * Returns a display label and the opaque version string used as the + * incremental-sync cache key (state.lastPullRev). `version` is null only when + * the git backend can't resolve a rev. + */ +async function refreshTeamRepo( + localConfig: LocalConfig, +): Promise<{ label: string; version: string | null; reportingOnly: boolean }> { + if (localConfig.repo.kind === 'http') { + const { resolveApiKey } = await import('./api-key.js'); + const { materializeHttpRepo, RepoNotAvailableError } = await import('./source-http.js'); + const apiKey = resolveApiKey(); + if (!apiKey) { + throw new Error('No API key configured. Re-run `teamai init --http --token ` or set TEAMAI_API_TOKEN.'); + } + const baseUrl = localConfig.repo.url; + if (!baseUrl) { + throw new Error('HTTP team repo has no url configured.'); + } + try { + const version = await materializeHttpRepo(baseUrl, localConfig.repo.localPath, apiKey); + return { label: `HTTP ${version ?? '(no version)'}`, version, reportingOnly: false }; + } catch (e) { + if (e instanceof RepoNotAvailableError) { + // Reporting-only endpoint: /repo not live yet. Skip skill/rule sync + // quietly (status reporting still runs via its own hook handler). + log.debug(`[pull] ${(e as Error).message} — skipping repo sync (reporting-only)`); + return { label: 'HTTP (reporting-only, no /repo yet)', version: null, reportingOnly: true }; + } + throw e; + } + } + + const result = await pullRepo(localConfig.repo.localPath); + let version: string | null = null; + try { + version = await getHeadRev(localConfig.repo.localPath); + } catch { + // Can't resolve a rev → skip the incremental fast-path and do a full sync. + log.debug('Rev check failed, proceeding with full sync'); + version = null; + } + return { label: result, version, reportingOnly: false }; +} + async function buildRolePullContext(localConfig: LocalConfig): Promise { if (!localConfig.primaryRole) return null; @@ -240,22 +290,28 @@ async function pullForScope( return; } - // Step 1: git pull + // Step 1: refresh team repo (git pull, or HTTP /repo materialization) const pullSpin = spinner(`[${scopeLabel}] Pulling team repo...`).start(); + let currentRev: string | null = null; + // Reporting-only HTTP endpoints have no team repo to write to, so the + // team-repo-dependent built-in skills (teamai-share-learnings, teamai-wiki) + // are useless there and must not be injected. + let reportingOnly = false; try { - const result = await pullRepo(localConfig.repo.localPath); - pullSpin.succeed(`[${scopeLabel}] Team repo: ${result}`); + const { label, version, reportingOnly: ro } = await refreshTeamRepo(localConfig); + currentRev = version; + reportingOnly = ro; + pullSpin.succeed(`[${scopeLabel}] Team repo: ${label}`); } catch (e) { pullSpin.fail(`[${scopeLabel}] Pull failed: ${(e as Error).message}`); return; } - // Step 1b: Skip sync if repo HEAD hasn't changed since last pull + // Step 1b: Skip sync if the repo version hasn't changed since last pull if (!options.force && !options.dryRun) { try { - const currentRev = await getHeadRev(localConfig.repo.localPath); const state = await loadStateForScope(localConfig.scope, localConfig.projectRoot); - if (state.lastPullRev && state.lastPullRev === currentRev) { + if (currentRev && state.lastPullRev && state.lastPullRev === currentRev) { log.success(`[${scopeLabel}] Already synced at ${currentRev}, skipping`); // 即使 repo 未变化,仍部署 CLI 内置资源(确保 CLI 升级后新版本 agent/rules 生效) if (!options.dryRun) { @@ -263,7 +319,7 @@ async function pullForScope( if (cfg) { try { const { deployBuiltinAgents } = await import('./builtin-agents.js'); await deployBuiltinAgents(cfg, localConfig); } catch {} try { const { deployBuiltinRules } = await import('./builtin-rules.js'); await deployBuiltinRules(cfg, localConfig); } catch {} - try { const { deployBuiltinSkills } = await import('./builtin-skills.js'); await deployBuiltinSkills(cfg, localConfig, { skipWiki: !isWikiEnabled() }); } catch {} + try { const { deployBuiltinSkills } = await import('./builtin-skills.js'); await deployBuiltinSkills(cfg, localConfig, { skipWiki: !isWikiEnabled(), reportingOnly }); } catch {} } } return; @@ -516,11 +572,16 @@ async function pullForScope( } else if (!options.dryRun) { const state = await loadStateForScope(localConfig.scope, localConfig.projectRoot); state.lastPull = new Date().toISOString(); - try { - state.lastPullRev = await getHeadRev(localConfig.repo.localPath); - } catch { - // Non-critical: if we can't get the rev, just clear it - state.lastPullRev = null; + if (currentRev !== null) { + // HTTP mode: server version already resolved during refresh. + state.lastPullRev = currentRev; + } else { + try { + state.lastPullRev = await getHeadRev(localConfig.repo.localPath); + } catch { + // Non-critical: if we can't get the rev, just clear it + state.lastPullRev = null; + } } await saveStateForScope(state, localConfig.scope, localConfig.projectRoot); } @@ -730,7 +791,7 @@ async function pullForScope( if (!options.dryRun) { try { const { deployBuiltinSkills } = await import('./builtin-skills.js'); - const deployed = await deployBuiltinSkills(freshConfig, localConfig, { skipWiki: !wikiEnabled }); + const deployed = await deployBuiltinSkills(freshConfig, localConfig, { skipWiki: !wikiEnabled, reportingOnly }); if (deployed > 0) { log.debug(`[${scopeLabel}] Deployed ${deployed} built-in skill(s)`); } @@ -765,8 +826,10 @@ async function pullForScope( } } - // Step 5: Auto-report usage data (user scope only) - if (!options.dryRun && localConfig.scope === 'user') { + // Step 5: Auto-report usage data (user scope only). This pushes usage back to + // the team git repo, so it only applies to git-backed repos — HTTP consumers + // have no local git checkout and are read-only, so skip it entirely. + if (!options.dryRun && localConfig.scope === 'user' && localConfig.repo.kind !== 'http') { try { const { reportUsageToTeam } = await import('./team-push.js'); await reportUsageToTeam(localConfig.repo.localPath, localConfig.username); diff --git a/src/push.ts b/src/push.ts index 1de7c8f..c178128 100644 --- a/src/push.ts +++ b/src/push.ts @@ -1,5 +1,6 @@ import path from 'node:path'; import { autoDetectInit, loadStateForScope, saveStateForScope } from './config.js'; +import { assertNotReadOnly } from './read-only.js'; import { createGit, pullRepo, pushRepoBranch, checkoutMaster, generateBranchName, resetToCleanMaster, getDefaultBranch } from './utils/git.js'; import { syncTeamUpdatesToLocal } from './utils/pre-push-sync.js'; import { getProvider } from './providers/index.js'; @@ -107,6 +108,7 @@ export { createPrWithFallback }; export async function push(options: GlobalOptions & { all?: boolean; role?: string }): Promise { // Auto-detect scope: project scope if cwd has project config, else user scope const { localConfig, teamConfig } = await autoDetectInit(); + assertNotReadOnly(localConfig, 'teamai push'); const scopeLabel = localConfig.scope; // Pull latest default branch BEFORE scanning so detection runs against up-to-date repo. diff --git a/src/read-only.ts b/src/read-only.ts new file mode 100644 index 0000000..b4232f9 --- /dev/null +++ b/src/read-only.ts @@ -0,0 +1,17 @@ +import type { LocalConfig } from './types.js'; + +/** + * Reject write operations against a read-only HTTP team repo (issue #1, 方案一). + * + * HTTP consumers only pull; push / contribute / remove and member+reviewer + * setup are not supported. Lives in its own module so command tests that fully + * mock `./config.js` are unaffected. + */ +export function assertNotReadOnly(localConfig: LocalConfig, op: string): void { + if (localConfig.repo.kind === 'http') { + throw new Error( + `This team uses a read-only HTTP source — \`${op}\` is not supported. ` + + `Ask a team admin to update the team repo.`, + ); + } +} diff --git a/src/remove.ts b/src/remove.ts index 5b94d71..17d848e 100644 --- a/src/remove.ts +++ b/src/remove.ts @@ -1,4 +1,5 @@ import { autoDetectInit, loadStateForScope, saveStateForScope } from './config.js'; +import { assertNotReadOnly } from './read-only.js'; import { pullRepo, pushRepoBranch, checkoutMaster, generateBranchName } from './utils/git.js'; import { createPrWithFallback, filterExistingTopLevelPaths } from './push.js'; import { log, spinner } from './utils/logger.js'; @@ -25,6 +26,7 @@ export async function remove( // Auto-detect scope const { localConfig, teamConfig } = await autoDetectInit(); + assertNotReadOnly(localConfig, 'teamai remove'); // Pull latest before making changes try { diff --git a/src/skill-command.ts b/src/skill-command.ts new file mode 100644 index 0000000..79c1246 --- /dev/null +++ b/src/skill-command.ts @@ -0,0 +1,192 @@ +/** + * Shared skill-distribution primitive (issue #1, §二·五). + * + * This is the single, global skill-distribution executor used by BOTH: + * - push path (方案二 status reporting): commands come from `sync`, + * targetSkillsDir = the agent's own skills dir (e.g. ~/.codebuddy/skills). + * - pull path (方案一 HTTP team repo): commands come from `GET /repo`, + * targetSkillsDir = localPath/skills (materialized into the team repo tree). + * + * A skill package is a ZIP whose top level is a single skill directory named + * after the slug (`/SKILL.md ...`). We decompress with `fflate` (pure JS, + * cross-platform) — never the system `unzip`, which is absent on Windows. + */ + +import path from 'node:path'; +import { unzipSync } from 'fflate'; +import { ensureDir, remove, writeFile, pathExists } from './utils/fs.js'; +import { assertSafeResourceName } from './utils/path-safety.js'; +import { log } from './utils/logger.js'; + +/** Command types in the iWiki/clawpro contract. */ +export type SkillCommandType = 'install_skill' | 'uninstall_skill' | 'update_skill'; + +/** A single skill-distribution command (server `skill_distribution_record`). */ +export interface SkillCommand { + /** Command id (int, echoed back in the ack request body). Optional for the pull path. */ + id?: number; + type: SkillCommandType; + skill_slug: string; + /** Target version (required for install/update; optional for uninstall). */ + skill_version?: string; + /** Temporary, SMH-signed download URL (required for install/update). */ + download_url?: string; +} + +/** Maximum accepted skill-package size (defensive limit). */ +const MAX_ZIP_BYTES = 50 * 1024 * 1024; + +/** Optional download host allowlist via env (comma-separated). Empty = allow all. */ +function downloadHostAllowlist(): string[] { + const raw = process.env.TEAMAI_SKILL_DOWNLOAD_HOSTS; + if (!raw) return []; + return raw.split(',').map((h) => h.trim()).filter(Boolean); +} + +function assertAllowedDownloadHost(downloadUrl: string): void { + const allow = downloadHostAllowlist(); + if (allow.length === 0) return; + let host: string; + try { + host = new URL(downloadUrl).host; + } catch { + throw new Error(`Invalid download_url: ${downloadUrl}`); + } + if (!allow.includes(host)) { + throw new Error(`download_url host "${host}" is not in TEAMAI_SKILL_DOWNLOAD_HOSTS allowlist`); + } +} + +/** + * Download a skill ZIP. The URL is SMH-signed (auth lives in the query string), + * so we GET it directly, following 30x redirects, WITHOUT a Bearer header. + */ +async function downloadZip(downloadUrl: string): Promise { + assertAllowedDownloadHost(downloadUrl); + log.debug(`[skill-command] downloading skill package: ${downloadUrl}`); + const res = await fetch(downloadUrl, { redirect: 'follow' }); + if (!res.ok) { + // Surface the server's response body (truncated) + the URL so a failed + // install is actually diagnosable from debug.log / the ack error field. + let detail = ''; + try { + const body = (await res.text()).trim(); + if (body) detail = `: ${body.slice(0, 300)}`; + } catch { + // body may be unreadable — best-effort only + } + throw new Error(`download failed: HTTP ${res.status}${detail} (url: ${downloadUrl})`); + } + const buf = new Uint8Array(await res.arrayBuffer()); + if (buf.byteLength > MAX_ZIP_BYTES) { + throw new Error(`skill package too large: ${buf.byteLength} bytes`); + } + return buf; +} + +/** + * Locate the SKILL.md inside a skill ZIP and return the path prefix that should + * be stripped when installing. Tolerates the layouts seen in the wild: + * + * 1. `/SKILL.md ...` → prefix `/` (teamai contract) + * 2. `SKILL.md ...` → prefix `''` (flat zip, e.g. skillhub/clawpro) + * 3. `/SKILL.md` → prefix `/` (nested dir whose name ≠ slug) + * + * Returns null when no SKILL.md is present at all (truly malformed package). + */ +function resolveSkillPrefix(entries: Record, slug: string): string | null { + const has = (k: string) => Object.prototype.hasOwnProperty.call(entries, k); + if (has(`${slug}/SKILL.md`)) return `${slug}/`; + if (has('SKILL.md')) return ''; + for (const key of Object.keys(entries)) { + const m = key.match(/^([^/]+)\/SKILL\.md$/); + if (m) return `${m[1]}/`; + } + return null; +} + +/** + * Decompress a skill ZIP and install it into `targetSkillsDir//`. + * + * Accepts both the nested (`/SKILL.md`) and the flat (`SKILL.md` at root) + * package layouts (see {@link resolveSkillPrefix}). Every entry is verified to + * stay within the destination (path-traversal protection). Install is + * overwrite-idempotent: any pre-existing `/` is removed first. + */ +export async function installSkillZip( + zip: Uint8Array, + slug: string, + targetSkillsDir: string, +): Promise { + assertSafeResourceName(slug); + + let entries: Record; + try { + entries = unzipSync(zip); + } catch (e) { + throw new Error(`failed to unzip skill package: ${(e as Error).message}`); + } + + const prefix = resolveSkillPrefix(entries, slug); + if (prefix === null) { + throw new Error(`skill package missing ${slug}/SKILL.md (no SKILL.md found at zip root or under any top-level dir — malformed package)`); + } + + const destRoot = path.join(targetSkillsDir, slug); + // Overwrite-idempotent: clear any prior install first. + await remove(destRoot); + await ensureDir(destRoot); + + const resolvedRoot = path.resolve(destRoot); + for (const [entryPath, bytes] of Object.entries(entries)) { + // For a nested layout, only extract the chosen subtree; for a flat layout + // (prefix === '') every entry belongs to the skill. + if (prefix && !entryPath.startsWith(prefix)) continue; + // Directory entries have a trailing slash and empty bytes — ensureDir handles parents. + if (entryPath.endsWith('/')) continue; + + const rel = prefix ? entryPath.slice(prefix.length) : entryPath; + if (!rel) continue; + const outPath = path.resolve(destRoot, rel); + if (outPath !== resolvedRoot && !outPath.startsWith(resolvedRoot + path.sep)) { + throw new Error(`path traversal detected in skill package: ${entryPath}`); + } + await ensureDir(path.dirname(outPath)); + await writeFile(outPath, Buffer.from(bytes).toString('utf-8')); + } +} + +/** + * Execute one skill command against a target skills directory. + * + * - install_skill / update_skill: download zip → unzip → install to `/`. + * - uninstall_skill: remove `/` (missing dir ⇒ idempotent success). + * + * Throws on failure so the caller can ack(failed, error). Never retries. + */ +export async function executeSkillCommand(cmd: SkillCommand, targetSkillsDir: string): Promise { + assertSafeResourceName(cmd.skill_slug); + + switch (cmd.type) { + case 'install_skill': + case 'update_skill': { + if (!cmd.download_url) { + throw new Error(`${cmd.type} requires download_url`); + } + const zip = await downloadZip(cmd.download_url); + await installSkillZip(zip, cmd.skill_slug, targetSkillsDir); + log.debug(`[skill-command] ${cmd.type} ${cmd.skill_slug}@${cmd.skill_version ?? '?'} → ${targetSkillsDir}`); + return; + } + case 'uninstall_skill': { + const dir = path.join(targetSkillsDir, cmd.skill_slug); + if (await pathExists(dir)) { + await remove(dir); + } + log.debug(`[skill-command] uninstall_skill ${cmd.skill_slug} (from ${targetSkillsDir})`); + return; + } + default: + throw new Error(`unknown skill command type: ${(cmd as SkillCommand).type}`); + } +} diff --git a/src/source-http.ts b/src/source-http.ts new file mode 100644 index 0000000..5c9f8d1 --- /dev/null +++ b/src/source-http.ts @@ -0,0 +1,133 @@ +/** + * Git-free HTTP team repo client (issue #1, 方案一). + * + * `GET {baseUrl}/repo` returns a snapshot of the team repo: + * { + * version, // opaque cache key (commit hash / etag) + * files: [{ path, content }], // non-skill resources (teamai.yaml, rules/**) + * commands: [{ type, skill_slug, skill_version, download_url }] // skills + * } + * + * materializeHttpRepo() writes `files[]` into localPath (path-traversal guarded) + * and runs `commands[]` through the SHARED executor (executeSkillCommand) so the + * resulting `localPath/skills` tree is identical to a git clone. The rest of the + * pull pipeline then deploys it to each agent unchanged. + */ + +import path from 'node:path'; +import { writeFile, remove, ensureDir } from './utils/fs.js'; +import { executeSkillCommand, type SkillCommand } from './skill-command.js'; +import { log } from './utils/logger.js'; + +export interface RepoFile { + path: string; + content: string; +} + +export interface RepoSnapshot { + version: string | null; + files: RepoFile[]; + commands: SkillCommand[]; +} + +/** + * Raised when the endpoint is reachable and authenticated, but does NOT serve a + * usable `/repo` yet (HTTP 404, or 200 with a non-JSON body such as an SPA HTML + * shell). This is an EXPECTED state for a reporter-only backend whose `/repo` + * will come online later — callers can fall back to "reporting-only" setup + * instead of hard-failing. Auth (401/403) and transport errors are NOT this. + */ +export class RepoNotAvailableError extends Error { + constructor(message: string) { + super(message); + this.name = 'RepoNotAvailableError'; + } +} + +/** + * Fetch the team repo snapshot. + * - 401/403 → throws Error (bad API key). + * - 404 or non-JSON 200 body → throws {@link RepoNotAvailableError} (/repo not live yet). + * - other non-2xx / transport errors → throws Error. + */ +export async function fetchRepoSnapshot(baseUrl: string, apiKey: string): Promise { + const url = `${baseUrl.replace(/\/$/, '')}/repo`; + const res = await fetch(url, { + headers: { Authorization: `Bearer ${apiKey}` }, + }); + if (res.status === 401 || res.status === 403) { + throw new Error('Authentication failed — check your API key (pass --token to `teamai init --http`, or set TEAMAI_API_TOKEN).'); + } + if (res.status === 404) { + throw new RepoNotAvailableError(`/repo not available yet (HTTP 404) at ${url}`); + } + if (!res.ok) { + throw new Error(`GET /repo failed: HTTP ${res.status}`); + } + // A reporter-only backend may answer 200 with an SPA HTML shell. Treat any + // non-JSON body as "/repo not live yet" rather than a hard parse failure. + const text = await res.text(); + let raw: Partial; + try { + raw = JSON.parse(text) as Partial; + } catch { + throw new RepoNotAvailableError(`/repo returned a non-JSON body (likely not implemented yet) at ${url}`); + } + return { + version: typeof raw.version === 'string' ? raw.version : null, + files: Array.isArray(raw.files) ? raw.files : [], + commands: Array.isArray(raw.commands) ? raw.commands : [], + }; +} + +/** + * Write a single inlined file under localPath, guarding against path traversal. + * Throws if the resolved destination escapes localPath. + */ +async function writeRepoFile(localPath: string, file: RepoFile): Promise { + const resolvedRoot = path.resolve(localPath); + const outPath = path.resolve(localPath, file.path); + if (outPath !== resolvedRoot && !outPath.startsWith(resolvedRoot + path.sep)) { + throw new Error(`path traversal detected in /repo file: ${file.path}`); + } + await ensureDir(path.dirname(outPath)); + await writeFile(outPath, file.content); +} + +/** + * Materialize an HTTP team repo into `localPath` (git-clone-equivalent on disk). + * + * - files[] → written verbatim (teamai.yaml, rules/**, roles.yaml, ...) + * - commands[] → executed against `localPath/skills` via the shared executor. + * + * Returns the server `version` (used as the incremental pull cache key). + */ +export async function materializeHttpRepo( + baseUrl: string, + localPath: string, + apiKey: string, +): Promise { + const snapshot = await fetchRepoSnapshot(baseUrl, apiKey); + + await ensureDir(localPath); + for (const file of snapshot.files) { + await writeRepoFile(localPath, file); + } + + const skillsDir = path.join(localPath, 'skills'); + await ensureDir(skillsDir); + for (const cmd of snapshot.commands) { + try { + await executeSkillCommand(cmd, skillsDir); + } catch (e) { + log.warn(`[http-repo] skill command ${cmd.type} ${cmd.skill_slug} failed: ${(e as Error).message}`); + } + } + + return snapshot.version; +} + +/** Remove a materialized skill from the local repo tree (uninstall command). */ +export async function removeMaterializedSkill(localPath: string, slug: string): Promise { + await remove(path.join(localPath, 'skills', slug)); +} diff --git a/src/status-report.ts b/src/status-report.ts new file mode 100644 index 0000000..d716f41 --- /dev/null +++ b/src/status-report.ts @@ -0,0 +1,362 @@ +/** + * Agent status reporter (issue #1, 方案二) — hooks-driven online reporting. + * + * Three interfaces (iWiki §5.A): report / sync / ack. + * - report (SessionStart only): upsert local info + installed skill list. + * - sync (SessionStart + UserPromptSubmit): report status + pull commands. + * commands drive install/update (pull) + uninstall (delete) of skills. + * - ack (per command): success | failed (terminal, no retry). + * + * Endpoint paths are NOT hard-coded — they live in an internal mapping that + * defaults to the iWiki/clawpro contract and can be overridden via env + * (per reviewer note "接口名不一定写死,内部有个映射关系就好"). + * + * The whole flow is best-effort and MUST NOT block the agent: failures are + * swallowed and unsent payloads are buffered to an offline queue for next time. + */ + +import os from 'node:os'; +import path from 'node:path'; +import YAML from 'yaml'; +import { loadTeamConfig } from './config.js'; +import { resolveApiKey } from './api-key.js'; +import { executeSkillCommand, type SkillCommand } from './skill-command.js'; +import { + listDirs, + pathExists, + readFileSafe, + ensureDir, +} from './utils/fs.js'; +import { resolveBaseDir, type LocalConfig, type TeamaiConfig } from './types.js'; +import { getMachineId, deriveLocalAgentId } from './machine-id.js'; +import { log } from './utils/logger.js'; + +// ─── Endpoint mapping (internal, overridable) ─────────────── + +export interface EndpointMap { + report: string; + sync: string; + /** ack endpoint — the command id travels in the request body (id: int). */ + ack: string; +} + +/** Default contract paths (clawpro backend, post v1-removal). */ +const DEFAULT_ENDPOINTS: EndpointMap = { + report: '/api/local-agent/report', + sync: '/api/local-agent/sync', + ack: '/api/local-agent/commands/ack', +}; + +/** + * Resolve the endpoint map. Optional env override TEAMAI_REPORT_PATHS is a JSON + * object `{ report, sync, ack }` with plain path strings. + */ +export function resolveEndpoints(): EndpointMap { + const raw = process.env.TEAMAI_REPORT_PATHS; + if (!raw) return DEFAULT_ENDPOINTS; + try { + const parsed = JSON.parse(raw) as { report?: string; sync?: string; ack?: string }; + return { + report: parsed.report ?? DEFAULT_ENDPOINTS.report, + sync: parsed.sync ?? DEFAULT_ENDPOINTS.sync, + ack: parsed.ack ?? DEFAULT_ENDPOINTS.ack, + }; + } catch { + return DEFAULT_ENDPOINTS; + } +} + +// ─── Reportable agents (phase 1: workbuddy / codebuddy) ───── + +/** + * Agents that report in phase 1. Overridable via TEAMAI_REPORT_AGENTS + * (comma-separated) so future phases / tests can widen the set. + */ +export function getReportableAgents(): Set { + const raw = process.env.TEAMAI_REPORT_AGENTS; + if (raw) return new Set(raw.split(',').map((s) => s.trim()).filter(Boolean)); + return new Set(['workbuddy', 'codebuddy']); +} + +// ─── Endpoint resolution ──────────────────────────────────── + +/** + * Resolve the reporting base URL. Shares the HTTP team-repo endpoint when the + * repo is an http source; otherwise falls back to env TEAMAI_REPORT_ENDPOINT. + * Returns null when not configured (opt-in: no endpoint ⇒ no reporting). + */ +export function resolveReportEndpoint(localConfig: LocalConfig): string | null { + const repo = localConfig.repo as { kind?: string; url?: string }; + if (repo.kind === 'http' && repo.url) return repo.url.replace(/\/$/, ''); + const fromEnv = process.env.TEAMAI_REPORT_ENDPOINT; + return fromEnv ? fromEnv.replace(/\/$/, '') : null; +} + +// ─── Skill scanning ───────────────────────────────────────── + +export interface ReportedSkill { + slug: string; + version: string; + display_name: string; +} + +const FRONTMATTER_REGEX = /^---\n([\s\S]*?)\n---/; + +async function readSkillMeta(skillMdPath: string): Promise<{ name: string; version: string }> { + const content = await readFileSafe(skillMdPath); + if (!content) return { name: '', version: '' }; + const fm = content.match(FRONTMATTER_REGEX); + if (!fm) return { name: '', version: '' }; + try { + const parsed = YAML.parse(fm[1]) as Record | null; + const name = typeof parsed?.name === 'string' ? parsed.name : ''; + const version = parsed?.version != null ? String(parsed.version) : ''; + return { name, version }; + } catch { + return { name: '', version: '' }; + } +} + +/** + * Scan an agent's skills directory and return every installed skill. + */ +export async function scanReportableSkills(skillsDir: string): Promise { + if (!(await pathExists(skillsDir))) return []; + const dirs = await listDirs(skillsDir); + const skills: ReportedSkill[] = []; + for (const slug of dirs) { + if (slug.startsWith('.')) continue; + const skillMd = path.join(skillsDir, slug, 'SKILL.md'); + if (!(await pathExists(skillMd))) continue; + const meta = await readSkillMeta(skillMd); + skills.push({ + slug, + version: meta.version, + display_name: meta.name || slug, + }); + } + skills.sort((a, b) => a.slug.localeCompare(b.slug)); + return skills; +} + +// ─── Offline queue ────────────────────────────────────────── + +function queuePath(): string { + return path.join(process.env.HOME ?? '', '.teamai', 'reporter', 'queue.jsonl'); +} + +interface QueuedRequest { + url: string; + body: unknown; +} + +async function enqueue(req: QueuedRequest): Promise { + const p = queuePath(); + await ensureDir(path.dirname(p)); + const fse = await import('fs-extra'); + await fse.default.appendFile(p, JSON.stringify(req) + '\n', 'utf-8'); +} + +/** Replay buffered requests; drop those that succeed, keep the rest. */ +async function flushQueue(apiKey: string): Promise { + const p = queuePath(); + const raw = await readFileSafe(p); + if (!raw) return; + const lines = raw.split('\n').filter((l) => l.trim()); + const remaining: string[] = []; + for (const line of lines) { + let req: QueuedRequest; + try { + req = JSON.parse(line); + } catch { + continue; // drop malformed + } + try { + await postJson(req.url, apiKey, req.body); + } catch { + remaining.push(line); + } + } + const fse = await import('fs-extra'); + if (remaining.length === 0) { + await fse.default.remove(p); + } else { + await fse.default.writeFile(p, remaining.join('\n') + '\n', 'utf-8'); + } +} + +// ─── HTTP ─────────────────────────────────────────────────── + +async function postJson(url: string, apiKey: string, body: unknown): Promise { + const res = await fetch(url, { + method: 'POST', + headers: { + Authorization: `Bearer ${apiKey}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(body), + }); + if (!res.ok) { + throw new Error(`HTTP ${res.status}`); + } + const text = await res.text(); + return text ? JSON.parse(text) : {}; +} + +// ─── Main entry ───────────────────────────────────────────── + +export interface StatusReportOptions { + stdin: Record; + tool: string; + phase: 'session' | 'message'; + /** Override config loading (tests). */ + localConfig?: LocalConfig | null; + teamConfig?: TeamaiConfig | null; +} + +/** + * Run one reporter cycle. Never throws — all failures degrade silently and + * unsent payloads land in the offline queue. + */ +export async function runStatusReport(opts: StatusReportOptions): Promise { + try { + await runStatusReportInner(opts); + } catch (e) { + log.debug(`[status-report] swallowed error: ${(e as Error).message}`); + } +} + +async function runStatusReportInner(opts: StatusReportOptions): Promise { + const agentType = opts.tool; + if (!getReportableAgents().has(agentType)) { + log.debug(`[status-report] agent "${agentType}" not in reportable set, skipping`); + return; + } + + const localConfig = opts.localConfig ?? (await loadLocalConfigFromStdin(opts.stdin)); + if (!localConfig) return; + + const endpoint = resolveReportEndpoint(localConfig); + const apiKey = resolveApiKey(); + if (!endpoint || !apiKey) { + log.debug('[status-report] no endpoint/apiKey configured, skipping (opt-in)'); + return; + } + + const teamConfig = + opts.teamConfig ?? (await loadTeamConfig(localConfig.repo.localPath)); + if (!teamConfig) return; + + const toolPath = teamConfig.toolPaths[agentType]; + const skillsRel = toolPath?.skills; + if (!skillsRel) { + log.debug(`[status-report] no skills path for agent "${agentType}"`); + return; + } + + const baseDir = resolveBaseDir(localConfig); + const skillsDir = path.join(baseDir, skillsRel); + const installPath = path.join(baseDir, path.dirname(skillsRel)); + const machineId = getMachineId(); + const localAgentId = deriveLocalAgentId(agentType, machineId, installPath); + const endpoints = resolveEndpoints(); + + // Make every invocation visible: which agent/phase/endpoint actually ran. + // Without this there is no trace that a UserPromptSubmit/SessionStart hook + // fired at all, so "sync never triggered" is impossible to diagnose. + log.debug(`[status-report] run: agent=${agentType} phase=${opts.phase} id=${localAgentId} endpoint=${endpoint}`); + + // Best-effort: replay anything stuck in the offline queue first. + await flushQueue(apiKey).catch(() => {}); + + // ① report — SessionStart only. + if (opts.phase === 'session') { + const skills = await scanReportableSkills(skillsDir); + const reportBody = { + local_agent_id: localAgentId, + agent_type: agentType, + agent_version: '', + host_name: os.hostname(), + os: `${process.platform}/${process.arch}`, + started_at: new Date().toISOString(), + skills, + }; + await sendOrQueue(`${endpoint}${endpoints.report}`, apiKey, reportBody, 'report'); + } + + // ② sync — both phases. Returns commands to execute. + const syncBody = { + local_agent_id: localAgentId, + agent_type: agentType, + status: 'running', + }; + let syncResp: unknown; + try { + syncResp = await postJson(`${endpoint}${endpoints.sync}`, apiKey, syncBody); + } catch (e) { + log.debug(`[status-report] sync FAILED (queued, phase=${opts.phase}): ${(e as Error).message}`); + await enqueue({ url: `${endpoint}${endpoints.sync}`, body: syncBody }); + return; // no commands to act on + } + + const commands = extractCommands(syncResp); + log.debug(`[status-report] sync OK (phase=${opts.phase}): ${commands.length} command(s)`); + for (const cmd of commands) { + const label = `${cmd.type} ${cmd.skill_slug}@${cmd.skill_version || '?'}`; + // Log what the server told us to do (incl. the signed download_url) so a + // failed install is diagnosable: you can see exactly what was fetched. + log.debug(`[status-report] command: ${label}${cmd.download_url ? ` url=${cmd.download_url}` : ''}`); + // ③ execute + ack each command (terminal — no retry). + let status: 'success' | 'failed' = 'success'; + let error = ''; + try { + await executeSkillCommand(cmd, skillsDir); + log.debug(`[status-report] ${label} → ${skillsDir} OK`); + } catch (e) { + status = 'failed'; + error = (e as Error).message; + log.error(`[status-report] ${label} FAILED: ${error}`); + } + if (cmd.id != null) { + const ackBody = { id: cmd.id, status, error }; + await sendOrQueue(`${endpoint}${endpoints.ack}`, apiKey, ackBody, `ack#${cmd.id}(${status})`); + } + } +} + +async function sendOrQueue(url: string, apiKey: string, body: unknown, label: string): Promise { + try { + await postJson(url, apiKey, body); + log.debug(`[status-report] ${label} OK`); + } catch (e) { + log.debug(`[status-report] ${label} FAILED (queued): ${(e as Error).message}`); + await enqueue({ url, body }); + } +} + +function extractCommands(resp: unknown): SkillCommand[] { + if (!resp || typeof resp !== 'object') return []; + const arr = (resp as { commands?: unknown }).commands; + if (!Array.isArray(arr)) return []; + const out: SkillCommand[] = []; + for (const c of arr) { + if (c && typeof c === 'object' && typeof (c as SkillCommand).skill_slug === 'string') { + out.push(c as SkillCommand); + } + } + return out; +} + +/** + * Load the local config for the scope implied by the hook's cwd. Project scope + * is preferred when the cwd has a project-scope teamai config; otherwise user. + */ +async function loadLocalConfigFromStdin(stdin: Record): Promise { + const { detectProjectConfig, loadLocalConfigForScope } = await import('./config.js'); + const cwd = typeof stdin.cwd === 'string' ? stdin.cwd : undefined; + if (cwd) { + const projectConfig = await detectProjectConfig(cwd); + if (projectConfig) return projectConfig; + } + return loadLocalConfigForScope('user'); +} diff --git a/src/types.ts b/src/types.ts index 5d21590..de50639 100644 --- a/src/types.ts +++ b/src/types.ts @@ -125,7 +125,11 @@ export const TeamaiConfigSchema = z.object({ cursor: { skills: '.cursor/skills', rules: '.cursor/rules', settings: '.cursor/hooks.json', agents: '.cursor/agents' }, codebuddy: { skills: '.codebuddy/skills', rules: '.codebuddy/rules', settings: '.codebuddy/settings.json', claudemd: '.codebuddy/CODEBUDDY.md', agents: '.codebuddy/agents' }, openclaw: { skills: '.openclaw/skills', rules: '.openclaw/rules' }, - workbuddy: { skills: '.workbuddy/skills', rules: '.workbuddy/rules' }, + // WorkBuddy embeds the CodeBuddy CLI engine and reads Claude-format hooks + // from ~/.workbuddy/settings.json (verified on WorkBuddy 5.2.0: SessionStart + // / PostToolUse / UserPromptSubmit fire with PascalCase events + CLI tool + // names). It is therefore wired exactly like codebuddy, not via OpenClaw. + workbuddy: { skills: '.workbuddy/skills', rules: '.workbuddy/rules', settings: '.workbuddy/settings.json' }, }), }); @@ -148,6 +152,10 @@ export const LocalConfigSchema = z.object({ repo: z.object({ localPath: z.string(), remote: z.string(), + /** Team repo backend. Defaults to 'git' for backward compatibility. */ + kind: z.enum(['git', 'http']).optional(), + /** Base URL of the HTTP team repo (only when kind === 'http'). */ + url: z.string().optional(), }), username: z.string(), updatePolicy: z.enum(['auto', 'prompt', 'skip']).optional(), diff --git a/src/uninstall.ts b/src/uninstall.ts index 0e6b69b..d505c47 100644 --- a/src/uninstall.ts +++ b/src/uninstall.ts @@ -1,6 +1,7 @@ import path from 'node:path'; import { autoDetectInit } from './config.js'; import { reconcileHooks } from './hooks.js'; +import { removeOpenClawHooks, OPENCLAW_HOOK_DIR } from './openclaw-hooks.js'; import { TEAMAI_RULES_START, TEAMAI_RULES_END, @@ -41,6 +42,8 @@ interface UninstallOptions extends GlobalOptions { interface RemovalPlan { /** Tool settings files that contain teamai hooks. */ hookFiles: Array<{ path: string; tool: string }>; + /** OpenClaw-style hook dirs (/./hooks) holding teamai HOOK.md+handler.ts. */ + openclawHookDirs: Array<{ hooksDir: string; tool: string }>; /** CLAUDE.md files with teamai rules blocks. */ claudeMdFiles: string[]; /** Skill directories synced from team repo. */ @@ -133,6 +136,7 @@ async function buildRemovalPlan( const plan: RemovalPlan = { hookFiles: [], + openclawHookDirs: [], claudeMdFiles: [], skillDirs: [], ruleFiles: [], @@ -155,6 +159,13 @@ async function buildRemovalPlan( if (await pathExists(settingsPath)) { plan.hookFiles.push({ path: settingsPath, tool }); } + } else { + // OpenClaw-style agents (no settings file) inject a HOOK.md + handler.ts + // under /./hooks/. Mirror that for removal. + const hooksDir = path.join(baseDir, `.${tool}`, 'hooks'); + if (await pathExists(path.join(hooksDir, OPENCLAW_HOOK_DIR))) { + plan.openclawHookDirs.push({ hooksDir, tool }); + } } // (b) CLAUDE.md teamai section blocks @@ -229,6 +240,7 @@ async function buildRemovalPlan( function isPlanEmpty(plan: RemovalPlan): boolean { return ( plan.hookFiles.length === 0 && + plan.openclawHookDirs.length === 0 && plan.claudeMdFiles.length === 0 && plan.skillDirs.length === 0 && plan.ruleFiles.length === 0 && @@ -251,6 +263,14 @@ function printSummary(plan: RemovalPlan): void { console.log(''); } + if (plan.openclawHookDirs.length > 0) { + console.log(` OpenClaw Hooks (${plan.openclawHookDirs.length} 个目录):`); + for (const { hooksDir } of plan.openclawHookDirs) { + console.log(` ${path.join(hooksDir, OPENCLAW_HOOK_DIR)}/`); + } + console.log(''); + } + if (plan.claudeMdFiles.length > 0) { console.log(` CLAUDE.md 规则块 (${plan.claudeMdFiles.length} 个文件):`); for (const p of plan.claudeMdFiles) { @@ -300,6 +320,15 @@ async function executeRemoval(plan: RemovalPlan): Promise { } } + // (a2) Remove OpenClaw-style hook dirs + for (const { hooksDir } of plan.openclawHookDirs) { + try { + await removeOpenClawHooks(hooksDir); + } catch (e) { + log.warn(`移除 OpenClaw hook 失败 ${hooksDir}: ${(e as Error).message}`); + } + } + // (b) Clean CLAUDE.md teamai section blocks for (const claudeMdPath of plan.claudeMdFiles) { try {