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
6 changes: 6 additions & 0 deletions docs/usage-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -1482,6 +1482,12 @@ These paths are verified against the ZCode desktop app: profiles created in its

Oh My Pi (OMP) is available as a built-in target. TeamAI deploys skills, rules, and subagents to OMP's native directories — `.omp/skills/`, `.omp/rules/`, and `.omp/agents/` at project scope, and `~/.omp/agent/skills/`, `~/.omp/agent/rules/`, and `~/.omp/agent/agents/` at user scope (user-scope resources live under the agent directory `~/.omp/agent/`, a different prefix from the project one, so TeamAI switches prefixes with the scope). Instructions (`claudemd`) deploy to the matching `AGENTS.md`, and MCP servers merge into `~/.omp/agent/mcp.json` / `<project>/.omp/mcp.json` (Claude `mcpServers` shape — see the MCP section above). Skills are one-level `<name>/SKILL.md` bundles and TeamAI fills in a `description` on sync, which OMP's native skill provider requires to discover a skill. These paths follow OMP's documented discovery layout (verified against OMP 18.2.5). Hooks ride OMP's extension runner: `teamai pull` writes a single generated extension to `~/.omp/agent/extensions/teamai-hooks.ts` (never a project copy — OMP auto-loads both roots and would double-dispatch every event), which forwards OMP's `session_start` / `session_stop` / `before_agent_start` / `tool_result` events to the same `teamai hook-dispatch` entry point every other agent uses, gated on the session `cwd`. The `session_stop` handler returns nothing, so a dispatch can never force a session continuation, and there is no matcher-scoped post-tool-use pass because OMP's tool ids are lowercase (`bash`, `read`, …) and it has no `Skill` / `TodoWrite` tool. `teamai uninstall` removes the extension. OMP profiles (`OMP_PROFILE` / `PI_CODING_AGENT_DIR` / `PI_CONFIG_DIR`), which relocate the agent directory, are not supported; the default `~/.omp/agent/` layout is used.

### DeepSeek Harness

DeepSeek Harness (`dsh`) is supported for TeamAI skills and shared resources. DSH's official Claude-hook bridge is a profile plugin rather than a settings-file hook surface, so when a user-level `~/.dsh/` installation is present, `teamai init`, `teamai pull`, or `teamai hooks inject` writes a Claude-compatible hook config and a Cordis patch under `~/.teamai/dsh/`.

TeamAI prints the exact absolute patch path. Add that `--patch` flag to the command that starts your DSH profile, for example `dsh tui --patch "<printed-path>"`. This is a one-time launcher opt-in; `teamai hooks remove` and `teamai uninstall` remove the TeamAI patch while preserving other hook entries in the generated config.

### JoyCode

