From 89583ae245b79f1fcba44385417b7303849b84e3 Mon Sep 17 00:00:00 2001 From: dvd233 <111864431+dvd233@users.noreply.github.com> Date: Sun, 20 Sep 2026 15:37:07 -0700 Subject: [PATCH] feat(hooks): add DeepSeek Harness hook bridge Fixes #623 --- docs/usage-guide.md | 6 ++ docs/usage-guide.zh-CN.md | 6 ++ src/__tests__/dsh-hooks.test.ts | 106 ++++++++++++++++++++++++++++++++ src/dsh-hooks.ts | 89 +++++++++++++++++++++++++++ src/hooks.ts | 20 ++++++ src/init.ts | 2 +- src/uninstall.ts | 33 +++++++++- 7 files changed, 260 insertions(+), 2 deletions(-) create mode 100644 src/__tests__/dsh-hooks.test.ts create mode 100644 src/dsh-hooks.ts diff --git a/docs/usage-guide.md b/docs/usage-guide.md index dc2238e65..dc77c6855 100644 --- a/docs/usage-guide.md +++ b/docs/usage-guide.md @@ -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` / `/.omp/mcp.json` (Claude `mcpServers` shape — see the MCP section above). Skills are one-level `/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 ""`. 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. diff --git a/docs/usage-guide.zh-CN.md b/docs/usage-guide.zh-CN.md index 8af8c6a5f..e87a04703 100644 --- a/docs/usage-guide.zh-CN.md +++ b/docs/usage-guide.zh-CN.md @@ -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` / `/.omp/mcp.json`(Claude `mcpServers` 结构,见上文 MCP 章节)。Skills 采用一层 `/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 文件。 diff --git a/src/__tests__/dsh-hooks.test.ts b/src/__tests__/dsh-hooks.test.ts new file mode 100644 index 000000000..eb8e5e9fc --- /dev/null +++ b/src/__tests__/dsh-hooks.test.ts @@ -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; + 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 }; + 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 }>> }; + 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); + }); +}); diff --git a/src/dsh-hooks.ts b/src/dsh-hooks.ts new file mode 100644 index 000000000..44284253d --- /dev/null +++ b/src/dsh-hooks.ts @@ -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 `. + * 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 { + 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.`); + } +} diff --git a/src/hooks.ts b/src/hooks.ts index d5f8a32b5..d11e6340a 100644 --- a/src/hooks.ts +++ b/src/hooks.ts @@ -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 diff --git a/src/init.ts b/src/init.ts index aa685f9ad..bf8a84595 100644 --- a/src/init.ts +++ b/src/init.ts @@ -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. diff --git a/src/uninstall.ts b/src/uninstall.ts index f27e79f41..88f0d66cf 100644 --- a/src/uninstall.ts +++ b/src/uninstall.ts @@ -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. */ @@ -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[]; @@ -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 || @@ -240,7 +246,7 @@ async function discoverToolResources( scope: Scope, ): Promise { const res: ToolResources = { - hookFiles: [], openclawHookDirs: [], opencodeHookScopes: [], ompHookFile: null, claudeMdFiles: [], + hookFiles: [], openclawHookDirs: [], opencodeHookScopes: [], ompHookFile: null, dshHookFile: null, claudeMdFiles: [], skillDirs: [], ruleFiles: [], agentFiles: [], }; @@ -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 // /.config/opencode/plugin (where teamai writes them) or @@ -471,6 +481,8 @@ async function buildRemovalPlan( openclawHookDirs: [], opencodeHookScopes: [], ompHookFile: null, + dshHookFile: null, + hookManifestPath: hookTargets[0].manifestPath, claudeMdFiles: [], skillDirs: [], ruleFiles: [], @@ -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); @@ -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 && @@ -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) { @@ -721,6 +741,17 @@ async function executeRemoval(plan: RemovalPlan): Promise { } } + // (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.