mirror of
https://github.com/Tencent/teamai-cli.git
synced 2026-10-02 03:14:40 +08:00
feat(hooks): Cursor hooks.json 与 Claude/CodeBuddy 对齐 (merge request !72)
Squash merge branch 'feat/align-cursor-hooks' into 'master' ## 变更说明 - **Cursor**:`hooks.json` 注入与 Claude 语义一致的 4 个事件(sessionStart / stop / postToolUse / userPromptSubmit),共 8 条 hook(含 pull、update、track、track-slash、dashboard-report) - **postToolUse** 追踪使用 `Skill` matcher(替代 Read) - **doctor**:校验各工具 settings 中是否包含全部 teamai 子命令 - **测试**:`hooks.test.ts`、`hooks-e2e.test.ts`(Vitest) - **README**:去掉已废弃的 `teamai sync` 行,更新 hooks 说明与示意图 ## 测试 - `npm run build` && `npx vitest run`(385 passed)
This commit is contained in:
@@ -28,7 +28,6 @@ teamai init --repo yourteam/yourproject
|
||||
| `teamai init` | 初始化(自动安装 gf CLI、OAuth 登录、关联仓库、注册成员、配置 reviewers、注入 hooks) |
|
||||
| `teamai push [--all]` | 推送本地新资源到独立分支并创建 Merge Request |
|
||||
| `teamai pull [--silent]` | 拉取团队资源并注入到本地 AI 工具 |
|
||||
| `teamai sync` | 双向同步(push + pull) |
|
||||
| `teamai status` | 查看本地 vs 团队仓库差异 |
|
||||
| `teamai list [type]` | 列出资源(skills\|rules\|docs) |
|
||||
| `teamai members` | 列出已注册的团队成员 |
|
||||
@@ -55,13 +54,13 @@ teamai init --repo yourteam/yourproject
|
||||
│ ▲
|
||||
│ │ reviewer 审批合并 MR
|
||||
▼
|
||||
SessionStart hook → teamai pull --silent
|
||||
SessionStart hook → teamai pull
|
||||
自动拉取到所有成员本地
|
||||
```
|
||||
|
||||
- `teamai push` 会创建独立分支(`teamai/push/<user>/<timestamp>`),推送后自动创建 Merge Request 并指派 reviewers
|
||||
- `teamai init` 初始化时可配置默认 reviewers(记录在 `teamai.yaml` 的 `reviewers` 字段)
|
||||
- `teamai init` 会自动注入 SessionStart hook,每次启动 AI 工具会话时自动拉取团队最新内容(支持 Claude Code、Codex、Claude Code Internal、Cursor、CodeBuddy IDE)
|
||||
- `teamai init` 会自动注入与各工具格式对齐的 hooks(含 `sessionStart`、`stop`、`postToolUse`、`userPromptSubmit` 等),会话中会执行 `teamai pull`、`teamai update`、追踪与仪表盘等(支持 Claude Code、Codex、Claude Code Internal、Cursor、CodeBuddy IDE)
|
||||
- Skills 同步到 `~/.claude/skills/`、`~/.codex/skills/`、`~/.claude-internal/skills/`、`~/.cursor/skills/`、`~/.codebuddy/skills/`
|
||||
- Rules 同步到各工具的 rules 目录,并通过标记注释合并到 `CLAUDE.md`(支持 claude、claude-internal、codebuddy)
|
||||
- Docs 同步到 `~/.teamai/docs/`
|
||||
|
||||
@@ -0,0 +1,292 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import path from 'node:path';
|
||||
import os from 'node:os';
|
||||
import fse from 'fs-extra';
|
||||
|
||||
vi.mock('../utils/logger.js', () => ({
|
||||
log: {
|
||||
info: vi.fn(),
|
||||
success: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
import { injectHooks, removeHooks, injectHooksToAllTools, TEAMAI_HOOK_SUBCOMMANDS, CLAUDE_TO_CURSOR_EVENTS } from '../hooks.js';
|
||||
|
||||
let tmpDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
tmpDir = await fse.mkdtemp(path.join(os.tmpdir(), 'teamai-hooks-e2e-'));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fse.remove(tmpDir);
|
||||
});
|
||||
|
||||
function claudePath(): string {
|
||||
return path.join(tmpDir, 'claude', 'settings.json');
|
||||
}
|
||||
|
||||
function cursorPath(): string {
|
||||
return path.join(tmpDir, 'cursor', 'hooks.json');
|
||||
}
|
||||
|
||||
async function readResult(filePath: string): Promise<Record<string, unknown>> {
|
||||
return fse.readJson(filePath);
|
||||
}
|
||||
|
||||
describe('hooks E2E — real file I/O', () => {
|
||||
describe('inject — full injection to temp directories', () => {
|
||||
it('creates Claude settings.json with all 4 events and 8 hooks', async () => {
|
||||
const p = claudePath();
|
||||
await injectHooks(p, 'claude');
|
||||
|
||||
const result = await readResult(p);
|
||||
const hooks = result.hooks as Record<string, unknown[]>;
|
||||
|
||||
expect(Object.keys(hooks)).toEqual(['SessionStart', 'Stop', 'PostToolUse', 'UserPromptSubmit']);
|
||||
expect(hooks.SessionStart).toHaveLength(2);
|
||||
expect(hooks.Stop).toHaveLength(2);
|
||||
expect(hooks.PostToolUse).toHaveLength(2);
|
||||
expect(hooks.UserPromptSubmit).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('creates Cursor hooks.json with all 4 events and 8 hooks', async () => {
|
||||
const p = cursorPath();
|
||||
await injectHooks(p, 'cursor');
|
||||
|
||||
const result = await readResult(p);
|
||||
expect(result.version).toBe(1);
|
||||
const hooks = result.hooks as Record<string, unknown[]>;
|
||||
|
||||
expect(Object.keys(hooks)).toEqual(['sessionStart', 'stop', 'postToolUse', 'userPromptSubmit']);
|
||||
expect(hooks.sessionStart).toHaveLength(2);
|
||||
expect(hooks.stop).toHaveLength(2);
|
||||
expect(hooks.postToolUse).toHaveLength(2);
|
||||
expect(hooks.userPromptSubmit).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('all TEAMAI_HOOK_SUBCOMMANDS present in Claude output', async () => {
|
||||
const p = claudePath();
|
||||
await injectHooks(p, 'claude');
|
||||
|
||||
const content = await fse.readFile(p, 'utf-8');
|
||||
for (const subcmd of TEAMAI_HOOK_SUBCOMMANDS) {
|
||||
expect(content).toContain(`teamai ${subcmd}`);
|
||||
}
|
||||
});
|
||||
|
||||
it('all TEAMAI_HOOK_SUBCOMMANDS present in Cursor output', async () => {
|
||||
const p = cursorPath();
|
||||
await injectHooks(p, 'cursor');
|
||||
|
||||
const content = await fse.readFile(p, 'utf-8');
|
||||
for (const subcmd of TEAMAI_HOOK_SUBCOMMANDS) {
|
||||
expect(content).toContain(`teamai ${subcmd}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Cursor vs Claude semantic alignment', () => {
|
||||
it('same set of teamai subcommands in both formats', async () => {
|
||||
const cp = claudePath();
|
||||
const kp = cursorPath();
|
||||
await injectHooks(cp, 'claude');
|
||||
await injectHooks(kp, 'cursor');
|
||||
|
||||
const claudeContent = await fse.readFile(cp, 'utf-8');
|
||||
const cursorContent = await fse.readFile(kp, 'utf-8');
|
||||
|
||||
for (const subcmd of TEAMAI_HOOK_SUBCOMMANDS) {
|
||||
expect(claudeContent).toContain(`teamai ${subcmd}`);
|
||||
expect(cursorContent).toContain(`teamai ${subcmd}`);
|
||||
}
|
||||
});
|
||||
|
||||
it('event names map 1:1 via CLAUDE_TO_CURSOR_EVENTS', async () => {
|
||||
const cp = claudePath();
|
||||
const kp = cursorPath();
|
||||
await injectHooks(cp, 'claude');
|
||||
await injectHooks(kp, 'cursor');
|
||||
|
||||
const claudeResult = await readResult(cp);
|
||||
const cursorResult = await readResult(kp);
|
||||
const claudeHooks = claudeResult.hooks as Record<string, unknown[]>;
|
||||
const cursorHooks = cursorResult.hooks as Record<string, unknown[]>;
|
||||
|
||||
for (const [claudeEvent, cursorEvent] of Object.entries(CLAUDE_TO_CURSOR_EVENTS)) {
|
||||
expect(claudeHooks[claudeEvent]).toBeDefined();
|
||||
expect(cursorHooks[cursorEvent]).toBeDefined();
|
||||
expect(claudeHooks[claudeEvent].length).toBe(cursorHooks[cursorEvent].length);
|
||||
}
|
||||
});
|
||||
|
||||
it('PostToolUse track hook uses Skill matcher in both formats', async () => {
|
||||
const cp = claudePath();
|
||||
const kp = cursorPath();
|
||||
await injectHooks(cp, 'claude');
|
||||
await injectHooks(kp, 'cursor');
|
||||
|
||||
const claudeResult = await readResult(cp);
|
||||
const cursorResult = await readResult(kp);
|
||||
|
||||
const claudePostTool = (claudeResult.hooks as Record<string, Array<{ matcher: string }>>).PostToolUse;
|
||||
const cursorPostTool = (cursorResult.hooks as Record<string, Array<{ matcher?: string; command: string }>>).postToolUse;
|
||||
|
||||
const claudeSkillHook = claudePostTool.find((h) => h.matcher === 'Skill');
|
||||
expect(claudeSkillHook).toBeDefined();
|
||||
|
||||
const cursorTrackHook = cursorPostTool.find(
|
||||
(h) => h.command.includes('teamai track') && !h.command.includes('track-slash')
|
||||
);
|
||||
expect(cursorTrackHook).toBeDefined();
|
||||
expect(cursorTrackHook?.matcher).toBe('Skill');
|
||||
});
|
||||
|
||||
it('tool-specific commands use the correct --tool parameter', async () => {
|
||||
const cp = claudePath();
|
||||
const kp = cursorPath();
|
||||
await injectHooks(cp, 'claude');
|
||||
await injectHooks(kp, 'cursor');
|
||||
|
||||
const claudeContent = await fse.readFile(cp, 'utf-8');
|
||||
const cursorContent = await fse.readFile(kp, 'utf-8');
|
||||
|
||||
expect(claudeContent).toContain('--tool claude');
|
||||
expect(claudeContent).not.toContain('--tool cursor');
|
||||
|
||||
expect(cursorContent).toContain('--tool cursor');
|
||||
expect(cursorContent).not.toContain('--tool claude');
|
||||
});
|
||||
});
|
||||
|
||||
describe('idempotency', () => {
|
||||
it('double inject produces identical file content', async () => {
|
||||
const p = claudePath();
|
||||
await injectHooks(p, 'claude');
|
||||
const first = await fse.readFile(p, 'utf-8');
|
||||
|
||||
await injectHooks(p, 'claude');
|
||||
const second = await fse.readFile(p, 'utf-8');
|
||||
|
||||
expect(second).toBe(first);
|
||||
});
|
||||
|
||||
it('double inject for Cursor produces identical file content', async () => {
|
||||
const p = cursorPath();
|
||||
await injectHooks(p, 'cursor');
|
||||
const first = await fse.readFile(p, 'utf-8');
|
||||
|
||||
await injectHooks(p, 'cursor');
|
||||
const second = await fse.readFile(p, 'utf-8');
|
||||
|
||||
expect(second).toBe(first);
|
||||
});
|
||||
});
|
||||
|
||||
describe('remove — complete cleanup', () => {
|
||||
it('Claude: removes all teamai hooks, file remains valid JSON', async () => {
|
||||
const p = claudePath();
|
||||
await fse.ensureDir(path.dirname(p));
|
||||
await fse.writeJson(p, {
|
||||
hooks: {
|
||||
SessionStart: [
|
||||
{
|
||||
matcher: '*',
|
||||
hooks: [{ type: 'command', command: 'echo "user hook"' }],
|
||||
description: 'My hook',
|
||||
},
|
||||
],
|
||||
},
|
||||
language: 'zh',
|
||||
});
|
||||
|
||||
await injectHooks(p, 'claude');
|
||||
await removeHooks(p, 'claude');
|
||||
|
||||
const result = await readResult(p);
|
||||
expect(result.language).toBe('zh');
|
||||
|
||||
const hooks = result.hooks as Record<string, unknown[]>;
|
||||
expect(hooks.SessionStart).toHaveLength(1);
|
||||
expect(hooks.Stop).toHaveLength(0);
|
||||
expect(hooks.PostToolUse).toHaveLength(0);
|
||||
expect(hooks.UserPromptSubmit).toHaveLength(0);
|
||||
|
||||
const content = await fse.readFile(p, 'utf-8');
|
||||
expect(content).not.toContain('[teamai]');
|
||||
});
|
||||
|
||||
it('Cursor: removes all teamai hooks, file remains valid JSON', async () => {
|
||||
const p = cursorPath();
|
||||
await fse.ensureDir(path.dirname(p));
|
||||
await fse.writeJson(p, {
|
||||
version: 1,
|
||||
hooks: {
|
||||
sessionStart: [
|
||||
{ command: 'echo "user hook"', timeout: 5 },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
await injectHooks(p, 'cursor');
|
||||
await removeHooks(p, 'cursor');
|
||||
|
||||
const result = await readResult(p);
|
||||
expect(result.version).toBe(1);
|
||||
|
||||
const hooks = result.hooks as Record<string, unknown[]>;
|
||||
expect(hooks.sessionStart).toHaveLength(1);
|
||||
expect(hooks.stop).toHaveLength(0);
|
||||
expect(hooks.postToolUse).toHaveLength(0);
|
||||
expect(hooks.userPromptSubmit).toHaveLength(0);
|
||||
|
||||
const content = await fse.readFile(p, 'utf-8');
|
||||
expect(content).not.toContain('teamai pull');
|
||||
expect(content).not.toContain('teamai update');
|
||||
expect(content).not.toContain('teamai track');
|
||||
});
|
||||
});
|
||||
|
||||
describe('injectHooksToAllTools — multi-tool E2E', () => {
|
||||
it('injects aligned hooks into claude and cursor simultaneously', async () => {
|
||||
const originalHome = process.env.HOME;
|
||||
process.env.HOME = tmpDir;
|
||||
|
||||
await injectHooksToAllTools({
|
||||
claude: { settings: '.claude/settings.json' },
|
||||
cursor: { settings: '.cursor/hooks.json' },
|
||||
codex: {},
|
||||
});
|
||||
|
||||
process.env.HOME = originalHome;
|
||||
|
||||
const claudeFile = path.join(tmpDir, '.claude', 'settings.json');
|
||||
const cursorFile = path.join(tmpDir, '.cursor', 'hooks.json');
|
||||
|
||||
expect(await fse.pathExists(claudeFile)).toBe(true);
|
||||
expect(await fse.pathExists(cursorFile)).toBe(true);
|
||||
|
||||
const claudeResult = await fse.readJson(claudeFile);
|
||||
const cursorResult = await fse.readJson(cursorFile);
|
||||
|
||||
const claudeEvents = Object.keys(claudeResult.hooks);
|
||||
const cursorEvents = Object.keys(cursorResult.hooks);
|
||||
|
||||
expect(claudeEvents).toHaveLength(4);
|
||||
expect(cursorEvents).toHaveLength(4);
|
||||
|
||||
for (const subcmd of TEAMAI_HOOK_SUBCOMMANDS) {
|
||||
const claudeContent = JSON.stringify(claudeResult);
|
||||
const cursorContent = JSON.stringify(cursorResult);
|
||||
expect(claudeContent).toContain(`teamai ${subcmd}`);
|
||||
expect(cursorContent).toContain(`teamai ${subcmd}`);
|
||||
}
|
||||
|
||||
expect(JSON.stringify(claudeResult)).toContain('--tool claude');
|
||||
expect(JSON.stringify(cursorResult)).toContain('--tool cursor');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,445 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
// ── Mocks ────────────────────────────────────────────────
|
||||
|
||||
let mockFiles: Record<string, unknown> = {};
|
||||
|
||||
vi.mock('../utils/fs.js', () => ({
|
||||
readJson: vi.fn(async (filePath: string) => mockFiles[filePath] ?? null),
|
||||
writeJson: vi.fn(async (filePath: string, data: unknown) => {
|
||||
mockFiles[filePath] = JSON.parse(JSON.stringify(data));
|
||||
}),
|
||||
expandHome: (p: string) => p,
|
||||
ensureDir: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../utils/logger.js', () => ({
|
||||
log: {
|
||||
info: vi.fn(),
|
||||
success: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
import { injectHooks, removeHooks, injectHooksToAllTools, TEAMAI_HOOK_SUBCOMMANDS, CLAUDE_TO_CURSOR_EVENTS } from '../hooks.js';
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────
|
||||
|
||||
function extractCommands(hooks: Record<string, unknown[]>): string[] {
|
||||
const cmds: string[] = [];
|
||||
for (const entries of Object.values(hooks)) {
|
||||
for (const entry of entries as Array<Record<string, unknown>>) {
|
||||
if (entry.command) {
|
||||
cmds.push(entry.command as string);
|
||||
} else if (entry.hooks) {
|
||||
for (const h of entry.hooks as Array<Record<string, string>>) {
|
||||
cmds.push(h.command);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return cmds;
|
||||
}
|
||||
|
||||
function extractTeamaiSubcommands(hooks: Record<string, unknown[]>): string[] {
|
||||
const cmds = extractCommands(hooks);
|
||||
const subcmds = new Set<string>();
|
||||
for (const cmd of cmds) {
|
||||
const match = cmd.match(/teamai\s+([\w-]+)/);
|
||||
if (match) subcmds.add(match[1]);
|
||||
}
|
||||
return [...subcmds].sort();
|
||||
}
|
||||
|
||||
// ── Tests ────────────────────────────────────────────────
|
||||
|
||||
describe('hooks', () => {
|
||||
beforeEach(() => {
|
||||
mockFiles = {};
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('inject — empty file', () => {
|
||||
it('Claude format: injects 4 events with 8 hooks into empty settings.json', async () => {
|
||||
await injectHooks('/test/settings.json', 'claude');
|
||||
|
||||
const result = mockFiles['/test/settings.json'] as { hooks: Record<string, unknown[]> };
|
||||
expect(result.hooks).toBeDefined();
|
||||
|
||||
const events = Object.keys(result.hooks);
|
||||
expect(events).toEqual(['SessionStart', 'Stop', 'PostToolUse', 'UserPromptSubmit']);
|
||||
|
||||
for (const event of events) {
|
||||
expect(result.hooks[event]).toHaveLength(2);
|
||||
}
|
||||
});
|
||||
|
||||
it('Cursor format: injects 4 events with 8 hooks into empty hooks.json', async () => {
|
||||
await injectHooks('/test/hooks.json', 'cursor');
|
||||
|
||||
const result = mockFiles['/test/hooks.json'] as { version: number; hooks: Record<string, unknown[]> };
|
||||
expect(result.version).toBe(1);
|
||||
expect(result.hooks).toBeDefined();
|
||||
|
||||
const events = Object.keys(result.hooks);
|
||||
expect(events).toEqual(['sessionStart', 'stop', 'postToolUse', 'userPromptSubmit']);
|
||||
|
||||
for (const event of events) {
|
||||
expect(result.hooks[event]).toHaveLength(2);
|
||||
}
|
||||
});
|
||||
|
||||
it('Claude uses PascalCase event names', async () => {
|
||||
await injectHooks('/test/settings.json', 'claude');
|
||||
const result = mockFiles['/test/settings.json'] as { hooks: Record<string, unknown[]> };
|
||||
for (const event of Object.keys(result.hooks)) {
|
||||
expect(event[0]).toBe(event[0].toUpperCase());
|
||||
}
|
||||
});
|
||||
|
||||
it('Cursor uses camelCase event names', async () => {
|
||||
await injectHooks('/test/hooks.json', 'cursor');
|
||||
const result = mockFiles['/test/hooks.json'] as { hooks: Record<string, unknown[]> };
|
||||
for (const event of Object.keys(result.hooks)) {
|
||||
expect(event[0]).toBe(event[0].toLowerCase());
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('inject — idempotency', () => {
|
||||
it('double inject does not duplicate hooks', async () => {
|
||||
await injectHooks('/test/settings.json', 'claude');
|
||||
await injectHooks('/test/settings.json', 'claude');
|
||||
|
||||
const result = mockFiles['/test/settings.json'] as { hooks: Record<string, unknown[]> };
|
||||
for (const event of Object.values(result.hooks)) {
|
||||
expect(event).toHaveLength(2);
|
||||
}
|
||||
});
|
||||
|
||||
it('double inject for Cursor does not duplicate hooks', async () => {
|
||||
await injectHooks('/test/hooks.json', 'cursor');
|
||||
await injectHooks('/test/hooks.json', 'cursor');
|
||||
|
||||
const result = mockFiles['/test/hooks.json'] as { hooks: Record<string, unknown[]> };
|
||||
for (const event of Object.values(result.hooks)) {
|
||||
expect(event).toHaveLength(2);
|
||||
}
|
||||
});
|
||||
|
||||
it('updates command when content changes (Claude)', async () => {
|
||||
mockFiles['/test/settings.json'] = {
|
||||
hooks: {
|
||||
SessionStart: [
|
||||
{
|
||||
matcher: '*',
|
||||
hooks: [{ type: 'command', command: 'bash -lc "teamai pull --silent" 2>/dev/null || true' }],
|
||||
description: '[teamai] Auto-pull team resources on session start',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
await injectHooks('/test/settings.json', 'claude');
|
||||
|
||||
const result = mockFiles['/test/settings.json'] as { hooks: Record<string, unknown[]> };
|
||||
const sessionStart = result.hooks.SessionStart as Array<{ hooks: Array<{ command: string }> }>;
|
||||
expect(sessionStart[0].hooks[0].command).toContain('teamai pull');
|
||||
expect(sessionStart[0].hooks[0].command).not.toContain('--silent');
|
||||
});
|
||||
|
||||
it('updates command when content changes (Cursor)', async () => {
|
||||
mockFiles['/test/hooks.json'] = {
|
||||
version: 1,
|
||||
hooks: {
|
||||
sessionStart: [
|
||||
{ command: 'bash -lc "teamai pull --silent" 2>/dev/null || true', timeout: 30 },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
await injectHooks('/test/hooks.json', 'cursor');
|
||||
|
||||
const result = mockFiles['/test/hooks.json'] as { hooks: Record<string, Array<{ command: string }>> };
|
||||
const pullHook = result.hooks.sessionStart.find((h) => h.command.includes('teamai pull'));
|
||||
expect(pullHook?.command).not.toContain('--silent');
|
||||
});
|
||||
});
|
||||
|
||||
describe('inject — preserves non-teamai hooks', () => {
|
||||
it('Claude format: preserves user hooks', async () => {
|
||||
const userHook = {
|
||||
matcher: '*',
|
||||
hooks: [{ type: 'command', command: 'echo "my custom hook"' }],
|
||||
description: 'My custom hook',
|
||||
};
|
||||
mockFiles['/test/settings.json'] = {
|
||||
hooks: { SessionStart: [userHook] },
|
||||
language: 'en',
|
||||
};
|
||||
|
||||
await injectHooks('/test/settings.json', 'claude');
|
||||
|
||||
const result = mockFiles['/test/settings.json'] as {
|
||||
hooks: Record<string, unknown[]>;
|
||||
language: string;
|
||||
};
|
||||
expect(result.hooks.SessionStart).toHaveLength(3);
|
||||
expect(result.hooks.SessionStart[0]).toEqual(userHook);
|
||||
expect(result.language).toBe('en');
|
||||
});
|
||||
|
||||
it('Cursor format: preserves user hooks', async () => {
|
||||
const userHook = { command: 'echo "my custom hook"', timeout: 5 };
|
||||
mockFiles['/test/hooks.json'] = {
|
||||
version: 1,
|
||||
hooks: { sessionStart: [userHook] },
|
||||
};
|
||||
|
||||
await injectHooks('/test/hooks.json', 'cursor');
|
||||
|
||||
const result = mockFiles['/test/hooks.json'] as { hooks: Record<string, unknown[]> };
|
||||
expect(result.hooks.sessionStart).toHaveLength(3);
|
||||
expect(result.hooks.sessionStart[0]).toEqual(userHook);
|
||||
});
|
||||
});
|
||||
|
||||
describe('remove', () => {
|
||||
it('Claude format: removes all teamai hooks, preserves others', async () => {
|
||||
await injectHooks('/test/settings.json', 'claude');
|
||||
|
||||
const userHook = {
|
||||
matcher: '*',
|
||||
hooks: [{ type: 'command', command: 'echo "keep me"' }],
|
||||
description: 'User hook',
|
||||
};
|
||||
const result = mockFiles['/test/settings.json'] as { hooks: Record<string, unknown[]> };
|
||||
result.hooks.SessionStart.push(userHook);
|
||||
mockFiles['/test/settings.json'] = result;
|
||||
|
||||
await removeHooks('/test/settings.json', 'claude');
|
||||
|
||||
const after = mockFiles['/test/settings.json'] as { hooks: Record<string, unknown[]> };
|
||||
expect(after.hooks.SessionStart).toHaveLength(1);
|
||||
expect(after.hooks.SessionStart[0]).toEqual(userHook);
|
||||
expect(after.hooks.Stop).toHaveLength(0);
|
||||
expect(after.hooks.PostToolUse).toHaveLength(0);
|
||||
expect(after.hooks.UserPromptSubmit).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('Cursor format: removes all teamai hooks, preserves others', async () => {
|
||||
await injectHooks('/test/hooks.json', 'cursor');
|
||||
|
||||
const userHook = { command: 'echo "keep me"', timeout: 5 };
|
||||
const result = mockFiles['/test/hooks.json'] as { hooks: Record<string, unknown[]> };
|
||||
result.hooks.sessionStart.push(userHook);
|
||||
mockFiles['/test/hooks.json'] = result;
|
||||
|
||||
await removeHooks('/test/hooks.json', 'cursor');
|
||||
|
||||
const after = mockFiles['/test/hooks.json'] as { hooks: Record<string, unknown[]> };
|
||||
expect(after.hooks.sessionStart).toHaveLength(1);
|
||||
expect(after.hooks.sessionStart[0]).toEqual(userHook);
|
||||
expect(after.hooks.stop).toHaveLength(0);
|
||||
expect(after.hooks.postToolUse).toHaveLength(0);
|
||||
expect(after.hooks.userPromptSubmit).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('inject — tool parameterization', () => {
|
||||
it('Claude hooks contain --tool parameter matching the tool name', async () => {
|
||||
await injectHooks('/test/settings.json', 'claude');
|
||||
const result = mockFiles['/test/settings.json'] as { hooks: Record<string, unknown[]> };
|
||||
const cmds = extractCommands(result.hooks);
|
||||
const toolCmds = cmds.filter((c) => c.includes('--tool'));
|
||||
expect(toolCmds.length).toBeGreaterThan(0);
|
||||
for (const cmd of toolCmds) {
|
||||
expect(cmd).toContain('--tool claude');
|
||||
}
|
||||
});
|
||||
|
||||
it('Cursor hooks contain --tool cursor', async () => {
|
||||
await injectHooks('/test/hooks.json', 'cursor');
|
||||
const result = mockFiles['/test/hooks.json'] as { hooks: Record<string, unknown[]> };
|
||||
const cmds = extractCommands(result.hooks);
|
||||
const toolCmds = cmds.filter((c) => c.includes('--tool'));
|
||||
expect(toolCmds.length).toBeGreaterThan(0);
|
||||
for (const cmd of toolCmds) {
|
||||
expect(cmd).toContain('--tool cursor');
|
||||
}
|
||||
});
|
||||
|
||||
it('codebuddy hooks contain --tool codebuddy', async () => {
|
||||
await injectHooks('/test/settings.json', 'codebuddy');
|
||||
const result = mockFiles['/test/settings.json'] as { hooks: Record<string, unknown[]> };
|
||||
const cmds = extractCommands(result.hooks);
|
||||
const toolCmds = cmds.filter((c) => c.includes('--tool'));
|
||||
for (const cmd of toolCmds) {
|
||||
expect(cmd).toContain('--tool codebuddy');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('injectHooksToAllTools', () => {
|
||||
it('injects into tools with settings path, skips those without', async () => {
|
||||
const originalHome = process.env.HOME;
|
||||
process.env.HOME = '/test-home';
|
||||
|
||||
await injectHooksToAllTools({
|
||||
claude: { settings: '.claude/settings.json' },
|
||||
codex: {},
|
||||
cursor: { settings: '.cursor/hooks.json' },
|
||||
});
|
||||
|
||||
expect(mockFiles['/test-home/.claude/settings.json']).toBeDefined();
|
||||
expect(mockFiles['/test-home/.cursor/hooks.json']).toBeDefined();
|
||||
expect(Object.keys(mockFiles)).toHaveLength(2);
|
||||
|
||||
process.env.HOME = originalHome;
|
||||
});
|
||||
});
|
||||
|
||||
describe('format alignment', () => {
|
||||
it('Claude and Cursor inject the same set of teamai subcommands', async () => {
|
||||
await injectHooks('/test/claude.json', 'claude');
|
||||
await injectHooks('/test/cursor.json', 'cursor');
|
||||
|
||||
const claudeResult = mockFiles['/test/claude.json'] as { hooks: Record<string, unknown[]> };
|
||||
const cursorResult = mockFiles['/test/cursor.json'] as { hooks: Record<string, unknown[]> };
|
||||
|
||||
const claudeSubcmds = extractTeamaiSubcommands(claudeResult.hooks);
|
||||
const cursorSubcmds = extractTeamaiSubcommands(cursorResult.hooks);
|
||||
|
||||
expect(claudeSubcmds).toEqual(cursorSubcmds);
|
||||
expect(claudeSubcmds).toEqual([...TEAMAI_HOOK_SUBCOMMANDS].sort());
|
||||
});
|
||||
|
||||
it('Claude PascalCase events map 1:1 to Cursor camelCase events', async () => {
|
||||
await injectHooks('/test/claude.json', 'claude');
|
||||
await injectHooks('/test/cursor.json', 'cursor');
|
||||
|
||||
const claudeResult = mockFiles['/test/claude.json'] as { hooks: Record<string, unknown[]> };
|
||||
const cursorResult = mockFiles['/test/cursor.json'] as { hooks: Record<string, unknown[]> };
|
||||
|
||||
const claudeEvents = Object.keys(claudeResult.hooks).sort();
|
||||
const cursorEvents = Object.keys(cursorResult.hooks).sort();
|
||||
|
||||
expect(claudeEvents).toHaveLength(cursorEvents.length);
|
||||
|
||||
for (const claudeEvent of claudeEvents) {
|
||||
const expectedCursorEvent = CLAUDE_TO_CURSOR_EVENTS[claudeEvent];
|
||||
expect(expectedCursorEvent).toBeDefined();
|
||||
expect(cursorEvents).toContain(expectedCursorEvent);
|
||||
}
|
||||
});
|
||||
|
||||
it('same number of hooks per corresponding event', async () => {
|
||||
await injectHooks('/test/claude.json', 'claude');
|
||||
await injectHooks('/test/cursor.json', 'cursor');
|
||||
|
||||
const claudeResult = mockFiles['/test/claude.json'] as { hooks: Record<string, unknown[]> };
|
||||
const cursorResult = mockFiles['/test/cursor.json'] as { hooks: Record<string, unknown[]> };
|
||||
|
||||
for (const [claudeEvent, cursorEvent] of Object.entries(CLAUDE_TO_CURSOR_EVENTS)) {
|
||||
expect(claudeResult.hooks[claudeEvent].length).toBe(
|
||||
cursorResult.hooks[cursorEvent].length
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('PostToolUse/postToolUse track hook uses Skill matcher in both formats', async () => {
|
||||
await injectHooks('/test/claude.json', 'claude');
|
||||
await injectHooks('/test/cursor.json', 'cursor');
|
||||
|
||||
const claudeResult = mockFiles['/test/claude.json'] as { hooks: Record<string, Array<{ matcher: string }>> };
|
||||
const cursorResult = mockFiles['/test/cursor.json'] as { hooks: Record<string, Array<{ matcher?: string; command: string }>> };
|
||||
|
||||
const claudeTrack = claudeResult.hooks.PostToolUse.find((h) => h.matcher === 'Skill');
|
||||
expect(claudeTrack).toBeDefined();
|
||||
|
||||
const cursorTrack = cursorResult.hooks.postToolUse.find(
|
||||
(h) => h.command.includes('teamai track') && !h.command.includes('track-slash')
|
||||
);
|
||||
expect(cursorTrack?.matcher).toBe('Skill');
|
||||
});
|
||||
|
||||
it('Cursor hooks have timeout values', async () => {
|
||||
await injectHooks('/test/hooks.json', 'cursor');
|
||||
const result = mockFiles['/test/hooks.json'] as { hooks: Record<string, Array<{ timeout?: number }>> };
|
||||
for (const entries of Object.values(result.hooks)) {
|
||||
for (const entry of entries) {
|
||||
expect(entry.timeout).toBeGreaterThan(0);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('Claude hooks have [teamai] description prefix', async () => {
|
||||
await injectHooks('/test/settings.json', 'claude');
|
||||
const result = mockFiles['/test/settings.json'] as { hooks: Record<string, Array<{ description?: string }>> };
|
||||
for (const entries of Object.values(result.hooks)) {
|
||||
for (const entry of entries) {
|
||||
expect(entry.description).toMatch(/^\[teamai\]/);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('no hardcoded tool names in commands — commands are parameterized', async () => {
|
||||
await injectHooks('/test/a.json', 'tool-alpha');
|
||||
await injectHooks('/test/b.json', 'tool-beta');
|
||||
|
||||
const resultA = mockFiles['/test/a.json'] as { hooks: Record<string, unknown[]> };
|
||||
const resultB = mockFiles['/test/b.json'] as { hooks: Record<string, unknown[]> };
|
||||
|
||||
const cmdsA = extractCommands(resultA.hooks).filter((c) => c.includes('--tool'));
|
||||
const cmdsB = extractCommands(resultB.hooks).filter((c) => c.includes('--tool'));
|
||||
|
||||
for (const cmd of cmdsA) {
|
||||
expect(cmd).toContain('--tool tool-alpha');
|
||||
expect(cmd).not.toContain('--tool tool-beta');
|
||||
}
|
||||
for (const cmd of cmdsB) {
|
||||
expect(cmd).toContain('--tool tool-beta');
|
||||
expect(cmd).not.toContain('--tool tool-alpha');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('TEAMAI_HOOK_SUBCOMMANDS export', () => {
|
||||
it('contains all expected subcommands', () => {
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('handles settings.json with non-hooks fields', async () => {
|
||||
mockFiles['/test/settings.json'] = {
|
||||
language: '中文',
|
||||
model: 'GLM5',
|
||||
skipDangerousModePermissionPrompt: true,
|
||||
};
|
||||
|
||||
await injectHooks('/test/settings.json', 'claude');
|
||||
|
||||
const result = mockFiles['/test/settings.json'] as Record<string, unknown>;
|
||||
expect(result.language).toBe('中文');
|
||||
expect(result.model).toBe('GLM5');
|
||||
expect(result.hooks).toBeDefined();
|
||||
});
|
||||
|
||||
it('second inject leaves settings JSON semantically equivalent (idempotent)', async () => {
|
||||
await injectHooks('/test/settings.json', 'claude');
|
||||
const afterFirst = JSON.stringify(mockFiles['/test/settings.json']);
|
||||
|
||||
await injectHooks('/test/settings.json', 'claude');
|
||||
const afterSecond = JSON.stringify(mockFiles['/test/settings.json']);
|
||||
|
||||
expect(afterSecond).toBe(afterFirst);
|
||||
});
|
||||
});
|
||||
});
|
||||
+7
-10
@@ -4,7 +4,8 @@ import { pathExists, readFileSafe } from './utils/fs.js';
|
||||
import { isGfInstalled, gfIsAuthenticated } from './utils/gf-cli.js';
|
||||
import { log } from './utils/logger.js';
|
||||
import type { GlobalOptions } from './types.js';
|
||||
import { TeamaiConfigSchema, TEAMAI_ENV_START, TEAMAI_HOOK_DESCRIPTION_PREFIX, type TeamaiConfig } from './types.js';
|
||||
import { TeamaiConfigSchema, TEAMAI_ENV_START, type TeamaiConfig } from './types.js';
|
||||
import { TEAMAI_HOOK_SUBCOMMANDS } from './hooks.js';
|
||||
|
||||
interface Check {
|
||||
name: string;
|
||||
@@ -12,24 +13,20 @@ interface Check {
|
||||
fix?: string;
|
||||
}
|
||||
|
||||
// Tools that use Cursor hooks.json format (no description field)
|
||||
const CURSOR_TOOLS = new Set(['cursor']);
|
||||
|
||||
function buildHookChecks(toolPaths: TeamaiConfig['toolPaths']): Check[] {
|
||||
const checks: Check[] = [];
|
||||
for (const [tool, paths] of Object.entries(toolPaths)) {
|
||||
if (!paths.settings) continue;
|
||||
checks.push({
|
||||
name: `teamai hook in ${tool} settings`,
|
||||
name: `teamai hooks in ${tool} settings`,
|
||||
check: async () => {
|
||||
const settingsPath = path.join(process.env.HOME ?? '', paths.settings!);
|
||||
if (!await pathExists(settingsPath)) return false;
|
||||
const content = await readFileSafe(settingsPath);
|
||||
// Cursor hooks.json has no description field; detect by command content
|
||||
if (CURSOR_TOOLS.has(tool)) {
|
||||
return content?.includes('teamai pull') ?? false;
|
||||
}
|
||||
return content?.includes(TEAMAI_HOOK_DESCRIPTION_PREFIX) ?? false;
|
||||
if (!content) return false;
|
||||
return TEAMAI_HOOK_SUBCOMMANDS.every(
|
||||
(subcmd) => content.includes(`teamai ${subcmd}`)
|
||||
);
|
||||
},
|
||||
fix: 'Run `teamai init` to inject hooks',
|
||||
});
|
||||
|
||||
+70
-42
@@ -21,6 +21,17 @@ function getDashboardReportCommand(tool: string): string {
|
||||
return `bash -lc "teamai dashboard-report --stdin --tool ${tool}" 2>>~/.teamai/debug.log || true`;
|
||||
}
|
||||
|
||||
/** Subcommands expected in each tool settings file (for `teamai doctor`). */
|
||||
export const TEAMAI_HOOK_SUBCOMMANDS = ['pull', 'update', 'track', 'track-slash', 'dashboard-report'] as const;
|
||||
|
||||
/** Claude PascalCase event → Cursor camelCase event (for tests / docs). */
|
||||
export const CLAUDE_TO_CURSOR_EVENTS: Record<string, string> = {
|
||||
SessionStart: 'sessionStart',
|
||||
Stop: 'stop',
|
||||
PostToolUse: 'postToolUse',
|
||||
UserPromptSubmit: 'userPromptSubmit',
|
||||
};
|
||||
|
||||
// ─── Claude Code / Claude Internal format (settings.json) ───
|
||||
|
||||
interface HookEntry {
|
||||
@@ -154,21 +165,27 @@ interface CursorHooksJson {
|
||||
hooks: Record<string, CursorHookEntry[]>;
|
||||
}
|
||||
|
||||
const CURSOR_SESSION_START_HOOK: CursorHookEntry = {
|
||||
command: TEAMAI_PULL_COMMAND,
|
||||
timeout: 30,
|
||||
};
|
||||
|
||||
const CURSOR_STOP_HOOK: CursorHookEntry = {
|
||||
command: TEAMAI_UPDATE_COMMAND,
|
||||
timeout: 90,
|
||||
};
|
||||
|
||||
const CURSOR_POST_TOOL_USE_HOOK: CursorHookEntry = {
|
||||
command: getTrackCommand('cursor'),
|
||||
timeout: 10,
|
||||
matcher: 'Read',
|
||||
};
|
||||
/** Build Cursor hooks.json entries aligned with Claude semantics (camelCase events, Skill matcher). */
|
||||
function buildCursorHooks(tool: string): Record<string, CursorHookEntry[]> {
|
||||
return {
|
||||
sessionStart: [
|
||||
{ command: TEAMAI_PULL_COMMAND, timeout: 30 },
|
||||
{ command: getDashboardReportCommand(tool), timeout: 10 },
|
||||
],
|
||||
stop: [
|
||||
{ command: TEAMAI_UPDATE_COMMAND, timeout: 90 },
|
||||
{ command: getDashboardReportCommand(tool), timeout: 10 },
|
||||
],
|
||||
postToolUse: [
|
||||
{ command: getTrackCommand(tool), timeout: 10, matcher: 'Skill' },
|
||||
{ command: getDashboardReportCommand(tool), timeout: 10 },
|
||||
],
|
||||
userPromptSubmit: [
|
||||
{ command: getTrackSlashCommand(tool), timeout: 10 },
|
||||
{ command: getDashboardReportCommand(tool), timeout: 10 },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Tool format detection ──────────────────────────────────
|
||||
|
||||
@@ -180,10 +197,25 @@ function detectFormat(tool: string): ToolFormat {
|
||||
return CURSOR_TOOLS.has(tool) ? 'cursor' : 'claude';
|
||||
}
|
||||
|
||||
function extractTeamaiSubcommand(command: string): string | null {
|
||||
const match = command.match(/teamai\s+([\w-]+)/);
|
||||
return match ? match[1] : null;
|
||||
}
|
||||
|
||||
function isTeamaiHookCommand(command: string): boolean {
|
||||
return /"teamai\s/.test(command);
|
||||
}
|
||||
|
||||
// ─── Claude Code hooks injection ────────────────────────────
|
||||
|
||||
/** Known teamai command substrings used to identify teamai-managed hooks. */
|
||||
const TEAMAI_COMMAND_MARKERS = ['teamai pull', 'teamai update', 'teamai track', 'teamai dashboard'];
|
||||
const TEAMAI_COMMAND_MARKERS = [
|
||||
'teamai pull',
|
||||
'teamai update',
|
||||
'teamai track',
|
||||
'teamai track-slash',
|
||||
'teamai dashboard-report',
|
||||
];
|
||||
|
||||
/**
|
||||
* Remove all teamai-managed hooks (identified by command content).
|
||||
@@ -306,7 +338,7 @@ async function removeClaudeHooks(settingsPath: string): Promise<void> {
|
||||
|
||||
// ─── Cursor hooks injection ─────────────────────────────────
|
||||
|
||||
async function injectCursorHooks(hooksPath: string): Promise<void> {
|
||||
async function injectCursorHooks(hooksPath: string, tool: string): Promise<void> {
|
||||
const expanded = expandHome(hooksPath);
|
||||
await ensureDir(path.dirname(expanded));
|
||||
const hooksJson: CursorHooksJson = (await readJson<CursorHooksJson>(expanded)) ?? {
|
||||
@@ -321,33 +353,31 @@ async function injectCursorHooks(hooksPath: string): Promise<void> {
|
||||
hooksJson.hooks = {};
|
||||
}
|
||||
|
||||
const desiredHooks = buildCursorHooks(tool);
|
||||
let changed = false;
|
||||
|
||||
// Helper: ensure a Cursor hook exists for a given event type
|
||||
const ensureCursorHook = (
|
||||
eventType: string,
|
||||
expected: CursorHookEntry,
|
||||
matchBy: (h: CursorHookEntry) => boolean,
|
||||
): void => {
|
||||
if (!hooksJson.hooks[eventType]) {
|
||||
hooksJson.hooks[eventType] = [];
|
||||
for (const [event, newEntries] of Object.entries(desiredHooks)) {
|
||||
if (!hooksJson.hooks[event]) {
|
||||
hooksJson.hooks[event] = [];
|
||||
}
|
||||
const idx = hooksJson.hooks[eventType].findIndex(matchBy);
|
||||
if (idx < 0) {
|
||||
hooksJson.hooks[eventType].push(expected);
|
||||
changed = true;
|
||||
} else {
|
||||
const cur = hooksJson.hooks[eventType][idx];
|
||||
if (cur.command !== expected.command || cur.matcher !== expected.matcher) {
|
||||
hooksJson.hooks[eventType][idx] = expected;
|
||||
|
||||
for (const newEntry of newEntries) {
|
||||
const newSubcmd = extractTeamaiSubcommand(newEntry.command);
|
||||
const existingIdx = hooksJson.hooks[event].findIndex(
|
||||
(h) => extractTeamaiSubcommand(h.command) === newSubcmd,
|
||||
);
|
||||
if (existingIdx >= 0) {
|
||||
const cur = hooksJson.hooks[event][existingIdx];
|
||||
if (JSON.stringify(cur) !== JSON.stringify(newEntry)) {
|
||||
hooksJson.hooks[event][existingIdx] = newEntry;
|
||||
changed = true;
|
||||
}
|
||||
} else {
|
||||
hooksJson.hooks[event].push(newEntry);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
ensureCursorHook('sessionStart', CURSOR_SESSION_START_HOOK, (h) => h.command.includes('teamai pull'));
|
||||
ensureCursorHook('stop', CURSOR_STOP_HOOK, (h) => h.command.includes('teamai update'));
|
||||
ensureCursorHook('postToolUse', CURSOR_POST_TOOL_USE_HOOK, (h) => h.command.includes('teamai track'));
|
||||
}
|
||||
|
||||
if (changed) {
|
||||
await writeJson(expanded, hooksJson);
|
||||
@@ -364,9 +394,7 @@ async function removeCursorHooks(hooksPath: string): Promise<void> {
|
||||
|
||||
let changed = false;
|
||||
for (const [event, entries] of Object.entries(hooksJson.hooks)) {
|
||||
const filtered = entries.filter(
|
||||
(h) => !h.command.includes('teamai pull') && !h.command.includes('teamai update') && !h.command.includes('teamai track')
|
||||
);
|
||||
const filtered = entries.filter((h) => !isTeamaiHookCommand(h.command));
|
||||
if (filtered.length !== entries.length) {
|
||||
hooksJson.hooks[event] = filtered;
|
||||
changed = true;
|
||||
@@ -388,7 +416,7 @@ export async function injectHooks(settingsPath: string, tool?: string): Promise<
|
||||
const toolName = tool ?? 'claude';
|
||||
const format = detectFormat(toolName);
|
||||
if (format === 'cursor') {
|
||||
await injectCursorHooks(settingsPath);
|
||||
await injectCursorHooks(settingsPath, toolName);
|
||||
} else {
|
||||
await injectClaudeHooks(settingsPath, toolName);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user