@khorsheed/dsh-capability-catalog 0.1.95

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 (109) hide show
  1. package/LICENSE +21 -0
  2. package/README.i18n.yaml +6 -0
  3. package/README.md +286 -0
  4. package/README.zh.md +138 -0
  5. package/cordis.patch.yml +8 -0
  6. package/lib/client.js +10100 -0
  7. package/lib/index.js +4022 -0
  8. package/lib/invariant.js +25 -0
  9. package/lib/mcpConnector-C2TbkPBY.js +69 -0
  10. package/lib/typert.host.d.ts +3 -0
  11. package/lib/typert.host.js +1417 -0
  12. package/lib/typert.remote-client.d.ts +68 -0
  13. package/lib/typert.remote-client.js +1136 -0
  14. package/lib/types/capabilities.d.ts +103 -0
  15. package/lib/types/capabilities.js +116 -0
  16. package/lib/types/channels.d.ts +41 -0
  17. package/lib/types/channels.js +75 -0
  18. package/lib/types/client/AddMcpDialog.d.ts +13 -0
  19. package/lib/types/client/AddMcpDialog.js +79 -0
  20. package/lib/types/client/AddSkillModal.d.ts +17 -0
  21. package/lib/types/client/AddSkillModal.js +138 -0
  22. package/lib/types/client/BundleFileTree.d.ts +7 -0
  23. package/lib/types/client/BundleFileTree.js +74 -0
  24. package/lib/types/client/CapabilityCatalogCard.d.ts +57 -0
  25. package/lib/types/client/CapabilityCatalogCard.js +479 -0
  26. package/lib/types/client/CredentialField.d.ts +14 -0
  27. package/lib/types/client/CredentialField.js +9 -0
  28. package/lib/types/client/McpCards.d.ts +13 -0
  29. package/lib/types/client/McpCards.js +12 -0
  30. package/lib/types/client/McpServerManageModal.d.ts +18 -0
  31. package/lib/types/client/McpServerManageModal.js +63 -0
  32. package/lib/types/client/MetadataRow.d.ts +10 -0
  33. package/lib/types/client/MetadataRow.js +6 -0
  34. package/lib/types/client/ModalShell.d.ts +16 -0
  35. package/lib/types/client/ModalShell.js +27 -0
  36. package/lib/types/client/ModeChips.d.ts +38 -0
  37. package/lib/types/client/ModeChips.js +42 -0
  38. package/lib/types/client/SchemaView.d.ts +11 -0
  39. package/lib/types/client/SchemaView.js +143 -0
  40. package/lib/types/client/SkillCards.d.ts +28 -0
  41. package/lib/types/client/SkillCards.js +43 -0
  42. package/lib/types/client/SkillDetailModal.d.ts +69 -0
  43. package/lib/types/client/SkillDetailModal.js +167 -0
  44. package/lib/types/client/ToolCards.d.ts +28 -0
  45. package/lib/types/client/ToolCards.js +52 -0
  46. package/lib/types/client/ToolDetailModal.d.ts +12 -0
  47. package/lib/types/client/ToolDetailModal.js +29 -0
  48. package/lib/types/client/icons.d.ts +26 -0
  49. package/lib/types/client/icons.js +10 -0
  50. package/lib/types/client/index.d.ts +21 -0
  51. package/lib/types/client/index.js +196 -0
  52. package/lib/types/client/locales.d.ts +392 -0
  53. package/lib/types/client/locales.js +395 -0
  54. package/lib/types/client/mcp-model.d.ts +24 -0
  55. package/lib/types/client/mcp-model.js +87 -0
  56. package/lib/types/client/mode-model.d.ts +108 -0
  57. package/lib/types/client/mode-model.js +140 -0
  58. package/lib/types/client/preset-display.d.ts +21 -0
  59. package/lib/types/client/preset-display.js +20 -0
  60. package/lib/types/client/settle.d.ts +40 -0
  61. package/lib/types/client/settle.js +57 -0
  62. package/lib/types/client/slots.d.ts +86 -0
  63. package/lib/types/client/slots.js +1 -0
  64. package/lib/types/envHint.d.ts +22 -0
  65. package/lib/types/envHint.js +76 -0
  66. package/lib/types/import.d.ts +106 -0
  67. package/lib/types/import.js +635 -0
  68. package/lib/types/index.d.ts +216 -0
  69. package/lib/types/index.js +739 -0
  70. package/lib/types/invariant.d.ts +16 -0
  71. package/lib/types/invariant.js +23 -0
  72. package/lib/types/mcpConnector.d.ts +24 -0
  73. package/lib/types/mcpConnector.js +73 -0
  74. package/lib/types/mcpStore.d.ts +68 -0
  75. package/lib/types/mcpStore.js +235 -0
  76. package/lib/types/mcpTools.d.ts +45 -0
  77. package/lib/types/mcpTools.js +125 -0
  78. package/lib/types/mcps.d.ts +64 -0
  79. package/lib/types/mcps.js +157 -0
  80. package/lib/types/modes.d.ts +65 -0
  81. package/lib/types/modes.js +97 -0
  82. package/lib/types/namespace.d.ts +9 -0
  83. package/lib/types/namespace.js +8 -0
  84. package/lib/types/official-tools.d.ts +3 -0
  85. package/lib/types/official-tools.js +44 -0
  86. package/lib/types/preset-scope.d.ts +105 -0
  87. package/lib/types/preset-scope.js +117 -0
  88. package/lib/types/remote.d.ts +48 -0
  89. package/lib/types/remote.js +120 -0
  90. package/lib/types/scoped-delivery.d.ts +243 -0
  91. package/lib/types/scoped-delivery.js +686 -0
  92. package/lib/types/scoped-edits.d.ts +90 -0
  93. package/lib/types/scoped-edits.js +234 -0
  94. package/lib/types/settings.d.ts +28 -0
  95. package/lib/types/settings.js +28 -0
  96. package/lib/types/shellEnv.d.ts +65 -0
  97. package/lib/types/shellEnv.js +232 -0
  98. package/lib/types/skills.d.ts +103 -0
  99. package/lib/types/skills.js +303 -0
  100. package/lib/types/tool-origin.d.ts +29 -0
  101. package/lib/types/tool-origin.js +30 -0
  102. package/lib/types/types.d.ts +315 -0
  103. package/lib/types/types.js +8 -0
  104. package/lib/types/zip.d.ts +24 -0
  105. package/lib/types/zip.js +80 -0
  106. package/locale/en.json +6 -0
  107. package/locale/zh.json +6 -0
  108. package/package.json +119 -0
  109. package/scripts/gen-official-tools.mts +52 -0
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Writing side of preset-scoped delivery: rewrite one managed skill's
3
+ * `metadata.presetScope`, adopt an installed skill into the managed root, and
4
+ * release one back out.
5
+ *
6
+ * Every write here is deliberately narrow. The preset scope is edited in place
7
+ * in the skill's OWN frontmatter (the same store the delivery reads), adoption
8
+ * copies before it removes anything, and release refuses to overwrite an
9
+ * existing default-root skill. Nothing deletes a managed skill: releasing is the
10
+ * documented recovery direction, and a broken plugin must not take the user's
11
+ * files with it.
12
+ * @module @khorsheed/dsh-capability-catalog/scoped-edits
13
+ */
14
+ /** Outcome of one write: a reason is present exactly when it failed. */
15
+ export interface ScopedEditResult {
16
+ readonly ok: boolean;
17
+ readonly error?: string;
18
+ }
19
+ /** Where a managed skill came from, for the release path. */
20
+ export interface ManagedSkillLocation {
21
+ readonly name: string;
22
+ /** The absolute `SKILL.md` path inside the managed root. */
23
+ readonly path: string;
24
+ /** Its directory (the bundle the release moves). */
25
+ readonly dir: string;
26
+ }
27
+ /**
28
+ * Rewrite a SKILL.md's `metadata.presetScope` list.
29
+ *
30
+ * The frontmatter is edited line by line rather than re-serialized, so every
31
+ * other key, comment, and quote in the file survives untouched. The canonical
32
+ * inline form is written: `presetScope: []` for "every preset", otherwise
33
+ * `presetScope: [a, b]`.
34
+ * @param content - the current SKILL.md text.
35
+ * @param presets - the preset ids to declare (deduplicated, order preserved).
36
+ * @returns the rewritten file text.
37
+ * @throws when the file has no frontmatter block to edit.
38
+ */
39
+ export declare function withPresetScope(content: string, presets: readonly string[]): string;
40
+ /** Find the managed entry for one skill name. */
41
+ export declare function managedLocation(dshHome: string, name: string): Promise<ManagedSkillLocation | undefined>;
42
+ /**
43
+ * Set one managed skill's preset scope.
44
+ * @param dshHome - the harness home owning the managed root.
45
+ * @param name - the skill name (its directory under the managed root).
46
+ * @param presets - the preset ids to declare.
47
+ * @returns the outcome, with a reason on failure.
48
+ */
49
+ export declare function setManagedPresetScope(dshHome: string, name: string, presets: readonly string[]): Promise<ScopedEditResult>;
50
+ /** The default root currently supplying one skill, with its shape. */
51
+ export interface SkillSource {
52
+ readonly root: string;
53
+ readonly dir?: string;
54
+ readonly file?: string;
55
+ }
56
+ /**
57
+ * Locate one skill in the default roots.
58
+ * @param dshHome - the harness home.
59
+ * @param workdir - the session workspace, when known.
60
+ * @param name - the skill name.
61
+ * @returns the source found, or undefined.
62
+ */
63
+ export declare function findSkillSource(dshHome: string, workdir: string | undefined, name: string): SkillSource | undefined;
64
+ /**
65
+ * Move an installed skill into the managed root and declare its preset scope.
66
+ *
67
+ * Order matters: the source is only removed after the copy has been committed
68
+ * and the scope written, so a failure at any step leaves one complete copy
69
+ * behind. The copy lands as a temp sibling and is committed with one rename, so
70
+ * the managed root never holds a half-written skill.
71
+ * @param dshHome - the harness home.
72
+ * @param name - the skill name.
73
+ * @param presets - the preset ids to declare.
74
+ * @param workdir - the session workspace, when known.
75
+ * @returns the outcome, with a reason on failure.
76
+ */
77
+ export declare function adoptManagedSkill(dshHome: string, name: string, presets: readonly string[], workdir?: string | undefined): Promise<ScopedEditResult>;
78
+ /**
79
+ * Move a managed skill back to the user skill root — the recovery direction.
80
+ *
81
+ * A managed skill is delivered by this plugin, so releasing it restores plain
82
+ * filesystem behavior that survives the plugin being removed.
83
+ * @param dshHome - the harness home.
84
+ * @param name - the skill name.
85
+ * @returns the outcome, with a reason on failure.
86
+ */
87
+ export declare function releaseManagedSkill(dshHome: string, name: string): Promise<ScopedEditResult>;
88
+ /** Whether a path exists and is a directory. */
89
+ export declare function isDirectory(path: string): Promise<boolean>;
90
+ //# sourceMappingURL=scoped-edits.d.ts.map
@@ -0,0 +1,234 @@
1
+ /**
2
+ * Writing side of preset-scoped delivery: rewrite one managed skill's
3
+ * `metadata.presetScope`, adopt an installed skill into the managed root, and
4
+ * release one back out.
5
+ *
6
+ * Every write here is deliberately narrow. The preset scope is edited in place
7
+ * in the skill's OWN frontmatter (the same store the delivery reads), adoption
8
+ * copies before it removes anything, and release refuses to overwrite an
9
+ * existing default-root skill. Nothing deletes a managed skill: releasing is the
10
+ * documented recovery direction, and a broken plugin must not take the user's
11
+ * files with it.
12
+ * @module @khorsheed/dsh-capability-catalog/scoped-edits
13
+ */
14
+ import { cp, mkdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises';
15
+ import { existsSync } from 'node:fs';
16
+ import { dirname, join } from 'node:path';
17
+ import { defaultSkillRoots, isKebabId, scanManagedSkills, scopedSkillsRoot, splitFrontmatter, } from './scoped-delivery.js';
18
+ /** Shared guard: every write path addresses one skill directory by name. */
19
+ function invalidName(name) {
20
+ return isKebabId(name) ? undefined : { ok: false, error: `"${name}" is not a valid skill name` };
21
+ }
22
+ /**
23
+ * Rewrite a SKILL.md's `metadata.presetScope` list.
24
+ *
25
+ * The frontmatter is edited line by line rather than re-serialized, so every
26
+ * other key, comment, and quote in the file survives untouched. The canonical
27
+ * inline form is written: `presetScope: []` for "every preset", otherwise
28
+ * `presetScope: [a, b]`.
29
+ * @param content - the current SKILL.md text.
30
+ * @param presets - the preset ids to declare (deduplicated, order preserved).
31
+ * @returns the rewritten file text.
32
+ * @throws when the file has no frontmatter block to edit.
33
+ */
34
+ export function withPresetScope(content, presets) {
35
+ const split = splitFrontmatter(content);
36
+ if (split === undefined) {
37
+ throw new Error('the skill has no frontmatter block to edit');
38
+ }
39
+ const ids = [...new Set(presets)];
40
+ const lines = split.front.split('\n');
41
+ const kept = [];
42
+ for (let index = 0; index < lines.length; index += 1) {
43
+ const line = lines[index] ?? '';
44
+ const match = /^(\s*)presetScope\s*:/.exec(line);
45
+ if (match === null) {
46
+ kept.push(line);
47
+ continue;
48
+ }
49
+ // Drop the key and any block-list items it owned.
50
+ const indent = (match[1] ?? '').length;
51
+ while (index + 1 < lines.length) {
52
+ const next = lines[index + 1] ?? '';
53
+ const entry = /^(\s*)-\s*/.exec(next);
54
+ if (entry === null || (entry[1] ?? '').length <= indent)
55
+ break;
56
+ index += 1;
57
+ }
58
+ }
59
+ const rendered = `presetScope: [${ids.join(', ')}]`;
60
+ const metadataAt = kept.findIndex(line => /^\s*metadata\s*:\s*$/.test(line));
61
+ if (metadataAt >= 0) {
62
+ const indent = (/^(\s*)/.exec(kept[metadataAt] ?? '')?.[1] ?? '').length + 2;
63
+ kept.splice(metadataAt + 1, 0, `${' '.repeat(indent)}${rendered}`);
64
+ }
65
+ else if (kept.some(line => line.trim() !== '')) {
66
+ kept.push('metadata:', ` ${rendered}`);
67
+ }
68
+ else {
69
+ kept.push('metadata:', ` ${rendered}`);
70
+ }
71
+ return `---\n${kept.join('\n')}\n---\n${split.body}`;
72
+ }
73
+ /** Find the managed entry for one skill name. */
74
+ export async function managedLocation(dshHome, name) {
75
+ const root = scopedSkillsRoot(dshHome);
76
+ const skill = (await scanManagedSkills(root)).find(entry => entry.name === name);
77
+ if (skill === undefined)
78
+ return undefined;
79
+ return { name: skill.name, path: skill.path, dir: skill.dir };
80
+ }
81
+ /** Write a file through a sibling temp file and one atomic rename. */
82
+ async function writeAtomic(path, content) {
83
+ const temp = join(dirname(path), `.${Date.now()}-${process.pid}.tmp`);
84
+ await writeFile(temp, content, 'utf8');
85
+ await rename(temp, path);
86
+ }
87
+ /**
88
+ * Set one managed skill's preset scope.
89
+ * @param dshHome - the harness home owning the managed root.
90
+ * @param name - the skill name (its directory under the managed root).
91
+ * @param presets - the preset ids to declare.
92
+ * @returns the outcome, with a reason on failure.
93
+ */
94
+ export async function setManagedPresetScope(dshHome, name, presets) {
95
+ const badName = invalidName(name);
96
+ if (badName !== undefined)
97
+ return badName;
98
+ const location = await managedLocation(dshHome, name);
99
+ if (location === undefined) {
100
+ return { ok: false, error: `"${name}" is not a managed skill` };
101
+ }
102
+ try {
103
+ const content = await readFile(location.path, 'utf8');
104
+ const updated = withPresetScope(content, presets);
105
+ // Nothing to write is not a failure: the declared scope already matches.
106
+ if (updated !== content)
107
+ await writeAtomic(location.path, updated);
108
+ return { ok: true };
109
+ }
110
+ catch (error) {
111
+ return { ok: false, error: messageOf(error) };
112
+ }
113
+ }
114
+ /**
115
+ * Locate one skill in the default roots.
116
+ * @param dshHome - the harness home.
117
+ * @param workdir - the session workspace, when known.
118
+ * @param name - the skill name.
119
+ * @returns the source found, or undefined.
120
+ */
121
+ export function findSkillSource(dshHome, workdir, name) {
122
+ for (const root of defaultSkillRoots(dshHome, workdir)) {
123
+ const dir = join(root, name);
124
+ if (existsSync(join(dir, 'SKILL.md')))
125
+ return { root, dir };
126
+ const file = join(root, `${name}.md`);
127
+ if (existsSync(file))
128
+ return { root, file };
129
+ }
130
+ return undefined;
131
+ }
132
+ /**
133
+ * Move an installed skill into the managed root and declare its preset scope.
134
+ *
135
+ * Order matters: the source is only removed after the copy has been committed
136
+ * and the scope written, so a failure at any step leaves one complete copy
137
+ * behind. The copy lands as a temp sibling and is committed with one rename, so
138
+ * the managed root never holds a half-written skill.
139
+ * @param dshHome - the harness home.
140
+ * @param name - the skill name.
141
+ * @param presets - the preset ids to declare.
142
+ * @param workdir - the session workspace, when known.
143
+ * @returns the outcome, with a reason on failure.
144
+ */
145
+ export async function adoptManagedSkill(dshHome, name, presets, workdir) {
146
+ const badName = invalidName(name);
147
+ if (badName !== undefined)
148
+ return badName;
149
+ const root = scopedSkillsRoot(dshHome);
150
+ const target = join(root, name);
151
+ if (existsSync(target)) {
152
+ return { ok: false, error: `the managed root already holds "${name}"` };
153
+ }
154
+ const source = findSkillSource(dshHome, workdir, name);
155
+ if (source === undefined) {
156
+ return { ok: false, error: `"${name}" was not found in a default skill root` };
157
+ }
158
+ const staging = join(root, `.${name}-${process.pid}`);
159
+ try {
160
+ await mkdir(root, { recursive: true });
161
+ await rm(staging, { recursive: true, force: true });
162
+ if (source.dir !== undefined) {
163
+ await cp(source.dir, staging, { recursive: true });
164
+ }
165
+ else {
166
+ await mkdir(staging, { recursive: true });
167
+ await writeFile(join(staging, 'SKILL.md'), await readFile(source.file ?? '', 'utf8'), 'utf8');
168
+ }
169
+ const staged = join(staging, 'SKILL.md');
170
+ const content = await readFile(staged, 'utf8');
171
+ await writeFile(staged, withPresetScope(content, presets), 'utf8');
172
+ await rename(staging, target);
173
+ }
174
+ catch (error) {
175
+ await rm(staging, { recursive: true, force: true }).catch(() => { });
176
+ return { ok: false, error: messageOf(error) };
177
+ }
178
+ // Committed: only now drop the default-root copy, and never fail the whole
179
+ // operation over it — the managed copy is already authoritative.
180
+ try {
181
+ if (source.dir !== undefined)
182
+ await rm(source.dir, { recursive: true, force: true });
183
+ else if (source.file !== undefined)
184
+ await rm(source.file, { force: true });
185
+ }
186
+ catch (error) {
187
+ return { ok: false, error: `adopted "${name}", but the default-root copy could not be removed: ${messageOf(error)}` };
188
+ }
189
+ return { ok: true };
190
+ }
191
+ /**
192
+ * Move a managed skill back to the user skill root — the recovery direction.
193
+ *
194
+ * A managed skill is delivered by this plugin, so releasing it restores plain
195
+ * filesystem behavior that survives the plugin being removed.
196
+ * @param dshHome - the harness home.
197
+ * @param name - the skill name.
198
+ * @returns the outcome, with a reason on failure.
199
+ */
200
+ export async function releaseManagedSkill(dshHome, name) {
201
+ const badName = invalidName(name);
202
+ if (badName !== undefined)
203
+ return badName;
204
+ const location = await managedLocation(dshHome, name);
205
+ if (location === undefined) {
206
+ return { ok: false, error: `"${name}" is not a managed skill` };
207
+ }
208
+ const target = join(dshHome, 'skills', name);
209
+ if (existsSync(target)) {
210
+ return { ok: false, error: `"${name}" already exists in ${join(dshHome, 'skills')}` };
211
+ }
212
+ try {
213
+ await mkdir(dirname(target), { recursive: true });
214
+ await cp(location.dir, target, { recursive: true });
215
+ await rm(location.dir, { recursive: true, force: true });
216
+ return { ok: true };
217
+ }
218
+ catch (error) {
219
+ return { ok: false, error: messageOf(error) };
220
+ }
221
+ }
222
+ /** Whether a path exists and is a directory. */
223
+ export async function isDirectory(path) {
224
+ try {
225
+ return (await stat(path)).isDirectory();
226
+ }
227
+ catch {
228
+ return false;
229
+ }
230
+ }
231
+ /** Render an unknown failure. */
232
+ function messageOf(error) {
233
+ return error instanceof Error ? error.message : String(error);
234
+ }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * The capability-catalog row's plugin Config: the `mcp` block carries the
3
+ * persisted MCP management state (server configs, namespaced credential refs,
4
+ * and discovered tools) so MCP servers survive a restart. Credential values are
5
+ * stored here (the user's own config document) but are never exposed over the
6
+ * Remote — `mcpSnapshot` reports only `configured` state.
7
+ *
8
+ * Marked `.volatile()` so store mutations write through the SettingsForms
9
+ * service (host 0.1.7+) without a row reload, and external document updates
10
+ * reach the running fiber's Volatile reference. The block stays permissive
11
+ * (`z.any()`): the catalog card reads/writes it through the Remote, and a
12
+ * hand-typed schemastery shape for the nested config/tuple/secret structure is
13
+ * fragile.
14
+ *
15
+ * Dual-line: `.volatile()` exists only on schemastery 3.18.4+ (the 0.1.7 host
16
+ * line). On 0.1.5's schemastery the call is absent — the plain field then
17
+ * rides the legacy `settings.register` namespace path instead (see index.ts).
18
+ */
19
+ import z from '@deepseek-ai/schemastery';
20
+ /** The persisted MCP state block lives under this key of the plugin config. */
21
+ export declare const MCP_SETTINGS_KEY = "mcp";
22
+ /** Section shape: the MCP management state block (permissive) + nothing else. */
23
+ export declare const CapabilityCatalogSettingsSchema: z<Schemastery.ObjectS<{
24
+ mcp: z<any, any>;
25
+ }>, Schemastery.ObjectT<{
26
+ mcp: z<any, any>;
27
+ }>>;
28
+ //# sourceMappingURL=settings.d.ts.map
@@ -0,0 +1,28 @@
1
+ /**
2
+ * The capability-catalog row's plugin Config: the `mcp` block carries the
3
+ * persisted MCP management state (server configs, namespaced credential refs,
4
+ * and discovered tools) so MCP servers survive a restart. Credential values are
5
+ * stored here (the user's own config document) but are never exposed over the
6
+ * Remote — `mcpSnapshot` reports only `configured` state.
7
+ *
8
+ * Marked `.volatile()` so store mutations write through the SettingsForms
9
+ * service (host 0.1.7+) without a row reload, and external document updates
10
+ * reach the running fiber's Volatile reference. The block stays permissive
11
+ * (`z.any()`): the catalog card reads/writes it through the Remote, and a
12
+ * hand-typed schemastery shape for the nested config/tuple/secret structure is
13
+ * fragile.
14
+ *
15
+ * Dual-line: `.volatile()` exists only on schemastery 3.18.4+ (the 0.1.7 host
16
+ * line). On 0.1.5's schemastery the call is absent — the plain field then
17
+ * rides the legacy `settings.register` namespace path instead (see index.ts).
18
+ */
19
+ import z from '@deepseek-ai/schemastery';
20
+ /** The persisted MCP state block lives under this key of the plugin config. */
21
+ export const MCP_SETTINGS_KEY = 'mcp';
22
+ const mcpField = z.any().default({});
23
+ /** Section shape: the MCP management state block (permissive) + nothing else. */
24
+ export const CapabilityCatalogSettingsSchema = z.object({
25
+ [MCP_SETTINGS_KEY]: typeof mcpField.volatile === 'function'
26
+ ? mcpField.volatile()
27
+ : mcpField,
28
+ });
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Skill env injection via the official `ctx.shellEnv` seam (service-agnostic).
3
+ *
4
+ * The catalog lets a user configure a credential for a skill's declared env var
5
+ * (e.g. `$WEREED_API_KEY`). That value is NOT in `process.env` — the launch env
6
+ * is a read-only snapshot and never injects credentials. This module registers
7
+ * a `ctx.shellEnv` contributor that exposes each configured credential as a
8
+ * trusted, per-execution `DSH_<KEY>` variable. The model references it by shell
9
+ * expansion (`--key="$DSH_WEREED_API_KEY"`) so the raw value never enters the
10
+ * model's context; it only becomes visible if the model (or a command) actively
11
+ * prints it — a default-hide, not a hard secret boundary.
12
+ *
13
+ * Constraint handling (shell-env/src/index.ts):
14
+ * - keys must start `DSH_` and match `/^[A-Z][A-Z0-9_]*$/`; one owner per key;
15
+ * reserved built-ins (`DSH_HOME`/`DSH_SHELL`/`DSH_SESSION_ID`) are not ownable.
16
+ * - `resolve()` is synchronous; credential reads are async, so values live in a
17
+ * cache (`envCache`) refreshed on apply + on `credentials/*-updated`.
18
+ *
19
+ * Robustness (addressed for the review):
20
+ * - read the ref namespace: `credentials.resolve(credentialRef(key))` (value from
21
+ * the .credentials store that `setCredential` writes) — never `readRecord`.
22
+ * - filter env keys that map onto reserved (`HOME`/`SHELL`/`SESSION_ID`) or are
23
+ * already owned by another contributor (e.g. `DSH_SESSION_JSONL`) so a single
24
+ * bad/conflicting key cannot break the whole registration; skip + warn.
25
+ * - use a *declared superset*: register the full valid key set once and keep it;
26
+ * deleting a credential just makes `resolve` return empty (no delete-then-
27
+ * re-register window). Re-register only when the key SET changes.
28
+ * - serialise refresh (single in-flight + queued) and gate commits on a
29
+ * generation counter + `disposed` so a stale run cannot overwrite or
30
+ * re-register after dispose.
31
+ * @module @khorsheed/dsh-capability-catalog/shellEnv
32
+ */
33
+ import type { Context } from '@deepseek-ai/cordis';
34
+ declare const DSH_PREFIX = "DSH_";
35
+ /** The `ctx.shellEnv` registry slice this module consumes (optional). */
36
+ export interface ShellEnvRegistryLike {
37
+ register: (contributor: {
38
+ name: string;
39
+ variables: Record<string, {
40
+ description: string;
41
+ }>;
42
+ resolve: (execution: unknown) => Record<string, string>;
43
+ }) => () => void;
44
+ list: () => readonly {
45
+ readonly key: string;
46
+ readonly contributor: string;
47
+ }[];
48
+ }
49
+ /**
50
+ * Install the skill env injection contributor. Returns a disposer; the `ctx.on`
51
+ * and `shellEnv.register` disposers are also effect-scoped to the calling fiber.
52
+ * Degrades silently (a no-op) when `shellEnv`, `credentials`, or `skills` is
53
+ * absent in the composition.
54
+ *
55
+ * Boot-timing robustness: the contributor's `register()` runs `ctx.effect(...)`
56
+ * internally, so it must be called while its backing fiber is still `active`
57
+ * (alpha's reordered app-boot can leave it in a transient inactive window where
58
+ * a captured reference would throw `INACTIVE_EFFECT`). This install never caches
59
+ * a service reference across an `await`; it re-reads `ctx.get` (strict — returns
60
+ * the impl only while its fiber is active) immediately before calling `register`,
61
+ * and self-retries on `INACTIVE_EFFECT` so it self-heals once boot stabilizes.
62
+ */
63
+ export declare function installSkillEnvInjection(ctx: Context, getScope: () => Promise<unknown | undefined>): () => void;
64
+ export { DSH_PREFIX };
65
+ //# sourceMappingURL=shellEnv.d.ts.map
@@ -0,0 +1,232 @@
1
+ /**
2
+ * Skill env injection via the official `ctx.shellEnv` seam (service-agnostic).
3
+ *
4
+ * The catalog lets a user configure a credential for a skill's declared env var
5
+ * (e.g. `$WEREED_API_KEY`). That value is NOT in `process.env` — the launch env
6
+ * is a read-only snapshot and never injects credentials. This module registers
7
+ * a `ctx.shellEnv` contributor that exposes each configured credential as a
8
+ * trusted, per-execution `DSH_<KEY>` variable. The model references it by shell
9
+ * expansion (`--key="$DSH_WEREED_API_KEY"`) so the raw value never enters the
10
+ * model's context; it only becomes visible if the model (or a command) actively
11
+ * prints it — a default-hide, not a hard secret boundary.
12
+ *
13
+ * Constraint handling (shell-env/src/index.ts):
14
+ * - keys must start `DSH_` and match `/^[A-Z][A-Z0-9_]*$/`; one owner per key;
15
+ * reserved built-ins (`DSH_HOME`/`DSH_SHELL`/`DSH_SESSION_ID`) are not ownable.
16
+ * - `resolve()` is synchronous; credential reads are async, so values live in a
17
+ * cache (`envCache`) refreshed on apply + on `credentials/*-updated`.
18
+ *
19
+ * Robustness (addressed for the review):
20
+ * - read the ref namespace: `credentials.resolve(credentialRef(key))` (value from
21
+ * the .credentials store that `setCredential` writes) — never `readRecord`.
22
+ * - filter env keys that map onto reserved (`HOME`/`SHELL`/`SESSION_ID`) or are
23
+ * already owned by another contributor (e.g. `DSH_SESSION_JSONL`) so a single
24
+ * bad/conflicting key cannot break the whole registration; skip + warn.
25
+ * - use a *declared superset*: register the full valid key set once and keep it;
26
+ * deleting a credential just makes `resolve` return empty (no delete-then-
27
+ * re-register window). Re-register only when the key SET changes.
28
+ * - serialise refresh (single in-flight + queued) and gate commits on a
29
+ * generation counter + `disposed` so a stale run cannot overwrite or
30
+ * re-register after dispose.
31
+ * @module @khorsheed/dsh-capability-catalog/shellEnv
32
+ */
33
+ import { decodeCredentialDecls, decodeEnvDecls, mergeCredentialDecls } from './skills.js';
34
+ const DSH_PREFIX = 'DSH_';
35
+ const ENV_KEY_SUFFIX = /^[A-Z][A-Z0-9_]*$/;
36
+ /** Reserved `DSH_*` names the host's shell-env registry owns as built-in facts
37
+ * (the instance's real HOME / SHELL / SESSION_ID). A credential must never be
38
+ * allowed to take one of these over — a mapped value shadowing the built-in
39
+ * `DSH_HOME` would make the agent read the configured credential as the
40
+ * instance home. Keyed on the MAPPED name (not the raw declaration), so it
41
+ * matches the upstream registry's own reserved set (dsh-home-paths/dsh-shell)
42
+ * and survives prefix/schema drift. The catalog's own `DSH_`-prefixed keys are
43
+ * unaffected: declaring e.g. `DSH_HOME` maps to `DSH_DSH_HOME`, which is not a
44
+ * reserved name and never collides. */
45
+ const RESERVED_ENV_KEYS = new Set(['DSH_HOME', 'DSH_SHELL', 'DSH_SESSION_ID']);
46
+ /**
47
+ * Install the skill env injection contributor. Returns a disposer; the `ctx.on`
48
+ * and `shellEnv.register` disposers are also effect-scoped to the calling fiber.
49
+ * Degrades silently (a no-op) when `shellEnv`, `credentials`, or `skills` is
50
+ * absent in the composition.
51
+ *
52
+ * Boot-timing robustness: the contributor's `register()` runs `ctx.effect(...)`
53
+ * internally, so it must be called while its backing fiber is still `active`
54
+ * (alpha's reordered app-boot can leave it in a transient inactive window where
55
+ * a captured reference would throw `INACTIVE_EFFECT`). This install never caches
56
+ * a service reference across an `await`; it re-reads `ctx.get` (strict — returns
57
+ * the impl only while its fiber is active) immediately before calling `register`,
58
+ * and self-retries on `INACTIVE_EFFECT` so it self-heals once boot stabilizes.
59
+ */
60
+ export function installSkillEnvInjection(ctx, getScope) {
61
+ let disposed = false;
62
+ let envCache = new Map();
63
+ let disposeEnv;
64
+ let refreshKey;
65
+ let refreshGen = 0;
66
+ let inFlight;
67
+ let queued = false;
68
+ let retryTimer;
69
+ let retries = 0;
70
+ const MAX_RETRIES = 30;
71
+ const RETRY_DELAY = 300;
72
+ const cancelRetry = () => {
73
+ if (retryTimer !== undefined) {
74
+ clearTimeout(retryTimer);
75
+ retryTimer = undefined;
76
+ }
77
+ };
78
+ const scheduleRetry = () => {
79
+ if (disposed || retries >= MAX_RETRIES)
80
+ return;
81
+ cancelRetry();
82
+ retries++;
83
+ retryTimer = setTimeout(() => {
84
+ retryTimer = undefined;
85
+ if (!disposed)
86
+ void refresh();
87
+ }, RETRY_DELAY);
88
+ };
89
+ /** True for the `INACTIVE_EFFECT` Cordis error thrown by `ctx.effect` when the
90
+ * fiber is inactive — a transient boot-time race, not a real owner collision. */
91
+ const isInactiveEffect = (err) => {
92
+ return !!err && typeof err === 'object' && 'code' in err
93
+ && err.code === 'INACTIVE_EFFECT';
94
+ };
95
+ /** One refresh pass; commits only if still the latest generation and not disposed. */
96
+ const refresh = async () => {
97
+ if (disposed)
98
+ return;
99
+ if (inFlight !== undefined) {
100
+ // Bump the generation so the in-flight pass is now stale and will not
101
+ // commit the value it read before this update arrived (strict
102
+ // latest-wins); the queued pass re-reads + commits fresh.
103
+ refreshGen++;
104
+ queued = true;
105
+ return;
106
+ }
107
+ // Re-resolve the services FRESH on every pass (never across an `await`).
108
+ // `ctx.get` (strict) returns the implementation only while its providing
109
+ // fiber is active, so a reload/reorder that makes a previously-captured
110
+ // reference inactive is never called into here. If any service is not
111
+ // currently active, this is a transient boot window — retry.
112
+ const shellEnv = ctx.get?.('shellEnv');
113
+ const credentials = ctx.get?.('credentials');
114
+ const skills = ctx.get?.('skills');
115
+ if (shellEnv?.register === undefined || credentials === undefined || skills === undefined) {
116
+ scheduleRetry();
117
+ return;
118
+ }
119
+ const gen = ++refreshGen;
120
+ inFlight = (async () => {
121
+ const scope = await getScope().catch(() => undefined);
122
+ const snapshotOptions = scope === undefined ? {} : { scope };
123
+ try {
124
+ const snapshot = await skills.snapshot(snapshotOptions);
125
+ // Keys already owned by another contributor (must not redeclare).
126
+ const occupied = new Set((shellEnv.list?.() ?? []).filter(e => e.contributor !== 'capability-catalog').map(e => e.key));
127
+ const keys = new Set();
128
+ const values = new Map();
129
+ for (const row of snapshot.skills) {
130
+ // Only skills the model may invoke get their credentials injected —
131
+ // a model-disabled (user-only) skill cannot be used by the model, so
132
+ // putting its secret in the model shell would be pure exposure.
133
+ if (row.invocation?.modelInvocable === false)
134
+ continue;
135
+ let def;
136
+ try {
137
+ def = await skills.get(row.name, snapshotOptions);
138
+ }
139
+ catch {
140
+ continue;
141
+ }
142
+ if (def === undefined)
143
+ continue;
144
+ const decls = mergeCredentialDecls(decodeCredentialDecls(def.metadata), decodeEnvDecls(def.content ?? ''));
145
+ for (const decl of decls) {
146
+ if (!ENV_KEY_SUFFIX.test(decl.key))
147
+ continue;
148
+ const dshKey = `${DSH_PREFIX}${decl.key}`;
149
+ // Never let a credential take over a reserved built-in DSH_* name
150
+ // (DSH_HOME etc.). Declaring `HOME` maps to `DSH_HOME` and is
151
+ // dropped here; declaring `DSH_HOME` maps to `DSH_DSH_HOME`, which
152
+ // is not reserved and stays.
153
+ if (RESERVED_ENV_KEYS.has(dshKey) || occupied.has(dshKey))
154
+ continue;
155
+ keys.add(decl.key);
156
+ const resolved = await credentials.resolve(decl.key).catch(() => undefined);
157
+ if (resolved !== undefined && resolved.value.length > 0)
158
+ values.set(dshKey, resolved.value);
159
+ }
160
+ }
161
+ if (disposed || gen !== refreshGen)
162
+ return; // stale or disposed — drop the commit
163
+ envCache = values;
164
+ const keySet = [...keys].sort().join('\n');
165
+ if (keySet !== refreshKey) {
166
+ disposeEnv?.();
167
+ disposeEnv = undefined;
168
+ refreshKey = undefined;
169
+ const variables = {};
170
+ for (const k of keys) {
171
+ variables[`${DSH_PREFIX}${k}`] = { description: `Configured via the capability catalog for skill env \`${k}\`` };
172
+ }
173
+ // Re-resolve the registry immediately before `register` — never call
174
+ // `ctx.effect` on a reference captured across an `await`. If the
175
+ // service is inactive right now (boot reorder window), drop this pass
176
+ // and retry; leave refreshKey unset so the next pass re-registers.
177
+ const shellEnvNow = ctx.get?.('shellEnv');
178
+ if (shellEnvNow?.register === undefined) {
179
+ scheduleRetry();
180
+ return;
181
+ }
182
+ try {
183
+ disposeEnv = shellEnvNow.register({
184
+ name: 'capability-catalog',
185
+ variables,
186
+ resolve: () => {
187
+ const out = {};
188
+ for (const [k, v] of envCache) {
189
+ if (Object.hasOwn(variables, k))
190
+ out[k] = v;
191
+ }
192
+ return out;
193
+ },
194
+ });
195
+ refreshKey = keySet;
196
+ retries = 0;
197
+ }
198
+ catch (err) {
199
+ // Register failed (e.g. a TOCTOU owner collision). Leave refreshKey
200
+ // unset so the NEXT refresh retries regardless of the key set even
201
+ // if the key set later returns to a previously-registered value.
202
+ process.stderr.write(`capability-catalog: skill env contributor register failed (${String(err)})`);
203
+ if (isInactiveEffect(err))
204
+ scheduleRetry();
205
+ }
206
+ }
207
+ }
208
+ catch (err) {
209
+ process.stderr.write(`capability-catalog: skill env injection refresh failed (${String(err)})`);
210
+ }
211
+ finally {
212
+ inFlight = undefined;
213
+ if (queued && !disposed) {
214
+ queued = false;
215
+ void refresh();
216
+ }
217
+ }
218
+ })();
219
+ await inFlight;
220
+ };
221
+ void refresh();
222
+ const offRecord = ctx.on('credentials/record-updated', () => { void refresh(); });
223
+ const offReference = ctx.on('credentials/reference-updated', () => { void refresh(); });
224
+ return () => {
225
+ disposed = true;
226
+ cancelRetry();
227
+ disposeEnv?.();
228
+ offRecord();
229
+ offReference();
230
+ };
231
+ }
232
+ export { DSH_PREFIX };