JoyCode is available as a built-in target. Skills, rules, and subagents are deployed to `.joycode/skills/`, `.joycode/rules/`, and `.joycode/agents/`. Rules use Cursor-compatible `.mdc` files, including the same derived frontmatter and body-only round-trip behavior described below. Subagents use Markdown with YAML frontmatter.
Expand Down
6 changes: 6 additions & 0 deletions docs/usage-guide.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -1442,6 +1442,12 @@ ZCode 已作为内置目标支持。Skills 下发到 `.zcode/skills/`(ZCode

Oh My Pi(OMP)已作为内置目标支持。TeamAI 将 Skills、Rules 和 Subagents 下发到 OMP 的原生目录——项目级为 `.omp/skills/`、`.omp/rules/` 和 `.omp/agents/`,用户级为 `~/.omp/agent/skills/`、`~/.omp/agent/rules/` 和 `~/.omp/agent/agents/`(用户级资源位于 agent 目录 `~/.omp/agent/` 下,与项目级前缀不同,TeamAI 会随作用域自动切换)。指令(`claudemd`)下发到对应的 `AGENTS.md`;MCP Server 合并进 `~/.omp/agent/mcp.json` / `<project>/.omp/mcp.json`(Claude `mcpServers` 结构,见上文 MCP 章节)。Skills 采用一层 `<name>/SKILL.md` 目录结构,TeamAI 在同步时补全 `description`——OMP 原生 skill 发现要求该字段。以上路径遵循 OMP 官方文档的发现布局(对照 OMP 18.2.5 验证)。Hooks 走 OMP 的 extension runner:`teamai pull` 会生成唯一的 extension 写入 `~/.omp/agent/extensions/teamai-hooks.ts`(绝不写项目副本——OMP 会同时加载两个根并导致每个事件双派发),它把 OMP 的 `session_start` / `session_stop` / `before_agent_start` / `tool_result` 事件转发给所有 agent 共用的 `teamai hook-dispatch` 入口,并按会话 `cwd` 做项目门控。`session_stop` 处理器不返回任何值,分发绝不会强制会话继续;由于 OMP 的工具名是小写(`bash`、`read` 等)且没有 `Skill` / `TodoWrite` 工具,post-tool-use 不做 matcher 定向分发。`teamai uninstall` 会移除该 extension。OMP 的 profile(`OMP_PROFILE` / `PI_CODING_AGENT_DIR` / `PI_CONFIG_DIR`,会迁移 agent 目录)暂不支持,使用默认的 `~/.omp/agent/` 布局。

### DeepSeek Harness

DeepSeek Harness(`dsh`)支持 TeamAI Skills 和共享资源。DSH 官方的 Claude Hook Bridge 是通过 profile 插件加载的,并不是设置文件中的 Hooks;因此检测到用户级 `~/.dsh/` 安装后,`teamai init`、`teamai pull` 或 `teamai hooks inject` 会在 `~/.teamai/dsh/` 下生成兼容 Claude 的 Hook 配置和 Cordis patch。

TeamAI 会打印带绝对路径的 patch。将这个 `--patch` 参数加到启动 DSH profile 的命令中,例如 `dsh tui --patch "<打印出的路径>"`。这是一次性的启动器选择;`teamai hooks remove` 和 `teamai uninstall` 会移除 TeamAI patch,同时保留生成配置中的其他 Hook 条目。

### JoyCode

JoyCode 已作为内置目标支持。Skills、Rules 和 Subagents 分别下发到 `.joycode/skills/`、`.joycode/rules/` 和 `.joycode/agents/`。Rules 使用与 Cursor 兼容的 `.mdc` 格式,包括下文所述的派生 frontmatter 和仅正文往返同步;Subagents 使用带 YAML frontmatter 的 Markdown 文件。
Expand Down
106 changes: 106 additions & 0 deletions src/__tests__/dsh-hooks.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import path from 'node:path';
import os from 'node:os';
import fse from 'fs-extra';
import YAML from 'yaml';

vi.mock('../utils/logger.js', () => ({
log: { info: vi.fn(), success: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn(), dim: vi.fn() },
}));

import {
DSH_HOOK_CONFIG_FILE,
DSH_PATCH_FILE,
resolveDshHooksDir,
} from '../dsh-hooks.js';
import { reconcileHooksToAllTools } from '../hooks.js';
import type { HookDef } from '../types.js';

describe('DeepSeek Harness hook bridge', () => {
let tmp: string;
let home: string;
let previousHome: string | undefined;

beforeEach(async () => {
tmp = await fse.mkdtemp(path.join(os.tmpdir(), 'teamai-dsh-hooks-'));
home = path.join(tmp, 'home');
await fse.ensureDir(home);
previousHome = process.env.HOME;
process.env.HOME = home;
});

afterEach(async () => {
if (previousHome === undefined) delete process.env.HOME;
else process.env.HOME = previousHome;
await fse.remove(tmp);
});

const toolPaths = { dsh: { skills: '.dsh/skills' } } as Record<string, { settings?: string }>;
const manifest = () => path.join(tmp, 'managed-hooks.json');
const bridgeDir = () => resolveDshHooksDir();
const hooksFile = () => path.join(bridgeDir(), DSH_HOOK_CONFIG_FILE);
const patchFile = () => path.join(bridgeDir(), DSH_PATCH_FILE);

it('does not create the bridge when dsh is not installed', async () => {
await reconcileHooksToAllTools(toolPaths, home, [], manifest());
expect(await fse.pathExists(path.join(home, '.teamai', 'dsh'))).toBe(false);
});

it('writes Claude-compatible hooks and a parseable dsh patch when dsh is installed', async () => {
await fse.ensureDir(path.join(home, '.dsh'));
await reconcileHooksToAllTools(toolPaths, home, [], manifest());

const hooks = JSON.parse(await fse.readFile(hooksFile(), 'utf8')) as { hooks: Record<string, unknown[]> };
expect(hooks.hooks.SessionStart).toBeDefined();
expect(JSON.stringify(hooks)).toContain('--tool dsh');

const patch = YAML.parse(await fse.readFile(patchFile(), 'utf8')) as Array<{ insert: Array<{ id: string; name: string; config: { configPath: string } }> }>;
expect(patch).toEqual([
{
insert: [{
id: 'teamai-hooks-claude-code',
name: '@deepseek-ai/dsh-hooks-claude-code',
config: { configPath: hooksFile() },
}],
},
]);
});

it('preserves team hook definitions in the bridge config', async () => {
await fse.ensureDir(path.join(home, '.dsh'));
const teamDef: HookDef = {
source: 'team',
key: 'team-dsh-test',
event: 'UserPromptSubmit',
matcher: '*',
command: 'echo team-dsh-test',
description: '[teamai:hook:team-dsh-test] team test',
};

await reconcileHooksToAllTools(toolPaths, home, [teamDef], manifest());

const hooks = JSON.parse(await fse.readFile(hooksFile(), 'utf8')) as { hooks: Record<string, Array<{ hooks: Array<{ command: string }> }>> };
expect(hooks.hooks.UserPromptSubmit.some((entry) => entry.hooks[0].command === teamDef.command)).toBe(true);
});

it('is idempotent and removes the bridge on removeAll', async () => {
await fse.ensureDir(path.join(home, '.dsh'));
await reconcileHooksToAllTools(toolPaths, home, [], manifest());
const firstHooks = await fse.readFile(hooksFile(), 'utf8');
const firstPatch = await fse.readFile(patchFile(), 'utf8');

await reconcileHooksToAllTools(toolPaths, home, [], manifest());
expect(await fse.readFile(hooksFile(), 'utf8')).toBe(firstHooks);
expect(await fse.readFile(patchFile(), 'utf8')).toBe(firstPatch);

await reconcileHooksToAllTools(toolPaths, home, [], manifest(), { removeAll: true });
expect(await fse.pathExists(patchFile())).toBe(false);
expect(JSON.stringify(JSON.parse(await fse.readFile(hooksFile(), 'utf8')))).not.toContain('--tool dsh');
});

it('does not touch dsh during a settings-only cleanup', async () => {
await fse.ensureDir(path.join(home, '.dsh'));
await reconcileHooksToAllTools(toolPaths, home, [], manifest(), { settingsOnly: true });
expect(await fse.pathExists(path.join(home, '.teamai', 'dsh'))).toBe(false);
});
});
89 changes: 89 additions & 0 deletions src/dsh-hooks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
/**
* DeepSeek Harness hook bridge.
*
* DSH ships the Claude Code hook bridge as a loadable plugin, but it does not
* read Claude's settings files itself. Keep the generated Claude-compatible
* hook config under TeamAI's data directory and provide a small Cordis patch
* that users can add to their dsh invocation.
*/

import path from 'node:path';
import { reconcileHooks } from './hooks.js';
import type { BuiltinHookOverride } from './builtin-hooks.js';
import type { HookDef } from './types.js';
import { getUserHome } from './utils/home.js';
import { pathExists, remove, writeIfChanged } from './utils/fs.js';
import { log } from './utils/logger.js';

export const DSH_HOOK_CONFIG_FILE = 'hooks.json';
export const DSH_PATCH_FILE = 'cordis.patch.yml';
export const DSH_HOOK_PLUGIN_ID = 'teamai-hooks-claude-code';
export const DSH_HOOK_PLUGIN_PACKAGE = '@deepseek-ai/dsh-hooks-claude-code';

/** The TeamAI-managed DSH bridge directory under the resolved user home. */
export function resolveDshHooksDir(): string {
return path.join(getUserHome(), '.teamai', 'dsh');
}

export function resolveDshHookConfigPath(): string {
return path.join(resolveDshHooksDir(), DSH_HOOK_CONFIG_FILE);
}

export function resolveDshPatchPath(): string {
return path.join(resolveDshHooksDir(), DSH_PATCH_FILE);
}

/**
* Build the user patch overlay consumed by `dsh --patch <path>`.
* JSON-quoting the absolute path is valid YAML and preserves Windows
* backslashes without relying on YAML escape rules.
*/
export function buildDshPatch(configPath: string): string {
return [
'- insert:',
` - id: ${DSH_HOOK_PLUGIN_ID}`,
` name: ${JSON.stringify(DSH_HOOK_PLUGIN_PACKAGE)}`,
' config:',
` configPath: ${JSON.stringify(configPath)}`,
'',
].join('\n');
}

export interface DshHookReconcileOptions {
manifestPath: string;
removeAll?: boolean;
builtinOverride?: BuiltinHookOverride;
}

/**
* Reconcile TeamAI's normal Claude-shaped hook set into DSH's bridge config.
* The adapter is intentionally user-scoped: DSH's skill provider and the
* `--patch` launcher both resolve from the user's DSH installation, even when
* TeamAI is reconciling a project-scoped team config.
*/
export async function reconcileDshHooks(
teamDefs: HookDef[],
opts: DshHookReconcileOptions,
): Promise<void> {
const configPath = resolveDshHookConfigPath();
const patchPath = resolveDshPatchPath();

await reconcileHooks(configPath, 'dsh', teamDefs, {
manifestPath: opts.manifestPath,
removeAll: opts.removeAll,
builtinOverride: opts.builtinOverride,
});

if (opts.removeAll) {
if (await pathExists(patchPath)) {
await remove(patchPath);
log.success(`Removed DeepSeek Harness hook patch from ${patchPath}`);
}
return;
}

const changed = await writeIfChanged(patchPath, buildDshPatch(configPath));
if (changed) {
log.info(`DeepSeek Harness hooks prepared. Add --patch "${patchPath}" to your dsh command to enable them.`);
}
}
20 changes: 20 additions & 0 deletions src/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1391,6 +1391,26 @@ export async function reconcileHooksToAllTools(
}
continue;
}
// DeepSeek Harness has no settings-file hook surface. Its official
// Claude-hook bridge is a Cordis plugin loaded through a user-supplied
// profile patch, so keep the generated config and patch in ~/.teamai.
if (tool === 'dsh') {
if (opts.settingsOnly) continue;
try {
const dshHome = getUserHome();
if (opts.removeAll || await pathExists(path.join(dshHome, '.dsh'))) {
const { reconcileDshHooks } = await import('./dsh-hooks.js');
await reconcileDshHooks(teamDefs, {
manifestPath,
removeAll: opts.removeAll,
builtinOverride: opts.builtinOverride,
});
}
} catch (e) {
log.warn(`Failed to reconcile DeepSeek Harness hooks: ${(e as Error).message}`);
}
continue;
}
if (!paths.settings) continue;
// Only reconcile hooks for tools the user actually has installed. Without
// this gate, `hooks inject`/`remove` would create root directories for
Expand Down
2 changes: 1 addition & 1 deletion src/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1625,7 +1625,7 @@ export async function init(options: GlobalOptions & {

log.success('teamai initialized successfully!');
log.info('Built-in skills (e.g. team-wiki-codebase) are ready to use in your IDE now.');
log.info('Skills, rules, env and docs will auto-sync on each session start (via hooks).');
log.info('Skills, rules, env and docs auto-sync on each session start when the selected agent has active TeamAI hooks.');
log.info('Run `teamai status` to check current config.');

// Close the readline singleton so the process can exit cleanly.
Expand Down
33 changes: 32 additions & 1 deletion src/uninstall.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,10 @@ interface RemovalPlan {
opencodeHookScopes: Array<{ baseDir: string; scope: Scope }>;
/** teamai-managed OMP extension file (~/.omp/agent/extensions/teamai-hooks.ts), if present. */
ompHookFile: string | null;
/** TeamAI-managed DeepSeek Harness patch (~/.teamai/dsh/cordis.patch.yml), if present. */
dshHookFile: string | null;
/** Manifest used by the primary hook injection scope. */
hookManifestPath: string;
/** CLAUDE.md files with teamai rules blocks. */
claudeMdFiles: string[];
/** Skill directories synced from team repo. */
Expand Down Expand Up @@ -103,6 +107,7 @@ interface ToolResources {
openclawHookDirs: Array<{ hooksDir: string; tool: string }>;
opencodeHookScopes: Array<{ baseDir: string; scope: Scope }>;
ompHookFile: string | null;
dshHookFile: string | null;
claudeMdFiles: string[];
skillDirs: string[];
ruleFiles: string[];
Expand All @@ -115,6 +120,7 @@ function hasToolResources(r: ToolResources): boolean {
r.openclawHookDirs.length > 0 ||
r.opencodeHookScopes.length > 0 ||
r.ompHookFile !== null ||
r.dshHookFile !== null ||
r.claudeMdFiles.length > 0 ||
r.skillDirs.length > 0 ||
r.ruleFiles.length > 0 ||
Expand Down Expand Up @@ -240,7 +246,7 @@ async function discoverToolResources(
scope: Scope,
): Promise<ToolResources> {
const res: ToolResources = {
hookFiles: [], openclawHookDirs: [], opencodeHookScopes: [], ompHookFile: null, claudeMdFiles: [],
hookFiles: [], openclawHookDirs: [], opencodeHookScopes: [], ompHookFile: null, dshHookFile: null, claudeMdFiles: [],
skillDirs: [], ruleFiles: [], agentFiles: [],
};

Expand All @@ -256,6 +262,10 @@ async function discoverToolResources(
manifestPath: standaloneHookManifestPath,
});
}
} else if (tool === 'dsh') {
const { resolveDshPatchPath } = await import('./dsh-hooks.js');
const patchPath = resolveDshPatchPath();
if (await pathExists(patchPath)) res.dshHookFile = patchPath;
} else if (tool === 'opencode') {
// OpenCode has no settings file; its teamai hooks are plugin .ts files under
// <base>/.config/opencode/plugin (where teamai writes them) or
Expand Down Expand Up @@ -471,6 +481,8 @@ async function buildRemovalPlan(
openclawHookDirs: [],
opencodeHookScopes: [],
ompHookFile: null,
dshHookFile: null,
hookManifestPath: hookTargets[0].manifestPath,
claudeMdFiles: [],
skillDirs: [],
ruleFiles: [],
Expand All @@ -493,6 +505,7 @@ async function buildRemovalPlan(
plan.openclawHookDirs.push(...res.openclawHookDirs);
plan.opencodeHookScopes.push(...res.opencodeHookScopes);
if (res.ompHookFile) plan.ompHookFile = res.ompHookFile;
if (res.dshHookFile) plan.dshHookFile = res.dshHookFile;
plan.claudeMdFiles.push(...res.claudeMdFiles);
plan.skillDirs.push(...res.skillDirs);
plan.ruleFiles.push(...res.ruleFiles);
Expand Down Expand Up @@ -546,6 +559,7 @@ function isPlanEmpty(plan: RemovalPlan): boolean {
plan.openclawHookDirs.length === 0 &&
plan.opencodeHookScopes.length === 0 &&
plan.ompHookFile === null &&
plan.dshHookFile === null &&
plan.claudeMdFiles.length === 0 &&
plan.skillDirs.length === 0 &&
plan.ruleFiles.length === 0 &&
Expand Down Expand Up @@ -600,6 +614,12 @@ function printSummary(plan: RemovalPlan, agentFilter?: string): void {
console.log('');
}

if (plan.dshHookFile !== null) {
console.log(' DeepSeek Harness hook patch:');
console.log(` ${plan.dshHookFile}`);
console.log('');
}

if (plan.claudeMdFiles.length > 0) {
console.log(` CLAUDE.md rule blocks (${plan.claudeMdFiles.length} files):`);
for (const p of plan.claudeMdFiles) {
Expand Down Expand Up @@ -721,6 +741,17 @@ async function executeRemoval(plan: RemovalPlan): Promise<void> {
}
}

// (a2d) Remove the DSH bridge config and profile patch through the same
// adapter used by `teamai hooks remove`, preserving unrelated hook entries.
if (plan.dshHookFile !== null) {
try {
const { reconcileDshHooks } = await import('./dsh-hooks.js');
await reconcileDshHooks([], { manifestPath: plan.hookManifestPath, removeAll: true });
} catch (e) {
log.warn(`Failed to remove DeepSeek Harness hooks: ${(e as Error).message}`);
}
}

// (a3) Remove HTTP-source agent hooks across all formats via their manifest
// (issue #238). Dynamic import mirrors teardownPlugins — keeps local-agent's
// heavy dependency graph out of uninstall's static import chain. Best-effort.
Expand Down
Loading