feat: add DeepSeek Harness guide, hooks generator, and setup skill

- docs/DEEPSEEK-HARNESS-GUIDE.md: skills roots, hooks bridge mounting, loader rules learned from practice, scope table | - scripts/dsh/generate-hooks-config.js: convert hooks/hooks.json to the dsh-hooks-claude-code bridge format (lower-case tool matchers, inline plugin-root env prefix, timeout preserved, unsupported events skipped) | - skills/deepseek-harness-setup: skills-first entry point per repo policy | - tests/scripts/dsh-generate-hooks-config.test.js: 8 unit + CLI tests
This commit is contained in:
phungthien269
2026-10-02 00:17:22 +07:00
parent c70874fae9
commit 85dd488892
4 changed files with 465 additions and 0 deletions
+128
View File
@@ -0,0 +1,128 @@
# DeepSeek Harness Guide
ECC runs on the [DeepSeek Harness](https://github.com/deepseek-ai/dsh) (DSH) via its
native skill roots and the first-party Claude Code hooks bridge. This guide covers
what works out of the box, how to mount the ECC hooks, and the current limits.
Unlike the Qwen or Codex adapters, there is no dedicated installer target yet; the
setup is a small amount of manual configuration described below.
## Prerequisites
- DeepSeek Harness 0.2.0-rc.1 or newer (`dsh --version`).
- ECC repository cloned locally, for example `~/ECC` (the hooks bridge commands
reference this path, so keep the checkout in place).
- Skills need no extra tooling; hooks use the `@deepseek-ai/dsh-hooks-claude-code`
plugin that ships inside the DSH runtime itself.
## Skills (automatic)
DSH loads skills from these roots (earlier roots win on name conflicts):
```text
<project>/.dsh/skills
<project>/.agents/skills
custom dirs (~/.dsh/profiles/<profile>/cordis.patch.yml -> skill-filesystem)
~/.dsh/skills
~/.agents/skills
```
Because ECC already ships `.agents/skills/` for Codex, opening a session with the
ECC checkout as the working directory exposes a subset of ECC skills immediately.
To expose the full curated set in every project, link the catalog once:
```bash
ln -s /path/to/ECC/skills/<skill-name> ~/.dsh/skills/<skill-name>
```
DSH reads `SKILL.md` frontmatter (name/description), which matches the ECC skill
format, so no conversion is needed. Name conflicts with pre-existing user skills
are resolved by root priority; skip those skills when linking.
## Hooks
### How the bridge works
DSH bridges ECC command hooks through `@deepseek-ai/dsh-hooks-claude-code`
(first-party, bundled with the runtime). Constraints that shape the conversion:
- Supported events: `SessionStart`, `UserPromptSubmit`, `PreToolUse`,
`PostToolUse`, `Stop`, `SubagentStart`, `SubagentStop`. ECC events without a
bridge counterpart (`PreCompact`, `PostToolUseFailure`, `SessionEnd`) are
skipped by the generator.
- DSH tool names are lower-case (`bash`, `edit`, `write`, `skill`, `task`),
while ECC matchers use Claude Code names (`Bash`, `Edit|MultiEdit`, ...). The
generator maps known tool names and keeps regex passthrough tokens.
- The bridge runs commands with `bash -c` and does not export the plugin root as
an environment variable for hook processes. The generator prefixes each command
with `CLAUDE_PLUGIN_ROOT=<ecc-root> ECC_PLUGIN_ROOT=<ecc-root>` (env-prefix
form), which the ECC hook bootstrap reads at startup.
- `timeout` values are seconds in both formats and are preserved.
### Generate the config
```bash
node scripts/dsh/generate-hooks-config.js --ecc-root /path/to/ECC --out ~/.dsh/ecc/hooks-dsh.json
```
The script validates nothing about your DSH install; it only converts
`hooks/hooks.json` into the bridge format and prints what it skipped.
### Mount into a profile
Add a bare insert entry to the profile patch layer
(`~/.dsh/profiles/<profile>/cordis.patch.yml`). This mirrors how the bundled web
app preset mounts its plugins:
```yaml
- insert:
- id: ecc-hooks-claude-code
name: "@deepseek-ai/dsh-hooks-claude-code"
config:
configPath: /absolute/path/to/hooks-dsh.json
```
Configuration notes from practice:
- A plain top-level entry (`- id: ...`) is silently dropped; the loader only
treats `insert` entries as additions.
- `insert` under an existing non-group id is rejected ("not a group"); use a bare
insert without a target id.
- The plugin package does not need to be added to the profile `package.json`;
first-party plugins resolve from the DSH runtime install.
- Validate without booting: `cd ~/.dsh && dsh --profile <profile> --dump-config`
(compose errors and dropped entries are reported on stderr).
### Verify
```bash
# Compose check (no session boot)
cd ~/.dsh && dsh --profile web --dump-config > /dev/null && echo OK
# End-to-end: fire a SessionStart hook in a one-shot headless session
# (uses a test config with a single marker-writing hook)
dsh --profile headless --patch /tmp/ecc-patch-test.yml 'Reply with exactly: BOOT-OK'
```
In a live web session, the GateGuard fact-forcing hook denies the first
edit/write/bash attempt until facts are presented — a visible signal that the
bridge is active.
## Updating
```bash
cd /path/to/ECC && git pull
node scripts/dsh/generate-hooks-config.js --ecc-root /path/to/ECC --out ~/.dsh/ecc/hooks-dsh.json
```
Linked skills reflect the pull immediately (symlinks), only the hooks config
needs regeneration.
## Scope
| Surface | Status on DSH |
|---------|---------------|
| Skills (293) | Works; link or rely on `.agents/skills` |
| Hooks (20 command hooks, 4 of 7 events) | Works via the bridge; 3 events unsupported |
| Commands (94 slash commands) | Not available (Claude Code mechanism); workflows are covered by skills |
| Agents (68) | Not available as subagents; equivalent personas exist as skills |
+154
View File
@@ -0,0 +1,154 @@
#!/usr/bin/env node
/**
* Generate a DeepSeek Harness (DSH) hooks config from hooks/hooks.json.
*
* DSH bridges ECC command hooks through the first-party plugin
* @deepseek-ai/dsh-hooks-claude-code. The bridge supports a subset of
* Claude Code hook events and lower-cases tool names in matchers, so this
* script converts the canonical ECC hooks.json into a DSH-compatible file.
*
* Usage:
* node scripts/dsh/generate-hooks-config.js [--ecc-root <dir>] [--out <file>]
*
* Defaults:
* --ecc-root directory of this checkout (repo root)
* --out <ecc-root>/hooks/hooks.dsh.json
*
* The output is a Claude Code hooks-format file consumed by the bridge:
* { "hooks": { "PreToolUse": [ { "matcher": "...", "hooks": [...] } ] } }
*
* Unsupported ECC events (no DSH bridge counterpart) are skipped and
* reported on stderr: PreCompact, PostToolUseFailure, SessionEnd.
*/
'use strict';
const fs = require('fs');
const path = require('path');
const SUPPORTED_EVENTS = new Set([
'SessionStart', 'UserPromptSubmit', 'PreToolUse', 'PostToolUse', 'Stop',
'SubagentStart', 'SubagentStop',
]);
// Claude Code tool names -> DSH lower-case tool names used by the bridge.
const TOOL_NAME_MAP = {
Bash: 'bash',
PowerShell: 'powershell',
Write: 'write',
Edit: 'edit',
MultiEdit: 'edit',
Skill: 'skill',
Task: 'task',
};
function parseArgs(argv) {
const opts = { eccRoot: null, out: null };
for (let i = 2; i < argv.length; i++) {
const a = argv[i];
if (a === '--ecc-root') opts.eccRoot = argv[++i];
else if (a === '--out') opts.out = argv[++i];
else {
process.stderr.write('Unknown argument: ' + a + '\n');
process.exit(2);
}
}
return opts;
}
/**
* Map a Claude Code matcher expression to DSH tool-name tokens.
* Split on |, map known tool names to lower-case DSH names, keep regex
* passthrough tokens (like .* or ^mcp__) untouched, de-duplicate.
*/
function mapMatcher(matcher) {
if (typeof matcher !== 'string' || matcher.length === 0) return matcher;
const parts = matcher.split('|').map(function (t) {
const v = t.trim();
if (v === '' || v === '.*' || v === '^mcp__') return v;
const mapped = TOOL_NAME_MAP[v];
return mapped !== undefined ? mapped : v.toLowerCase();
});
return Array.from(new Set(parts)).join('|');
}
/**
* Rewrite an ECC command into a DSH bridge command.
*
* The bridge runs commands with bash -c but does not export the plugin
* root as an environment variable for every hook process. Each ECC hook
* command embeds a resolver (`node -e "..." <hook-id> <script> [args]`)
* that resolves scripts/hooks/plugin-hook-bootstrap.js from the
* CLAUDE_PLUGIN_ROOT environment variable, so prefixing the variables
* inline (env-prefix form) is the only required change.
*/
function mapCommand(command, eccRoot) {
const prefix = 'CLAUDE_PLUGIN_ROOT=' + JSON.stringify(eccRoot) +
' ECC_PLUGIN_ROOT=' + JSON.stringify(eccRoot) + ' ';
return prefix + command;
}
/**
* Convert the ECC hooks document into the DSH bridge format.
* Returns { config, report } where report lists skipped events/entries.
*/
function generateConfig(eccRoot, sourceDoc) {
const out = { hooks: {} };
const report = [];
for (const entry of Object.entries(sourceDoc.hooks || {})) {
const event = entry[0];
const groups = entry[1];
if (!SUPPORTED_EVENTS.has(event)) {
report.push('skip event ' + event + ' (not bridged by DSH)');
continue;
}
const outGroups = [];
for (const group of groups) {
const hooks = [];
for (const h of group.hooks || []) {
if (h.type !== 'command' || typeof h.command !== 'string') {
report.push('skip non-command hook on ' + event);
continue;
}
const mapped = { type: 'command', command: mapCommand(h.command, eccRoot) };
if (typeof h.timeout === 'number') mapped.timeout = h.timeout;
hooks.push(mapped);
report.push('ok ' + event);
}
if (hooks.length) {
outGroups.push({ matcher: mapMatcher(group.matcher), hooks: hooks });
}
}
if (outGroups.length) out.hooks[event] = outGroups;
}
return { config: out, report: report };
}
function main() {
const opts = parseArgs(process.argv);
const eccRoot = path.resolve(opts.eccRoot || path.join(__dirname, '..', '..'));
const sourcePath = path.join(eccRoot, 'hooks', 'hooks.json');
if (!fs.existsSync(sourcePath)) {
process.stderr.write('ECC hooks.json not found at ' + sourcePath + '\n');
process.exit(1);
}
let doc;
try {
doc = JSON.parse(fs.readFileSync(sourcePath, 'utf8'));
} catch (err) {
process.stderr.write('Failed to parse ' + sourcePath + ': ' + err.message + '\n');
process.exit(1);
}
const result = generateConfig(eccRoot, doc);
const outPath = opts.out || path.join(eccRoot, 'hooks', 'hooks.dsh.json');
fs.mkdirSync(path.dirname(outPath), { recursive: true });
fs.writeFileSync(outPath, JSON.stringify(result.config, null, 2) + '\n');
const okCount = result.report.filter(function (r) { return r.indexOf('ok ') === 0; }).length;
const notes = result.report.filter(function (r) { return r.indexOf('ok ') !== 0; });
process.stderr.write('wrote ' + outPath + '\n');
process.stderr.write('hook commands: ' + okCount + '\n');
if (notes.length) process.stderr.write(notes.join('; ') + '\n');
}
module.exports = { generateConfig, mapMatcher, mapCommand, SUPPORTED_EVENTS, TOOL_NAME_MAP };
if (require.main === module) main();
+79
View File
@@ -0,0 +1,79 @@
---
name: deepseek-harness-setup
description: "Install and operate Everything Claude Code (ECC) on the DeepSeek Harness (DSH): native skill roots (~/.dsh/skills, .agents/skills), the @deepseek-ai/dsh-hooks-claude-code bridge for command hooks, bare-insert patch mounting, generator usage, event-support limits, and update workflow. Use when setting up ECC on DeepSeek Harness, wiring ECC hooks into a DSH profile, or debugging why ECC skills or hooks do not appear in a DSH session."
metadata:
origin: community
---
# DeepSeek Harness Setup
Run ECC on the DeepSeek Harness. Full guide: `docs/DEEPSEEK-HARNESS-GUIDE.md`.
## When to Use
- Installing ECC skills or hooks on a DeepSeek Harness install.
- Deciding which ECC surfaces work on DSH and which do not.
- Debugging a DSH session that does not show ECC skills or does not fire ECC hooks.
## How It Works
1. **Skills need no conversion.** DSH reads `SKILL.md` frontmatter
(name/description) from native skill roots. Link the ECC catalog once:
```bash
ln -s /path/to/ECC/skills/<skill-name> ~/.dsh/skills/<skill-name>
```
Root priority (project `.dsh` and `.agents` roots beat `~/.dsh`): skip
linking skills whose names already exist in a higher-priority root.
2. **Hooks go through the first-party bridge.** Generate a DSH-format config
from the canonical ECC hooks file:
```bash
node scripts/dsh/generate-hooks-config.js --ecc-root /path/to/ECC --out ~/.dsh/ecc/hooks-dsh.json
```
The generator lower-cases tool-name matchers, prefixes
`CLAUDE_PLUGIN_ROOT`/`ECC_PLUGIN_ROOT` inline (the bridge does not export
the plugin root), preserves timeouts, and skips events DSH cannot bridge:
`PreCompact`, `PostToolUseFailure`, `SessionEnd`.
3. **Mount with a bare insert** in `~/.dsh/profiles/<profile>/cordis.patch.yml`:
```yaml
- insert:
- id: ecc-hooks-claude-code
name: "@deepseek-ai/dsh-hooks-claude-code"
config:
configPath: /absolute/path/to/hooks-dsh.json
```
Loader rules that cost debugging time when learned the hard way:
a plain `- id:` top-level entry is silently dropped; `insert` under an
existing non-group id is rejected as "not a group"; first-party plugin
packages need no profile `package.json` entry.
4. **Validate by composing, then verify live.**
```bash
cd ~/.dsh && dsh --profile web --dump-config > /dev/null && echo OK
```
A live GateGuard denial on the session's first edit/write/bash is the
visible signal the bridge is active.
## Updating
`git pull` reflects into linked skills immediately; regenerate the hooks
config with the same generator command. If the ECC checkout moves, regenerate
(the path is embedded in every hook command) and relink skills.
## Limits
| Surface | Status on DSH |
|---------|---------------|
| Skills | Full catalog works |
| Hooks | 20 command hooks on 4 of 7 bridge events |
| Slash commands | Not available (Claude Code mechanism); workflows covered by skills |
| Agents | Not available as subagents; equivalent personas exist as skills |
@@ -0,0 +1,104 @@
/**
* Tests for scripts/dsh/generate-hooks-config.js
*
* Run with: node tests/scripts/dsh-generate-hooks-config.test.js
*/
const assert = require('assert');
const fs = require('fs');
const os = require('os');
const path = require('path');
const { spawnSync } = require('child_process');
const gen = require('../../scripts/dsh/generate-hooks-config');
function test(name, fn) {
try {
fn();
console.log(' \u2713 ' + name);
return true;
} catch (err) {
console.log(' \u2717 ' + name);
console.log(' Error: ' + err.message);
return false;
}
}
function runTests() {
let passed = 0;
let failed = 0;
if (test('mapMatcher maps Claude tool names to DSH lower-case names', () => {
assert.strictEqual(gen.mapMatcher('Bash'), 'bash');
assert.strictEqual(gen.mapMatcher('Edit|MultiEdit'), 'edit');
assert.strictEqual(gen.mapMatcher('Write|Edit|MultiEdit'), 'write|edit');
})) passed++; else failed++;
if (test('mapMatcher keeps regex passthrough tokens untouched', () => {
assert.strictEqual(gen.mapMatcher('.*'), '.*');
assert.strictEqual(gen.mapMatcher('^mcp__'), '^mcp__');
assert.strictEqual(gen.mapMatcher('.*|Task'), '.*|task');
})) passed++; else failed++;
if (test('mapCommand prefixes plugin-root env vars and keeps the resolver intact', () => {
const cmd = 'node -e "resolver" pretooluse:bash:check-console-log scripts/hooks/check-console-log.js standard';
const out = gen.mapCommand(cmd, '/ecc');
assert.strictEqual(out, 'CLAUDE_PLUGIN_ROOT="/ecc" ECC_PLUGIN_ROOT="/ecc" ' + cmd);
})) passed++; else failed++;
if (test('mapCommand passes through non-resolver commands with prefix only', () => {
const out = gen.mapCommand('echo hi', '/ecc');
assert.strictEqual(out, 'CLAUDE_PLUGIN_ROOT="/ecc" ECC_PLUGIN_ROOT="/ecc" echo hi');
})) passed++; else failed++;
if (test('generateConfig skips unsupported events and reports them', () => {
const doc = { hooks: {
PreToolUse: [{ matcher: 'Bash', hooks: [{ type: 'command', command: 'node -e "x" node scripts/hooks/a.js' }] }],
PreCompact: [{ matcher: '.*', hooks: [{ type: 'command', command: 'echo hi' }] }],
SessionEnd: [{ matcher: '.*', hooks: [{ type: 'command', command: 'echo hi' }] }],
} };
const { config, report } = gen.generateConfig('/ecc', doc);
assert.deepStrictEqual(Object.keys(config.hooks), ['PreToolUse']);
assert.ok(report.some(r => r.includes('PreCompact')));
assert.ok(report.some(r => r.includes('SessionEnd')));
})) passed++; else failed++;
if (test('generateConfig preserves timeout and maps matcher groups', () => {
const doc = { hooks: {
PostToolUse: [{ matcher: 'Edit|MultiEdit|Write', hooks: [
{ type: 'command', command: 'node -e "x" node scripts/hooks/b.js', timeout: 30 },
] }],
} };
const { config } = gen.generateConfig('/ecc', doc);
const g = config.hooks.PostToolUse[0];
assert.strictEqual(g.matcher, 'edit|write');
assert.strictEqual(g.hooks[0].timeout, 30);
})) passed++; else failed++;
if (test('generateConfig supports the bridge-only Subagent events', () => {
assert.ok(gen.SUPPORTED_EVENTS.has('SubagentStart'));
assert.ok(gen.SUPPORTED_EVENTS.has('SubagentStop'));
})) passed++; else failed++;
if (test('CLI end-to-end writes valid JSON to --out', () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-dsh-'));
const outPath = path.join(tmp, 'hooks.dsh.json');
const repoRoot = path.resolve(__dirname, '..', '..');
const res = spawnSync('node', [
path.join(repoRoot, 'scripts', 'dsh', 'generate-hooks-config.js'),
'--ecc-root', repoRoot, '--out', outPath,
], { encoding: 'utf8' });
assert.strictEqual(res.status, 0, 'CLI exit 0, stderr: ' + res.stderr);
const doc = JSON.parse(fs.readFileSync(outPath, 'utf8'));
assert.ok(doc.hooks.PreToolUse.length > 0, 'PreToolUse groups present');
assert.ok(Object.keys(doc.hooks).includes('SessionStart'));
assert.ok(!Object.keys(doc.hooks).includes('PreCompact'), 'unsupported event excluded');
fs.rmSync(tmp, { recursive: true, force: true });
})) passed++; else failed++;
console.log('');
console.log('Passed: ' + passed + ', Failed: ' + failed);
return failed === 0;
}
process.exit(runTests() ? 0 : 1);