dsh-paoding 0.2.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.
Files changed (42) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +95 -0
  3. package/README_en.md +95 -0
  4. package/bin/dsh-paoding.mjs +12 -0
  5. package/cordis.patch.yml +6 -0
  6. package/docs/architecture.md +292 -0
  7. package/docs/architecture_en.md +363 -0
  8. package/docs/configuration.md +551 -0
  9. package/docs/configuration_en.md +560 -0
  10. package/docs/images/1.png +0 -0
  11. package/docs/images/2.png +0 -0
  12. package/docs/images/3.png +0 -0
  13. package/docs/images/4.png +0 -0
  14. package/docs/images/5.png +0 -0
  15. package/docs/images/6.png +0 -0
  16. package/docs/installation.md +364 -0
  17. package/docs/installation_en.md +367 -0
  18. package/docs/orchestration.md +254 -0
  19. package/docs/orchestration_en.md +255 -0
  20. package/install.sh +31 -0
  21. package/package.json +29 -0
  22. package/plugins/paoding-config-ui/api-core.mjs +100 -0
  23. package/plugins/paoding-config-ui/index.mjs +327 -0
  24. package/plugins/paoding-config-ui/lib/base.css +615 -0
  25. package/plugins/paoding-config-ui/lib/client.js +1154 -0
  26. package/plugins/paoding-config-ui/package.json +23 -0
  27. package/plugins/paoding-config-ui/runtime-inventory.mjs +212 -0
  28. package/presets/orchestrator/agent.cordis.yml +323 -0
  29. package/presets/orchestrator/preset.yml +6 -0
  30. package/presets/orchestrator/restrict.mjs +191 -0
  31. package/tools/install.mjs +24 -0
  32. package/tools/lib/alloc.mjs +138 -0
  33. package/tools/lib/cli.mjs +522 -0
  34. package/tools/lib/compose.mjs +647 -0
  35. package/tools/lib/config.mjs +297 -0
  36. package/tools/lib/host.mjs +288 -0
  37. package/tools/lib/skills.mjs +146 -0
  38. package/tools/lib/spans.mjs +188 -0
  39. package/tools/lib/state.mjs +328 -0
  40. package/tools/lib/util.mjs +26 -0
  41. package/tools/lib/wizard.mjs +516 -0
  42. package/tools/lib/yaml.mjs +266 -0
