@stage-labs/metro 0.1.0-beta.279 → 0.1.0-beta.280

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.
@@ -0,0 +1,53 @@
1
+ import { createHash, randomUUID } from 'node:crypto';
2
+ import { isRecord } from '@metro-labs/core/is-record';
3
+ import { SKILL_NAME, skillGeneration, type SkillGeneration, type SkillSource } from '@metro-labs/core/skill-source';
4
+ import { ApiError } from '@metro-labs/http/api-error';
5
+ import type { GitHubSnapshot } from './github-fetch.js';
6
+
7
+ const FIELDS = new Set(['name', 'description', 'argument-hint', 'disable-model-invocation', 'user-invocable']);
8
+ export const skillDigest = (bytes: string | Uint8Array): string => createHash('sha256').update(bytes).digest('hex');
9
+
10
+ function validRef(value: unknown): value is string {
11
+ return typeof value === 'string' && value.length <= 200 && /^[A-Za-z0-9][A-Za-z0-9._/-]*$/.test(value) && !value.includes('..') && !value.includes('//');
12
+ }
13
+
14
+ function validFolder(value: unknown): value is string {
15
+ if (typeof value !== 'string' || value.length > 240) return false;
16
+ if (value === '') return true;
17
+ const parts = value.split('/');
18
+ return parts.length <= 6 && parts.every((part) => /^[A-Za-z0-9._-]{1,80}$/.test(part) && part !== '.' && part !== '..');
19
+ }
20
+
21
+ export function githubSource(raw: unknown): SkillSource & { token: string } {
22
+ if (!isRecord(raw) || typeof raw.repository !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9-]{0,38}\/[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/.test(raw.repository)) throw new ApiError('Use a GitHub repository in owner/repository form.', 400);
23
+ if (!validRef(raw.ref)) throw new ApiError('Use a branch, tag or full commit SHA.', 400);
24
+ if (!validFolder(raw.folder)) throw new ApiError('Use a relative skills folder, or leave it empty for the repository root.', 400);
25
+ if (typeof raw.token !== 'string' || !/^github_pat_[A-Za-z0-9_]{20,240}$/.test(raw.token)) throw new ApiError('Use a fine-grained GitHub token with Contents read access to this repository only.', 400);
26
+ return { repository: raw.repository, ref: raw.ref, folder: raw.folder, token: raw.token };
27
+ }
28
+
29
+ function passiveMetadata(meta: Record<string, unknown>): void {
30
+ if (Object.keys(meta).some((key) => !FIELDS.has(key))) throw new Error('Unsupported skill metadata. Only name, description, argument-hint, disable-model-invocation and user-invocable are allowed. Hooks, tool grants, model changes and agents are not imported.');
31
+ if (['disable-model-invocation', 'user-invocable'].some((key) => key in meta && typeof meta[key] !== 'boolean') || ('argument-hint' in meta && typeof meta['argument-hint'] !== 'string')) throw new Error('Invalid skill metadata types.');
32
+ }
33
+
34
+ function skillMetadata(name: string, bytes: Buffer): { name: string; description: string } {
35
+ const text = new TextDecoder('utf-8', { fatal: true }).decode(bytes);
36
+ const match = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/.exec(text);
37
+ if (match === null || /!`|\0/.test(text)) throw new Error('A skill needs YAML frontmatter. Shell substitutions are not supported.');
38
+ const meta: unknown = Bun.YAML.parse(match[1] ?? '');
39
+ if (!isRecord(meta) || meta.name !== name || !SKILL_NAME.test(name) || typeof meta.description !== 'string' || meta.description.trim() === '' || meta.description.length > 1024) throw new Error('A skill needs a name matching its folder and a description of up to 1024 characters.');
40
+ passiveMetadata(meta);
41
+ return { name, description: meta.description };
42
+ }
43
+
44
+ export function validatedGeneration(source: SkillSource | null, snapshot: GitHubSnapshot | null): SkillGeneration {
45
+ const files = snapshot?.files ?? new Map<string, Buffer>();
46
+ const skills = [...files].filter(([path]) => path.endsWith('/SKILL.md')).map(([path, bytes]) => skillMetadata(path.split('/')[0] ?? '', bytes));
47
+ const manifest = {
48
+ source: source === null || snapshot === null ? null : { repository: source.repository, ref: source.ref, folder: source.folder, repositoryId: snapshot.repositoryId, commit: snapshot.commit },
49
+ skills,
50
+ files: [...files].map(([path, bytes]) => ({ path, size: bytes.byteLength, sha256: skillDigest(bytes), ...(snapshot?.executables?.includes(path) === true ? { executable: true } : {}) })),
51
+ };
52
+ return skillGeneration({ ...manifest, id: skillDigest(randomUUID()), createdAt: new Date().toISOString() });
53
+ }
@@ -18,6 +18,8 @@ export interface ClaudeSkill {
18
18
  path: string;
19
19
  editable: boolean;
20
20
  updatedAt: string | null;
21
+ managed?: boolean;
22
+ shadowed?: boolean;
21
23
  }
22
24
 
23
25
  const frontmatterField = (text: string, field: string): string => {
@@ -1,3 +1,3 @@
1
1
  {
2
- "version": "0.1.0-beta.279"
2
+ "version": "0.1.0-beta.280"
3
3
  }
@@ -13,6 +13,7 @@
13
13
  "./automation-types": "./src/automation-types.ts",
14
14
  "./call": "./src/call.ts",
15
15
  "./secure-fs": "./src/secure-fs.ts",
16
+ "./skill-source": "./src/skill-source.ts",
16
17
  "./version": "./src/version.ts",
17
18
  "./train-error": "./src/train-error.ts",
18
19
  "./trains/protocol": "./src/protocol.ts",
@@ -0,0 +1,96 @@
1
+ import { join } from 'node:path';
2
+ import { isRecord } from './is-record.js';
3
+
4
+ export const SKILL_NAME = /^[a-z0-9][a-z0-9-]{0,63}$/;
5
+ export const SKILL_HASH = /^[a-f0-9]{64}$/;
6
+ export const SKILL_COMMIT = /^[a-f0-9]{40}$/;
7
+ export const SKILL_FILE_MAX = 256 * 1024;
8
+ export const SKILL_TOTAL_MAX = 8 * 1024 * 1024;
9
+ export const SKILL_FILES_MAX = 256;
10
+ export const SKILL_MANIFEST_MAX = 128 * 1024;
11
+
12
+ export interface SkillSource {
13
+ repository: string;
14
+ ref: string;
15
+ folder: string;
16
+ }
17
+
18
+ export interface SkillFile {
19
+ path: string;
20
+ size: number;
21
+ sha256: string;
22
+ executable?: boolean;
23
+ }
24
+
25
+ export interface SkillGeneration {
26
+ id: string;
27
+ source: (SkillSource & { repositoryId: number; commit: string }) | null;
28
+ createdAt: string;
29
+ skills: { name: string; description: string }[];
30
+ files: SkillFile[];
31
+ }
32
+
33
+ export interface SkillActivation {
34
+ generation: string | null;
35
+ loading: string | null;
36
+ rollback?: string | null;
37
+ appliedAt: string | null;
38
+ problem: string | null;
39
+ shadowed: string[];
40
+ }
41
+
42
+ export const skillSourceRoot = (claudeDir: string): string => join(claudeDir, 'metro-github');
43
+ export const skillRelease = (root: string, id: string): string => {
44
+ if (!SKILL_HASH.test(id)) throw new Error('Invalid skills generation.');
45
+ return join(root, 'releases', id);
46
+ };
47
+
48
+ export function skillPath(path: string): boolean {
49
+ const parts = path.split('/');
50
+ return path.length <= 240 && parts.length >= 2 && parts.length <= 8 && SKILL_NAME.test(parts[0] ?? '') &&
51
+ parts.every((part) => /^[A-Za-z0-9][A-Za-z0-9._-]{0,79}$/.test(part)) &&
52
+ !parts.slice(1, -1).includes('SKILL.md') && !(parts.length > 2 && parts.at(-1) === 'SKILL.md');
53
+ }
54
+
55
+ function manifestSkill(value: unknown): SkillGeneration['skills'][number] {
56
+ if (!isRecord(value) || typeof value.name !== 'string' || !SKILL_NAME.test(value.name) || typeof value.description !== 'string' || value.description.length > 1024) throw new Error('Invalid skill name or description.');
57
+ return { name: value.name, description: value.description };
58
+ }
59
+
60
+ function fileSize(value: unknown): number {
61
+ if (typeof value !== 'number' || !Number.isInteger(value) || value < 0 || value > SKILL_FILE_MAX) throw new Error('Invalid skills file size.');
62
+ return value;
63
+ }
64
+
65
+ function manifestFile(value: unknown): SkillFile {
66
+ if (!isRecord(value)) throw new Error('Invalid skills file.');
67
+ if (typeof value.path !== 'string' || !skillPath(value.path) || typeof value.sha256 !== 'string' || !SKILL_HASH.test(value.sha256)) throw new Error('Invalid skills file path or hash.');
68
+ return { path: value.path, size: fileSize(value.size), sha256: value.sha256, ...(value.executable === true ? { executable: true } : {}) };
69
+ }
70
+
71
+ function manifestSource(s: unknown): SkillGeneration['source'] {
72
+ if (s === null) return null;
73
+ if (!isRecord(s) || typeof s.repository !== 'string' || typeof s.ref !== 'string' || typeof s.folder !== 'string' || typeof s.repositoryId !== 'number' || typeof s.commit !== 'string' || !SKILL_COMMIT.test(s.commit)) throw new Error('Invalid skills provenance.');
74
+ return { repository: s.repository, ref: s.ref, folder: s.folder, repositoryId: s.repositoryId, commit: s.commit };
75
+ }
76
+
77
+ function checkFiles(skills: SkillGeneration['skills'], files: SkillFile[]): void {
78
+ if (files.reduce((sum, file) => sum + file.size, 0) > SKILL_TOTAL_MAX || new Set(files.map((f) => f.path.toLowerCase())).size !== files.length || new Set(skills.map((s) => s.name)).size !== skills.length) throw new Error('Duplicate or oversized skills files.');
79
+ if (files.some((f) => !skills.some((s) => f.path.startsWith(`${s.name}/`))) || skills.some((s) => !files.some((f) => f.path === `${s.name}/SKILL.md`))) throw new Error('Every skill needs its own SKILL.md.');
80
+ }
81
+
82
+ function boundedGeneration(generation: SkillGeneration): SkillGeneration {
83
+ if (Buffer.byteLength(JSON.stringify(generation)) > SKILL_MANIFEST_MAX) throw new Error('Skills manifest is too large.');
84
+ return generation;
85
+ }
86
+
87
+ export function skillGeneration(raw: unknown): SkillGeneration {
88
+ if (!isRecord(raw) || typeof raw.id !== 'string' || !SKILL_HASH.test(raw.id) || typeof raw.createdAt !== 'string' || !Array.isArray(raw.skills) || !Array.isArray(raw.files)) throw new Error('Invalid skills manifest.');
89
+ if (raw.skills.length > 64 || raw.files.length > SKILL_FILES_MAX) throw new Error('Skills manifest is too large.');
90
+ const skills = raw.skills.map(manifestSkill);
91
+ const files = raw.files.map(manifestFile);
92
+ const source = manifestSource(raw.source);
93
+ checkFiles(skills, files);
94
+ if (source === null) checkFiles([], files);
95
+ return boundedGeneration({ id: raw.id, source, createdAt: raw.createdAt, skills, files });
96
+ }
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.1.0-beta.279",
2
+ "version": "0.1.0-beta.280",
3
3
  "dependencies": {
4
4
  "@anthropic-ai/claude-agent-sdk": "0.3.287",
5
5
  "@modelcontextprotocol/sdk": "^1.29.0",
@@ -2,6 +2,8 @@ import { dirname, join } from 'node:path';
2
2
  import type { SDKMessage } from '@anthropic-ai/claude-agent-sdk';
3
3
  import { AutomationStore } from '@metro-labs/core/automation-store';
4
4
  import { errMsg, log } from '@metro-labs/core/log';
5
+ import { skillSourceRoot } from '@metro-labs/core/skill-source';
6
+ import { SkillsRefresh } from './skills.js';
5
7
  import { failureSummary, type Activity } from './activity.js';
6
8
  import { approvalsThrough } from './approvals.js';
7
9
  import type { RunnerConfig } from './config.js';
@@ -30,7 +32,8 @@ async function connected(cfg: RunnerConfig, hooks: AppHooks, release: () => Prom
30
32
  const store = new SessionStore(cfg.statePath, cfg.claudeDir, cfg.cwd);
31
33
  let tools: MetroTools | null = null;
32
34
  const automationStore = new AutomationStore(join(dirname(cfg.statePath), 'automation'));
33
- const runner = new Runner({ store, automationStore, readOnly: (tool) => tools?.readOnly(tool) ?? false, ...hooks });
35
+ const skills = new SkillsRefresh(skillSourceRoot(cfg.claudeDir), cfg.claudeDir);
36
+ const runner = new Runner({ store, automationStore, skills, readOnly: (tool) => tools?.readOnly(tool) ?? false, ...hooks });
34
37
  const link = await MetroLink.open(cfg.mcpUrl, cfg.key, {
35
38
  channel: (event) => { runner.chat(event); },
36
39
  call: (notice) => { runner.calls.notice(notice); },
@@ -1,5 +1,8 @@
1
1
  import { query, type CanUseTool, type Options, type Query, type SDKMessage, type SDKUserMessage } from '@anthropic-ai/claude-agent-sdk';
2
2
  import { errMsg, log } from '@metro-labs/core/log';
3
+ import { skillSourceRoot } from '@metro-labs/core/skill-source';
4
+ import { type SkillsRefresh } from './skills.js';
5
+ import { SkillWork } from './skill-work.js';
3
6
  import { isRecord } from '@metro-labs/core/is-record';
4
7
  import type { AutomationStore } from '@metro-labs/core/automation-store';
5
8
  import type { Activity } from './activity.js';
@@ -41,6 +44,7 @@ export function runnerOptions(cfg: RunnerConfig, tools: MetroTools, canUseTool:
41
44
  env: runnerEnv(env),
42
45
  ...(cfg.claude === null ? {} : { pathToClaudeCodeExecutable: cfg.claude }),
43
46
  settingSources: ['user', 'project', 'local'],
47
+ additionalDirectories: [skillSourceRoot(cfg.claudeDir)],
44
48
  systemPrompt: { type: 'preset', preset: 'claude_code', append: cfg.prompt === null ? FRONT_RULES : `${cfg.prompt}\n\n${FRONT_RULES}` },
45
49
  mcpServers: { [METRO_SERVER]: tools.config },
46
50
  permissionMode: cfg.permissionMode,
@@ -73,6 +77,7 @@ export interface RunnerParts {
73
77
  open?: OpenSession;
74
78
  activity?: Activity;
75
79
  automationStore?: AutomationStore;
80
+ skills?: SkillsRefresh;
76
81
  }
77
82
 
78
83
  function streamOutput(event: Record<string, unknown>): boolean {
@@ -94,6 +99,7 @@ export class Runner {
94
99
  private readonly recovery: TaskRecovery;
95
100
  private readonly watch: SessionWatch;
96
101
  private readonly tasks = new ActivityTasks(() => undefined);
102
+ private readonly skillWork = new SkillWork();
97
103
  private compactTimer: ReturnType<typeof setTimeout> | null = null;
98
104
  private session: Query | null = null;
99
105
  private sessionId: string | null = null;
@@ -109,7 +115,7 @@ export class Runner {
109
115
  constructor(private readonly parts: RunnerParts) {
110
116
  this.watch = new SessionWatch(parts.readOnly);
111
117
  this.inbox = new Inbox((unanswered) => { this.keep(unanswered); }, {
112
- ready: () => this.watch.admitting,
118
+ ready: () => { parts.skills?.check(); return this.watch.admitting && parts.skills?.busy !== true; },
113
119
  dispatch: (message) => { this.automation?.dispatched(message.uuid); this.watch.dispatched(message.uuid); },
114
120
  input: (input) => { this.cancelCompact(); parts.activity?.input(input); },
115
121
  queue: (count, oldest) => { parts.activity?.pending(count, oldest); },
@@ -146,6 +152,11 @@ export class Runner {
146
152
  this.session = (this.parts.open ?? query)({ prompt: this.inbox, options });
147
153
  this.model = options.model ?? null;
148
154
  this.queueModel(this.model);
155
+ this.parts.skills?.start({
156
+ safe: () => !this.ended && this.watch.safe && this.watch.admitting && this.compactAsked === null && !this.calls.live && this.skillWork.safe,
157
+ reload: () => this.session?.reloadSkills() ?? Promise.reject(new Error('The SDK session is closed.')),
158
+ wake: () => { this.inbox.notify(); },
159
+ });
149
160
  this.recovery.start();
150
161
  this.automation?.start();
151
162
  return this.session;
@@ -167,7 +178,7 @@ export class Runner {
167
178
  this.recovery.observe(m);
168
179
  this.automation?.observe(m);
169
180
  this.trackInput(m);
170
- this.watch.observe(m);
181
+ this.observeWork(m);
171
182
  this.note(m);
172
183
  this.parts.activity?.observe(message);
173
184
  this.inbox.notify();
@@ -176,6 +187,7 @@ export class Runner {
176
187
  }
177
188
  } finally {
178
189
  this.cancelCompact();
190
+ this.parts.skills?.close();
179
191
  this.automation?.close(false);
180
192
  this.recovery.close(false);
181
193
  this.ended = true;
@@ -200,6 +212,7 @@ export class Runner {
200
212
 
201
213
  close(cancelActive = false): void {
202
214
  this.cancelCompact();
215
+ this.parts.skills?.close();
203
216
  this.calls.close();
204
217
  try {
205
218
  this.recovery.close(cancelActive);
@@ -281,8 +294,18 @@ export class Runner {
281
294
  }
282
295
  }
283
296
 
297
+ private observeWork(message: Record<string, unknown>): void {
298
+ this.watch.observe(message);
299
+ this.skillWork.observe(message);
300
+ this.parts.skills?.check();
301
+ }
302
+
303
+ private get maintenanceBusy(): boolean {
304
+ return this.ended || this.parts.skills?.busy === true || this.compactAsked !== null || !this.watch.safe || !this.watch.admitting;
305
+ }
306
+
284
307
  private compactReady(resuming = false): boolean {
285
- const busy = this.ended || this.compactAsked !== null || !this.watch.safe || !this.watch.admitting || (!resuming && (this.calls.live || this.inbox.pending > 0 || this.tasks.running > 0));
308
+ const busy = this.maintenanceBusy || (!resuming && (this.calls.live || this.inbox.pending > 0 || this.tasks.running > 0));
286
309
  return compactDue({ context: this.watch.context, floor: this.compactFloor, limit: this.parts.compactAt ?? COMPACT_AT, busy });
287
310
  }
288
311
 
@@ -0,0 +1,76 @@
1
+ import { isRecord } from '@metro-labs/core/is-record';
2
+
3
+ const TERMINAL = new Set(['completed', 'failed', 'killed', 'stopped']);
4
+ interface Task { tool: string | null; background: boolean }
5
+
6
+ export class SkillWork {
7
+ private readonly tasks = new Map<string, Task>();
8
+ private readonly tools = new Map<string, string | null>();
9
+ private overflow = false;
10
+ get safe(): boolean { return !this.overflow && this.tasks.size === 0 && this.tools.size === 0; }
11
+
12
+ private blocks(message: Record<string, unknown>): void {
13
+ if (!isRecord(message.message) || !Array.isArray(message.message.content)) return;
14
+ const parent = typeof message.parent_tool_use_id === 'string' ? message.parent_tool_use_id : null;
15
+ for (const block of message.message.content.filter(isRecord)) {
16
+ if (block.type === 'tool_use' && typeof block.id === 'string') this.tools.set(block.id, parent);
17
+ if (block.type === 'tool_result' && typeof block.tool_use_id === 'string') this.tools.delete(block.tool_use_id);
18
+ }
19
+ }
20
+
21
+ private retire(id: string, tool = this.tasks.get(id)?.tool): void {
22
+ this.tasks.delete(id);
23
+ if (tool == null) return;
24
+ const retired = new Set([tool]);
25
+ for (let changed = true; changed;) {
26
+ changed = false;
27
+ for (const [child, parent] of this.tools) {
28
+ if (parent === null || !retired.has(parent) || retired.has(child)) continue;
29
+ retired.add(child);
30
+ changed = true;
31
+ }
32
+ }
33
+ for (const child of retired) this.tools.delete(child);
34
+ }
35
+
36
+ private background(id: string, value: unknown): boolean {
37
+ return typeof value === 'boolean' ? value : this.tasks.get(id)?.background ?? false;
38
+ }
39
+
40
+ private task(message: Record<string, unknown>): void {
41
+ if (typeof message.task_id !== 'string') return;
42
+ const id = message.task_id;
43
+ const tool = typeof message.tool_use_id === 'string' ? message.tool_use_id : this.tasks.get(id)?.tool ?? null;
44
+ if (message.subtype === 'task_started') this.tasks.set(id, { tool, background: this.background(id, message.is_backgrounded) });
45
+ if (message.subtype === 'task_notification') this.retire(id, tool);
46
+ if (message.subtype === 'task_updated' && isRecord(message.patch)) this.patch(id, message.patch);
47
+ }
48
+
49
+ private patch(id: string, patch: Record<string, unknown>): void {
50
+ if (TERMINAL.has(String(patch.status))) { this.retire(id); return; }
51
+ if (!this.tasks.has(id) && !['running', 'paused', 'pending'].includes(String(patch.status))) return;
52
+ const task = this.tasks.get(id) ?? { tool: null, background: false };
53
+ if (typeof patch.is_backgrounded === 'boolean') task.background = patch.is_backgrounded;
54
+ this.tasks.set(id, task);
55
+ }
56
+
57
+ private snapshot(entries: unknown[]): void {
58
+ const ids = new Set(entries.filter(isRecord).flatMap((task) => typeof task.task_id === 'string' ? [task.task_id] : []));
59
+ for (const [id, task] of this.tasks) if (task.background && !ids.has(id)) this.retire(id);
60
+ for (const id of ids) this.tasks.set(id, { tool: this.tasks.get(id)?.tool ?? null, background: true });
61
+ }
62
+
63
+ private frontDone(message: Record<string, unknown>): void {
64
+ if (message.parent_tool_use_id != null) return;
65
+ for (const [id, parent] of this.tools) if (parent === null) this.tools.delete(id);
66
+ }
67
+
68
+ observe(message: Record<string, unknown>): void {
69
+ if (this.overflow) return;
70
+ this.blocks(message);
71
+ if (message.type === 'result') this.frontDone(message);
72
+ if (message.type === 'system') this.task(message);
73
+ if (message.subtype === 'background_tasks_changed' && Array.isArray(message.tasks)) this.snapshot(message.tasks);
74
+ this.overflow = this.tools.size > 4096 || this.tasks.size > 4096;
75
+ }
76
+ }
@@ -0,0 +1,169 @@
1
+ import { createHash, randomUUID } from 'node:crypto';
2
+ import { closeSync, constants, fstatSync, openSync, readSync, lstatSync, statSync, mkdirSync, readFileSync, readdirSync, readlinkSync, renameSync, rmSync, symlinkSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { isRecord } from '@metro-labs/core/is-record';
5
+ import { SKILL_FILE_MAX, SKILL_HASH, SKILL_MANIFEST_MAX, skillGeneration, skillRelease, type SkillGeneration } from '@metro-labs/core/skill-source';
6
+
7
+ const digest = (bytes: string | Uint8Array): string => createHash('sha256').update(bytes).digest('hex');
8
+ const LINK = /^\.\.\/releases\/[a-f0-9]{64}\/visible-[a-f0-9]{64}$/;
9
+
10
+ function directory(path: string): void {
11
+ mkdirSync(path, { recursive: true, mode: 0o700 });
12
+ if (!lstatSync(path).isDirectory()) throw new Error('The managed skills directory is not a regular directory.');
13
+ }
14
+
15
+ export function prepareSkillsRoot(root: string): void {
16
+ directory(root);
17
+ directory(join(root, 'releases'));
18
+ directory(join(root, '.claude'));
19
+ }
20
+
21
+ export function pendingSkills(root: string): SkillGeneration | null {
22
+ const path = join(root, 'pending.json');
23
+ let stat;
24
+ try { stat = lstatSync(path); } catch (err) { if (isRecord(err) && err.code === 'ENOENT') return null; throw err; }
25
+ if (!stat.isFile() || stat.size > SKILL_MANIFEST_MAX) throw new Error('Invalid pending skills manifest.');
26
+ return skillGeneration(JSON.parse(readFileSync(path, 'utf8')) as unknown);
27
+ }
28
+
29
+ function localText(file: string): string {
30
+ const fd = openSync(file, constants.O_RDONLY | constants.O_NONBLOCK);
31
+ try {
32
+ const stat = fstatSync(fd);
33
+ if (!stat.isFile() || stat.size > SKILL_FILE_MAX) throw new Error('A local skill cannot be checked safely.');
34
+ const bytes = Buffer.alloc(SKILL_FILE_MAX + 1);
35
+ let size = 0;
36
+ for (;;) {
37
+ const read = readSync(fd, bytes, size, bytes.length - size, size);
38
+ if (read === 0) break;
39
+ size += read;
40
+ if (size === bytes.length) break;
41
+ }
42
+ if (size > SKILL_FILE_MAX) throw new Error('A local skill is too large.');
43
+ return bytes.subarray(0, size).toString('utf8');
44
+ } finally { closeSync(fd); }
45
+ }
46
+
47
+ export type LocalAliases = Map<string, { signature: string; name: string | null }>;
48
+
49
+ function localAlias(file: string, cache: LocalAliases): string | null {
50
+ try {
51
+ const stat = statSync(file);
52
+ const signature = `${stat.ino}:${stat.size}:${stat.mtimeMs}:${stat.ctimeMs}`;
53
+ const cached = cache.get(file);
54
+ if (cached?.signature === signature) return cached.name;
55
+ const header = /^---\r?\n([\s\S]*?)\r?\n---/.exec(localText(file))?.[1];
56
+ const meta: unknown = header === undefined ? null : Bun.YAML.parse(header);
57
+ const name = isRecord(meta) && typeof meta.name === 'string' ? meta.name : null;
58
+ cache.set(file, { signature, name });
59
+ return name;
60
+ } catch (err) { return isRecord(err) && ['ENOENT', 'ENOTDIR'].includes(String(err.code)) ? null : '*'; }
61
+ }
62
+
63
+ export function localSkillNames(claude: string, cache: LocalAliases = new Map()): string[] {
64
+ const names = new Set<string>();
65
+ let folders: string[];
66
+ try { folders = readdirSync(join(claude, 'skills')); } catch (err) { if (isRecord(err) && err.code === 'ENOENT') return []; throw err; }
67
+ if (folders.length > 2000) return ['*'];
68
+ const paths = new Set<string>();
69
+ for (const name of folders) {
70
+ names.add(name);
71
+ const path = join(claude, 'skills', name, 'SKILL.md');
72
+ paths.add(path);
73
+ const alias = localAlias(path, cache);
74
+ if (alias !== null) names.add(alias);
75
+ }
76
+ for (const path of cache.keys()) if (!paths.has(path)) cache.delete(path);
77
+ return [...names].sort();
78
+ }
79
+
80
+ export function projectionKey(generation: SkillGeneration, locals: string[]): string {
81
+ return `${generation.id}:${digest(JSON.stringify(locals))}`;
82
+ }
83
+
84
+ function onlyManifestFiles(root: string, generation: SkillGeneration): void {
85
+ const files = new Set(generation.files.map((file) => file.path));
86
+ const directories = new Set(generation.files.flatMap((file) => file.path.split('/').slice(0, -1).map((_part, index, parts) => parts.slice(0, index + 1).join('/'))));
87
+ const visit = (path: string): void => {
88
+ for (const entry of readdirSync(join(root, path), { withFileTypes: true })) {
89
+ const relative = path === '' ? entry.name : `${path}/${entry.name}`;
90
+ if (entry.isDirectory() && directories.has(relative)) visit(relative);
91
+ else if (!entry.isFile() || !files.has(relative)) throw new Error('Unexpected file in staged skills.');
92
+ }
93
+ };
94
+ visit('');
95
+ }
96
+
97
+ function verifiedFiles(release: string, generation: SkillGeneration): void {
98
+ directory(release);
99
+ directory(join(release, 'skills'));
100
+ onlyManifestFiles(join(release, 'skills'), generation);
101
+ for (const file of generation.files) {
102
+ let parent = join(release, 'skills');
103
+ for (const part of file.path.split('/').slice(0, -1)) {
104
+ parent = join(parent, part);
105
+ if (!lstatSync(parent).isDirectory()) throw new Error('A managed skill contains a linked directory.');
106
+ }
107
+ const path = join(release, 'skills', file.path);
108
+ const stat = lstatSync(path);
109
+ if (!stat.isFile() || stat.size !== file.size || digest(readFileSync(path)) !== file.sha256) throw new Error('A staged skill changed before activation.');
110
+ }
111
+ }
112
+
113
+ export function projectSkills(root: string, generation: SkillGeneration, locals: string[]): { target: string; shadowed: string[] } {
114
+ prepareSkillsRoot(root);
115
+ const release = skillRelease(root, generation.id);
116
+ verifiedFiles(release, generation);
117
+ const shadowed = generation.skills.filter((skill) => locals.includes('*') || locals.includes(skill.name)).map((skill) => skill.name);
118
+ const name = `visible-${digest(JSON.stringify(locals))}`;
119
+ const visible = join(release, name);
120
+ directory(visible);
121
+ const expected = generation.skills.filter((skill) => !shadowed.includes(skill.name)).map((skill) => skill.name);
122
+ if (readdirSync(visible).some((entry) => !expected.includes(entry))) throw new Error('Unexpected file in skills projection.');
123
+ for (const skill of generation.skills) {
124
+ if (shadowed.includes(skill.name)) continue;
125
+ const path = join(visible, skill.name);
126
+ try { symlinkSync(`../skills/${skill.name}`, path, 'dir'); }
127
+ catch (err) { if (!isRecord(err) || err.code !== 'EEXIST' || readlinkSync(path) !== `../skills/${skill.name}`) throw err; }
128
+ }
129
+ return { target: `../releases/${generation.id}/${name}`, shadowed };
130
+ }
131
+
132
+ export function currentSkills(root: string): string | null {
133
+ try {
134
+ const target = readlinkSync(join(root, '.claude', 'skills'));
135
+ if (!LINK.test(target)) throw new Error('The managed skills link has an unexpected target.');
136
+ return target;
137
+ } catch (err) { if (isRecord(err) && err.code === 'ENOENT') return null; throw err; }
138
+ }
139
+
140
+ export function switchSkills(root: string, target: string | null): void {
141
+ prepareSkillsRoot(root);
142
+ const link = join(root, '.claude', 'skills');
143
+ currentSkills(root);
144
+ if (target === null) { rmSync(link, { force: true }); return; }
145
+ if (!LINK.test(target)) throw new Error('Invalid managed skills target.');
146
+ const next = `${link}-${randomUUID()}`;
147
+ try { symlinkSync(target, next, 'dir'); renameSync(next, link); }
148
+ finally { rmSync(next, { force: true }); }
149
+ }
150
+
151
+ function oldRelease(path: string): boolean {
152
+ let stat;
153
+ try { stat = lstatSync(join(path, 'manifest.json')); }
154
+ catch (err) { if (!isRecord(err) || err.code !== 'ENOENT') throw err; stat = lstatSync(path); }
155
+ return Date.now() - stat.mtimeMs > 5 * 60_000;
156
+ }
157
+
158
+ export function pruneSkills(root: string, keep: Set<string>): void {
159
+ const pending = pendingSkills(root);
160
+ if (pending !== null) keep.add(pending.id);
161
+ for (const id of readdirSync(join(root, 'releases'))) {
162
+ if (!SKILL_HASH.test(id) || keep.has(id)) continue;
163
+ const release = skillRelease(root, id);
164
+ if (!lstatSync(release).isDirectory()) continue;
165
+ try {
166
+ if (oldRelease(release)) rmSync(release, { recursive: true });
167
+ } catch (err) { if (!isRecord(err) || err.code !== 'ENOENT') throw err; }
168
+ }
169
+ }