dsh-claude-compat 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 biedongbin
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,104 @@
1
+ <p align="center">
2
+ <img src="https://img.shields.io/badge/dsh-plugin-blue?style=for-the-badge" alt="DSH Plugin">
3
+ </p>
4
+
5
+ <h1 align="center">dsh-claude-compat</h1>
6
+
7
+ <p align="center">
8
+ <a href="README.zh-CN.md"><img src="https://img.shields.io/badge/🌐_įŽ€äŊ“中文-į‚šæˆ‘é˜…č¯ģ中文-red?style=for-the-badge" alt="įŽ€äŊ“中文"></a>
9
+ </p>
10
+
11
+ <p align="center">
12
+ <img src="https://img.shields.io/badge/version-0.1.0-blue?style=flat-square" alt="Version">
13
+ <img src="https://img.shields.io/badge/node-%3E%3D20-green?style=flat-square&logo=node.js&logoColor=white" alt="Node">
14
+ <img src="https://img.shields.io/badge/license-MIT-orange?style=flat-square" alt="License">
15
+ </p>
16
+
17
+ <p align="center">DeepSeek Harness plugin that bridges Claude Code's <code>.claude/</code> directory into DSH natively — reuse your skills, slash commands, and rules with zero migration.</p>
18
+
19
+ ## What it does
20
+
21
+ | `.claude/` path | Mechanism | Behavior |
22
+ |---|---|---|
23
+ | `skills/**/SKILL.md` | DSH skill provider | Name + description in the model-visible catalog; body loads on demand via the `skill` tool. New skills appear on the next catalog reconcile — no restart. |
24
+ | `commands/*.md` | DSH skill provider | Same, plus user-invocable: `/command-name` works in the slash menu. |
25
+ | `rules/*.md` | Message-stream injection | Rules are concatenated, wrapped in a `<system-reminder>` envelope, and prepended as a user-role message at the front of the message array once per session — the same channel Claude Code uses (`prependUserContext`), which models follow reliably. |
26
+
27
+ `CLAUDE.md` / `AGENTS.md` are **not** touched — DSH's built-in `dsh-agent-instructions` already handles those.
28
+
29
+ ## Requirements
30
+
31
+ - DSH with a profile (e.g. `web`)
32
+ - A project using Claude Code conventions: `.claude/skills/`, `.claude/commands/`, `.claude/rules/`
33
+
34
+ ## Install
35
+
36
+ ```bash
37
+ dsh plugin --profile web add dsh-claude-compat
38
+ ```
39
+
40
+ Or from GitHub:
41
+
42
+ ```bash
43
+ dsh plugin --profile web add github:biedongbin/dsh-claude-compat
44
+ ```
45
+
46
+ Then register it in `~/.dsh/profiles/web/cordis.patch.yml` (merge into your existing patch list):
47
+
48
+ ```yaml
49
+ - insert:
50
+ - id: claude-compat
51
+ name: 'dsh-claude-compat'
52
+ config:
53
+ enableSkills: true
54
+ enableRules: true
55
+ ```
56
+
57
+ Restart DSH (`dsh web`). Done — skills show up in `/`, rules are injected into every new session.
58
+
59
+ ## Configuration
60
+
61
+ | Option | Default | Description |
62
+ |---|---|---|
63
+ | `enableSkills` | `true` | Register the `.claude/skills` + `.claude/commands` provider |
64
+ | `enableRules` | `true` | Inject `.claude/rules/*.md` into the message stream |
65
+ | `rulesMaxBytes` | `65536` | Hard cap on total injected rules text |
66
+ | `projectRootMarkers` | `[".git"]` | Ancestor markers for project-root discovery |
67
+ | `skillRank` | `150` | Provider rank: between DSH-native `.dsh/skills` (100) and `.agents/skills` (200) — DSH-native wins conflicts |
68
+ | `skillSource` | `project-claude` | Source tag for catalog entries |
69
+
70
+ ## Notes
71
+
72
+ - **Skill naming**: DSH requires kebab-case skill names. Nested skill directories are flattened (`gitnexus/gitnexus-guide` → `gitnexus-gitnexus-guide`); invalid frontmatter names fall back to the directory name.
73
+ - **Rules granularity**: rules are read per new session (cached per session cwd). Editing a rule mid-session takes effect in the next session.
74
+ - **Rules content**: rules are injected verbatim as instructions to the model. Only commit rules you want the model to follow — same trust level as `CLAUDE.md`.
75
+
76
+ ## Acknowledgments
77
+
78
+ - [Linux.do](https://linux.do) — community
79
+ - [Claude Code](https://claude.com/claude-code) — the `.claude/` conventions this plugin bridges
80
+ - [DeepSeek Harness](https://www.npmjs.com/package/@deepseek-ai/dsh) — the runtime this plugin extends
81
+
82
+ ## License
83
+
84
+ [MIT](LICENSE)
85
+
86
+ ## ⭐ Star History
87
+
88
+ If this project helps you, please give it a ⭐ — it motivates us to keep improving.
89
+
90
+ <p align="center">
91
+ <img src="https://img.shields.io/github/stars/biedongbin/dsh-claude-compat?style=for-the-badge&logo=github&color=gold" alt="GitHub Stars">
92
+ <img src="https://img.shields.io/github/forks/biedongbin/dsh-claude-compat?style=for-the-badge&logo=github" alt="GitHub Forks">
93
+ <img src="https://img.shields.io/github/watchers/biedongbin/dsh-claude-compat?style=for-the-badge&logo=github" alt="GitHub Watchers">
94
+ </p>
95
+
96
+ <div align="center">
97
+ <a href="https://www.star-history.com/#biedongbin/dsh-claude-compat&Date">
98
+ <picture>
99
+ <source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=biedongbin/dsh-claude-compat&type=Date&theme=dark" />
100
+ <source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=biedongbin/dsh-claude-compat&type=Date" />
101
+ <img alt="Star History Chart" src="https://api.star-history.com/svg?repos=biedongbin/dsh-claude-compat&type=Date" width="760" />
102
+ </picture>
103
+ </a>
104
+ </div>
@@ -0,0 +1,104 @@
1
+ <p align="center">
2
+ <img src="https://img.shields.io/badge/dsh-插äģļ-blue?style=for-the-badge" alt="DSH 插äģļ">
3
+ </p>
4
+
5
+ <h1 align="center">dsh-claude-compat</h1>
6
+
7
+ <p align="center">
8
+ <a href="README.md"><img src="https://img.shields.io/badge/🌐_English-Click_me-red?style=for-the-badge" alt="English"></a>
9
+ </p>
10
+
11
+ <p align="center">
12
+ <img src="https://img.shields.io/badge/version-0.1.0-blue?style=flat-square" alt="Version">
13
+ <img src="https://img.shields.io/badge/node-%3E%3D20-green?style=flat-square&logo=node.js&logoColor=white" alt="Node">
14
+ <img src="https://img.shields.io/badge/license-MIT-orange?style=flat-square" alt="License">
15
+ </p>
16
+
17
+ <p align="center">DeepSeek Harness 插äģļīŧšæŠŠ Claude Code įš„ <code>.claude/</code> į›ŽåŊ•åŽŸį”ŸæĄĨæŽĨčŋ› DSH —— skills、斜杠å‘Ŋäģ¤ã€rules é›ļčŋį§ģį›´æŽĨå¤į”¨ã€‚</p>
18
+
19
+ ## 功čƒŊ
20
+
21
+ | `.claude/` čˇ¯åž„ | æœēåˆļ | 行ä¸ē |
22
+ |---|---|---|
23
+ | `skills/**/SKILL.md` | DSH skill provider | äģ… name + description čŋ›æ¨Ąåž‹å¯č§į›ŽåŊ•īŧ›æ­Ŗæ–‡æŒ‰éœ€åŠ čŊŊīŧˆ`skill` åˇĨå…ˇīŧ‰ã€‚æ–°åĸž skill 下æŦĄ catalog åˆˇæ–°åŗå‡ēįŽ°īŧŒæ— éœ€é‡å¯ã€‚ |
24
+ | `commands/*.md` | DSH skill provider | 同上īŧŒä¸”į”¨æˆˇå¯į›´æŽĨč°ƒį”¨īŧšæ–œæ čœå•é‡Œ `/command-name` å¯į”¨ã€‚ |
25
+ | `rules/*.md` | æļˆæ¯æĩæŗ¨å…Ĩ | rules 全文æ‹ŧæŽĨīŧŒåŒ… `<system-reminder>` äŋĄå°īŧŒæ¯äŧšč¯ä¸€æŦĄäģĨ user-role æļˆæ¯æ’在æļˆæ¯æ•°į섿œ€å‰ —— 与 Claude Code 同通道īŧˆ`prependUserContext`īŧ‰īŧŒæ¨Ąåž‹å¯é éĩåžĒ。 |
26
+
27
+ `CLAUDE.md` / `AGENTS.md` **不įĸ°** —— DSH 内įŊŽ `dsh-agent-instructions` åˇ˛å¤„į†ã€‚
28
+
29
+ ## įŽ¯åĸƒčĻæą‚
30
+
31
+ - DSH 及å…ļ profileīŧˆåĻ‚ `web`īŧ‰
32
+ - äŊŋᔍ Claude Code įēĻåŽšįš„éĄšį›Žīŧš`.claude/skills/`、`.claude/commands/`、`.claude/rules/`
33
+
34
+ ## åŽ‰čŖ…
35
+
36
+ ```bash
37
+ dsh plugin --profile web add dsh-claude-compat
38
+ ```
39
+
40
+ 或äģŽ GitHubīŧš
41
+
42
+ ```bash
43
+ dsh plugin --profile web add github:biedongbin/dsh-claude-compat
44
+ ```
45
+
46
+ į„ļ后在 `~/.dsh/profiles/web/cordis.patch.yml` æŗ¨å†Œīŧˆåˆåšļčŋ›įŽ°æœ‰ patch åˆ—čĄ¨īŧ‰īŧš
47
+
48
+ ```yaml
49
+ - insert:
50
+ - id: claude-compat
51
+ name: 'dsh-claude-compat'
52
+ config:
53
+ enableSkills: true
54
+ enableRules: true
55
+ ```
56
+
57
+ 重启 DSHīŧˆ`dsh web`īŧ‰ã€‚厌成 —— skills å‡ēįŽ°åœ¨ `/` čœå•īŧŒrules æŗ¨å…Ĩ每ä¸Ē新äŧšč¯ã€‚
58
+
59
+ ## 配įŊŽ
60
+
61
+ | 选项 | éģ˜čޤå€ŧ | č¯´æ˜Ž |
62
+ |---|---|---|
63
+ | `enableSkills` | `true` | æŗ¨å†Œ `.claude/skills` + `.claude/commands` provider |
64
+ | `enableRules` | `true` | æŗ¨å…Ĩ `.claude/rules/*.md` 到æļˆæ¯æĩ |
65
+ | `rulesMaxBytes` | `65536` | æŗ¨å…Ĩ rules æ€ģ量įĄŦ上限 |
66
+ | `projectRootMarkers` | `[".git"]` | éĄšį›Žæ šå‘įŽ°įš„įĨ–先标čް |
67
+ | `skillRank` | `150` | provider 排名īŧšäģ‹äēŽ DSH åŽŸį”Ÿ `.dsh/skills`īŧˆ100īŧ‰ä¸Ž `.agents/skills`īŧˆ200īŧ‰äš‹é—´ —— 冞įǁæ—ļ DSH åŽŸį”Ÿäŧ˜å…ˆ |
68
+ | `skillSource` | `project-claude` | catalog æĄį›ŽæĨæēæ ‡į­ž |
69
+
70
+ ## č¯´æ˜Ž
71
+
72
+ - **Skill å‘Ŋ名**īŧšDSH čĻæą‚ kebab-case。åĩŒåĨ— skill į›ŽåŊ•æ‰åšŗåŒ–īŧˆ`gitnexus/gitnexus-guide` → `gitnexus-gitnexus-guide`īŧ‰īŧ›frontmatter åå­—éžæŗ•æ—ļå›žé€€į›ŽåŊ•名。
73
+ - **Rules ធåēĻ**īŧšæ¯ä¸Ē新äŧšč¯č¯ģ取īŧˆæŒ‰äŧšč¯ cwd įŧ“å­˜īŧ‰ã€‚äŧšč¯ä¸­æ”š ruleīŧŒä¸‹ä¸€äŧšč¯į”Ÿæ•ˆã€‚
74
+ - **Rules 内厚**īŧšrules åŽŸæ–‡æŗ¨å…Ĩä¸ēæ¨Ąåž‹æŒ‡äģ¤ã€‚åĒ提äē¤äŊ æƒŗčŽŠæ¨Ąåž‹éĩåžĒįš„ rule —— äŋĄäģģįē§åˆĢ同 `CLAUDE.md`。
75
+
76
+ ## į¤žåŒē鏪č°ĸ
77
+
78
+ - [Linux.do](https://linux.do) —— į¤žåŒē
79
+ - [Claude Code](https://claude.com/claude-code) —— æœŦ插äģļæĄĨæŽĨįš„ `.claude/` įēĻ厚
80
+ - [DeepSeek Harness](https://www.npmjs.com/package/@deepseek-ai/dsh) —— æœŦ插äģ￉Šåą•įš„čŋčĄŒæ—ļ
81
+
82
+ ## License
83
+
84
+ [MIT](LICENSE)
85
+
86
+ ## ⭐ Star History
87
+
88
+ åĻ‚æžœčŋ™ä¸ĒéĄšį›Žå¯šäŊ æœ‰å¸ŽåŠŠīŧŒč¯ˇįģ™ä¸Ē ⭐ —— čŋ™æ˜¯æˆ‘äģŦ持įģ­æ”ščŋ›įš„动力。
89
+
90
+ <p align="center">
91
+ <img src="https://img.shields.io/github/stars/biedongbin/dsh-claude-compat?style=for-the-badge&logo=github&color=gold" alt="GitHub Stars">
92
+ <img src="https://img.shields.io/github/forks/biedongbin/dsh-claude-compat?style=for-the-badge&logo=github" alt="GitHub Forks">
93
+ <img src="https://img.shields.io/github/watchers/biedongbin/dsh-claude-compat?style=for-the-badge&logo=github" alt="GitHub Watchers">
94
+ </p>
95
+
96
+ <div align="center">
97
+ <a href="https://www.star-history.com/#biedongbin/dsh-claude-compat&Date">
98
+ <picture>
99
+ <source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=biedongbin/dsh-claude-compat&type=Date&theme=dark" />
100
+ <source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=biedongbin/dsh-claude-compat&type=Date" />
101
+ <img alt="Star History Chart" src="https://api.star-history.com/svg?repos=biedongbin/dsh-claude-compat&type=Date" width="760" />
102
+ </picture>
103
+ </a>
104
+ </div>
package/package.json ADDED
@@ -0,0 +1,42 @@
1
+ {
2
+ "name": "dsh-claude-compat",
3
+ "version": "0.1.0",
4
+ "description": "DeepSeek Harness plugin: bridge Claude Code's .claude/ directory (skills, commands, rules) into DSH native skill registry and message-stream rules injection.",
5
+ "type": "module",
6
+ "main": "src/index.js",
7
+ "exports": {
8
+ ".": "./src/index.js"
9
+ },
10
+ "files": [
11
+ "src",
12
+ "README.md"
13
+ ],
14
+ "keywords": [
15
+ "dsh",
16
+ "deepseek-harness",
17
+ "claude-code",
18
+ "claude",
19
+ "skills",
20
+ "slash-commands",
21
+ "rules"
22
+ ],
23
+ "license": "MIT",
24
+ "dependencies": {
25
+ "yaml": "^2.0.0"
26
+ },
27
+ "peerDependencies": {
28
+ "@deepseek-ai/cordis": "*",
29
+ "@deepseek-ai/dsh-skill": "*",
30
+ "@deepseek-ai/dsh-llm": "*",
31
+ "@deepseek-ai/schemastery": "*"
32
+ },
33
+ "peerDependenciesMeta": {
34
+ "@deepseek-ai/cordis": { "optional": true },
35
+ "@deepseek-ai/dsh-skill": { "optional": true },
36
+ "@deepseek-ai/dsh-llm": { "optional": true },
37
+ "@deepseek-ai/schemastery": { "optional": true }
38
+ },
39
+ "engines": {
40
+ "node": ">=20"
41
+ }
42
+ }
package/src/index.js ADDED
@@ -0,0 +1,380 @@
1
+ // dsh-claude-compat — bridge Claude Code's .claude/ into DSH.
2
+ //
3
+ // Two contributions to the live runtime:
4
+ // 1. Skill provider on ctx.skills — scans <projectRoot>/.claude/skills/**/SKILL.md
5
+ // and <projectRoot>/.claude/commands/*.md, surfaces them as DSH skills so the
6
+ // `/skill-name` slash trigger, the `skill` tool, and the model-visible catalog
7
+ // pick them up natively. Only name+description load at discovery; the body
8
+ // loads on demand — same contract as the shipped filesystem provider.
9
+ // 2. System prompt section — injects <projectRoot>/.claude/rules/*.md as ordered
10
+ // guidance. CLAUDE.md / AGENTS.md are already handled by dsh-agent-instructions,
11
+ // so we do NOT re-inject them here.
12
+ //
13
+ // Skill name flattening: .claude/skills/gitnexus/gitnexus-guide/SKILL.md →
14
+ // "gitnexus-gitnexus-guide" (DSH skill names must be kebab-case; the shipped
15
+ // provider is one-level only, we recurse up to 3 and join segments with '-').
16
+ //
17
+ // Commands: .claude/commands/commit-changes.md → skill "commit-changes",
18
+ // user-invocable forced true so `/commit-changes` works in the slash menu.
19
+ //
20
+ // Rules text is re-read every system-prompt assembly (per model step), so rule
21
+ // edits take effect without a DSH restart. No file watcher — the cost of
22
+ // stat'ing a handful of small markdown files is negligible per step.
23
+
24
+ import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
25
+ import { readFile, readdir, stat } from 'node:fs/promises';
26
+ import { join, dirname, resolve } from 'node:path';
27
+ import z from '@deepseek-ai/schemastery';
28
+ import { parse } from 'yaml';
29
+ import { isSkillName } from '@deepseek-ai/dsh-skill';
30
+ import { createUserMessage } from '@deepseek-ai/dsh-llm';
31
+
32
+ export const name = 'claude-compat';
33
+ export const inject = ['skills'];
34
+
35
+ export const Config = z.object({
36
+ projectRootMarkers: z.array(z.string()).default(['.git']),
37
+ // 150: between project-dsh (100) and project-agents (200). DSH-native skills
38
+ // win over Claude skills; Claude skills win over user-level.
39
+ skillRank: z.number().default(150),
40
+ skillSource: z.string().default('project-claude'),
41
+ rulesMaxBytes: z.number().default(65536),
42
+ enableRules: z.boolean().default(true),
43
+ enableSkills: z.boolean().default(true),
44
+ });
45
+
46
+ export function apply(ctx, config = {}) {
47
+ if (config.enableSkills !== false) {
48
+ ctx.skills.registerProvider((control) =>
49
+ new ClaudeCompatSkillProvider(ctx, control, config));
50
+ }
51
+ if (config.enableRules !== false) {
52
+ registerRulesSection(ctx, config);
53
+ }
54
+ }
55
+
56
+ // ─── skill provider ──────────────────────────────────────────────────────────
57
+
58
+ class ClaudeCompatSkillProvider {
59
+ constructor(ctx, control, config) {
60
+ this.ctx = ctx;
61
+ this.name = 'claude-compat';
62
+ this.config = config;
63
+ this.skillRank = config.skillRank ?? 150;
64
+ this.source = config.skillSource ?? 'project-claude';
65
+ control.signal.addEventListener('abort', () => {}, { once: true });
66
+ }
67
+
68
+ async list(options) {
69
+ const cwd = options?.cwd;
70
+ if (cwd === undefined || cwd === null) return [];
71
+ const projectRoot = await findProjectRoot(cwd, this.config.projectRootMarkers);
72
+ if (projectRoot === undefined) return [];
73
+ const claudeDir = join(projectRoot, '.claude');
74
+ if (!(await pathExists(claudeDir))) return [];
75
+
76
+ const candidates = [];
77
+ for (const c of await discoverSkills(join(claudeDir, 'skills'), this.name, this.source, this.skillRank)) {
78
+ candidates.push(c);
79
+ }
80
+ for (const c of await discoverCommands(join(claudeDir, 'commands'), this.name, this.source, this.skillRank)) {
81
+ candidates.push(c);
82
+ }
83
+ return candidates;
84
+ }
85
+
86
+ async get(candidate) {
87
+ const locator = candidate.locator;
88
+ const raw = await readTextSafe(locator.path);
89
+ if (raw === undefined) return undefined;
90
+ const parsed = parseFrontmatter(raw);
91
+ const content = parsed === undefined ? raw.trim() : parsed.body.trim();
92
+ return {
93
+ name: candidate.name,
94
+ description: candidate.description,
95
+ ...(candidate.whenToUse !== undefined ? { whenToUse: candidate.whenToUse } : {}),
96
+ invocation: candidate.invocation,
97
+ source: candidate.source,
98
+ provider: this.name,
99
+ resourceBase: { kind: 'directory', path: locator.directory },
100
+ path: locator.path,
101
+ content,
102
+ };
103
+ }
104
+ }
105
+
106
+ // ─── discovery: .claude/skills (recursive, ≤3 levels) ────────────────────────
107
+
108
+ async function discoverSkills(rootDir, providerName, source, rank) {
109
+ const out = [];
110
+ if (!(await pathExists(rootDir))) return out;
111
+ await walk(rootDir, '', 0);
112
+ return out;
113
+
114
+ async function walk(dir, prefix, depth) {
115
+ let entries;
116
+ try {
117
+ entries = await readdir(dir, { withFileTypes: true, encoding: 'utf8' });
118
+ } catch { return; }
119
+ if (entries.some(e => e.isFile() && e.name === 'SKILL.md')) {
120
+ const skillPath = join(dir, 'SKILL.md');
121
+ const c = await parseSkillCandidateFile(skillPath, providerName, source, rank, prefix || undefined);
122
+ if (c !== undefined) out.push(c);
123
+ return; // bundle — don't descend
124
+ }
125
+ if (depth >= 3) return;
126
+ for (const entry of entries) {
127
+ if (!entry.isDirectory()) continue;
128
+ if (entry.name.startsWith('.') || entry.name === 'node_modules' || entry.name === '__pycache__') continue;
129
+ const childPrefix = prefix ? `${prefix}-${entry.name}` : entry.name;
130
+ await walk(join(dir, entry.name), childPrefix, depth + 1);
131
+ }
132
+ }
133
+ }
134
+
135
+ // ─── discovery: .claude/commands (flat) ──────────────────────────────────────
136
+
137
+ async function discoverCommands(rootDir, providerName, source, rank) {
138
+ const out = [];
139
+ if (!(await pathExists(rootDir))) return out;
140
+ let entries;
141
+ try {
142
+ entries = await readdir(rootDir, { withFileTypes: true, encoding: 'utf8' });
143
+ } catch { return out; }
144
+ for (const entry of entries) {
145
+ if (!entry.isFile() || !entry.name.endsWith('.md')) continue;
146
+ const path = join(rootDir, entry.name);
147
+ const stem = entry.name.slice(0, -3);
148
+ if (!isSkillName(stem)) continue; // non-kebab names rejected by registry anyway
149
+ const raw = await readTextSafe(path);
150
+ if (raw === undefined) continue;
151
+ const parsed = parseFrontmatter(raw);
152
+ const description = parsed === undefined ? stem : (stringField(parsed.data, 'description') ?? stem);
153
+ out.push({
154
+ name: stem,
155
+ description,
156
+ invocation: { modelInvocable: true, userInvocable: true },
157
+ provider: providerName,
158
+ source,
159
+ rank,
160
+ locator: { path, directory: rootDir },
161
+ resourceBase: { kind: 'directory', path: rootDir },
162
+ path,
163
+ });
164
+ }
165
+ return out;
166
+ }
167
+
168
+ async function parseSkillCandidateFile(path, providerName, source, rank, flatName) {
169
+ const raw = await readTextSafe(path);
170
+ if (raw === undefined) return undefined;
171
+ const parsed = parseFrontmatter(raw);
172
+ if (parsed === undefined) return undefined;
173
+ const fmName = stringField(parsed.data, 'name');
174
+ const description = stringField(parsed.data, 'description');
175
+ if (description === undefined) return undefined;
176
+ // Prefer frontmatter name when it is a valid kebab-case skill name. Some Claude
177
+ // skills use names with ':' or other chars DSH rejects (e.g.
178
+ // "salus:ai-robot-coding-env-check"); for those, fall back to the flattened
179
+ // directory name, which is virtually always kebab-case.
180
+ let name;
181
+ if (fmName !== undefined && isSkillName(fmName)) name = fmName;
182
+ else if (flatName !== undefined && isSkillName(flatName)) name = flatName;
183
+ if (name === undefined) return undefined;
184
+ let invocation;
185
+ try { invocation = parseInvocationPolicy(parsed.data); }
186
+ catch { invocation = { modelInvocable: true, userInvocable: true }; }
187
+ const whenToUse = stringField(parsed.data, 'whenToUse');
188
+ return {
189
+ name,
190
+ description,
191
+ ...(whenToUse !== undefined ? { whenToUse } : {}),
192
+ invocation,
193
+ provider: providerName,
194
+ source,
195
+ rank,
196
+ locator: { path, directory: dirname(path) },
197
+ resourceBase: { kind: 'directory', path: dirname(path) },
198
+ path,
199
+ };
200
+ }
201
+
202
+ // ─── rules section ───────────────────────────────────────────────────────────
203
+
204
+ function registerRulesSection(ctx, config) {
205
+ const maxBytes = config.rulesMaxBytes ?? 65536;
206
+ // Cache built messages per session cwd — building reads ~13 files, and
207
+ // pre-step fires every step.
208
+ const cache = new Map();
209
+ ctx.on('agent/pre-step', async ({ agent, messages }, next) => {
210
+ const decision = await next();
211
+ if (decision.kind !== 'enter') return decision;
212
+ const present = (list) => list.some((m) => m?.source?.kind === 'claude-compat');
213
+ const alreadyInjected = present(messages)
214
+ || present(decision.messages)
215
+ || agent.session.surface.nodes.some((seq) => {
216
+ const event = agent.session.events[seq];
217
+ return event?.type === 'user/message'
218
+ && event.data?.source?.kind === 'claude-compat';
219
+ });
220
+ if (alreadyInjected) return decision;
221
+ // Session cwd, NOT process.cwd() — the DSH process may be launched from
222
+ // anywhere (e.g. the profile dir); the workspace is per-session. Same
223
+ // source of truth as dsh-agent-instructions.
224
+ const cwd = agent.session.header?.cwd ?? process.cwd();
225
+ if (!cache.has(cwd)) {
226
+ const text = buildRulesText(cwd, config, maxBytes);
227
+ if (text.length === 0) return decision;
228
+ // Claude Code's mechanism (leaked source, api.ts prependUserContext):
229
+ // rules as ONE user-role <system-reminder> message at the very front
230
+ // of the message array. Not in system prompt.
231
+ cache.set(cwd, createUserMessage({
232
+ content: [{ type: 'text', text }],
233
+ source: { kind: 'claude-compat', form: 'rules' },
234
+ }));
235
+ }
236
+ return {
237
+ kind: 'enter',
238
+ messages: decision.messages.toSpliced(0, 0, cache.get(cwd)),
239
+ };
240
+ });
241
+ }
242
+
243
+ function buildRulesText(cwd, config, maxBytes) {
244
+ const projectRoot = findProjectRootSync(cwd, config.projectRootMarkers);
245
+ if (projectRoot === undefined) return '';
246
+ const rulesDir = join(projectRoot, '.claude', 'rules');
247
+ const files = listMdFilesSync(rulesDir);
248
+ if (files.length === 0) return '';
249
+ const parts = [];
250
+ let total = 0;
251
+ for (const f of files) {
252
+ let raw;
253
+ try { raw = readFileSync(f, 'utf8'); } catch { continue; }
254
+ const basename = f.split('/').pop();
255
+ const chunk = `## ${basename}\n\n${raw.trim()}\n`;
256
+ if (total + chunk.length > maxBytes) break;
257
+ parts.push(chunk);
258
+ total += chunk.length;
259
+ }
260
+ if (parts.length === 0) return '';
261
+ // Exact envelope Claude Code uses in prependUserContext (api.ts):
262
+ // user-role <system-reminder> with "# claudeMd" framing.
263
+ return `<system-reminder>
264
+ As you answer the user's questions, you can use the following context:
265
+ # claudeMd
266
+ ${parts.join('\n')}
267
+ </system-reminder>`;
268
+ }
269
+
270
+ function findProjectRootSync(cwd, markers = ['.git']) {
271
+ let current = resolve(cwd);
272
+ while (true) {
273
+ for (const marker of markers) {
274
+ if (existsSync(join(current, marker))) return current;
275
+ }
276
+ const parent = dirname(current);
277
+ if (parent === current) return undefined;
278
+ current = parent;
279
+ }
280
+ }
281
+
282
+ function listMdFilesSync(dir) {
283
+ let entries;
284
+ try { entries = readdirSync(dir); } catch { return []; }
285
+ const files = [];
286
+ for (const name of entries) {
287
+ if (!name.endsWith('.md')) continue;
288
+ const p = join(dir, name);
289
+ try { if (statSync(p).isFile()) files.push(p); } catch {}
290
+ }
291
+ files.sort();
292
+ return files;
293
+ }
294
+
295
+ // ─── helpers ─────────────────────────────────────────────────────────────────
296
+
297
+ async function findProjectRoot(cwd, markers = ['.git']) {
298
+ let current = resolve(cwd);
299
+ while (true) {
300
+ for (const marker of markers) {
301
+ if (await pathExists(join(current, marker))) return current;
302
+ }
303
+ const parent = dirname(current);
304
+ if (parent === current) return undefined;
305
+ current = parent;
306
+ }
307
+ }
308
+
309
+ async function pathExists(path) {
310
+ try { await stat(path); return true; } catch { return false; }
311
+ }
312
+
313
+ async function readTextSafe(path) {
314
+ try { return await readFile(path, { encoding: 'utf8' }); }
315
+ catch { return undefined; }
316
+ }
317
+
318
+ async function listMdFiles(dir) {
319
+ let entries;
320
+ try { entries = await readdir(dir, { withFileTypes: true, encoding: 'utf8' }); }
321
+ catch { return []; }
322
+ const files = [];
323
+ for (const e of entries) {
324
+ if (e.isFile() && e.name.endsWith('.md')) files.push(join(dir, e.name));
325
+ }
326
+ files.sort();
327
+ return files;
328
+ }
329
+
330
+ function parseFrontmatter(raw) {
331
+ const firstLineEnd = raw.indexOf('\n');
332
+ if (firstLineEnd < 0) return undefined;
333
+ if (raw.slice(0, firstLineEnd).replace(/\r$/, '') !== '---') return undefined;
334
+ const start = firstLineEnd + 1;
335
+ const closing = findClosingFrontmatter(raw, start);
336
+ if (closing === undefined) return undefined;
337
+ let parsed;
338
+ try { parsed = parse(raw.slice(start, closing.start)); }
339
+ catch { return undefined; }
340
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) return undefined;
341
+ return { data: parsed, body: raw.slice(closing.bodyStart) };
342
+ }
343
+
344
+ function findClosingFrontmatter(raw, start) {
345
+ let lineStart = start;
346
+ while (lineStart <= raw.length) {
347
+ const nextNewline = raw.indexOf('\n', lineStart);
348
+ const lineEnd = nextNewline < 0 ? raw.length : nextNewline;
349
+ if (raw.slice(lineStart, lineEnd).replace(/\r$/, '') === '---') {
350
+ return { start: lineStart, bodyStart: nextNewline < 0 ? raw.length : nextNewline + 1 };
351
+ }
352
+ if (nextNewline < 0) return undefined;
353
+ lineStart = nextNewline + 1;
354
+ }
355
+ return undefined;
356
+ }
357
+
358
+ function stringField(data, key) {
359
+ const v = data[key];
360
+ return typeof v === 'string' && v.length > 0 ? v : undefined;
361
+ }
362
+
363
+ function parseInvocationPolicy(data) {
364
+ const miv = data['disable-model-invocation'];
365
+ const uiv = data['user-invocable'];
366
+ return {
367
+ modelInvocable: !truthy(miv),
368
+ userInvocable: uiv === undefined ? true : truthy(uiv),
369
+ };
370
+ }
371
+
372
+ function truthy(v) {
373
+ if (typeof v === 'boolean') return v;
374
+ if (typeof v === 'string') {
375
+ const s = v.toLowerCase();
376
+ return s === 'true' || s === 'yes' || s === 'on' || s === '1';
377
+ }
378
+ if (typeof v === 'number') return v !== 0;
379
+ return false;
380
+ }