From 2d4e559511fd024bd9bab6e3052d0064509b0cd3 Mon Sep 17 00:00:00 2001 From: jeffyxu Date: Wed, 24 Jun 2026 18:08:11 +0800 Subject: [PATCH 01/17] feat: HTTP team repo + hooks-based agent status reporting (issue #1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 实现 issue #1 评审通过的两套方案,共享同一套 skill 分发原语。 方案一(git-free HTTP 团队仓库 + skill 只读拉取): - source-http.ts: GET /repo 物化(files[] 内联 + commands[] 走共享执行器), 路径穿越防护,version 作为增量缓存 key - init --http : 只读消费者 onboarding(只需 API key,跳过 git/clone/member/reviewer) - pull.ts: 抽象 refreshTeamRepo() 收口 git/http 两种刷新,其余管线原样复用 - read-only.ts: http kind 下 push/contribute/remove 明确拒绝 方案二(hooks 驱动的 agent 状态上报): - machine-id.ts: 跨平台 machine_id(macOS ioreg / Windows reg / Linux machine-id) + local_agent_id 派生(install_path 仅本地哈希,不上报) - status-report.ts: report/sync/ack 三接口 + 离线队列 + clawpro/local 来源区分; 接口路径走可覆盖的内部映射(默认 iWiki 契约,TEAMAI_REPORT_PATHS 可覆盖) - 挂到既有 hook dispatch: session-start→report+sync,prompt-submit→sync - openclaw-hooks.ts: WorkBuddy(龙虾系)HOOK.md + handler.ts 注入适配器 共享地基: - skill-command.ts: executeSkillCommand(fflate 解压 zip,含 SKILL.md 校验、 路径穿越防护、SMH 直连下载),push/pull 两条路径共用 - api-key.ts: 统一 Bearer 凭证解析/保存(0600,不入 config/不上报) - teamai login 命令 测试: 本地实现三接口 mock HTTP 服务(进程内 helper + scripts/mock-teamai-server.mjs 独立可运行版),单测 + 集成端到端共 49 个新用例全部通过。 Co-Authored-By: Claude Opus 4.8 (cherry picked from commit e8e50532024bfccf83a1b968cfc7d5a0de4472e3) --- package-lock.json | 7 + package.json | 1 + scripts/mock-teamai-server.mjs | 122 ++++++ src/__tests__/api-key.test.ts | 66 ++++ src/__tests__/helpers/mock-server.ts | 129 +++++++ src/__tests__/http-repo-integration.test.ts | 108 ++++++ src/__tests__/machine-id.test.ts | 54 +++ src/__tests__/openclaw-hooks.test.ts | 54 +++ src/__tests__/skill-command.test.ts | 112 ++++++ src/__tests__/source-http.test.ts | 85 +++++ src/__tests__/status-report.test.ts | 194 ++++++++++ src/api-key.ts | 52 +++ src/contribute.ts | 2 + src/hook-handlers.ts | 24 ++ src/hooks.ts | 20 +- src/index.ts | 10 + src/init.ts | 103 +++++- src/machine-id.ts | 108 ++++++ src/openclaw-hooks.ts | 93 +++++ src/pull.ts | 68 +++- src/push.ts | 2 + src/read-only.ts | 17 + src/remove.ts | 2 + src/skill-command.ts | 159 ++++++++ src/source-http.ts | 103 ++++++ src/status-report.ts | 390 ++++++++++++++++++++ src/types.ts | 4 + 27 files changed, 2076 insertions(+), 13 deletions(-) create mode 100644 scripts/mock-teamai-server.mjs create mode 100644 src/__tests__/api-key.test.ts create mode 100644 src/__tests__/helpers/mock-server.ts create mode 100644 src/__tests__/http-repo-integration.test.ts create mode 100644 src/__tests__/machine-id.test.ts create mode 100644 src/__tests__/openclaw-hooks.test.ts create mode 100644 src/__tests__/skill-command.test.ts create mode 100644 src/__tests__/source-http.test.ts create mode 100644 src/__tests__/status-report.test.ts create mode 100644 src/api-key.ts create mode 100644 src/machine-id.ts create mode 100644 src/openclaw-hooks.ts create mode 100644 src/read-only.ts create mode 100644 src/skill-command.ts create mode 100644 src/source-http.ts create mode 100644 src/status-report.ts 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..1d476dc --- /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: 'rec-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/v1/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/v1/local-agent/sync') { + const body = await readBody(req); + console.log('[sync]', JSON.stringify(body)); + const commands = pendingCommands; + pendingCommands = []; + json(200, { ok: true, commands }); + return; + } + + const ack = url.pathname.match(/^\/api\/v1\/local-agent\/commands\/([^/]+)\/ack$/); + if (req.method === 'POST' && ack) { + const body = await readBody(req); + console.log(`[ack ${ack[1]}]`, 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..1eb49a2 --- /dev/null +++ b/src/__tests__/helpers/mock-server.ts @@ -0,0 +1,129 @@ +/** + * 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: string; 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/v1/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/v1/local-agent/sync') { + handle.syncs.push(await readBody(req)); + const commands = config.pendingCommands ?? []; + config.pendingCommands = []; // deliver once + json(200, { ok: true, commands }); + return; + } + + const ackMatch = url.pathname.match(/^\/api\/v1\/local-agent\/commands\/([^/]+)\/ack$/); + if (req.method === 'POST' && ackMatch) { + handle.acks.push({ id: ackMatch[1], body: await readBody(req) }); + 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..9b42b16 --- /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, 'workbuddy'); + + 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('workbuddy'); + // 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, 'workbuddy'); + await injectOpenClawHooks(hooksDir, 'workbuddy'); + 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, 'workbuddy'); + 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..5bf99ee --- /dev/null +++ b/src/__tests__/skill-command.test.ts @@ -0,0 +1,112 @@ +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 missing /SKILL.md', async () => { + const zip = zipSync({ 'weather/README.md': strToU8('no skill md') }); + await expect(installSkillZip(zip, 'weather', skillsDir)).rejects.toThrow(/missing weather\/SKILL\.md/); + }); + + 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: 'r1', + 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..0f35874 --- /dev/null +++ b/src/__tests__/source-http.test.ts @@ -0,0 +1,85 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; +import os from 'node:os'; +import { fetchRepoSnapshot, materializeHttpRepo } from '../source-http.js'; +import { startMockServer, type MockServerHandle } from './helpers/mock-server.js'; + +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/); + }); +}); + +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..4f5046c --- /dev/null +++ b/src/__tests__/status-report.test.ts @@ -0,0 +1,194 @@ +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, + getClawproSlugs, + 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 iWiki contract paths', () => { + const ep = resolveEndpoints(); + expect(ep.report).toBe('/api/v1/local-agent/report'); + expect(ep.sync).toBe('/api/v1/local-agent/sync'); + expect(ep.ack('xyz')).toBe('/api/v1/local-agent/commands/xyz/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/:id/done', + }); + const ep = resolveEndpoints(); + expect(ep.report).toBe('/r'); + expect(ep.ack('42')).toBe('/c/42/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('tags clawpro vs local', async () => { + setupHome(); + const skillsDir = path.join(tmpDir, '.codebuddy', 'skills'); + const skills = await scanReportableSkills(skillsDir, new Set(['someother'])); + expect(skills).toHaveLength(1); + expect(skills[0]).toMatchObject({ slug: 'mylocal', version: '2.0.0', source: 'local' }); + }); +}); + +// ─── 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; source: string }> }; + expect(report.agent_type).toBe('codebuddy'); + expect(report.skills.find((s) => s.slug === 'mylocal')?.source).toBe('local'); + // 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; next report tags clawpro', 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: 'rec-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: 'rec-1' }); + expect((server.acks[0].body as { status: string }).status).toBe('success'); + + // clawpro bookkeeping recorded → weather is now tagged clawpro. + const { deriveLocalAgentId, getMachineId } = await import('../machine-id.js'); + const localAgentId = deriveLocalAgentId('codebuddy', getMachineId(), path.join(tmpDir, '.codebuddy')); + const slugs = await getClawproSlugs(localAgentId); + expect(slugs.has('weather')).toBe(true); + }); +}); + +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/api-key.ts b/src/api-key.ts new file mode 100644 index 0000000..e617403 --- /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 login`) + * + * 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 "run `teamai login`" 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/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..e62969c 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,12 @@ 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. WorkBuddy is the phase-1 target (issue #1, 方案二 §四). + */ +const OPENCLAW_TOOLS = new Set(['workbuddy', '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 +413,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('--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') @@ -32,6 +33,15 @@ program await init({ ...globalOpts, ...cmdOpts }); }); +program + .command('login ') + .description('Save the API key for HTTP team repo / status reporting (stored 0600, never committed)') + .action(async (apiKey: string) => { + const { saveApiKey, getApiKeyPath } = await import('./api-key.js'); + await saveApiKey(apiKey); + log.success(`API key saved to ${getApiKeyPath()}`); + }); + program .command('push') .description('Push local resources to team repo') diff --git a/src/init.ts b/src/init.ts index 8cd5460..d236ba3 100644 --- a/src/init.ts +++ b/src/init.ts @@ -106,7 +106,108 @@ 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 }, +): Promise { + const { resolveApiKey } = await import('./api-key.js'); + const { materializeHttpRepo } = 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 + const apiKey = resolveApiKey(); + if (!apiKey) { + log.error('No API key found. Set TEAMAI_API_TOKEN or run `teamai login ` first.'); + process.exit(1); + } + + // Step 2: materialize repo from HTTP + const localPath = expandHome(path.join(teamaiHome, 'team-repo')); + const matSpin = spinner('Fetching team repo over HTTP...').start(); + try { + await materializeHttpRepo(url, localPath, apiKey!); + matSpin.succeed('Team repo materialized'); + } catch (e) { + matSpin.fail(`HTTP fetch failed: ${(e as Error).message}`); + process.exit(1); + } + + // Step 3: validate teamai.yaml + 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 (unchanged) + await injectHooksToAllTools(teamConfig.toolPaths, resolveBaseDir(localConfig)); + + 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 }): 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..a7e4253 --- /dev/null +++ b/src/openclaw-hooks.ts @@ -0,0 +1,93 @@ +/** + * OpenClaw / 龙虾-family hook injection (issue #1, 方案二 §四). + * + * WorkBuddy (category `lobster`) uses the OpenClaw hook engine — a different + * shape from Claude's settings.json: a `HOOK.md` (frontmatter `events:`) plus a + * `handler.ts` placed under `/teamai-status-report/`. Both shell out to + * the same `teamai hook-dispatch` entry point, so the reporting core is shared + * with the Claude/CodeBuddy path; only the trigger adapter differs. + * + * Events: `session:start` → session-start dispatch (report + sync); + * `command:new` → prompt-submit dispatch (sync only). + * + * NOTE: WorkBuddy's desktop hook config dir (`~/.openclaw/` vs `~/.workbuddy/`) + * is still pending confirmation (issue §七.1); callers pass the resolved dir. + */ + +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 = 'workbuddy'): 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..ff6bab1 100644 --- a/src/pull.ts +++ b/src/pull.ts @@ -33,6 +33,46 @@ 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 }> { + if (localConfig.repo.kind === 'http') { + const { resolveApiKey } = await import('./api-key.js'); + const { materializeHttpRepo } = await import('./source-http.js'); + const apiKey = resolveApiKey(); + if (!apiKey) { + throw new Error('No API key configured. Run `teamai login ` or set TEAMAI_API_TOKEN.'); + } + const baseUrl = localConfig.repo.url; + if (!baseUrl) { + throw new Error('HTTP team repo has no url configured.'); + } + const version = await materializeHttpRepo(baseUrl, localConfig.repo.localPath, apiKey); + return { label: `HTTP ${version ?? '(no version)'}`, version }; + } + + 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 }; +} + async function buildRolePullContext(localConfig: LocalConfig): Promise { if (!localConfig.primaryRole) return null; @@ -240,22 +280,23 @@ 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; try { - const result = await pullRepo(localConfig.repo.localPath); - pullSpin.succeed(`[${scopeLabel}] Team repo: ${result}`); + const { label, version } = await refreshTeamRepo(localConfig); + currentRev = version; + 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) { @@ -516,11 +557,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); } 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..1e535c5 --- /dev/null +++ b/src/skill-command.ts @@ -0,0 +1,159 @@ +/** + * 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 (echoed back in ack). Optional for the pull path. */ + id?: string; + 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); + const res = await fetch(downloadUrl, { redirect: 'follow' }); + if (!res.ok) { + throw new Error(`download failed: HTTP ${res.status}`); + } + 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; +} + +/** + * Decompress a skill ZIP and install it into `targetSkillsDir//`. + * + * Validates that the archive contains `/SKILL.md` and that every entry + * stays 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 = `${slug}/`; + const skillMdPath = `${slug}/SKILL.md`; + if (!Object.prototype.hasOwnProperty.call(entries, skillMdPath)) { + throw new Error(`skill package missing ${skillMdPath} (slug mismatch or 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)) { + // Only extract the single / subtree; ignore anything else in the zip. + if (!entryPath.startsWith(prefix)) continue; + // Directory entries have a trailing slash and empty bytes — ensureDir handles parents. + if (entryPath.endsWith('/')) continue; + + const rel = entryPath.slice(prefix.length); + 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..dea7c8e --- /dev/null +++ b/src/source-http.ts @@ -0,0 +1,103 @@ +/** + * 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[]; +} + +/** Fetch the team repo snapshot. Throws on auth/transport errors. */ +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 (run `teamai login `).'); + } + if (!res.ok) { + throw new Error(`GET /repo failed: HTTP ${res.status}`); + } + const raw = (await res.json()) as Partial; + 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..e69bea3 --- /dev/null +++ b/src/status-report.ts @@ -0,0 +1,390 @@ +/** + * 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, + * each tagged source = clawpro (server-managed) | local (user-installed). + * - sync (SessionStart + UserPromptSubmit): report status + pull commands. + * commands drive install/update (pull) + uninstall (delete) of clawpro 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, + readJson, + writeJson, + 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 path builder — receives the command id. */ + ack: (commandId: string) => string; +} + +/** Default contract paths (iWiki §5.A). */ +const DEFAULT_ENDPOINTS: EndpointMap = { + report: '/api/v1/local-agent/report', + sync: '/api/v1/local-agent/sync', + ack: (id) => `/api/v1/local-agent/commands/${id}/ack`, +}; + +/** + * Resolve the endpoint map. Optional env override TEAMAI_REPORT_PATHS is a JSON + * object `{ report, sync, ack }` where ack contains the literal `:id` token. + */ +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 + ? (id) => parsed.ack!.replace(':id', encodeURIComponent(id)) + : 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; +} + +// ─── clawpro skill bookkeeping ────────────────────────────── +// +// We record which slugs were installed via `sync` commands so that `report` +// can tag them `source: clawpro` (vs user-installed `local`). Keyed by +// local_agent_id so user/project scope (different ids) stay independent. + +function clawproRecordPath(): string { + return path.join(process.env.HOME ?? '', '.teamai', 'reporter', 'clawpro-skills.json'); +} + +type ClawproRecord = Record; + +async function loadClawproRecord(): Promise { + return (await readJson(clawproRecordPath())) ?? {}; +} + +export async function getClawproSlugs(localAgentId: string): Promise> { + const rec = await loadClawproRecord(); + return new Set(rec[localAgentId] ?? []); +} + +async function recordClawproSlug(localAgentId: string, slug: string, present: boolean): Promise { + const rec = await loadClawproRecord(); + const set = new Set(rec[localAgentId] ?? []); + if (present) set.add(slug); + else set.delete(slug); + rec[localAgentId] = [...set]; + await writeJson(clawproRecordPath(), rec); +} + +// ─── Skill scanning ───────────────────────────────────────── + +export interface ReportedSkill { + slug: string; + version: string; + display_name: string; + source: 'clawpro' | 'local'; +} + +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, tagging each skill clawpro/local. + */ +export async function scanReportableSkills( + skillsDir: string, + clawproSlugs: Set, +): 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, + source: clawproSlugs.has(slug) ? 'clawpro' : 'local', + }); + } + 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(); + + // Best-effort: replay anything stuck in the offline queue first. + await flushQueue(apiKey).catch(() => {}); + + // ① report — SessionStart only. + if (opts.phase === 'session') { + const clawpro = await getClawproSlugs(localAgentId); + const skills = await scanReportableSkills(skillsDir, clawpro); + 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); + } + + // ② 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 { + await enqueue({ url: `${endpoint}${endpoints.sync}`, body: syncBody }); + return; // no commands to act on + } + + const commands = extractCommands(syncResp); + for (const cmd of commands) { + // ③ execute + ack each command (terminal — no retry). + let status: 'success' | 'failed' = 'success'; + let error = ''; + try { + await executeSkillCommand(cmd, skillsDir); + // Maintain clawpro bookkeeping so future reports tag the slug correctly. + await recordClawproSlug(localAgentId, cmd.skill_slug, cmd.type !== 'uninstall_skill'); + } catch (e) { + status = 'failed'; + error = (e as Error).message; + } + if (cmd.id) { + const ackBody = { status, error }; + await sendOrQueue(`${endpoint}${endpoints.ack(cmd.id)}`, apiKey, ackBody); + } + } +} + +async function sendOrQueue(url: string, apiKey: string, body: unknown): Promise { + try { + await postJson(url, apiKey, body); + } catch { + 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..daaf8c6 100644 --- a/src/types.ts +++ b/src/types.ts @@ -148,6 +148,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(), From 4ccb19d3d558007f5459098677a7ca5190eda599 Mon Sep 17 00:00:00 2001 From: jeffyxu Date: Mon, 29 Jun 2026 12:10:56 +0800 Subject: [PATCH 02/17] fix: align local-agent reporter with updated backend contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 后端契约调整(对齐 clawpro https://5hborhrw.cvmopenclaw.site): 1. 接口名去掉 v1:/api/v1/local-agent/* → /api/local-agent/* 2. ack 的 command id 从 path 移到 request body,类型 int: POST /api/local-agent/commands/ack,body { id: , status, error } - status-report.ts: EndpointMap.ack 由路径构造函数改为固定路径字符串; 默认路径去 v1;ack body 带 int id。TEAMAI_REPORT_PATHS 覆盖保留。 - skill-command.ts: SkillCommand.id string → number。 - mock-server.ts / mock-teamai-server.mjs / 单测同步更新。 端到端验证(真实后端):report/sync → 200,ack 路由按 int body id 校验; 完整 install→ack 闭环(mock)通过。tsc 通过,vitest 1454 passed 无回归。 Co-authored-by: Cursor (cherry picked from commit fe5d649f4f29a0e70996a011ee670bb32ec7ca27) --- scripts/mock-teamai-server.mjs | 12 ++++++------ src/__tests__/helpers/mock-server.ts | 13 +++++++------ src/__tests__/skill-command.test.ts | 2 +- src/__tests__/status-report.test.ts | 18 ++++++++++-------- src/skill-command.ts | 4 ++-- src/status-report.ts | 24 +++++++++++------------- 6 files changed, 37 insertions(+), 36 deletions(-) diff --git a/scripts/mock-teamai-server.mjs b/scripts/mock-teamai-server.mjs index 1d476dc..4bbdb62 100644 --- a/scripts/mock-teamai-server.mjs +++ b/scripts/mock-teamai-server.mjs @@ -29,7 +29,7 @@ const SEED_INSTALL = process.env.SEED_INSTALL ?? ''; let pendingCommands = SEED_INSTALL ? [ { - id: 'rec-1', + id: 1, type: 'install_skill', skill_slug: SEED_INSTALL, skill_version: '1.0.0', @@ -89,14 +89,14 @@ const server = http.createServer(async (req, res) => { return; } - if (req.method === 'POST' && url.pathname === '/api/v1/local-agent/report') { + 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/v1/local-agent/sync') { + if (req.method === 'POST' && url.pathname === '/api/local-agent/sync') { const body = await readBody(req); console.log('[sync]', JSON.stringify(body)); const commands = pendingCommands; @@ -105,10 +105,10 @@ const server = http.createServer(async (req, res) => { return; } - const ack = url.pathname.match(/^\/api\/v1\/local-agent\/commands\/([^/]+)\/ack$/); - if (req.method === 'POST' && ack) { + // 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 ${ack[1]}]`, JSON.stringify(body)); + console.log(`[ack ${body.id}]`, JSON.stringify(body)); json(200, { ok: true }); return; } diff --git a/src/__tests__/helpers/mock-server.ts b/src/__tests__/helpers/mock-server.ts index 1eb49a2..035cc02 100644 --- a/src/__tests__/helpers/mock-server.ts +++ b/src/__tests__/helpers/mock-server.ts @@ -28,7 +28,7 @@ export interface MockServerHandle { close: () => Promise; reports: unknown[]; syncs: unknown[]; - acks: Array<{ id: string; body: 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`). */ @@ -97,13 +97,13 @@ export async function startMockServer(config: MockServerConfig): Promise { vi.stubGlobal('fetch', vi.fn(async () => new Response(zip as unknown as BodyInit, { status: 200 }))); const cmd: SkillCommand = { - id: 'r1', + id: 1, type: 'install_skill', skill_slug: 'weather', skill_version: '1.0.0', diff --git a/src/__tests__/status-report.test.ts b/src/__tests__/status-report.test.ts index 4f5046c..c8ed905 100644 --- a/src/__tests__/status-report.test.ts +++ b/src/__tests__/status-report.test.ts @@ -69,22 +69,23 @@ afterEach(async () => { // ─── pure helpers ─────────────────────────────────────── describe('resolveEndpoints', () => { - it('defaults to the iWiki contract paths', () => { + it('defaults to the clawpro contract paths (no v1)', () => { const ep = resolveEndpoints(); - expect(ep.report).toBe('/api/v1/local-agent/report'); - expect(ep.sync).toBe('/api/v1/local-agent/sync'); - expect(ep.ack('xyz')).toBe('/api/v1/local-agent/commands/xyz/ack'); + 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/:id/done', + ack: '/c/done', }); const ep = resolveEndpoints(); expect(ep.report).toBe('/r'); - expect(ep.ack('42')).toBe('/c/42/done'); + expect(ep.sync).toBe('/s'); + expect(ep.ack).toBe('/c/done'); }); }); @@ -147,7 +148,7 @@ describe('runStatusReport (session phase)', () => { // Seed the install command now that the server URL (and download endpoint) is known. server.seedCommands([ { - id: 'rec-1', + id: 1, type: 'install_skill', skill_slug: 'weather', skill_version: '1.0.0', @@ -161,7 +162,8 @@ describe('runStatusReport (session phase)', () => { 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: 'rec-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'); // clawpro bookkeeping recorded → weather is now tagged clawpro. diff --git a/src/skill-command.ts b/src/skill-command.ts index 1e535c5..cca0662 100644 --- a/src/skill-command.ts +++ b/src/skill-command.ts @@ -23,8 +23,8 @@ export type SkillCommandType = 'install_skill' | 'uninstall_skill' | 'update_ski /** A single skill-distribution command (server `skill_distribution_record`). */ export interface SkillCommand { - /** Command id (echoed back in ack). Optional for the pull path. */ - id?: string; + /** 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). */ diff --git a/src/status-report.ts b/src/status-report.ts index e69bea3..f523dcb 100644 --- a/src/status-report.ts +++ b/src/status-report.ts @@ -39,20 +39,20 @@ import { log } from './utils/logger.js'; export interface EndpointMap { report: string; sync: string; - /** ack path builder — receives the command id. */ - ack: (commandId: string) => string; + /** ack endpoint — the command id travels in the request body (id: int). */ + ack: string; } -/** Default contract paths (iWiki §5.A). */ +/** Default contract paths (clawpro backend, post v1-removal). */ const DEFAULT_ENDPOINTS: EndpointMap = { - report: '/api/v1/local-agent/report', - sync: '/api/v1/local-agent/sync', - ack: (id) => `/api/v1/local-agent/commands/${id}/ack`, + 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 }` where ack contains the literal `:id` token. + * object `{ report, sync, ack }` with plain path strings. */ export function resolveEndpoints(): EndpointMap { const raw = process.env.TEAMAI_REPORT_PATHS; @@ -62,9 +62,7 @@ export function resolveEndpoints(): EndpointMap { return { report: parsed.report ?? DEFAULT_ENDPOINTS.report, sync: parsed.sync ?? DEFAULT_ENDPOINTS.sync, - ack: parsed.ack - ? (id) => parsed.ack!.replace(':id', encodeURIComponent(id)) - : DEFAULT_ENDPOINTS.ack, + ack: parsed.ack ?? DEFAULT_ENDPOINTS.ack, }; } catch { return DEFAULT_ENDPOINTS; @@ -347,9 +345,9 @@ async function runStatusReportInner(opts: StatusReportOptions): Promise { status = 'failed'; error = (e as Error).message; } - if (cmd.id) { - const ackBody = { status, error }; - await sendOrQueue(`${endpoint}${endpoints.ack(cmd.id)}`, apiKey, ackBody); + if (cmd.id != null) { + const ackBody = { id: cmd.id, status, error }; + await sendOrQueue(`${endpoint}${endpoints.ack}`, apiKey, ackBody); } } } From 8bc1b2c2e88199bb59ca589c295b562d4c23e7fd Mon Sep 17 00:00:00 2001 From: jeffyxu Date: Mon, 29 Jun 2026 14:29:25 +0800 Subject: [PATCH 03/17] fix: wire WorkBuddy via settings.json Claude hooks (not OpenClaw) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Real-device verification (WorkBuddy 5.2.0) shows WorkBuddy embeds the CodeBuddy CLI engine and reads Claude-format hooks from ~/.workbuddy/settings.json: SessionStart / UserPromptSubmit / PostToolUse all fire with PascalCase event names and CLI-style tool names (tool_name="Bash"), identical to codebuddy. MR 191 originally (wrongly) assumed WorkBuddy used the OpenClaw HOOK.md engine, so teamai never wired it (0 workbuddy events on a real machine). Fix: - types.ts: toolPaths.workbuddy now carries settings: '.workbuddy/settings.json' → routes through the Claude-format injection path like codebuddy. - hooks.ts: drop workbuddy from OPENCLAW_TOOLS (kept for the still-unverified openclaw/qclaw/easyclaw/autoclaw variants). - openclaw-hooks.ts: clarify it's no longer for WorkBuddy; default tool=openclaw. - tests updated to assert workbuddy → settings.json hooks (--tool workbuddy), OpenClaw HOOK.md path only for openclaw. Verified end-to-end on the real machine: `teamai hooks inject` writes the workbuddy hook block (preserving claw/enabledPlugins/sandbox); real WorkBuddy fires the hooks; `hook-dispatch --tool workbuddy` records tool=workbuddy in the dashboard. tsc OK, vitest 110 files / 1466 passed. Co-authored-by: Cursor (cherry picked from commit d1ec907433177e3ac76ed81867fec928be647293) --- src/__tests__/openclaw-hooks.test.ts | 10 +++++----- src/hooks.ts | 9 +++++++-- src/openclaw-hooks.ts | 19 ++++++++++--------- src/types.ts | 6 +++++- 4 files changed, 27 insertions(+), 17 deletions(-) diff --git a/src/__tests__/openclaw-hooks.test.ts b/src/__tests__/openclaw-hooks.test.ts index 9b42b16..e9711de 100644 --- a/src/__tests__/openclaw-hooks.test.ts +++ b/src/__tests__/openclaw-hooks.test.ts @@ -17,7 +17,7 @@ afterEach(() => { describe('injectOpenClawHooks', () => { it('writes HOOK.md + handler.ts under /teamai-status-report', async () => { const hooksDir = path.join(tmpDir, 'hooks'); - await injectOpenClawHooks(hooksDir, 'workbuddy'); + await injectOpenClawHooks(hooksDir, 'openclaw'); const dir = path.join(hooksDir, OPENCLAW_HOOK_DIR); const hookMd = fs.readFileSync(path.join(dir, 'HOOK.md'), 'utf-8'); @@ -27,7 +27,7 @@ describe('injectOpenClawHooks', () => { expect(hookMd).toContain('session:start'); expect(hookMd).toContain('command:new'); expect(handler).toContain('hook-dispatch'); - expect(handler).toContain('workbuddy'); + expect(handler).toContain('openclaw'); // Maps OpenClaw events to teamai dispatch events. expect(handler).toContain('session-start'); expect(handler).toContain('prompt-submit'); @@ -35,8 +35,8 @@ describe('injectOpenClawHooks', () => { it('is idempotent (re-inject overwrites cleanly)', async () => { const hooksDir = path.join(tmpDir, 'hooks'); - await injectOpenClawHooks(hooksDir, 'workbuddy'); - await injectOpenClawHooks(hooksDir, 'workbuddy'); + 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); }); @@ -45,7 +45,7 @@ describe('injectOpenClawHooks', () => { 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, 'workbuddy'); + await injectOpenClawHooks(hooksDir, 'openclaw'); await removeOpenClawHooks(hooksDir); expect(fs.existsSync(path.join(hooksDir, OPENCLAW_HOOK_DIR))).toBe(false); // second removal does not throw diff --git a/src/hooks.ts b/src/hooks.ts index e62969c..c500221 100644 --- a/src/hooks.ts +++ b/src/hooks.ts @@ -9,9 +9,14 @@ import { resolveTeamHooks } from './resources/hooks.js'; /** * Lobster-family agents (OpenClaw engine) that use HOOK.md + handler.ts instead - * of settings.json. WorkBuddy is the phase-1 target (issue #1, 方案二 §四). + * 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(['workbuddy', 'openclaw', 'qclaw', 'easyclaw', 'autoclaw']); +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; diff --git a/src/openclaw-hooks.ts b/src/openclaw-hooks.ts index a7e4253..704dc4c 100644 --- a/src/openclaw-hooks.ts +++ b/src/openclaw-hooks.ts @@ -1,17 +1,18 @@ /** * OpenClaw / 龙虾-family hook injection (issue #1, 方案二 §四). * - * WorkBuddy (category `lobster`) uses the OpenClaw hook engine — a different - * shape from Claude's settings.json: a `HOOK.md` (frontmatter `events:`) plus a - * `handler.ts` placed under `/teamai-status-report/`. Both shell out to - * the same `teamai hook-dispatch` entry point, so the reporting core is shared - * with the Claude/CodeBuddy path; only the trigger adapter differs. + * 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). - * - * NOTE: WorkBuddy's desktop hook config dir (`~/.openclaw/` vs `~/.workbuddy/`) - * is still pending confirmation (issue §七.1); callers pass the resolved dir. */ import path from 'node:path'; @@ -75,7 +76,7 @@ export default async function handler(ctx: { event?: string } = {}): Promise`. * Idempotent — rewrites the two files each time. */ -export async function injectOpenClawHooks(hooksDir: string, tool = 'workbuddy'): Promise { +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)); diff --git a/src/types.ts b/src/types.ts index daaf8c6..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' }, }), }); From 83aaf683b0b350e0e93d8f6d6a9aa14586c8506d Mon Sep 17 00:00:00 2001 From: jeffyxu Date: Mon, 29 Jun 2026 14:39:09 +0800 Subject: [PATCH 04/17] fix: teamai uninstall also removes OpenClaw HOOK.md hooks MR 191 added injectOpenClawHooks but uninstall only removed hooks from tools with a `settings` path, so OpenClaw-style HOOK.md/handler.ts dirs (~/./hooks/teamai-status-report) leaked on uninstall. - uninstall.ts: discover + remove OpenClaw hook dirs for settings-less tools (mirrors the inject path); listed in the removal summary. - Added regression test asserting the OpenClaw HOOK.md dir is removed. Verified end-to-end: with workbuddy now settings-based, `uninstall` strips its teamai hooks while preserving claw/enabledPlugins/sandbox; OpenClaw HOOK.md dirs are removed (previously leaked); real-machine dry-run lists ~/.workbuddy/ settings.json. tsc OK, vitest 110 files / 1467 passed. Co-authored-by: Cursor (cherry picked from commit 4a9a2361c55292547dd033ade98825ac3083811a) --- src/__tests__/uninstall.test.ts | 32 ++++++++++++++++++++++++++++++++ src/uninstall.ts | 29 +++++++++++++++++++++++++++++ 2 files changed, 61 insertions(+) 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/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 { From 982cd719da91738ade52f6f1e4d98716661f1539 Mon Sep 17 00:00:00 2001 From: jeffyxu Date: Mon, 29 Jun 2026 15:15:05 +0800 Subject: [PATCH 05/17] feat: tolerate missing /repo in HTTP init (reporting-only mode) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A clawpro-style backend may ship the status-reporter endpoints before the team-repo /repo endpoint exists. Previously `teamai init --http ` hard- failed on such endpoints (/repo → 404 or 200 HTML), so there was no first-class way to configure endpoint+key for reporting. - source-http.ts: fetchRepoSnapshot now distinguishes "/repo not live yet" (404 or non-JSON 200 body) by throwing RepoNotAvailableError; auth (401/403) and other errors stay hard failures. - init.ts: on RepoNotAvailableError, init --http enters reporting-only mode — writes a minimal local teamai.yaml (default toolPaths), saves the http config (endpoint+key), injects hooks, and prints a clear notice. When /repo later comes online, a normal pull materializes skills with no re-init. - pull.ts: refreshTeamRepo swallows RepoNotAvailableError (reporting-only) so every session doesn't error while /repo is absent. - tests: RepoNotAvailableError classification (404 / non-JSON / 500). Verified e2e against the real reporter-only backend: `teamai login ` + `teamai init --http ` now succeeds (reporting-only), reporter report/sync hit the backend (200, no offline queue), and pull no longer hard-fails. tsc OK, vitest 110 files / 1470 passed. Co-authored-by: Cursor (cherry picked from commit 75b03c633a731f302375c764df970a5b84dba00d) --- src/__tests__/source-http.test.ts | 51 ++++++++++++++++++++++++++++++- src/init.ts | 38 ++++++++++++++++++----- src/pull.ts | 16 ++++++++-- src/source-http.ts | 34 +++++++++++++++++++-- 4 files changed, 126 insertions(+), 13 deletions(-) diff --git a/src/__tests__/source-http.test.ts b/src/__tests__/source-http.test.ts index 0f35874..0870ef5 100644 --- a/src/__tests__/source-http.test.ts +++ b/src/__tests__/source-http.test.ts @@ -2,9 +2,29 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import fs from 'node:fs'; import path from 'node:path'; import os from 'node:os'; -import { fetchRepoSnapshot, materializeHttpRepo } from '../source-http.js'; +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'; @@ -38,6 +58,35 @@ describe('fetchRepoSnapshot', () => { 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', () => { diff --git a/src/init.ts b/src/init.ts index d236ba3..54b40a1 100644 --- a/src/init.ts +++ b/src/init.ts @@ -116,7 +116,7 @@ export async function initHttp( options: GlobalOptions & { scope?: string; role?: string; force?: boolean }, ): Promise { const { resolveApiKey } = await import('./api-key.js'); - const { materializeHttpRepo } = await import('./source-http.js'); + const { materializeHttpRepo, RepoNotAvailableError } = await import('./source-http.js'); log.info('Initializing teamai (HTTP read-only consumer)...'); @@ -143,18 +143,37 @@ export async function initHttp( process.exit(1); } - // Step 2: materialize repo from HTTP + // 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) { - matSpin.fail(`HTTP fetch failed: ${(e as Error).message}`); - process.exit(1); + 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: validate teamai.yaml + // 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.'); @@ -199,8 +218,13 @@ export async function initHttp( // Step 5: inject hooks (unchanged) await injectHooksToAllTools(teamConfig.toolPaths, resolveBaseDir(localConfig)); - 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).'); + 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(); } diff --git a/src/pull.ts b/src/pull.ts index ff6bab1..5a4aeff 100644 --- a/src/pull.ts +++ b/src/pull.ts @@ -48,7 +48,7 @@ async function refreshTeamRepo( ): Promise<{ label: string; version: string | null }> { if (localConfig.repo.kind === 'http') { const { resolveApiKey } = await import('./api-key.js'); - const { materializeHttpRepo } = await import('./source-http.js'); + const { materializeHttpRepo, RepoNotAvailableError } = await import('./source-http.js'); const apiKey = resolveApiKey(); if (!apiKey) { throw new Error('No API key configured. Run `teamai login ` or set TEAMAI_API_TOKEN.'); @@ -57,8 +57,18 @@ async function refreshTeamRepo( if (!baseUrl) { throw new Error('HTTP team repo has no url configured.'); } - const version = await materializeHttpRepo(baseUrl, localConfig.repo.localPath, apiKey); - return { label: `HTTP ${version ?? '(no version)'}`, version }; + try { + const version = await materializeHttpRepo(baseUrl, localConfig.repo.localPath, apiKey); + return { label: `HTTP ${version ?? '(no version)'}`, version }; + } 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 }; + } + throw e; + } } const result = await pullRepo(localConfig.repo.localPath); diff --git a/src/source-http.ts b/src/source-http.ts index dea7c8e..2026d27 100644 --- a/src/source-http.ts +++ b/src/source-http.ts @@ -30,7 +30,26 @@ export interface RepoSnapshot { commands: SkillCommand[]; } -/** Fetch the team repo snapshot. Throws on auth/transport errors. */ +/** + * 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, { @@ -39,10 +58,21 @@ export async function fetchRepoSnapshot(baseUrl: string, apiKey: string): Promis if (res.status === 401 || res.status === 403) { throw new Error('Authentication failed — check your API key (run `teamai login `).'); } + 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}`); } - const raw = (await res.json()) as Partial; + // 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 : [], From 4ec3b564a082eee5395d0c770e11982c0e0467dd Mon Sep 17 00:00:00 2001 From: jeffyxu Date: Mon, 29 Jun 2026 15:18:09 +0800 Subject: [PATCH 06/17] refactor: fold `teamai login` into `init --http --token` (remove login command) Reduces command surface and puts endpoint + key in one place (addresses the "why are login and endpoint separate" awkwardness). The `login` command was only introduced in this unreleased HTTP/reporter feature set, so removing it is not a breaking change for released users. - index.ts: remove `login` command; add `--token ` to `init`. - init.ts: initHttp persists --token via saveApiKey (0600) before resolving; still falls back to TEAMAI_API_TOKEN / existing apikey file. - update "run `teamai login`" hints in source-http.ts / pull.ts / api-key.ts. One-command setup now: teamai init --http --token Verified e2e against the real reporter-only backend: single command saves the key, configures the http endpoint (reporting-only), injects hooks; reporter report/sync hit the backend (200). `login` no longer appears in --help. tsc OK, vitest 110 files / 1470 passed. Co-authored-by: Cursor (cherry picked from commit e98b48f7fc63eddc755fa9ef04260ad9055c49d3) --- src/api-key.ts | 4 ++-- src/index.ts | 10 +--------- src/init.ts | 15 ++++++++++----- src/pull.ts | 2 +- src/source-http.ts | 2 +- 5 files changed, 15 insertions(+), 18 deletions(-) diff --git a/src/api-key.ts b/src/api-key.ts index e617403..895a687 100644 --- a/src/api-key.ts +++ b/src/api-key.ts @@ -5,7 +5,7 @@ * 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 login`) + * 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 @@ -23,7 +23,7 @@ export function getApiKeyPath(): string { /** * Resolve the API key from env or the local file. Returns null when no key is - * configured (callers surface a friendly "run `teamai login`" hint). + * 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; diff --git a/src/index.ts b/src/index.ts index 4041d4c..379756c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -24,6 +24,7 @@ program .description('Initialize teamai (configure TGit, clone repo, register member)') .option('--repo ', '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') @@ -33,15 +34,6 @@ program await init({ ...globalOpts, ...cmdOpts }); }); -program - .command('login ') - .description('Save the API key for HTTP team repo / status reporting (stored 0600, never committed)') - .action(async (apiKey: string) => { - const { saveApiKey, getApiKeyPath } = await import('./api-key.js'); - await saveApiKey(apiKey); - log.success(`API key saved to ${getApiKeyPath()}`); - }); - program .command('push') .description('Push local resources to team repo') diff --git a/src/init.ts b/src/init.ts index 54b40a1..566d106 100644 --- a/src/init.ts +++ b/src/init.ts @@ -113,9 +113,9 @@ export function validateScopeMatch(remoteScope: Scope | undefined, localScope: S */ export async function initHttp( url: string, - options: GlobalOptions & { scope?: string; role?: string; force?: boolean }, + options: GlobalOptions & { scope?: string; role?: string; force?: boolean; token?: string }, ): Promise { - const { resolveApiKey } = await import('./api-key.js'); + 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)...'); @@ -136,10 +136,15 @@ export async function initHttp( } } - // Step 1: API key + // 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. Set TEAMAI_API_TOKEN or run `teamai login ` first.'); + log.error('No API key found. Pass --token to `teamai init --http`, or set TEAMAI_API_TOKEN.'); process.exit(1); } @@ -228,7 +233,7 @@ export async function initHttp( closePrompt(); } -export async function init(options: GlobalOptions & { repo?: string; scope?: string; role?: string; force?: boolean; http?: string }): Promise { +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); } diff --git a/src/pull.ts b/src/pull.ts index 5a4aeff..95cef4f 100644 --- a/src/pull.ts +++ b/src/pull.ts @@ -51,7 +51,7 @@ async function refreshTeamRepo( const { materializeHttpRepo, RepoNotAvailableError } = await import('./source-http.js'); const apiKey = resolveApiKey(); if (!apiKey) { - throw new Error('No API key configured. Run `teamai login ` or set TEAMAI_API_TOKEN.'); + 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) { diff --git a/src/source-http.ts b/src/source-http.ts index 2026d27..5c9f8d1 100644 --- a/src/source-http.ts +++ b/src/source-http.ts @@ -56,7 +56,7 @@ export async function fetchRepoSnapshot(baseUrl: string, apiKey: string): Promis headers: { Authorization: `Bearer ${apiKey}` }, }); if (res.status === 401 || res.status === 403) { - throw new Error('Authentication failed — check your API key (run `teamai login `).'); + 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}`); From ad80d5e8d2451b534ea98b6979d26ac724158a73 Mon Sep 17 00:00:00 2001 From: jeffyxu Date: Mon, 29 Jun 2026 15:33:18 +0800 Subject: [PATCH 07/17] =?UTF-8?q?fix:=20skip=20git=20usage=20auto-report?= =?UTF-8?q?=20for=20HTTP=20consumers=20(no=20.git=20=E2=86=92=20noisy=20ER?= =?UTF-8?q?ROR)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In HTTP team-repo mode the local team-repo path is not a git checkout, so pull's Step 5 usage auto-report (reportUsageToTeam, git-based) failed every session with `[ERROR] Auto-report skipped: fatal: not a git repository`. HTTP consumers are read-only and have no git remote to report to, so skip the step entirely when repo.kind === 'http'. Verified: `teamai pull` in HTTP reporting-only mode now produces no Auto-report error. tsc OK, vitest 110 files / 1470 passed. Co-authored-by: Cursor (cherry picked from commit 388078d0c587c03602b93a5635e193961e84e592) --- src/pull.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/pull.ts b/src/pull.ts index 95cef4f..924bde9 100644 --- a/src/pull.ts +++ b/src/pull.ts @@ -821,8 +821,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); From e4abe76bc65c351287077d8c45e9f6446636a2a4 Mon Sep 17 00:00:00 2001 From: jeffyxu Date: Mon, 29 Jun 2026 16:01:37 +0800 Subject: [PATCH 08/17] feat: log skill-command execution in the reporter (observability) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Skill install/uninstall driven by `sync` commands previously ran silently — on failure the reporter only ack'd `failed` with no local log, making it impossible to see why a dispatched skill (e.g. fd-find) didn't install. Now: - log.debug the number of commands returned by sync - log.debug each command success - log.error each command FAILURE with the underlying error message No behavior change beyond logging. tsc OK, vitest 110 files / 1470 passed. Co-authored-by: Cursor (cherry picked from commit 9bbabfcc0ef467b1addf605a29a2754632c455f4) --- .teamai/domains.yaml | 14 ++++++++++++++ src/status-report.ts | 5 +++++ 2 files changed, 19 insertions(+) create mode 100644 .teamai/domains.yaml diff --git a/.teamai/domains.yaml b/.teamai/domains.yaml new file mode 100644 index 0000000..7c21d0a --- /dev/null +++ b/.teamai/domains.yaml @@ -0,0 +1,14 @@ +version: 1 +confidence_threshold: 0.6 +domains: + - name: 推理 + description: "" + repos: + - url: https://github.com/owner/testrepo + confidence: 0.84 + signal: README 含 "推理服务" + locked: false + - url: https://github.com/owner/mergetest + confidence: 0.84 + signal: test signal + locked: false diff --git a/src/status-report.ts b/src/status-report.ts index f523dcb..f33e9f4 100644 --- a/src/status-report.ts +++ b/src/status-report.ts @@ -333,6 +333,9 @@ async function runStatusReportInner(opts: StatusReportOptions): Promise { } const commands = extractCommands(syncResp); + if (commands.length > 0) { + log.debug(`[status-report] sync returned ${commands.length} command(s) for ${agentType} (${localAgentId})`); + } for (const cmd of commands) { // ③ execute + ack each command (terminal — no retry). let status: 'success' | 'failed' = 'success'; @@ -341,9 +344,11 @@ async function runStatusReportInner(opts: StatusReportOptions): Promise { await executeSkillCommand(cmd, skillsDir); // Maintain clawpro bookkeeping so future reports tag the slug correctly. await recordClawproSlug(localAgentId, cmd.skill_slug, cmd.type !== 'uninstall_skill'); + log.debug(`[status-report] ${cmd.type} ${cmd.skill_slug}@${cmd.skill_version ?? '?'} → ${skillsDir} OK`); } catch (e) { status = 'failed'; error = (e as Error).message; + log.error(`[status-report] ${cmd.type} ${cmd.skill_slug}@${cmd.skill_version ?? '?'} FAILED: ${error}`); } if (cmd.id != null) { const ackBody = { id: cmd.id, status, error }; From 1dd8238ae07832063e742c4f81199ed0854b491f Mon Sep 17 00:00:00 2001 From: jeffyxu Date: Mon, 29 Jun 2026 16:02:11 +0800 Subject: [PATCH 09/17] chore: drop accidentally committed .teamai/domains.yaml (test artifact) Co-authored-by: Cursor (cherry picked from commit 882698389411271ca359725b17e61907143c12ad) --- .teamai/domains.yaml | 14 -------------- 1 file changed, 14 deletions(-) delete mode 100644 .teamai/domains.yaml diff --git a/.teamai/domains.yaml b/.teamai/domains.yaml deleted file mode 100644 index 7c21d0a..0000000 --- a/.teamai/domains.yaml +++ /dev/null @@ -1,14 +0,0 @@ -version: 1 -confidence_threshold: 0.6 -domains: - - name: 推理 - description: "" - repos: - - url: https://github.com/owner/testrepo - confidence: 0.84 - signal: README 含 "推理服务" - locked: false - - url: https://github.com/owner/mergetest - confidence: 0.84 - signal: test signal - locked: false From 9f99188b10d49343bca67e57642cb99274cb3d09 Mon Sep 17 00:00:00 2001 From: jeffyxu Date: Mon, 29 Jun 2026 17:43:03 +0800 Subject: [PATCH 10/17] feat: surface skill download URL + server error body in reporter logs A failed install previously logged only "download failed: HTTP 409", giving no clue what was being fetched. Now downloadZip logs the signed download_url before fetching and includes the server response body + URL in the thrown error (which lands in debug.log and the ack error field). The reporter also logs each sync command (incl. download_url) and renders empty skill_version as "?" instead of a bare "@". Co-authored-by: Cursor (cherry picked from commit 2a58ede7abf0e411ad61474adc0b65d21887f713) --- src/skill-command.ts | 12 +++++++++++- src/status-report.ts | 8 ++++++-- 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/src/skill-command.ts b/src/skill-command.ts index cca0662..96c6298 100644 --- a/src/skill-command.ts +++ b/src/skill-command.ts @@ -63,9 +63,19 @@ function assertAllowedDownloadHost(downloadUrl: string): void { */ 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) { - throw new Error(`download failed: HTTP ${res.status}`); + // 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) { diff --git a/src/status-report.ts b/src/status-report.ts index f33e9f4..efac33e 100644 --- a/src/status-report.ts +++ b/src/status-report.ts @@ -337,6 +337,10 @@ async function runStatusReportInner(opts: StatusReportOptions): Promise { log.debug(`[status-report] sync returned ${commands.length} command(s) for ${agentType} (${localAgentId})`); } 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 = ''; @@ -344,11 +348,11 @@ async function runStatusReportInner(opts: StatusReportOptions): Promise { await executeSkillCommand(cmd, skillsDir); // Maintain clawpro bookkeeping so future reports tag the slug correctly. await recordClawproSlug(localAgentId, cmd.skill_slug, cmd.type !== 'uninstall_skill'); - log.debug(`[status-report] ${cmd.type} ${cmd.skill_slug}@${cmd.skill_version ?? '?'} → ${skillsDir} OK`); + log.debug(`[status-report] ${label} → ${skillsDir} OK`); } catch (e) { status = 'failed'; error = (e as Error).message; - log.error(`[status-report] ${cmd.type} ${cmd.skill_slug}@${cmd.skill_version ?? '?'} FAILED: ${error}`); + log.error(`[status-report] ${label} FAILED: ${error}`); } if (cmd.id != null) { const ackBody = { id: cmd.id, status, error }; From 237bce894b1cc19d35c1e11e1ea8839710aa5168 Mon Sep 17 00:00:00 2001 From: jeffyxu Date: Mon, 29 Jun 2026 17:49:55 +0800 Subject: [PATCH 11/17] feat: log every reporter run + report/sync/ack outcome Previously a successful report or sync produced no log line, so there was no way to tell whether a SessionStart/UserPromptSubmit hook actually fired or whether sync ran ("seems sync never triggered"). Now each invocation logs run (agent/phase/id/endpoint) and the OK/FAILED outcome of report, sync (with command count), and each ack. Co-authored-by: Cursor (cherry picked from commit 49ac63953c9d6e33e8f9998a2be86ea91da2e00e) --- src/status-report.ts | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/src/status-report.ts b/src/status-report.ts index efac33e..d806112 100644 --- a/src/status-report.ts +++ b/src/status-report.ts @@ -299,6 +299,11 @@ async function runStatusReportInner(opts: StatusReportOptions): Promise { 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(() => {}); @@ -315,7 +320,7 @@ async function runStatusReportInner(opts: StatusReportOptions): Promise { started_at: new Date().toISOString(), skills, }; - await sendOrQueue(`${endpoint}${endpoints.report}`, apiKey, reportBody); + await sendOrQueue(`${endpoint}${endpoints.report}`, apiKey, reportBody, 'report'); } // ② sync — both phases. Returns commands to execute. @@ -327,15 +332,14 @@ async function runStatusReportInner(opts: StatusReportOptions): Promise { let syncResp: unknown; try { syncResp = await postJson(`${endpoint}${endpoints.sync}`, apiKey, syncBody); - } catch { + } 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); - if (commands.length > 0) { - log.debug(`[status-report] sync returned ${commands.length} command(s) for ${agentType} (${localAgentId})`); - } + 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 @@ -356,15 +360,17 @@ async function runStatusReportInner(opts: StatusReportOptions): Promise { } if (cmd.id != null) { const ackBody = { id: cmd.id, status, error }; - await sendOrQueue(`${endpoint}${endpoints.ack}`, apiKey, ackBody); + await sendOrQueue(`${endpoint}${endpoints.ack}`, apiKey, ackBody, `ack#${cmd.id}(${status})`); } } } -async function sendOrQueue(url: string, apiKey: string, body: unknown): Promise { +async function sendOrQueue(url: string, apiKey: string, body: unknown, label: string): Promise { try { await postJson(url, apiKey, body); - } catch { + log.debug(`[status-report] ${label} OK`); + } catch (e) { + log.debug(`[status-report] ${label} FAILED (queued): ${(e as Error).message}`); await enqueue({ url, body }); } } From 053736ebf85731083adf2ffa9970c6558b7a7c5a Mon Sep 17 00:00:00 2001 From: jeffyxu Date: Mon, 29 Jun 2026 18:25:19 +0800 Subject: [PATCH 12/17] feat: skip team-repo built-in skills in HTTP reporting-only mode teamai-share-learnings and teamai-wiki both write to the team repo, so in reporting-only HTTP mode (no /repo) they are non-functional. refreshTeamRepo now reports reportingOnly, which pull threads into deployBuiltinSkills to skip injecting them. Co-authored-by: Cursor (cherry picked from commit d5c40475ba99078174ba553719b707e6a0d88ccc) --- src/builtin-skills.ts | 10 +++++++++- src/pull.ts | 19 ++++++++++++------- 2 files changed, 21 insertions(+), 8 deletions(-) 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/pull.ts b/src/pull.ts index 924bde9..dabe2a1 100644 --- a/src/pull.ts +++ b/src/pull.ts @@ -45,7 +45,7 @@ interface RolePullContext { */ async function refreshTeamRepo( localConfig: LocalConfig, -): Promise<{ label: string; version: string | null }> { +): 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'); @@ -59,13 +59,13 @@ async function refreshTeamRepo( } try { const version = await materializeHttpRepo(baseUrl, localConfig.repo.localPath, apiKey); - return { label: `HTTP ${version ?? '(no version)'}`, version }; + 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 }; + return { label: 'HTTP (reporting-only, no /repo yet)', version: null, reportingOnly: true }; } throw e; } @@ -80,7 +80,7 @@ async function refreshTeamRepo( log.debug('Rev check failed, proceeding with full sync'); version = null; } - return { label: result, version }; + return { label: result, version, reportingOnly: false }; } async function buildRolePullContext(localConfig: LocalConfig): Promise { @@ -293,9 +293,14 @@ async function pullForScope( // 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 { label, version } = await refreshTeamRepo(localConfig); + 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}`); @@ -314,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; @@ -786,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)`); } From 1cdc98b49599999adffc81161e5d28803b2b949a Mon Sep 17 00:00:00 2001 From: jeffyxu Date: Mon, 29 Jun 2026 19:40:01 +0800 Subject: [PATCH 13/17] fix: accept flat skill zips (SKILL.md at root), not just /SKILL.md The clawpro/skillhub backend packages skills as a flat zip (SKILL.md + _meta.json at the root, e.g. find-skills-skill), but installSkillZip required a top-level / directory and failed with "skill package missing /SKILL.md". installSkillZip now resolves the SKILL.md location across three layouts (nested-by-slug, flat root, nested-other-name) and installs the contents into //. Co-authored-by: Cursor (cherry picked from commit fbdf643c79c42613eb52fb7c383fddeb893b9b83) --- src/__tests__/skill-command.test.ts | 20 +++++++++++++- src/skill-command.ts | 41 ++++++++++++++++++++++------- 2 files changed, 51 insertions(+), 10 deletions(-) diff --git a/src/__tests__/skill-command.test.ts b/src/__tests__/skill-command.test.ts index 76e0891..4b5e1ba 100644 --- a/src/__tests__/skill-command.test.ts +++ b/src/__tests__/skill-command.test.ts @@ -39,11 +39,29 @@ describe('installSkillZip', () => { expect(fs.readFileSync(path.join(skillsDir, 'weather', 'scripts', 'run.sh'), 'utf-8')).toBe('echo hi'); }); - it('rejects a package missing /SKILL.md', async () => { + 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); diff --git a/src/skill-command.ts b/src/skill-command.ts index 96c6298..79c1246 100644 --- a/src/skill-command.ts +++ b/src/skill-command.ts @@ -84,11 +84,33 @@ async function downloadZip(downloadUrl: string): Promise { 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//`. * - * Validates that the archive contains `/SKILL.md` and that every entry - * stays within the destination (path-traversal protection). Install is + * 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( @@ -105,10 +127,9 @@ export async function installSkillZip( throw new Error(`failed to unzip skill package: ${(e as Error).message}`); } - const prefix = `${slug}/`; - const skillMdPath = `${slug}/SKILL.md`; - if (!Object.prototype.hasOwnProperty.call(entries, skillMdPath)) { - throw new Error(`skill package missing ${skillMdPath} (slug mismatch or malformed package)`); + 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); @@ -118,12 +139,14 @@ export async function installSkillZip( const resolvedRoot = path.resolve(destRoot); for (const [entryPath, bytes] of Object.entries(entries)) { - // Only extract the single / subtree; ignore anything else in the zip. - if (!entryPath.startsWith(prefix)) continue; + // 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 = entryPath.slice(prefix.length); + 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}`); From bcbc17ea0f9207e4f8a7ead47c0a7250e1a7ac73 Mon Sep 17 00:00:00 2001 From: jeffyxu Date: Tue, 30 Jun 2026 11:35:25 +0800 Subject: [PATCH 14/17] fix: use reconcileTeamHooksForConfig for HTTP init hook injection Reconcile the issue-#1 HTTP init path onto main's unified-hooks (#65) architecture: the old `injectHooksToAllTools(toolPaths, baseDir)` entry no longer exists, so the HTTP consumer now injects hooks via the same authoritative `reconcileTeamHooksForConfig` path the git init uses. Fixes the tsc TS2304 (injectHooksToAllTools / resolveBaseDir not found) seen in CI. Co-authored-by: Cursor --- src/init.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/init.ts b/src/init.ts index 566d106..ba08716 100644 --- a/src/init.ts +++ b/src/init.ts @@ -220,8 +220,9 @@ export async function initHttp( // state may not exist yet } - // Step 5: inject hooks (unchanged) - await injectHooksToAllTools(teamConfig.toolPaths, resolveBaseDir(localConfig)); + // 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)!'); From 0d27baeb0c3a1ea80bff172b606debdb27c098a6 Mon Sep 17 00:00:00 2001 From: jeffyxu Date: Tue, 30 Jun 2026 11:41:04 +0800 Subject: [PATCH 15/17] docs: document git-free HTTP team repo + agent status reporting Add a "Read-only consumers (HTTP team repo, no git)" quick-start subsection to both README.md and README.zh-CN.md, covering `init --http --token`, reporting-only fallback, hooks-driven status reporting, and the local-only hashing of install path / machine id. Co-authored-by: Cursor --- README.md | 18 ++++++++++++++++++ README.zh-CN.md | 18 ++++++++++++++++++ 2 files changed, 36 insertions(+) diff --git a/README.md b/README.md index 29a4d49..f4d74e2 100644 --- a/README.md +++ b/README.md @@ -67,6 +67,24 @@ 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. + ## Commands | Command | Description | diff --git a/README.zh-CN.md b/README.zh-CN.md index 6638048..9a5778a 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -67,6 +67,24 @@ 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`,二者都不会上报。 + ## 命令 | 命令 | 说明 | From a2263e72c6f9b9e0ef841968d0e18a580a33ab54 Mon Sep 17 00:00:00 2001 From: jeffyxu Date: Tue, 30 Jun 2026 11:45:47 +0800 Subject: [PATCH 16/17] docs: document the HTTP contract (endpoints, /repo schema, env knobs) Add a collapsible "HTTP contract" block to both READMEs spelling out what a --http backend must serve: the fixed GET /repo snapshot shape, the three (overridable) local-agent reporter endpoints, the signed download_url + accepted zip layouts, and the env vars that make paths/hosts/agents configurable. Clarifies what is fixed vs configurable. Co-authored-by: Cursor --- README.md | 37 +++++++++++++++++++++++++++++++++++++ README.zh-CN.md | 37 +++++++++++++++++++++++++++++++++++++ 2 files changed, 74 insertions(+) diff --git a/README.md b/README.md index f4d74e2..04cd841 100644 --- a/README.md +++ b/README.md @@ -85,6 +85,43 @@ Once initialized, supported agents (CodeBuddy / WorkBuddy) report their installe > **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 9a5778a..22b7694 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -85,6 +85,43 @@ teamai init --http https://your-team-host/api --token > **隐私**: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 白名单,逗号分隔(空 = 全部放行) | + +
+ ## 命令 | 命令 | 说明 | From e1060b01fb77458ed9cbf843db61a2d4fb480eff Mon Sep 17 00:00:00 2001 From: jeffyxu Date: Tue, 30 Jun 2026 11:54:28 +0800 Subject: [PATCH 17/17] refactor(reporter): drop per-skill `source` tag and clawpro bookkeeping The report payload no longer tags each skill `source: clawpro|local`. With `source` gone, the entire clawpro-skills.json bookkeeping (getClawproSlugs / recordClawproSlug / clawproRecordPath) had no remaining consumer, so it is removed. `scanReportableSkills(skillsDir)` now just lists every skill found in the agent's own skills dir. Tests updated accordingly. Co-authored-by: Cursor --- src/__tests__/status-report.test.ts | 21 +++++------- src/status-report.ts | 51 +++-------------------------- 2 files changed, 13 insertions(+), 59 deletions(-) diff --git a/src/__tests__/status-report.test.ts b/src/__tests__/status-report.test.ts index c8ed905..f18ae69 100644 --- a/src/__tests__/status-report.test.ts +++ b/src/__tests__/status-report.test.ts @@ -8,7 +8,6 @@ import { resolveEndpoints, getReportableAgents, scanReportableSkills, - getClawproSlugs, runStatusReport, } from '../status-report.js'; import { startMockServer, type MockServerHandle } from './helpers/mock-server.js'; @@ -112,12 +111,13 @@ describe('resolveReportEndpoint', () => { }); describe('scanReportableSkills', () => { - it('tags clawpro vs local', async () => { + it('lists installed skills from the agent skills dir', async () => { setupHome(); const skillsDir = path.join(tmpDir, '.codebuddy', 'skills'); - const skills = await scanReportableSkills(skillsDir, new Set(['someother'])); + const skills = await scanReportableSkills(skillsDir); expect(skills).toHaveLength(1); - expect(skills[0]).toMatchObject({ slug: 'mylocal', version: '2.0.0', source: 'local' }); + expect(skills[0]).toMatchObject({ slug: 'mylocal', version: '2.0.0', display_name: 'mylocal' }); + expect(skills[0]).not.toHaveProperty('source'); }); }); @@ -132,16 +132,17 @@ describe('runStatusReport (session phase)', () => { 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; source: string }> }; + 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')?.source).toBe('local'); + 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; next report tags clawpro', async () => { + 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; @@ -165,12 +166,6 @@ describe('runStatusReport (session phase)', () => { 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'); - - // clawpro bookkeeping recorded → weather is now tagged clawpro. - const { deriveLocalAgentId, getMachineId } = await import('../machine-id.js'); - const localAgentId = deriveLocalAgentId('codebuddy', getMachineId(), path.join(tmpDir, '.codebuddy')); - const slugs = await getClawproSlugs(localAgentId); - expect(slugs.has('weather')).toBe(true); }); }); diff --git a/src/status-report.ts b/src/status-report.ts index d806112..d716f41 100644 --- a/src/status-report.ts +++ b/src/status-report.ts @@ -2,10 +2,9 @@ * 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, - * each tagged source = clawpro (server-managed) | local (user-installed). + * - report (SessionStart only): upsert local info + installed skill list. * - sync (SessionStart + UserPromptSubmit): report status + pull commands. - * commands drive install/update (pull) + uninstall (delete) of clawpro skills. + * 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 @@ -26,8 +25,6 @@ import { listDirs, pathExists, readFileSafe, - readJson, - writeJson, ensureDir, } from './utils/fs.js'; import { resolveBaseDir, type LocalConfig, type TeamaiConfig } from './types.js'; @@ -95,43 +92,12 @@ export function resolveReportEndpoint(localConfig: LocalConfig): string | null { return fromEnv ? fromEnv.replace(/\/$/, '') : null; } -// ─── clawpro skill bookkeeping ────────────────────────────── -// -// We record which slugs were installed via `sync` commands so that `report` -// can tag them `source: clawpro` (vs user-installed `local`). Keyed by -// local_agent_id so user/project scope (different ids) stay independent. - -function clawproRecordPath(): string { - return path.join(process.env.HOME ?? '', '.teamai', 'reporter', 'clawpro-skills.json'); -} - -type ClawproRecord = Record; - -async function loadClawproRecord(): Promise { - return (await readJson(clawproRecordPath())) ?? {}; -} - -export async function getClawproSlugs(localAgentId: string): Promise> { - const rec = await loadClawproRecord(); - return new Set(rec[localAgentId] ?? []); -} - -async function recordClawproSlug(localAgentId: string, slug: string, present: boolean): Promise { - const rec = await loadClawproRecord(); - const set = new Set(rec[localAgentId] ?? []); - if (present) set.add(slug); - else set.delete(slug); - rec[localAgentId] = [...set]; - await writeJson(clawproRecordPath(), rec); -} - // ─── Skill scanning ───────────────────────────────────────── export interface ReportedSkill { slug: string; version: string; display_name: string; - source: 'clawpro' | 'local'; } const FRONTMATTER_REGEX = /^---\n([\s\S]*?)\n---/; @@ -152,12 +118,9 @@ async function readSkillMeta(skillMdPath: string): Promise<{ name: string; versi } /** - * Scan an agent's skills directory, tagging each skill clawpro/local. + * Scan an agent's skills directory and return every installed skill. */ -export async function scanReportableSkills( - skillsDir: string, - clawproSlugs: Set, -): Promise { +export async function scanReportableSkills(skillsDir: string): Promise { if (!(await pathExists(skillsDir))) return []; const dirs = await listDirs(skillsDir); const skills: ReportedSkill[] = []; @@ -170,7 +133,6 @@ export async function scanReportableSkills( slug, version: meta.version, display_name: meta.name || slug, - source: clawproSlugs.has(slug) ? 'clawpro' : 'local', }); } skills.sort((a, b) => a.slug.localeCompare(b.slug)); @@ -309,8 +271,7 @@ async function runStatusReportInner(opts: StatusReportOptions): Promise { // ① report — SessionStart only. if (opts.phase === 'session') { - const clawpro = await getClawproSlugs(localAgentId); - const skills = await scanReportableSkills(skillsDir, clawpro); + const skills = await scanReportableSkills(skillsDir); const reportBody = { local_agent_id: localAgentId, agent_type: agentType, @@ -350,8 +311,6 @@ async function runStatusReportInner(opts: StatusReportOptions): Promise { let error = ''; try { await executeSkillCommand(cmd, skillsDir); - // Maintain clawpro bookkeeping so future reports tag the slug correctly. - await recordClawproSlug(localAgentId, cmd.skill_slug, cmd.type !== 'uninstall_skill'); log.debug(`[status-report] ${label} → ${skillsDir} OK`); } catch (e) { status = 'failed';