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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package-lock.json

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

6 changes: 3 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
{
"name": "teamai-cli",
"version": "0.16.5",
"version": "0.16.6",
"description": "TeamAI — the team harness for AI agents (skill sync + shared knowledge base, powered by Git)",
"type": "module",
"bin": {
"teamai": "./dist/index.js"
"teamai": "dist/index.js"
},
"files": [
"dist/**/*.js",
Expand All @@ -29,7 +29,7 @@
},
"repository": {
"type": "git",
"url": "https://github.com/Tencent/teamai-cli.git"
"url": "git+https://github.com/Tencent/teamai-cli.git"
},
"homepage": "https://github.com/Tencent/teamai-cli#readme",
"bugs": {
Expand Down
10 changes: 5 additions & 5 deletions skills/teamai-wiki/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -243,10 +243,10 @@ When invoked, first determine the subcommand (init/ingest/query/lint/status/expo

#### Step 1 — Parse arguments

- `WIKI_DIR`: wiki 目录路径。按以下顺序检测
1. team repo 中的 `wiki/` 目录(如果当前项目已通过 `teamai init` 配置)→ 首选
2. `~/.claude-internal/wiki/` 或 `~/.claude/wiki/`(本地 AI 工具 wiki 目录)
3. 当前目录的 `./wiki/`(fallback)
- `WIKI_DIR`: 根据当前项目的 teamai scope 决定
- 读取 `<projectRoot>/.teamai/config.yaml` 中的 `scope` 字段
- **project scope** → `<projectRoot>/.teamai/wiki/`
- **user scope**(或无项目配置时)→ `~/.teamai/wiki/`
- `SOURCE_DIR`: 可选的 `dir` 参数

如果 `WIKI_DIR` 已经存在且包含 `_metadata.json`,提示用户已经初始化过,询问是否要重新初始化。
Expand Down Expand Up @@ -1009,7 +1009,7 @@ Wiki exported to: <path>
8. **_metadata.json 是真相来源** — 页面列表、文件哈希、链接图都以此为准。
9. **命名一致性** — 文件名 kebab-case,标题 Title Case 或人类可读中文。
10. **幂等性** — 重复 ingest 同一源目录应产生相同结果(不会重复创建页面)。
11. **wiki 路径推断** — 优先使用 team repo 的 wiki/ 目录(通过 `teamai pull` 同步到本地);其次检查 `~/.claude-internal/wiki/``~/.claude/wiki/`;最后 fallback 到 `./wiki/`
11. **wiki 路径** — 读取 `<projectRoot>/.teamai/config.yaml` 的 `scope` 字段:project scope 用 `<projectRoot>/.teamai/wiki/`,user scope 用 `~/.teamai/wiki/`。与 teamai push/pull 的 `WikiHandler.getSharedWikiDir()` 逻辑对齐
12. **Obsidian 兼容** — 所有 `[[links]]` 使用 Obsidian 格式,方便用户在 Obsidian 中直接浏览。
13. **语言** — Wiki 页面内容默认使用中文撰写,技术术语保持英文原文。
14. **智能分类** — LLM 根据内容自动判断页面归属哪个分类目录,无需用户指定。
Expand Down
17 changes: 6 additions & 11 deletions src/__tests__/doctor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,10 +99,11 @@ describe('doctor — hook checks', () => {
});

it('should fail when a subcommand is missing from settings', async () => {
// Missing 'contribute-check' subcommand
// Missing 'hook-dispatch' subcommand (the only required one now)
mockedReadFileSafe.mockImplementation(async (filePath: string) => {
if (filePath.includes('settings.json')) {
return buildPartialHooksContent(['contribute-check']);
// Return settings without hook-dispatch
return '{ "hooks": { "command": "bash -lc \\"teamai pull\\"" } }';
}
if (filePath.includes('.zshrc') || filePath.includes('.bashrc')) {
return '# [teamai:env:start]';
Expand Down Expand Up @@ -136,14 +137,8 @@ describe('doctor — hook checks', () => {
});

it('should check all TEAMAI_HOOK_SUBCOMMANDS', () => {
// Verify the subcommands list is what we expect
expect(TEAMAI_HOOK_SUBCOMMANDS).toContain('pull');
expect(TEAMAI_HOOK_SUBCOMMANDS).toContain('update');
expect(TEAMAI_HOOK_SUBCOMMANDS).toContain('track');
expect(TEAMAI_HOOK_SUBCOMMANDS).toContain('track-slash');
expect(TEAMAI_HOOK_SUBCOMMANDS).toContain('dashboard-report');
expect(TEAMAI_HOOK_SUBCOMMANDS).toContain('contribute-check');
expect(TEAMAI_HOOK_SUBCOMMANDS).toContain('auto-recall');
expect(TEAMAI_HOOK_SUBCOMMANDS).toHaveLength(7);
// With the merged dispatch format, only hook-dispatch is needed
expect(TEAMAI_HOOK_SUBCOMMANDS).toContain('hook-dispatch');
expect(TEAMAI_HOOK_SUBCOMMANDS).toHaveLength(1);
});
});
210 changes: 210 additions & 0 deletions src/__tests__/hook-dispatch.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,210 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';

// ── Test doubles ────────────────────────────────────────

/** Minimal handler interface for testing. */
interface TestHandler {
name: string;
execute: ReturnType<typeof vi.fn>;
}

function createHandler(name: string, output?: string): TestHandler {
return {
name,
execute: vi.fn().mockResolvedValue(output ?? null),
};
}

// ── Import after understanding module shape ─────────────

import {
createDispatcher,
type HookHandler,
type DispatchResult,
} from '../hook-dispatch.js';

// ── Tests ───────────────────────────────────────────────

describe('hook-dispatch', () => {
describe('routing', () => {
it('dispatches to all handlers registered for the given event+matcher', async () => {
const pullHandler = createHandler('pull');
const dashboardHandler = createHandler('dashboard-report');

const dispatcher = createDispatcher({
handlers: [
{ event: 'session-start', matcher: '*', handler: pullHandler },
{ event: 'session-start', matcher: '*', handler: dashboardHandler },
{ event: 'stop', matcher: '*', handler: createHandler('update') },
],
});

const stdin = { session_id: 'test-123', cwd: '/tmp' };
await dispatcher.dispatch('session-start', '*', stdin, 'claude');

expect(pullHandler.execute).toHaveBeenCalledOnce();
expect(dashboardHandler.execute).toHaveBeenCalledOnce();
});

it('does not invoke handlers for a different event', async () => {
const stopHandler = createHandler('update');

const dispatcher = createDispatcher({
handlers: [
{ event: 'session-start', matcher: '*', handler: createHandler('pull') },
{ event: 'stop', matcher: '*', handler: stopHandler },
],
});

await dispatcher.dispatch('session-start', '*', {}, 'claude');

expect(stopHandler.execute).not.toHaveBeenCalled();
});

it('does not invoke handlers with a different matcher', async () => {
const skillHandler = createHandler('track');

const dispatcher = createDispatcher({
handlers: [
{ event: 'post-tool-use', matcher: '*', handler: createHandler('dashboard') },
{ event: 'post-tool-use', matcher: 'Skill', handler: skillHandler },
],
});

await dispatcher.dispatch('post-tool-use', 'Bash', {}, 'claude');

expect(skillHandler.execute).not.toHaveBeenCalled();
});

it('wildcard matcher handlers also fire when a specific matcher is dispatched', async () => {
const wildcardHandler = createHandler('dashboard');
const bashHandler = createHandler('auto-recall');

const dispatcher = createDispatcher({
handlers: [
{ event: 'post-tool-use', matcher: '*', handler: wildcardHandler },
{ event: 'post-tool-use', matcher: 'Bash', handler: bashHandler },
],
});

await dispatcher.dispatch('post-tool-use', 'Bash', {}, 'claude');

expect(wildcardHandler.execute).toHaveBeenCalledOnce();
expect(bashHandler.execute).toHaveBeenCalledOnce();
});
});

describe('isolation', () => {
it('a failing handler does not prevent other handlers from executing', async () => {
const failingHandler = createHandler('failing');
failingHandler.execute.mockRejectedValue(new Error('boom'));
const successHandler = createHandler('success');

const dispatcher = createDispatcher({
handlers: [
{ event: 'session-start', matcher: '*', handler: failingHandler },
{ event: 'session-start', matcher: '*', handler: successHandler },
],
});

await dispatcher.dispatch('session-start', '*', {}, 'claude');

expect(successHandler.execute).toHaveBeenCalledOnce();
});

it('returns errors from failed handlers in the result', async () => {
const failingHandler = createHandler('failing');
failingHandler.execute.mockRejectedValue(new Error('boom'));

const dispatcher = createDispatcher({
handlers: [
{ event: 'session-start', matcher: '*', handler: failingHandler },
{ event: 'session-start', matcher: '*', handler: createHandler('ok') },
],
});

const result = await dispatcher.dispatch('session-start', '*', {}, 'claude');

expect(result.errors).toHaveLength(1);
expect(result.errors[0].handlerName).toBe('failing');
expect(result.errors[0].error.message).toBe('boom');
});
});

describe('output merging', () => {
it('returns output from the handler that produces one', async () => {
const outputHandler = createHandler('auto-recall', '{"hookSpecificOutput":{"additionalContext":"found stuff"}}');
const silentHandler = createHandler('dashboard');

const dispatcher = createDispatcher({
handlers: [
{ event: 'post-tool-use', matcher: 'Bash', handler: outputHandler },
{ event: 'post-tool-use', matcher: '*', handler: silentHandler },
],
});

const result = await dispatcher.dispatch('post-tool-use', 'Bash', {}, 'claude');

expect(result.output).toBe('{"hookSpecificOutput":{"additionalContext":"found stuff"}}');
});

it('returns null output when no handler produces output', async () => {
const dispatcher = createDispatcher({
handlers: [
{ event: 'session-start', matcher: '*', handler: createHandler('pull') },
{ event: 'session-start', matcher: '*', handler: createHandler('dashboard') },
],
});

const result = await dispatcher.dispatch('session-start', '*', {}, 'claude');

expect(result.output).toBeNull();
});
});

describe('stdin sharing', () => {
it('passes the same stdin object to all handlers', async () => {
const handler1 = createHandler('h1');
const handler2 = createHandler('h2');

const dispatcher = createDispatcher({
handlers: [
{ event: 'stop', matcher: '*', handler: handler1 },
{ event: 'stop', matcher: '*', handler: handler2 },
],
});

const stdin = { session_id: 'abc', cwd: '/project' };
await dispatcher.dispatch('stop', '*', stdin, 'claude');

expect(handler1.execute).toHaveBeenCalledWith(stdin, 'claude');
expect(handler2.execute).toHaveBeenCalledWith(stdin, 'claude');
});
});

describe('timeout', () => {
it('aborts a handler that exceeds its timeout', async () => {
const slowHandler: TestHandler = {
name: 'slow',
execute: vi.fn().mockImplementation(
() => new Promise((resolve) => setTimeout(() => resolve('late'), 5000)),
),
};
const fastHandler = createHandler('fast', 'quick');

const dispatcher = createDispatcher({
handlers: [
{ event: 'session-start', matcher: '*', handler: slowHandler, timeoutMs: 50 },
{ event: 'session-start', matcher: '*', handler: fastHandler },
],
});

const result = await dispatcher.dispatch('session-start', '*', {}, 'claude');

expect(result.errors).toHaveLength(1);
expect(result.errors[0].handlerName).toBe('slow');
expect(result.errors[0].error.message).toContain('timeout');
expect(result.output).toBe('quick');
});
});
});
Loading
Loading