@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.
- package/CHANGELOG.md +13 -0
- package/README.en.md +31 -7
- package/README.i18n.yaml +2 -2
- package/README.md +31 -7
- package/cordis.patch.yml +1 -1
- package/lib/client.js +14792 -904
- package/lib/index.js +748 -263
- package/lib/typert.host.js +614 -295
- package/lib/typert.remote-client.d.ts +27 -4
- package/lib/typert.remote-client.js +599 -288
- package/lib/types/client/CapabilityCatalogCard.js +20 -5
- package/lib/types/client/SkillDetailModal.d.ts +13 -5
- package/lib/types/client/SkillDetailModal.js +9 -3
- package/lib/types/client/locales.d.ts +0 -4
- package/lib/types/client/locales.js +4 -8
- package/lib/types/envHint.d.ts +7 -1
- package/lib/types/envHint.js +52 -18
- package/lib/types/evidence.d.ts +142 -0
- package/lib/types/evidence.js +179 -0
- package/lib/types/frontmatter.d.ts +78 -0
- package/lib/types/frontmatter.js +186 -0
- package/lib/types/import.js +10 -19
- package/lib/types/index.d.ts +8 -3
- package/lib/types/index.js +21 -50
- package/lib/types/mcpStore.d.ts +6 -8
- package/lib/types/mcpStore.js +18 -43
- package/lib/types/mcps.d.ts +1 -6
- package/lib/types/mcps.js +0 -9
- package/lib/types/namespace.d.ts +2 -3
- package/lib/types/namespace.js +2 -3
- package/lib/types/remote.d.ts +11 -2
- package/lib/types/remote.js +4 -1
- package/lib/types/scoped-delivery.d.ts +36 -24
- package/lib/types/scoped-delivery.js +136 -100
- package/lib/types/scoped-edits.js +2 -1
- package/lib/types/shellEnv.d.ts +8 -1
- package/lib/types/shellEnv.js +37 -16
- package/lib/types/skills.d.ts +41 -0
- package/lib/types/skills.js +51 -9
- package/lib/types/tool-face.d.ts +68 -0
- package/lib/types/tool-face.js +82 -0
- package/lib/types/types.d.ts +40 -19
- package/package.json +20 -18
- package/lib/invariant.js +0 -25
- package/lib/types/invariant.d.ts +0 -16
- 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
|
|
162
|
-
if (
|
|
88
|
+
const parsed = parseSkillFrontmatter(content);
|
|
89
|
+
if (parsed === undefined)
|
|
163
90
|
return undefined;
|
|
164
|
-
const name =
|
|
165
|
-
|
|
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:
|
|
175
|
-
userInvocable:
|
|
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
|
-
|
|
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() || !
|
|
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
|
-
/**
|
|
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([
|
|
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,
|
|
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` };
|
package/lib/types/shellEnv.d.ts
CHANGED
|
@@ -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
|
|
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
|
package/lib/types/shellEnv.js
CHANGED
|
@@ -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
|
-
|
|
130
|
-
|
|
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
|
package/lib/types/skills.d.ts
CHANGED
|
@@ -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
|
package/lib/types/skills.js
CHANGED
|
@@ -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
|
|
222
|
-
if (
|
|
258
|
+
const target = await resolveBundleTarget(bundleDir, safe);
|
|
259
|
+
if (target === undefined)
|
|
223
260
|
return undefined;
|
|
224
261
|
try {
|
|
225
|
-
const buf = await readFile(
|
|
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
|
|
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) {
|