@@ -0,0 +1,146 @@
1
+ /**
2
+ * 技能发现与 SKILL.md 元数据解析:detectSkills 扫描四类根目录($DSH_HOME/
3
+ * $DSH_AGENTS_HOME/项目 .dsh|.agents skills),findSkillMeta 定位并读取一个
4
+ * SKILL.md、解析 frontmatter description(skillDescriptionOf /
5
+ * stripSkillDescription)。依赖 node:fs / node:os / node:path;被 state 引用。
6
+ */
7
+ import { readFileSync, readdirSync } from 'node:fs'
8
+ import { homedir } from 'node:os'
9
+ import path from 'node:path'
10
+
11
+ // ── skill detection ─────────────────────────────────────────────────────────
12
+
13
+ /**
14
+ * Discover installed skills from the same roots dsh-skill-filesystem scans:
15
+ * user roots ($DSH_HOME/skills, $DSH_AGENTS_HOME/skills or ~/.agents/skills)
16
+ * plus project roots (<cwd>/.dsh/skills, <cwd>/.agents/skills). A skill is a
17
+ * directory or a .md file; its name is the basename. Roots that do not exist
18
+ * are skipped.
19
+ */
20
+ export function detectSkills(dshHome, cwd) {
21
+ const agentsHome = process.env.DSH_AGENTS_HOME || path.join(homedir(), '.agents')
22
+ const roots = [
23
+ path.join(dshHome, 'skills'),
24
+ path.join(agentsHome, 'skills'),
25
+ path.join(cwd, '.dsh', 'skills'),
26
+ path.join(cwd, '.agents', 'skills'),
27
+ ]
28
+ const skills = new Set()
29
+ for (const root of roots) {
30
+ let entries
31
+ try {
32
+ entries = readdirSync(root, { withFileTypes: true })
33
+ } catch {
34
+ continue
35
+ }
36
+ for (const entry of entries) {
37
+ if (entry.isDirectory()) {
38
+ if (entry.name.startsWith('.')) continue
39
+ skills.add(entry.name)
40
+ } else if (entry.isFile() && entry.name.endsWith('.md')) {
41
+ skills.add(entry.name.slice(0, -'.md'.length))
42
+ }
43
+ }
44
+ }
45
+ return [...skills].sort()
46
+ }
47
+
48
+ /**
49
+ * Locate and read one skill's SKILL.md for the main-agent persona, shared by
50
+ * both load channels (main_agent_skills / main_agent_skills_inline): the
51
+ * default soft mode writes a compact skill row (name + description + absolute
52
+ * SKILL.md path) into the persona and the main agent reads the full rules on
53
+ * demand with its own read tool (zero directory injection); the optional hard
54
+ * inline mode inlines the full body instead. Uses the same four roots as
55
+ * detectSkills; within each root a directory skill (<root>/<name>/SKILL.md)
56
+ * wins over a bare <root>/<name>.md file. Strips the leading YAML
57
+ * frontmatter when present, then trims. `description` is parsed from the
58
+ * frontmatter (single-line, quoted, folded `>` or literal `|` block — block
59
+ * values join the indented continuation lines up to the next key line or the
60
+ * end of the frontmatter, then collapse whitespace, strip surrounding quotes
61
+ * and cap at 200 chars; empty string when absent). Returns
62
+ * { name, description, file, body } with `file` the absolute SKILL.md path,
63
+ * or null when not found / empty body.
64
+ */
65
+ export function findSkillMeta(name, dshHome, cwd) {
66
+ const agentsHome = process.env.DSH_AGENTS_HOME || path.join(homedir(), '.agents')
67
+ const roots = [
68
+ path.join(dshHome, 'skills'),
69
+ path.join(agentsHome, 'skills'),
70
+ path.join(cwd, '.dsh', 'skills'),
71
+ path.join(cwd, '.agents', 'skills'),
72
+ ]
73
+ const fmRe = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/
74
+ for (const root of roots) {
75
+ for (const candidate of [path.join(root, name, 'SKILL.md'), path.join(root, `${name}.md`)]) {
76
+ let text
77
+ try {
78
+ text = readFileSync(candidate, 'utf8')
79
+ } catch {
80
+ continue
81
+ }
82
+ const body = text.replace(fmRe, '').trim()
83
+ if (body === '') return null
84
+ const fm = text.match(fmRe)
85
+ return { name, description: fm ? skillDescriptionOf(fm[1]) : '', file: candidate, body }
86
+ }
87
+ }
88
+ return null
89
+ }
90
+
91
+ /**
92
+ * Parse one skill frontmatter's `description:` value. Handles plain
93
+ * single-line, single/double-quoted, and folded (`>`) / literal (`|`) block
94
+ * scalars (marker on the key line or on its own next line): block content is
95
+ * the following indented lines up to the first key line at or above the
96
+ * description key's indent (or the frontmatter end), whitespace-collapsed.
97
+ * Returns '' when the key is absent or empty.
98
+ */
99
+ export function skillDescriptionOf(frontmatter) {
100
+ const lines = frontmatter.split(/\r?\n/)
101
+ for (let i = 0; i < lines.length; i++) {
102
+ const key = lines[i].match(/^(\s*)description:\s*(.*)$/)
103
+ if (!key) continue
104
+ const indent = key[1].length
105
+ let value = key[2]
106
+ let contentFrom = i + 1
107
+ if (value !== '') {
108
+ if (!/^[>|][+-]?\d*$/.test(value)) return stripSkillDescription(value)
109
+ // block marker on the key line: content starts on the next line
110
+ } else if (lines[i + 1] && /^\s*[>|][+-]?\d*\s*$/.test(lines[i + 1])) {
111
+ contentFrom = i + 2 // marker on its own line
112
+ } else {
113
+ return '' // empty value, no block scalar
114
+ }
115
+ let stop = lines.length
116
+ let minIndent = Infinity
117
+ for (let j = contentFrom; j < lines.length; j++) {
118
+ const line = lines[j]
119
+ if (line.trim() === '') continue
120
+ const lineIndent = (line.match(/^[ \t]*/) ?? [''])[0].length
121
+ if (lineIndent <= indent) {
122
+ stop = j
123
+ break
124
+ }
125
+ if (lineIndent < minIndent) minIndent = lineIndent
126
+ }
127
+ if (minIndent === Infinity) return ''
128
+ const parts = []
129
+ for (let j = contentFrom; j < stop; j++) {
130
+ const line = lines[j]
131
+ parts.push(line.trim() === '' ? '' : line.slice(Math.min(minIndent, line.length)))
132
+ }
133
+ return stripSkillDescription(parts.join(' '))
134
+ }
135
+ return ''
136
+ }
137
+
138
+ /** Collapse whitespace, strip one pair of surrounding quotes, cap at 200 chars. */
139
+ export function stripSkillDescription(raw) {
140
+ let value = raw.trim()
141
+ const quoted = value.match(/^(['"])([\s\S]*)\1$/)
142
+ if (quoted) value = quoted[2]
143
+ value = value.replace(/\s+/g, ' ').trim()
144
+ if (value.length > 200) value = value.slice(0, 200)
145
+ return value
146
+ }
@@ -0,0 +1,188 @@
1
+ /**
2
+ * 源文本 span 定位:在 agent.cordis.yml 源文本中定位各内置角色 toolFilter.allow
3
+ * 与 persona 块标量的字节区间(yaml AST 优先、结构化 regex 兜底)——
4
+ * locateAllowBlocks / locateAllowBlocksYaml / locateAllowBlocksRegex /
5
+ * locatePersonaBlocks 及内部 walkMaps / leadingIndent / ALLOW_BLOCK_RE。
6
+ * 依赖 util(ROLES / warn);被 state 引用。
7
+ */
8
+ import { ROLES, warn } from './util.mjs'
9
+
10
+ // ── allow-list location & rewriting ─────────────────────────────────────────
11
+
12
+ /**
13
+ * Locate each role's toolFilter.allow block in the source agent.cordis.yml.
14
+ * Preferred strategy: parseDocument AST node ranges (surgical, byte-exact).
15
+ * Fallback: structured regex. Returns a Map role -> { start, end, itemIndent,
16
+ * names } where [start, end) is the exact span of the allow item lines.
17
+ */
18
+ export function locateAllowBlocks(srcText, yamlMod) {
19
+ if (yamlMod) {
20
+ try {
21
+ const blocks = locateAllowBlocksYaml(srcText, yamlMod)
22
+ for (const role of ROLES) {
23
+ if (!blocks.has(role)) throw new Error(`role ${role}: allow block not found`)
24
+ }
25
+ return blocks
26
+ } catch (yamlErr) {
27
+ warn(`source allow-list location via yaml failed (${yamlErr.message}) — falling back to regex`)
28
+ }
29
+ }
30
+ return locateAllowBlocksRegex(srcText)
31
+ }
32
+
33
+ export function locateAllowBlocksYaml(srcText, yamlMod) {
34
+ const doc = yamlMod.parseDocument(srcText)
35
+ const blocks = new Map()
36
+ walkMaps(doc.contents, (map) => {
37
+ const toolNamePair = map.items?.find((p) => p.key?.value === 'toolName')
38
+ if (!toolNamePair || !ROLES.includes(toolNamePair.value?.value)) return
39
+ const role = toolNamePair.value.value
40
+ if (blocks.has(role)) throw new Error(`duplicate role block: ${role}`)
41
+ const tfPair = map.items?.find((p) => p.key?.value === 'toolFilter')
42
+ const allowPair = tfPair?.value?.items?.find((p) => p.key?.value === 'allow')
43
+ const seq = allowPair?.value
44
+ if (!seq?.range || !Array.isArray(seq.items)) {
45
+ throw new Error(`role ${role}: toolFilter.allow list not found`)
46
+ }
47
+ const [seqStart, end] = seq.range
48
+ // seqStart points at the first item's `-`; the item line's leading
49
+ // whitespace lies before the range, so extend the replacement span back
50
+ // to the line start (otherwise the preserved indent would double).
51
+ const start = srcText.lastIndexOf('\n', seqStart - 1) + 1
52
+ if (srcText.slice(start, seqStart).trim() !== '' || srcText.slice(seqStart, seqStart + 1) !== '-') {
53
+ throw new Error(`role ${role}: unexpected allow-list layout`)
54
+ }
55
+ blocks.set(role, {
56
+ start,
57
+ end,
58
+ itemIndent: leadingIndent(srcText, seqStart),
59
+ names: seq.items.map((item) => String(item?.value ?? '')),
60
+ })
61
+ })
62
+ return blocks
63
+ }
64
+
65
+ export function walkMaps(node, visit) {
66
+ if (!node) return
67
+ if (node.constructor?.name === 'YAMLSeq') {
68
+ for (const item of node.items ?? []) walkMaps(item, visit)
69
+ } else if (node.constructor?.name === 'YAMLMap') {
70
+ visit(node)
71
+ for (const pair of node.items ?? []) walkMaps(pair.value, visit)
72
+ }
73
+ }
74
+
75
+ /** Number of leading spaces/tabs of the line containing `offset`. */
76
+ export function leadingIndent(text, offset) {
77
+ const lineStart = text.lastIndexOf('\n', offset - 1) + 1
78
+ const match = text.slice(lineStart, offset).match(/^[ \t]*/)
79
+ return match ? match[0].length : 0
80
+ }
81
+
82
+ export const ALLOW_BLOCK_RE = /toolFilter:\n([ \t]+)allow:\n((?:[ \t]+)- [^\n]*\n)+/g
83
+
84
+ export function locateAllowBlocksRegex(srcText) {
85
+ const blocks = new Map()
86
+ for (const role of ROLES) {
87
+ const marker = `toolName: ${role}`
88
+ const roleIdx = srcText.indexOf(marker)
89
+ if (roleIdx === -1) throw new Error(`role ${role}: "${marker}" not found`)
90
+ const nextIdx = srcText.indexOf('toolName: ', roleIdx + marker.length)
91
+ const segment = nextIdx === -1 ? srcText.slice(roleIdx) : srcText.slice(roleIdx, nextIdx)
92
+ ALLOW_BLOCK_RE.lastIndex = 0
93
+ const matches = [...segment.matchAll(ALLOW_BLOCK_RE)]
94
+ if (matches.length !== 1) {
95
+ throw new Error(`role ${role}: expected exactly 1 toolFilter.allow block, found ${matches.length}`)
96
+ }
97
+ const match = matches[0]
98
+ const allowLineEnd = match[0].indexOf('allow:\n') + 'allow:\n'.length
99
+ const itemsStart = roleIdx + match.index + allowLineEnd
100
+ const itemsEnd = roleIdx + match.index + match[0].length
101
+ const itemText = srcText.slice(itemsStart, itemsEnd)
102
+ blocks.set(role, {
103
+ start: itemsStart,
104
+ end: itemsEnd,
105
+ // itemsStart points at the first space of the first item line, so the
106
+ // indent is the leading whitespace of itemText itself (leadingIndent
107
+ // would measure the line before it).
108
+ itemIndent: (itemText.match(/^[ \t]*/) ?? [''])[0].length,
109
+ names: itemText
110
+ .split('\n')
111
+ .filter((l) => l.trim() !== '')
112
+ .map((l) => l.replace(/^[ \t]*- /, '').trim()),
113
+ })
114
+ }
115
+ return blocks
116
+ }
117
+
118
+ // ── generation extensions (persona, custom roles, restrict injection) ───────
119
+
120
+ /**
121
+ * Locate each role's persona block scalar span in the source
122
+ * agent.cordis.yml. Returns Map role -> { start, end, indent, text } where
123
+ * [start, end) covers the whole block scalar including its `|-` marker.
124
+ */
125
+ export function locatePersonaBlocks(srcText, yamlMod) {
126
+ const blocks = new Map()
127
+ if (yamlMod) {
128
+ try {
129
+ const doc = yamlMod.parseDocument(srcText)
130
+ walkMaps(doc.contents, (map) => {
131
+ const toolNamePair = map.items?.find((p) => p.key?.value === 'toolName')
132
+ if (!toolNamePair || !ROLES.includes(toolNamePair.value?.value)) return
133
+ const role = toolNamePair.value.value
134
+ const personaPair = map.items?.find((p) => p.key?.value === 'persona')
135
+ const node = personaPair?.value
136
+ if (!node?.range) throw new Error(`role ${role}: persona block not found`)
137
+ const [start, end] = node.range
138
+ const inner = srcText.slice(start, end)
139
+ const markerEnd = inner.indexOf('\n')
140
+ let indent = 0
141
+ if (markerEnd !== -1) {
142
+ const contentLineStart = start + markerEnd + 1
143
+ const contentLineEnd = srcText.indexOf('\n', contentLineStart)
144
+ const contentLine = srcText.slice(
145
+ contentLineStart,
146
+ contentLineEnd === -1 ? srcText.length : contentLineEnd,
147
+ )
148
+ indent = (contentLine.match(/^[ \t]*/) ?? [''])[0].length
149
+ }
150
+ blocks.set(role, { start, end, indent, text: inner })
151
+ })
152
+ for (const role of ROLES) if (!blocks.has(role)) throw new Error(`role ${role}: persona block not found`)
153
+ return blocks
154
+ } catch (yamlErr) {
155
+ warn(`source persona location via yaml failed (${yamlErr.message}) — falling back to regex`)
156
+ }
157
+ }
158
+ for (const role of ROLES) {
159
+ const marker = `toolName: ${role}`
160
+ const roleIdx = srcText.indexOf(marker)
161
+ if (roleIdx === -1) throw new Error(`role ${role}: "${marker}" not found`)
162
+ const personaStart = srcText.indexOf('persona: |-', roleIdx)
163
+ if (personaStart === -1) throw new Error(`role ${role}: persona block not found (regex)`)
164
+ const dashStart = personaStart + 'persona: '.length // points at the `|` of `|-`
165
+ const contentStart = dashStart + '|-'.length + 1
166
+ const firstLineEnd = srcText.indexOf('\n', contentStart)
167
+ const firstLine = srcText.slice(contentStart, firstLineEnd === -1 ? srcText.length : firstLineEnd)
168
+ const indent = (firstLine.match(/^[ \t]*/) ?? [''])[0].length
169
+ // Block ends at the first non-blank line indented shallower than the
170
+ // content (the next map key, e.g. toolFilter).
171
+ let cursor = contentStart
172
+ let end = contentStart
173
+ while (cursor < srcText.length) {
174
+ const lineEnd = srcText.indexOf('\n', cursor)
175
+ const line = srcText.slice(cursor, lineEnd === -1 ? srcText.length : lineEnd)
176
+ if (line.trim() !== '' && (line.match(/^[ \t]*/) ?? [''])[0].length < indent) break
177
+ end = lineEnd === -1 ? srcText.length : lineEnd + 1
178
+ cursor = end
179
+ }
180
+ blocks.set(role, {
181
+ start: dashStart,
182
+ end,
183
+ indent,
184
+ text: srcText.slice(dashStart, end),
185
+ })
186
+ }
187
+ return blocks
188
+ }
@@ -0,0 +1,328 @@
1
+ /**
2
+ * 公共状态管线:collectState(patch 分层扫描 / MCP 工具名解析 / 插件与技能检测 /
3
+ * smart defaults,含可选 runtimeFacts 合并)与 generateAndInstall(compose +
4
+ * yaml 校验 + dst 目录落盘 + saveConfig)。install.mjs 的公共导出
5
+ * (collectState / generateAndInstall)即来自本模块。
6
+ * 依赖 util / yaml / config / host / skills / spans / compose / alloc 与
7
+ * node:fs / node:path;被 cli 与 plugins/paoding-config-ui 引用。
8
+ */
9
+ import {
10
+ chmodSync,
11
+ copyFileSync,
12
+ existsSync,
13
+ mkdirSync,
14
+ readFileSync,
15
+ rmSync,
16
+ statSync,
17
+ writeFileSync,
18
+ } from 'node:fs'
19
+ import path from 'node:path'
20
+ import { DEFAULT_MAIN_AGENT_PERSONA_EXTRA, ROLES, SRC_DIR, warn } from './util.mjs'
21
+ import { flattenEntries, loadYaml, parsePatchFile } from './yaml.mjs'
22
+ import { loadConfig, normalizeMainAgentName, saveConfig, yamlScalar } from './config.mjs'
23
+ import {
24
+ extractMcp,
25
+ isEntryEnabled,
26
+ isMcpEntry,
27
+ KNOWN_HOST_PLUGINS,
28
+ pluginLabel,
29
+ resolveMCP,
30
+ } from './host.mjs'
31
+ import { detectSkills, findSkillMeta } from './skills.mjs'
32
+ import { locateAllowBlocks, locatePersonaBlocks } from './spans.mjs'
33
+ import { composeGenerated, extractMainAgentAllow } from './compose.mjs'
34
+ import { smartDefaults } from './alloc.mjs'
35
+
36
+ /**
37
+ * Reusable state collection for the CLI and the visual config UI.
38
+ * Runs the whole detection pipeline (patch scan, MCP handshakes, plugin and
39
+ * skill detection, smart defaults) and returns everything the generator needs,
40
+ * without writing anything or prompting. Throws on hard errors.
41
+ *
42
+ * `runtimeFacts`(可选,仅 Web 配置器经 ctx 传入)是运行时优先的补充事实:
43
+ * { ok, error?, pluginEntries, toolNames },来自 plugins/paoding-config-ui/
44
+ * runtime-inventory.mjs。不传时整段合并逻辑跳过,行为与旧版完全一致;传入时
45
+ * 把 preset 未覆盖的运行时工具并进 inventory / mcpReports / pluginReports,
46
+ * 让 bundle 形态加载的插件(@hyzyn/dsh-codegraph、dsh-mnemon 等)也能被识别。
47
+ * 文件扫描路径(KNOWN_HOST_PLUGINS 白名单等)始终保持为兜底,不动。
48
+ */
49
+ export async function collectState({ dshHome, configFile, profile = null, patches = [], cwd = process.cwd(), runtimeFacts = null }) {
50
+ const dstDir = path.join(dshHome, '.agent-presets', 'orchestrator')
51
+ const dstAgentFile = path.join(dstDir, 'agent.cordis.yml')
52
+
53
+ // Source preset files must exist before anything else.
54
+ const sourceFiles = ['agent.cordis.yml', 'preset.yml', 'restrict.mjs'].map((f) => path.join(SRC_DIR, f))
55
+ for (const file of sourceFiles) {
56
+ if (!existsSync(file)) throw new Error(`missing preset source file: ${file}`)
57
+ }
58
+
59
+ const yamlMod = loadYaml(dshHome)
60
+ const srcText = readFileSync(path.join(SRC_DIR, 'agent.cordis.yml'), 'utf8')
61
+ const restrictSrc = readFileSync(path.join(SRC_DIR, 'restrict.mjs'), 'utf8')
62
+
63
+ // Locate role allow + persona blocks; failure is a hard error.
64
+ let blocks
65
+ let personaBlocks
66
+ try {
67
+ blocks = locateAllowBlocks(srcText, yamlMod)
68
+ personaBlocks = locatePersonaBlocks(srcText, yamlMod)
69
+ } catch (err) {
70
+ throw new Error(`cannot locate role blocks in ${path.join(SRC_DIR, 'agent.cordis.yml')}: ${err.message}`)
71
+ }
72
+ let restrictBase
73
+ try {
74
+ restrictBase = extractMainAgentAllow(restrictSrc)
75
+ } catch (err) {
76
+ throw new Error(err.message)
77
+ }
78
+
79
+ // Load the existing config (null when absent); its profile wins unless an
80
+ // explicit profile was given.
81
+ let existing = null
82
+ try {
83
+ existing = loadConfig(configFile, yamlMod)
84
+ } catch (err) {
85
+ throw new Error(err.message)
86
+ }
87
+ const effectiveProfile = profile ?? existing?.profile ?? 'web'
88
+
89
+ // Scan the patch layers (home, profile, extra --patch files) in order.
90
+ const patchSources = [
91
+ { path: path.join(dshHome, 'cordis.patch.yml'), explicit: false },
92
+ { path: path.join(dshHome, 'profiles', effectiveProfile, 'cordis.patch.yml'), explicit: false },
93
+ ...patches.map((p) => ({ path: path.resolve(p), explicit: true })),
94
+ ]
95
+ const entries = []
96
+ const scanned = []
97
+ for (const source of patchSources) {
98
+ if (!existsSync(source.path)) {
99
+ if (source.explicit) warn(`${source.path}: not found — skipped`)
100
+ scanned.push({ path: source.path, status: 'missing' })
101
+ continue
102
+ }
103
+ const data = parsePatchFile(source.path, yamlMod)
104
+ if (data === null) {
105
+ scanned.push({ path: source.path, status: 'unparseable' })
106
+ continue
107
+ }
108
+ scanned.push({ path: source.path, status: 'parsed' })
109
+ entries.push(...flattenEntries(data))
110
+ }
111
+
112
+ // Detect enabled MCP servers and local tool plugins.
113
+ const mcps = []
114
+ const pluginReports = []
115
+ for (const entry of entries) {
116
+ if (isMcpEntry(entry) && isEntryEnabled(entry)) {
117
+ const mcp = extractMcp(entry)
118
+ if (mcp) mcps.push(mcp)
119
+ else warn(`MCP entry "${entry.name}" has no usable config (missing serverName/map) — skipped, its tools will be absent`)
120
+ }
121
+ if (typeof entry?.name === 'string') {
122
+ for (const plugin of KNOWN_HOST_PLUGINS) {
123
+ if (plugin.match.test(entry.name) && isEntryEnabled(entry) && entry.config?.enabled !== false) {
124
+ pluginReports.push({ label: pluginLabel(plugin), tools: [...plugin.tools] })
125
+ }
126
+ }
127
+ }
128
+ }
129
+
130
+ // Resolve exact MCP tool names into the detection inventory D.
131
+ const inventory = new Set()
132
+ const mcpReports = []
133
+ for (const mcp of mcps) {
134
+ const result = await resolveMCP(mcp)
135
+ for (const tool of result.tools) inventory.add(`mcp__${mcp.serverName}__${tool}`)
136
+ mcpReports.push({
137
+ serverName: mcp.serverName,
138
+ transport: mcp.transport,
139
+ count: result.tools.length,
140
+ source: result.source,
141
+ error: result.error,
142
+ })
143
+ }
144
+ for (const plugin of pluginReports) {
145
+ for (const tool of plugin.tools) inventory.add(tool)
146
+ }
147
+
148
+ const skills = detectSkills(dshHome, cwd)
149
+ const staticBase = {}
150
+ for (const role of ROLES) staticBase[role] = blocks.get(role).names
151
+ const suggested = smartDefaults(mcpReports, inventory, staticBase)
152
+
153
+ // ── 运行时事实合并(可选;CLI/无运行时不传 runtimeFacts,整段零开销跳过)──
154
+ // preset 自带名(restrictBase + 各角色 staticBase)之外的运行时工具叫 extras,
155
+ // 全部并进 inventory,UI 的角色候选 = blocks + inventory 立即可见;extra 里
156
+ // mcp__<server>__<tool> 形态的按 server 补一份 runtime 来源的 mcpReports
157
+ // (已有文件握手 report 的 server 不动);pluginEntries 里非内置包(避开
158
+ // @deepseek-ai/*、dsh-client*、cordis)的条目补进 pluginReports(工具数不填,
159
+ // 无归属信息;白名单命中的以白名单精确清单优先)。整段 try/catch:任何一步
160
+ // 失败只降级为少识别一部分,绝不影响文件扫描兜底结果。
161
+ if (runtimeFacts !== null && Array.isArray(runtimeFacts.toolNames)) {
162
+ try {
163
+ // 1) preset 自带名称全集(注意 staticBase 的值是数组)
164
+ const presetUniverse = new Set([...restrictBase, ...Object.values(staticBase).flat()])
165
+ // 2) extras = 运行时注册表里 preset 未覆盖的名字(去重、保序)
166
+ const extras = []
167
+ for (const name of runtimeFacts.toolNames) {
168
+ if (typeof name !== 'string' || name === '') continue
169
+ if (!presetUniverse.has(name) && !extras.includes(name)) extras.push(name)
170
+ }
171
+ // 3) extras 全部并入检测库存 D
172
+ for (const name of extras) inventory.add(name)
173
+
174
+ // 4) extras 里 mcp__<server>__<tool> 按 server 分组补 mcpReports
175
+ // (非贪婪正则取第一个 __ 分隔:前缀为 server、后缀为 tool;
176
+ // server 名自带 '_' 时仅影响展示分组,不影响 inventory 本体)
177
+ const runtimeMcp = new Map()
178
+ for (const name of extras) {
179
+ const m = /^mcp__(.+?)__(.+)$/.exec(name)
180
+ if (m) {
181
+ const group = runtimeMcp.get(m[1])
182
+ if (group) group.push(name)
183
+ else runtimeMcp.set(m[1], [name])
184
+ }
185
+ }
186
+ for (const [serverName, names] of runtimeMcp) {
187
+ if (mcpReports.some((r) => r.serverName === serverName)) continue // 握手结果优先
188
+ mcpReports.push({ serverName, transport: undefined, count: names.length, source: 'runtime', error: undefined })
189
+ }
190
+
191
+ // 5) 运行时插件条目补进 pluginReports(过滤内置包避免刷屏)
192
+ if (Array.isArray(runtimeFacts.pluginEntries) && runtimeFacts.pluginEntries.length > 0) {
193
+ for (const e of runtimeFacts.pluginEntries) {
194
+ const moduleName = typeof e?.moduleName === 'string' ? e.moduleName : ''
195
+ if (moduleName === '') continue
196
+ if (moduleName.startsWith('@deepseek-ai/')) continue
197
+ if (moduleName.includes('dsh-client')) continue
198
+ if (moduleName.includes('cordis')) continue
199
+ if (pluginReports.some((p) => p.label === moduleName)) continue // 白名单已覆盖
200
+ pluginReports.push({ label: moduleName, tools: [], runtime: true })
201
+ }
202
+ }
203
+ } catch (err) {
204
+ warn(`runtime facts merge failed (skipped, file-scan results kept): ${err?.message ?? err}`)
205
+ }
206
+ }
207
+
208
+ return {
209
+ dshHome,
210
+ cwd,
211
+ dstDir,
212
+ dstAgentFile,
213
+ configFile,
214
+ yamlMod,
215
+ srcText,
216
+ restrictSrc,
217
+ blocks,
218
+ personaBlocks,
219
+ restrictBase,
220
+ existing,
221
+ effectiveProfile,
222
+ scanned,
223
+ mcpReports,
224
+ pluginReports,
225
+ inventory,
226
+ skills,
227
+ staticBase,
228
+ suggested,
229
+ // 主 persona 尾部追加的默认常量(配置键 main_agent_persona_extra 缺省值):
230
+ // 供 UI state(serializeState → /api/paoding/state)展示与「恢复默认」用。
231
+ mainPersonaExtraDefault: DEFAULT_MAIN_AGENT_PERSONA_EXTRA,
232
+ // 运行时事实透传(原字段一个未动,仅追加;未传 runtimeFacts 时为空值,
233
+ // serializeState 与既有消费者均按缺省处理,零回归)
234
+ runtimePlugins: runtimeFacts?.pluginEntries ?? [],
235
+ runtimeOk: !!runtimeFacts?.ok,
236
+ runtimeError: runtimeFacts?.error ?? null,
237
+ }
238
+ }
239
+
240
+ /**
241
+ * Compose, validate and (unless dry-run) install the generated preset from an
242
+ * assignments object. Shared by the CLI and the visual config UI.
243
+ * Returns { text, roleResults, staleNote, wrote }.
244
+ */
245
+ export function generateAndInstall(state, assignments, { dryRun = false, saveConfigOnWrite = false } = {}) {
246
+ const { srcText, blocks, personaBlocks, inventory, restrictBase, yamlMod, dshHome, cwd, dstDir, dstAgentFile, configFile, scanned, effectiveProfile } = state
247
+
248
+ // Pre-read the SKILL.md metas of the skills selected for the main-agent
249
+ // persona (soft rows via main_agent_skills + optional hard inlining via
250
+ // main_agent_skills_inline; union, deduped). Names whose file is missing
251
+ // simply do not enter the map — composeGenerated warns about them.
252
+ const softNames = (assignments?.main_agent_skills ?? []).filter((n) => typeof n === 'string' && n !== '')
253
+ const hardNames = (assignments?.main_agent_skills_inline ?? []).filter((n) => typeof n === 'string' && n !== '')
254
+ const mainAgentSkillMetas = {}
255
+ for (const name of [...new Set([...softNames, ...hardNames])]) {
256
+ const hit = findSkillMeta(name, dshHome, cwd)
257
+ if (hit) mainAgentSkillMetas[name] = hit
258
+ }
259
+
260
+ // Compose the generated config; a composition error is a hard error.
261
+ let composed
262
+ try {
263
+ composed = composeGenerated(srcText, blocks, personaBlocks, assignments, inventory, restrictBase, mainAgentSkillMetas)
264
+ } catch (err) {
265
+ throw new Error(err.message)
266
+ }
267
+ const generatedText = composed.text
268
+ const roleResults = composed.roleResults
269
+
270
+ // Validate the generated YAML before writing anything.
271
+ if (yamlMod) {
272
+ // `!!js <expr>` 是 cordis 的 JS 表达式标签(tool-bash / tool-pwsh 的平台开关
273
+ // 用)。本校验只关心结构合法性、不求值表达式:dsh loadYaml 的默认 schema 未
274
+ // 注册该标签,直接解析会对每行打 TAG_RESOLVE_FAILED 噪音警告。这里在「校验
275
+ // 副本」上把 !!js 标量换成占位 true 再解析;生成文本原样写入,表达式求值仍
276
+ // 由 cordis 装载时完成,语义零影响。
277
+ const validateText = generatedText.replace(/!!js\s+[^\r\n]*/g, 'true')
278
+ try {
279
+ yamlMod.parse(validateText)
280
+ } catch (err) {
281
+ throw new Error(`generated agent.cordis.yml fails yaml validation: ${err.message}`)
282
+ }
283
+ }
284
+
285
+ // Freshness note: any parsed patch file modified after the last generated
286
+ // agent.cordis.yml means the user should rerun the installer after changes.
287
+ let staleNote = false
288
+ if (existsSync(dstAgentFile)) {
289
+ const targetMtime = statSync(dstAgentFile).mtimeMs
290
+ staleNote = scanned.some(
291
+ (s) => s.status === 'parsed' && existsSync(s.path) && statSync(s.path).mtimeMs > targetMtime,
292
+ )
293
+ }
294
+
295
+ let wrote = false
296
+ if (!dryRun) {
297
+ mkdirSync(path.join(dshHome, '.agent-presets'), { recursive: true })
298
+ rmSync(dstDir, { recursive: true, force: true })
299
+ mkdirSync(dstDir, { recursive: true })
300
+ for (const name of ['preset.yml', 'restrict.mjs']) {
301
+ const src = path.join(SRC_DIR, name)
302
+ const dst = path.join(dstDir, name)
303
+ const mainAgentName = normalizeMainAgentName(assignments?.main_agent_name ?? null)
304
+ if (name === 'preset.yml' && mainAgentName !== null) {
305
+ // main_agent_name 非空:preset 显示名改为配置名。读 SRC 文本、把首个
306
+ // (非注释的)顶层 `name:` 行值替换为安全 yaml 标量(含 ':' / '#' 等时
307
+ // 单引号包裹转义),其余字节原样;chmod 与下方一致。
308
+ const presetText = readFileSync(src, 'utf8')
309
+ const next = presetText.replace(/^name:.*$/m, `name: ${yamlScalar(mainAgentName)}`)
310
+ if (next === presetText) {
311
+ warn('preset.yml: 未找到可替换的顶层 name: 行(main_agent_name 未生效),已按原样写入')
312
+ }
313
+ writeFileSync(dst, next)
314
+ } else {
315
+ copyFileSync(src, dst)
316
+ }
317
+ chmodSync(dst, statSync(src).mode & 0o777) // preserve source permissions
318
+ }
319
+ writeFileSync(path.join(dstDir, 'agent.cordis.yml'), generatedText)
320
+ chmodSync(path.join(dstDir, 'agent.cordis.yml'), statSync(path.join(SRC_DIR, 'agent.cordis.yml')).mode & 0o777)
321
+ if (saveConfigOnWrite) {
322
+ saveConfig(configFile, { profile: effectiveProfile, ...assignments })
323
+ }
324
+ wrote = true
325
+ }
326
+
327
+ return { text: generatedText, roleResults, staleNote, wrote }
328
+ }