@khorsheed/dsh-capability-catalog 0.1.98 → 0.1.99

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 (46) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/README.en.md +31 -7
  3. package/README.i18n.yaml +2 -2
  4. package/README.md +31 -7
  5. package/cordis.patch.yml +1 -1
  6. package/lib/client.js +14792 -904
  7. package/lib/index.js +748 -263
  8. package/lib/typert.host.js +614 -295
  9. package/lib/typert.remote-client.d.ts +27 -4
  10. package/lib/typert.remote-client.js +599 -288
  11. package/lib/types/client/CapabilityCatalogCard.js +20 -5
  12. package/lib/types/client/SkillDetailModal.d.ts +13 -5
  13. package/lib/types/client/SkillDetailModal.js +9 -3
  14. package/lib/types/client/locales.d.ts +0 -4
  15. package/lib/types/client/locales.js +4 -8
  16. package/lib/types/envHint.d.ts +7 -1
  17. package/lib/types/envHint.js +52 -18
  18. package/lib/types/evidence.d.ts +142 -0
  19. package/lib/types/evidence.js +179 -0
  20. package/lib/types/frontmatter.d.ts +78 -0
  21. package/lib/types/frontmatter.js +186 -0
  22. package/lib/types/import.js +10 -19
  23. package/lib/types/index.d.ts +8 -3
  24. package/lib/types/index.js +21 -50
  25. package/lib/types/mcpStore.d.ts +6 -8
  26. package/lib/types/mcpStore.js +18 -43
  27. package/lib/types/mcps.d.ts +1 -6
  28. package/lib/types/mcps.js +0 -9
  29. package/lib/types/namespace.d.ts +2 -3
  30. package/lib/types/namespace.js +2 -3
  31. package/lib/types/remote.d.ts +11 -2
  32. package/lib/types/remote.js +4 -1
  33. package/lib/types/scoped-delivery.d.ts +36 -24
  34. package/lib/types/scoped-delivery.js +136 -100
  35. package/lib/types/scoped-edits.js +2 -1
  36. package/lib/types/shellEnv.d.ts +8 -1
  37. package/lib/types/shellEnv.js +37 -16
  38. package/lib/types/skills.d.ts +41 -0
  39. package/lib/types/skills.js +51 -9
  40. package/lib/types/tool-face.d.ts +68 -0
  41. package/lib/types/tool-face.js +82 -0
  42. package/lib/types/types.d.ts +40 -19
  43. package/package.json +20 -18
  44. package/lib/invariant.js +0 -25
  45. package/lib/types/invariant.d.ts +0 -16
  46. package/lib/types/invariant.js +0 -23
@@ -29,7 +29,11 @@ import { mkdir, readFile, readdir, stat } from 'node:fs/promises';
29
29
  import { homedir } from 'node:os';
30
30
  import { join } from 'node:path';
31
31
  import { createScope } from '@deepseek-ai/dsh-scope';
32
+ import { defineTool } from '@deepseek-ai/dsh-tools';
32
33
  import { acquireStandingScope } from './preset-scope.js';
34
+ import { collectSkillFiles, resolveBundleTarget, sanitizeBundlePath } from './skills.js';
35
+ import { isKebabId, parseSkillFrontmatter } from './frontmatter.js';
36
+ import { setToolOrigin } from './tool-origin.js';
33
37
  let presetRegistryProbe;
34
38
  /**
35
39
  * Resolve the preset-registry module by name. The 0.1.7-rc.1 host renamed
@@ -62,118 +66,39 @@ export const MANAGED_SKILL_RANK = 300;
62
66
  export const SCOPED_SKILLS_DIR = join('capability-catalog', 'skills');
63
67
  /** Debounce for filesystem-triggered rescans. */
64
68
  const RESCAN_DEBOUNCE_MS = 150;
65
- /** Frontmatter block, then the body it precedes. */
66
- const FRONTMATTER = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/;
67
- /** Kebab-case skill / preset id grammar (the registry's own). */
68
- const KEBAB_ID = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
69
69
  /** The managed root for one harness home. */
70
70
  export function scopedSkillsRoot(dshHome) {
71
71
  return join(dshHome, SCOPED_SKILLS_DIR);
72
72
  }
73
- /**
74
- * Read the `presetScope` list out of a frontmatter block.
75
- *
76
- * Accepted forms, at any indentation (the canonical one is
77
- * `metadata.presetScope`):
78
- *
79
- * ```yaml
80
- * metadata:
81
- * presetScope: [dsh-writing, dev]
82
- * presetScope:
83
- * - dsh-writing
84
- * ```
85
- * @param front - the frontmatter text without its `---` fences.
86
- * @returns the declared ids, or undefined when the key is absent.
87
- */
88
- export function parsePresetScopeFrontmatter(front) {
89
- const lines = front.split('\n');
90
- for (let index = 0; index < lines.length; index += 1) {
91
- const match = /^(\s*)presetScope\s*:\s*(.*)$/.exec(lines[index] ?? '');
92
- if (match === null)
93
- continue;
94
- const indent = (match[1] ?? '').length;
95
- const rest = (match[2] ?? '').trim();
96
- if (rest.startsWith('[')) {
97
- const inner = rest.slice(1, rest.lastIndexOf(']') === -1 ? undefined : rest.lastIndexOf(']'));
98
- return presetIdsOf(inner.split(','));
99
- }
100
- if (rest !== '')
101
- return presetIdsOf([rest]);
102
- const block = [];
103
- for (let item = index + 1; item < lines.length; item += 1) {
104
- const entry = /^(\s*)-\s*(.*?)\s*$/.exec(lines[item] ?? '');
105
- if (entry === null)
106
- break;
107
- if ((entry[1] ?? '').length <= indent)
108
- break;
109
- block.push(entry[2] ?? '');
110
- }
111
- return presetIdsOf(block);
112
- }
113
- return undefined;
114
- }
115
- /** Normalize YAML scalars onto the preset-id grammar. */
116
- function presetIdsOf(values) {
117
- const ids = [];
118
- for (const value of values) {
119
- const id = value.trim().replace(/^['"]|['"]$/g, '');
120
- if (id === '' || !KEBAB_ID.test(id))
121
- continue;
122
- if (!ids.includes(id))
123
- ids.push(id);
124
- }
125
- return ids;
126
- }
127
- /** Whether a string is a usable skill / preset id. */
128
- export function isKebabId(value) {
129
- return KEBAB_ID.test(value);
130
- }
131
- /** Split a SKILL.md into its frontmatter block and body. */
132
- export function splitFrontmatter(content) {
133
- const match = FRONTMATTER.exec(content);
134
- if (match === null)
135
- return undefined;
136
- return { front: match[1] ?? '', body: match[2] ?? '' };
137
- }
138
- /** Read a boolean frontmatter flag (`key: true|false`). */
139
- function frontmatterFlag(front, key) {
140
- const match = new RegExp(`^\\s*${key}\\s*:\\s*(true|false)\\s*$`, 'm').exec(front);
141
- if (match === null)
142
- return undefined;
143
- return match[1] === 'true';
144
- }
145
- /** Read a scalar frontmatter value, unquoting a simple quoted or plain form. */
146
- function frontmatterScalar(front, key) {
147
- const match = new RegExp(`^\\s*${key}\\s*:\\s*(.+)$`, 'm').exec(front);
148
- if (match === null)
149
- return undefined;
150
- const value = (match[1] ?? '').trim().replace(/^['"]|['"]$/g, '');
151
- return value === '' ? undefined : value;
152
- }
153
73
  /**
154
74
  * Parse one managed `SKILL.md` into its delivery record.
75
+ *
76
+ * The frontmatter is ONE real-YAML parse (see `./frontmatter.ts`): name and
77
+ * description read top-level only, the invocation flags accept the host
78
+ * loader's boolean variants, and the scope list comes from the canonical
79
+ * `metadata.presetScope` (falling back to a legacy top-level key). A malformed
80
+ * `metadata:` block degrades to no-metadata — the skill still delivers —
81
+ * rather than rejecting a file the rest of the block could still serve.
155
82
  * @param content - the file text.
156
83
  * @param dir - the skill's directory (its resource base).
157
84
  * @param path - the absolute `SKILL.md` path.
158
85
  * @returns the record, or undefined when the frontmatter is unusable.
159
86
  */
160
87
  export function managedSkillFrom(content, dir, path) {
161
- const split = splitFrontmatter(content);
162
- if (split === undefined)
88
+ const parsed = parseSkillFrontmatter(content);
89
+ if (parsed === undefined)
163
90
  return undefined;
164
- const name = frontmatterScalar(split.front, 'name');
165
- const description = frontmatterScalar(split.front, 'description');
166
- if (name === undefined || description === undefined || !KEBAB_ID.test(name))
91
+ const { name, description } = parsed;
92
+ if (name === undefined || description === undefined || !isKebabId(name))
167
93
  return undefined;
168
- const whenToUse = frontmatterScalar(split.front, 'when-to-use') ?? frontmatterScalar(split.front, 'whenToUse');
169
- const presetScope = parsePresetScopeFrontmatter(split.front);
170
94
  return {
171
95
  name,
172
96
  description,
173
- ...whenToUse === undefined ? {} : { whenToUse },
174
- modelInvocable: frontmatterFlag(split.front, 'disable-model-invocation') !== true,
175
- userInvocable: frontmatterFlag(split.front, 'user-invocable') !== false,
176
- presetScope,
97
+ ...parsed.whenToUse === undefined ? {} : { whenToUse: parsed.whenToUse },
98
+ modelInvocable: parsed.modelInvocable,
99
+ userInvocable: parsed.userInvocable,
100
+ presetScope: parsed.presetScope,
101
+ ...parsed.metadata === undefined ? {} : { metadata: parsed.metadata },
177
102
  dir,
178
103
  path,
179
104
  content,
@@ -195,7 +120,10 @@ export function candidateFor(skill) {
195
120
  rank: MANAGED_SKILL_RANK,
196
121
  locator: skill.path,
197
122
  path: skill.path,
198
- metadata: { presetScope: [...(skill.presetScope ?? [])] },
123
+ // The declared scope is overlaid onto the skill's own metadata block (never
124
+ // a rebuild): credentials declarations and other plugin data must survive
125
+ // delivery unchanged, or the detail surface loses them on adoption.
126
+ metadata: { ...skill.metadata, presetScope: [...(skill.presetScope ?? [])] },
199
127
  resourceBase: { kind: 'directory', path: skill.dir },
200
128
  };
201
129
  }
@@ -253,7 +181,7 @@ export async function scanManagedSkills(root) {
253
181
  }
254
182
  const found = [];
255
183
  for (const child of children) {
256
- if (!child.isDirectory() || !KEBAB_ID.test(child.name))
184
+ if (!child.isDirectory() || !isKebabId(child.name))
257
185
  continue;
258
186
  const dir = join(root, child.name);
259
187
  const path = join(dir, 'SKILL.md');
@@ -297,6 +225,9 @@ export class ScopedSkillDelivery {
297
225
  failure;
298
226
  started = false;
299
227
  disposed = false;
228
+ /** Reconcile gate: one pass in flight, one queued rerun (see reconcile). */
229
+ reconcileInFlight;
230
+ reconcileQueued = false;
300
231
  /** The probed registry's mount list (undefined until start()'s probe resolves). */
301
232
  presetMounts;
302
233
  /**
@@ -322,8 +253,49 @@ export class ScopedSkillDelivery {
322
253
  this.ctx.on('agent-preset/selected', () => { void this.reconcile(); });
323
254
  await this.reconcile();
324
255
  }
325
- /** Re-read policy and files, then converge the delivery registrations. */
256
+ /**
257
+ * Re-read policy and files, then converge the delivery registrations.
258
+ *
259
+ * Triggers arrive concurrently — the filesystem watcher, the
260
+ * `agent-preset/selected` event, and the management Remotes — and two
261
+ * overlapping passes can both decide a preset has no registration and each
262
+ * register one, leaving two same-named providers serving one preset. The
263
+ * gate allows ONE pass in flight and coalesces every trigger that arrives
264
+ * during it into ONE queued rerun (the pass re-reads policy and files from
265
+ * scratch, so a rerun loses no update). A caller that finds a pass in
266
+ * flight waits for the chain its flag provoked, so an awaited reconcile
267
+ * still reflects the write that triggered it.
268
+ */
326
269
  async reconcile() {
270
+ if (this.disposed)
271
+ return;
272
+ if (this.reconcileInFlight !== undefined) {
273
+ this.reconcileQueued = true;
274
+ let run = this.reconcileInFlight;
275
+ while (run !== undefined) {
276
+ await run;
277
+ if (this.disposed)
278
+ return;
279
+ run = this.reconcileInFlight;
280
+ }
281
+ return;
282
+ }
283
+ const run = (async () => {
284
+ try {
285
+ do {
286
+ this.reconcileQueued = false;
287
+ await this.reconcileNow();
288
+ } while (this.reconcileQueued && !this.disposed);
289
+ }
290
+ finally {
291
+ this.reconcileInFlight = undefined;
292
+ }
293
+ })();
294
+ this.reconcileInFlight = run;
295
+ return run;
296
+ }
297
+ /** One reconcile pass (call only through {@link reconcile}'s gate). */
298
+ async reconcileNow() {
327
299
  if (this.disposed)
328
300
  return;
329
301
  const registry = this.deps.registry();
@@ -565,6 +537,13 @@ export class ScopedSkillDelivery {
565
537
  invalidate = control.invalidate;
566
538
  return this.providerFor(presetId, control);
567
539
  });
540
+ // The bundle-file reader rides the same scope layer: a preset that mounts
541
+ // no fs tools (learning mode) still lets the model read a delivered
542
+ // skill's reference files. Best-effort — a composition without the tools
543
+ // service keeps the delivery and drops only the reader.
544
+ const withTools = await injectableScope(injected, TOOLS_SERVICE);
545
+ const tools = withTools?.get(TOOLS_SERVICE);
546
+ tools?.register?.(this.bundleFileTool(presetId));
568
547
  this.entries.push({
569
548
  presetId,
570
549
  key,
@@ -622,6 +601,56 @@ export class ScopedSkillDelivery {
622
601
  ? !isScoped(skill)
623
602
  : (skill.presetScope ?? []).includes(presetId)));
624
603
  }
604
+ /** The managed skills readable through one preset's reader: its own scoped
605
+ * delivery plus the unscoped ones every preset sees through the global layer. */
606
+ readableFor(presetId) {
607
+ return [...this.skillsFor(presetId), ...this.skillsFor(GLOBAL_PRESET)];
608
+ }
609
+ /** The read-only bundle-file reader served with one preset's delivery. */
610
+ bundleFileTool(presetId) {
611
+ return setToolOrigin(defineTool({
612
+ name: READ_SKILL_FILE_TOOL,
613
+ description: 'Read a file from the bundle of a skill delivered to this mode — the reference documents and scripts beside its SKILL.md (a SKILL.md may point at them, e.g. "see search.md"). Omit path to list the bundle files.',
614
+ parameters: {
615
+ name: { type: 'string', required: true, description: 'The exact skill name from the available skills list.' },
616
+ path: { type: 'string', description: 'The bundle-relative file path (e.g. search.md). Omit it to list the bundle files.' },
617
+ },
618
+ output: {
619
+ schema: { type: 'string' },
620
+ render: (_args, value) => [{ type: 'text', text: String(value) }],
621
+ },
622
+ execute: async (args) => this.readBundleFile(presetId, args.name, args.path),
623
+ }), { channel: 'plugin', owner: '@khorsheed/dsh-capability-catalog' });
624
+ }
625
+ /** Serve one bundle file (or the bundle's file list) to a delivered skill's caller. */
626
+ async readBundleFile(presetId, name, filePath) {
627
+ const skill = this.readableFor(presetId).find(entry => entry.name === name);
628
+ if (skill === undefined)
629
+ return `skill "${name}" is not delivered to this mode by the capability catalog`;
630
+ if (filePath === undefined || filePath.trim() === '') {
631
+ const files = [];
632
+ await collectSkillFiles(skill.dir, skill.dir, files, 0);
633
+ return files.length === 0 ? `skill "${name}" has no bundle files` : files.sort().join('\n');
634
+ }
635
+ const safe = sanitizeBundlePath(filePath);
636
+ if (safe === undefined)
637
+ return `refused: "${filePath}" is not a bundle-relative path`;
638
+ // Containment including symlinks — the bundle directory is user content.
639
+ const target = await resolveBundleTarget(skill.dir, safe);
640
+ if (target === undefined)
641
+ return `no such bundle file: ${safe}`;
642
+ let content;
643
+ try {
644
+ content = await readFile(target, 'utf8');
645
+ }
646
+ catch {
647
+ return `no such bundle file: ${safe}`;
648
+ }
649
+ if (content.length > MAX_BUNDLE_FILE_CHARS) {
650
+ return `${content.slice(0, MAX_BUNDLE_FILE_CHARS)}\n…(truncated at ${MAX_BUNDLE_FILE_CHARS} characters)`;
651
+ }
652
+ return content;
653
+ }
625
654
  /** Invalidate every registration's completed catalogs. */
626
655
  invalidateAll() {
627
656
  for (const entry of this.entries)
@@ -658,6 +687,12 @@ function describeError(error) {
658
687
  }
659
688
  /** The registry every delivery registration is made through. */
660
689
  const SKILLS_SERVICE = 'skills';
690
+ /** The tools registry the bundle-file reader registers through (optional). */
691
+ const TOOLS_SERVICE = 'tools';
692
+ /** The scoped bundle-file reader's tool name. */
693
+ export const READ_SKILL_FILE_TOOL = 'read_skill_file';
694
+ /** A bundle file's served text is capped so a giant asset cannot flood a turn. */
695
+ const MAX_BUNDLE_FILE_CHARS = 100_000;
661
696
  /** How long an injection may take before the registration is abandoned. */
662
697
  const INJECT_TIMEOUT_MS = 500;
663
698
  /**
@@ -668,9 +703,10 @@ const INJECT_TIMEOUT_MS = 500;
668
703
  * await, which gives `reconcile()` a settled answer instead of a registration
669
704
  * that appears at some later tick.
670
705
  * @param scopeCtx - the context minted for one preset's standing key.
706
+ * @param service - the service the injected context must reach.
671
707
  * @returns the injected context, or undefined when it never became available.
672
708
  */
673
- function injectableScope(scopeCtx) {
709
+ function injectableScope(scopeCtx, service = SKILLS_SERVICE) {
674
710
  return new Promise(resolve => {
675
711
  let settled = false;
676
712
  const finish = (value) => {
@@ -681,6 +717,6 @@ function injectableScope(scopeCtx) {
681
717
  resolve(value);
682
718
  };
683
719
  const timer = setTimeout(() => finish(undefined), INJECT_TIMEOUT_MS);
684
- scopeCtx.inject([SKILLS_SERVICE], (injected) => finish(injected));
720
+ scopeCtx.inject([service], (injected) => finish(injected));
685
721
  });
686
722
  }
@@ -14,7 +14,8 @@
14
14
  import { cp, mkdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises';
15
15
  import { existsSync } from 'node:fs';
16
16
  import { dirname, join } from 'node:path';
17
- import { defaultSkillRoots, isKebabId, scanManagedSkills, scopedSkillsRoot, splitFrontmatter, } from './scoped-delivery.js';
17
+ import { defaultSkillRoots, scanManagedSkills, scopedSkillsRoot, } from './scoped-delivery.js';
18
+ import { isKebabId, splitFrontmatter } from './frontmatter.js';
18
19
  /** Shared guard: every write path addresses one skill directory by name. */
19
20
  function invalidName(name) {
20
21
  return isKebabId(name) ? undefined : { ok: false, error: `"${name}" is not a valid skill name` };
@@ -10,6 +10,13 @@
10
10
  * model's context; it only becomes visible if the model (or a command) actively
11
11
  * prints it — a default-hide, not a hard secret boundary.
12
12
  *
13
+ * Coverage: the registry snapshot is read at the catalog's preset scope, which
14
+ * a preset-scoped managed skill never appears in — so the managed root is ALSO
15
+ * scanned directly (its skills' declared keys join the same set). The injected
16
+ * variables are instance-wide per execution: preset scope is a delivery policy,
17
+ * not a secret boundary, and a value leaves the shell env only if a command
18
+ * prints it.
19
+ *
13
20
  * Constraint handling (shell-env/src/index.ts):
14
21
  * - keys must start `DSH_` and match `/^[A-Z][A-Z0-9_]*$/`; one owner per key;
15
22
  * reserved built-ins (`DSH_HOME`/`DSH_SHELL`/`DSH_SESSION_ID`) are not ownable.
@@ -60,6 +67,6 @@ export interface ShellEnvRegistryLike {
60
67
  * the impl only while its fiber is active) immediately before calling `register`,
61
68
  * and self-retries on `INACTIVE_EFFECT` so it self-heals once boot stabilizes.
62
69
  */
63
- export declare function installSkillEnvInjection(ctx: Context, getScope: () => Promise<unknown | undefined>): () => void;
70
+ export declare function installSkillEnvInjection(ctx: Context, getScope: () => Promise<unknown | undefined>, getDshHome: () => string): () => void;
64
71
  export { DSH_PREFIX };
65
72
  //# sourceMappingURL=shellEnv.d.ts.map
@@ -10,6 +10,13 @@
10
10
  * model's context; it only becomes visible if the model (or a command) actively
11
11
  * prints it — a default-hide, not a hard secret boundary.
12
12
  *
13
+ * Coverage: the registry snapshot is read at the catalog's preset scope, which
14
+ * a preset-scoped managed skill never appears in — so the managed root is ALSO
15
+ * scanned directly (its skills' declared keys join the same set). The injected
16
+ * variables are instance-wide per execution: preset scope is a delivery policy,
17
+ * not a secret boundary, and a value leaves the shell env only if a command
18
+ * prints it.
19
+ *
13
20
  * Constraint handling (shell-env/src/index.ts):
14
21
  * - keys must start `DSH_` and match `/^[A-Z][A-Z0-9_]*$/`; one owner per key;
15
22
  * reserved built-ins (`DSH_HOME`/`DSH_SHELL`/`DSH_SESSION_ID`) are not ownable.
@@ -31,6 +38,7 @@
31
38
  * @module @khorsheed/dsh-capability-catalog/shellEnv
32
39
  */
33
40
  import { decodeCredentialDecls, decodeEnvDecls, mergeCredentialDecls } from './skills.js';
41
+ import { definitionFor, scanManagedSkills, scopedSkillsRoot } from './scoped-delivery.js';
34
42
  const DSH_PREFIX = 'DSH_';
35
43
  const ENV_KEY_SUFFIX = /^[A-Z][A-Z0-9_]*$/;
36
44
  /** Reserved `DSH_*` names the host's shell-env registry owns as built-in facts
@@ -57,7 +65,7 @@ const RESERVED_ENV_KEYS = new Set(['DSH_HOME', 'DSH_SHELL', 'DSH_SESSION_ID']);
57
65
  * the impl only while its fiber is active) immediately before calling `register`,
58
66
  * and self-retries on `INACTIVE_EFFECT` so it self-heals once boot stabilizes.
59
67
  */
60
- export function installSkillEnvInjection(ctx, getScope) {
68
+ export function installSkillEnvInjection(ctx, getScope, getDshHome) {
61
69
  let disposed = false;
62
70
  let envCache = new Map();
63
71
  let disposeEnv;
@@ -126,21 +134,8 @@ export function installSkillEnvInjection(ctx, getScope) {
126
134
  const occupied = new Set((shellEnv.list?.() ?? []).filter(e => e.contributor !== 'capability-catalog').map(e => e.key));
127
135
  const keys = new Set();
128
136
  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;
137
+ /** Fold one definition's declared env keys into the contributor's set. */
138
+ const collectDecls = async (def) => {
144
139
  const decls = mergeCredentialDecls(decodeCredentialDecls(def.metadata), decodeEnvDecls(def.content ?? ''));
145
140
  for (const decl of decls) {
146
141
  if (!ENV_KEY_SUFFIX.test(decl.key))
@@ -157,6 +152,32 @@ export function installSkillEnvInjection(ctx, getScope) {
157
152
  if (resolved !== undefined && resolved.value.length > 0)
158
153
  values.set(dshKey, resolved.value);
159
154
  }
155
+ };
156
+ for (const row of snapshot.skills) {
157
+ // Only skills the model may invoke get their credentials injected —
158
+ // a model-disabled (user-only) skill cannot be used by the model, so
159
+ // putting its secret in the model shell would be pure exposure.
160
+ if (row.invocation?.modelInvocable === false)
161
+ continue;
162
+ let def;
163
+ try {
164
+ def = await skills.get(row.name, snapshotOptions);
165
+ }
166
+ catch {
167
+ continue;
168
+ }
169
+ if (def === undefined)
170
+ continue;
171
+ await collectDecls(def);
172
+ }
173
+ // The registry read is scoped to the catalog's preset view: a skill
174
+ // scoped AWAY from that preset never appears in the snapshot, and its
175
+ // declared credentials would never be injected. Scan the managed root
176
+ // directly so a preset-scoped skill's keys are covered too.
177
+ for (const skill of await scanManagedSkills(scopedSkillsRoot(getDshHome())).catch(() => [])) {
178
+ if (!skill.modelInvocable)
179
+ continue;
180
+ await collectDecls(definitionFor(skill));
160
181
  }
161
182
  if (disposed || gen !== refreshGen)
162
183
  return; // stale or disposed — drop the commit
@@ -79,15 +79,56 @@ export declare function resolveServices(ctx: Context): {
79
79
  registry: RegistrySlice | undefined;
80
80
  credentials: CredentialsSlice | undefined;
81
81
  };
82
+ /**
83
+ * The health of one skills collection pass — the facts a completeness
84
+ * descriptor is built from. Counts only, never error text (which may carry
85
+ * paths). Filled by {@link collectSkills} when the caller passes a bag.
86
+ */
87
+ export interface SkillsCollectionReport {
88
+ /** Scope reads attempted. */
89
+ attemptedScopes: number;
90
+ /** Scope reads whose `registry.snapshot()` threw (source class: registry read). */
91
+ failedScopes: number;
92
+ /** Reads the registry itself flagged as incomplete discovery (`complete: false`). */
93
+ incompleteReads: number;
94
+ /** Skills whose body could not be loaded while fingerprinting. */
95
+ unreadableBodies: number;
96
+ }
82
97
  /**
83
98
  * Collect the skills portion of the catalog across the given view scopes.
84
99
  *
85
100
  * `bodySha` costs one registry load per skill, so it is opt-in: the browser
86
101
  * card wants a listing, and the capability fingerprint wants the bodies.
102
+ *
103
+ * `report`, when given, is filled with the collection's health (see
104
+ * {@link SkillsCollectionReport}); the Remote verbs never pass one, so their
105
+ * rows — and their digests — are exactly what they were.
87
106
  */
88
107
  export declare function collectSkills(registry: RegistrySlice, workdir: string | undefined, scopes?: readonly unknown[], collectOptions?: {
89
108
  bodySha?: boolean;
109
+ report?: SkillsCollectionReport;
90
110
  }): Promise<CapabilityCatalogSnapshot>;
111
+ /** Recursively collect a directory's file paths relative to `root`. */
112
+ export declare function collectSkillFiles(dir: string, root: string, out: string[], depth: number): Promise<void>;
113
+ /** Reject unsafe bundle-relative paths (absolute, drive-letter, or `..`). */
114
+ export declare function sanitizeBundlePath(path: string): string | undefined;
115
+ /**
116
+ * Resolve an already-sanitized bundle-relative path to its real on-disk
117
+ * target, refusing anything that escapes the bundle. {@link sanitizeBundlePath}
118
+ * has already removed the lexical escapes (absolute forms, drive letters,
119
+ * `..`); this closes what a lexical check cannot:
120
+ *
121
+ * - a symlink INSIDE the bundle whose target lives outside it (the bundle
122
+ * directory is user content), and
123
+ * - a prefix collision — containment is a separator-terminated comparison on
124
+ * realpaths, so `/root/foobar` never contain-matches `/root/foo`.
125
+ *
126
+ * @param bundleDir - the skill's bundle directory (its resource base).
127
+ * @param safe - a path that already passed {@link sanitizeBundlePath}.
128
+ * @returns the resolved absolute target, or undefined when the file is absent
129
+ * or would escape the bundle.
130
+ */
131
+ export declare function resolveBundleTarget(bundleDir: string, safe: string): Promise<string | undefined>;
91
132
  /** Read one skill-bundle file's text content, on demand. */
92
133
  export declare function readSkillFileContent(registry: RegistrySlice, name: string, filePath: string, workdir: string | undefined, scope?: unknown, fallback?: SkillDefinitionLike): Promise<CatalogSkillFileRead | undefined>;
93
134
  /** Delete a file skill's directory. Returns an error for built-in / plugin-provided
@@ -8,8 +8,8 @@
8
8
  * and reads credentials state through the optional `ctx.credentials` service.
9
9
  * @module @khorsheed/dsh-capability-catalog/skills
10
10
  */
11
- import { readFile, readdir, rm, stat } from 'node:fs/promises';
12
- import { join, relative } from 'node:path';
11
+ import { readFile, readdir, realpath, rm, stat } from 'node:fs/promises';
12
+ import { join, relative, sep } from 'node:path';
13
13
  import { hashSkillBody } from './capabilities.js';
14
14
  /** POSIX identifier — the minimal shape a credential reference name must match
15
15
  * (the env keys the catalog handles are a strict subset of this). */
@@ -132,12 +132,18 @@ async function skillBodySha(registry, name, options) {
132
132
  *
133
133
  * `bodySha` costs one registry load per skill, so it is opt-in: the browser
134
134
  * card wants a listing, and the capability fingerprint wants the bodies.
135
+ *
136
+ * `report`, when given, is filled with the collection's health (see
137
+ * {@link SkillsCollectionReport}); the Remote verbs never pass one, so their
138
+ * rows — and their digests — are exactly what they were.
135
139
  */
136
140
  export async function collectSkills(registry, workdir, scopes = [undefined], collectOptions = {}) {
137
141
  const byName = new Map();
138
142
  for (const scope of scopes) {
139
143
  const base = workdir === undefined ? {} : { cwd: workdir };
140
144
  const options = scope === undefined ? base : { ...base, scope };
145
+ if (collectOptions.report !== undefined)
146
+ collectOptions.report.attemptedScopes += 1;
141
147
  let snapshot;
142
148
  try {
143
149
  snapshot = await registry.snapshot(options);
@@ -145,14 +151,22 @@ export async function collectSkills(registry, workdir, scopes = [undefined], col
145
151
  catch {
146
152
  // A single scope (e.g. one agent preset) failing must not blank the whole
147
153
  // catalog; skip it and keep the rest.
154
+ if (collectOptions.report !== undefined)
155
+ collectOptions.report.failedScopes += 1;
148
156
  continue;
149
157
  }
158
+ if (snapshot.complete === false && collectOptions.report !== undefined) {
159
+ collectOptions.report.incompleteReads += 1;
160
+ }
150
161
  for (const summary of snapshot.skills) {
151
162
  if (byName.has(summary.name))
152
163
  continue;
153
164
  const bodySha = collectOptions.bodySha === true
154
165
  ? await skillBodySha(registry, summary.name, options)
155
166
  : undefined;
167
+ if (collectOptions.bodySha === true && bodySha === undefined && collectOptions.report !== undefined) {
168
+ collectOptions.report.unreadableBodies += 1;
169
+ }
156
170
  const row = skillRowFrom(summary, await skillUpdatedAt(summary), bodySha);
157
171
  byName.set(row.name, row);
158
172
  }
@@ -168,7 +182,7 @@ export async function collectSkills(registry, workdir, scopes = [undefined], col
168
182
  /** Directory names never listed as skill-bundle resources. */
169
183
  const SKIP_SKILL_DIRS = new Set(['.git', 'node_modules']);
170
184
  /** Recursively collect a directory's file paths relative to `root`. */
171
- async function collectSkillFiles(dir, root, out, depth) {
185
+ export async function collectSkillFiles(dir, root, out, depth) {
172
186
  if (depth > 16 || out.length > 1000)
173
187
  return;
174
188
  let entries;
@@ -191,7 +205,7 @@ async function collectSkillFiles(dir, root, out, depth) {
191
205
  }
192
206
  }
193
207
  /** Reject unsafe bundle-relative paths (absolute, drive-letter, or `..`). */
194
- function sanitizeBundlePath(path) {
208
+ export function sanitizeBundlePath(path) {
195
209
  if (path === '' || path.startsWith('/') || /^[a-zA-Z]:/.test(path))
196
210
  return undefined;
197
211
  const safe = [];
@@ -204,6 +218,29 @@ function sanitizeBundlePath(path) {
204
218
  }
205
219
  return safe.length === 0 ? undefined : safe.join('/');
206
220
  }
221
+ /**
222
+ * Resolve an already-sanitized bundle-relative path to its real on-disk
223
+ * target, refusing anything that escapes the bundle. {@link sanitizeBundlePath}
224
+ * has already removed the lexical escapes (absolute forms, drive letters,
225
+ * `..`); this closes what a lexical check cannot:
226
+ *
227
+ * - a symlink INSIDE the bundle whose target lives outside it (the bundle
228
+ * directory is user content), and
229
+ * - a prefix collision — containment is a separator-terminated comparison on
230
+ * realpaths, so `/root/foobar` never contain-matches `/root/foo`.
231
+ *
232
+ * @param bundleDir - the skill's bundle directory (its resource base).
233
+ * @param safe - a path that already passed {@link sanitizeBundlePath}.
234
+ * @returns the resolved absolute target, or undefined when the file is absent
235
+ * or would escape the bundle.
236
+ */
237
+ export async function resolveBundleTarget(bundleDir, safe) {
238
+ const base = await realpath(bundleDir).catch(() => bundleDir);
239
+ const target = await realpath(join(bundleDir, safe)).catch(() => undefined);
240
+ if (target === undefined || (target !== base && !target.startsWith(base + sep)))
241
+ return undefined;
242
+ return target;
243
+ }
207
244
  /** Read one skill-bundle file's text content, on demand. */
208
245
  export async function readSkillFileContent(registry, name, filePath, workdir, scope = undefined, fallback) {
209
246
  const base = workdir === undefined ? {} : { cwd: workdir };
@@ -218,11 +255,11 @@ export async function readSkillFileContent(registry, name, filePath, workdir, sc
218
255
  const safe = sanitizeBundlePath(filePath);
219
256
  if (safe === undefined)
220
257
  return undefined;
221
- const full = join(bundleDir, safe);
222
- if (!full.startsWith(bundleDir))
258
+ const target = await resolveBundleTarget(bundleDir, safe);
259
+ if (target === undefined)
223
260
  return undefined;
224
261
  try {
225
- const buf = await readFile(full);
262
+ const buf = await readFile(target);
226
263
  return { content: buf.toString('utf8') };
227
264
  }
228
265
  catch {
@@ -259,9 +296,14 @@ export async function loadSkillDetail(ctx, registry, name, workdir, scope = unde
259
296
  return undefined;
260
297
  // metadata.credentials are the user-configurable secrets; env-var references
261
298
  // detected in the body are the agent's own runtime inputs (DSH_HOME etc.) and
262
- // must NOT be offered as configurable credential forms — keep them apart.
299
+ // must NOT be offered as configurable credential forms — keep them apart. A
300
+ // body reference already declared as a credential renders in the credential
301
+ // form; repeating it as a read-only ref would show the same key twice.
263
302
  const metadataDecls = decodeCredentialDecls(def.metadata);
264
- const environmentRefs = decodeEnvDecls(def.content).map((decl) => decl.key);
303
+ const declaredKeys = new Set(metadataDecls.map((decl) => decl.key));
304
+ const environmentRefs = decodeEnvDecls(def.content)
305
+ .map((decl) => decl.key)
306
+ .filter((key) => !declaredKeys.has(key));
265
307
  const { credentials } = resolveServices(ctx);
266
308
  let credentialStates = [];
267
309
  if (metadataDecls.length > 0 && credentials !== undefined) {