plugnz 0.0.4 → 0.0.6

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/dist/plugnz.mjs +20000 -0
  2. package/package.json +13 -10
  3. package/bin/plgnz.mjs +0 -3
  4. package/bin/plugnz.mjs +0 -5
  5. package/src/cli.ts +0 -489
  6. package/src/compatibility.ts +0 -23
  7. package/src/consumer-profiles.ts +0 -77
  8. package/src/conversion.ts +0 -544
  9. package/src/doctor.ts +0 -277
  10. package/src/exec.ts +0 -91
  11. package/src/fingerprint.ts +0 -77
  12. package/src/globals.d.ts +0 -97
  13. package/src/hermes-identity.ts +0 -4
  14. package/src/host.ts +0 -131
  15. package/src/hosts/claude-code-writer.ts +0 -589
  16. package/src/hosts/claude-code.ts +0 -124
  17. package/src/hosts/codex-writer.ts +0 -418
  18. package/src/hosts/codex.ts +0 -183
  19. package/src/hosts/cursor-writer.ts +0 -279
  20. package/src/hosts/cursor.ts +0 -107
  21. package/src/hosts/dcode-writer.ts +0 -150
  22. package/src/hosts/dcode.ts +0 -74
  23. package/src/hosts/grok-writer.ts +0 -254
  24. package/src/hosts/grok.ts +0 -94
  25. package/src/hosts/hermes-writer.ts +0 -223
  26. package/src/hosts/hermes.ts +0 -88
  27. package/src/hosts/index.ts +0 -25
  28. package/src/hosts/kimi-writer.ts +0 -238
  29. package/src/hosts/kimi.ts +0 -140
  30. package/src/hosts/omp-writer.ts +0 -179
  31. package/src/hosts/omp.ts +0 -153
  32. package/src/hosts/opencode-writer.ts +0 -112
  33. package/src/hosts/opencode.ts +0 -38
  34. package/src/hosts/pi-writer.ts +0 -111
  35. package/src/hosts/pi.ts +0 -48
  36. package/src/hosts/writers.ts +0 -23
  37. package/src/hosts/zcode-cli-writer.ts +0 -239
  38. package/src/hosts/zcode-cli.ts +0 -195
  39. package/src/mcp-write.ts +0 -81
  40. package/src/mcp.ts +0 -161
  41. package/src/paths.ts +0 -101
  42. package/src/pin.ts +0 -135
  43. package/src/source.ts +0 -224
  44. package/src/state-write.ts +0 -25
  45. package/src/state.ts +0 -118
  46. package/src/update.ts +0 -230
package/src/conversion.ts DELETED
@@ -1,544 +0,0 @@
1
- /**
2
- * Codex projection for plugin content that Claude-style plugins express as
3
- * commands. This is deliberately a file-to-file transform: lifecycle code can
4
- * stage its destination before it asks a host writer to activate anything.
5
- */
6
- import { cpSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs';
7
- import { basename, dirname, join, resolve } from 'node:path';
8
- import { spawnSync } from 'node:child_process';
9
- import { parse as parseToml } from 'smol-toml';
10
- import { readPluginManifest } from './source';
11
- import { hermesCommandCompanionId } from './hermes-identity';
12
-
13
- declare const Bun: {
14
- YAML: { parse(input: string): unknown; stringify(input: unknown): string };
15
- };
16
-
17
- interface Command {
18
- name: string;
19
- description: string;
20
- argumentHint?: string;
21
- userInvocable?: boolean;
22
- allowImplicit: boolean;
23
- body: string;
24
- source: string;
25
- }
26
-
27
- interface OmpProjectionOptions {
28
- /** Native npm/link package identity recorded by OMP. */
29
- packageName: string;
30
- /** Version recorded in OMP's runtime lock. */
31
- version: string;
32
- /** Final owned package root. Command resource hints must survive staging. */
33
- activeRoot: string;
34
- /** Public plugin namespace used for manual-only skill commands. */
35
- namespace: string;
36
- }
37
-
38
- interface HermesSkill { name: string; description: string; userOnly: boolean }
39
-
40
- const COMMAND_FIELDS = new Set(['description', 'argument-hint', 'argument_hint', 'disable-model-invocation', 'disable_model_invocation', 'user-invocable', 'user_invocable']);
41
- const UNSUPPORTED_FIELDS = new Set(['allowed-tools', 'allowed_tools', 'permissionMode', 'permission_mode', 'hooks', 'model', 'context']);
42
- const UNSUPPORTED_CLAUDE_COMPONENTS = new Set(['agents', 'hooks', 'mcpServers', 'outputStyles', 'lspServers']);
43
-
44
- /**
45
- * Copy a package into a caller-owned temporary destination and add Codex skill
46
- * projections for its prompt commands. The source is checked before copying so
47
- * neither symlinks nor paths outside the source can enter the output.
48
- */
49
- export function projectPluginForCodex(sourceDir: string, destinationDir: string): void {
50
- const source = resolve(sourceDir);
51
- const destination = resolve(destinationDir);
52
- if (!existsSync(source) || !statSync(source).isDirectory()) throw new Error(`plugin source is not a directory: ${source}`);
53
- if (destination === source || destination.startsWith(`${source}/`)) throw new Error('destination must not be inside the plugin source');
54
- assertSafeTree(source);
55
-
56
- mkdirSync(destination, { recursive: true });
57
- if (readdirSync(destination).length !== 0) throw new Error(`Codex projection destination must be empty: ${destination}`);
58
- const stage = mkdtempSync(join(destination, '.plgnz-codex-stage-'));
59
- try {
60
- cpSync(source, stage, { recursive: true });
61
- const commands = discoverCommands(source);
62
- validateClaudeNativeSemantics(source, commands);
63
- const namespace = readPackageName(source);
64
- for (const command of commands) { if (command.userInvocable === false) throw new Error(`user-invocable: false is unsupported by Codex: ${command.source}`); writeCommandSkill(stage, command, namespace, new Set(commands.map(item => item.name))); }
65
- translateNativeSkillPolicies(stage);
66
- publishStage(stage, destination);
67
- } catch (error) {
68
- rmSync(stage, { recursive: true, force: true });
69
- throw error;
70
- }
71
- }
72
-
73
- /**
74
- * Build one OMP native extension package. OMP's npm/link lane discovers the
75
- * package's ordinary Agent Plugin skills and its commands/ directory together.
76
- * Manual-only skills are kept out of skills/ and exposed as namespaced commands;
77
- * a private source snapshot keeps every sibling reference and resource path.
78
- */
79
- export function projectPluginForOmp(sourceDir: string, destinationDir: string, options: OmpProjectionOptions): void {
80
- const source = resolve(sourceDir); const destination = resolve(destinationDir);
81
- if (!existsSync(source) || !statSync(source).isDirectory()) throw new Error(`plugin source is not a directory: ${source}`);
82
- if (destination === source || destination.startsWith(`${source}/`)) throw new Error('destination must not be inside the plugin source');
83
- assertSafeTree(source); mkdirSync(destination, { recursive: true });
84
- if (readdirSync(destination).length !== 0) throw new Error(`OMP projection destination must be empty: ${destination}`);
85
- cpSync(source, destination, { recursive: true });
86
- const privateRoot = join(destination, '.plgnz', 'source');
87
- mkdirSync(dirname(privateRoot), { recursive: true });
88
- cpSync(source, privateRoot, { recursive: true });
89
-
90
- const commands = discoverCommands(source);
91
- validateClaudeNativeSemantics(source, commands);
92
- const projected = new Set<string>();
93
- const commandNames = new Set(commands.map(command => command.name));
94
- rmSync(join(destination, 'commands'), { recursive: true, force: true });
95
- for (const command of commands) {
96
- if (command.userInvocable === false) throw new Error(`user-invocable: false is unsupported by OMP command projection: ${command.source}`);
97
- if (command.allowImplicit) throw new Error(`model-invocable source command is unsupported by OMP command projection: ${command.source}`);
98
- writeOmpCommand(destination, `${options.namespace}:${command.name}`, command.description, command.argumentHint, rewriteOmpCommandReferences(command.body, commandNames, options.namespace),
99
- join(options.activeRoot, '.plgnz', 'source', relativeCommandBase(source, command.source)), projected);
100
- }
101
-
102
- const skills = join(source, 'skills');
103
- if (existsSync(skills)) {
104
- for (const entry of readdirSync(skills).sort()) {
105
- const skillDir = join(skills, entry); const skillFile = join(skillDir, 'SKILL.md');
106
- if (!statSync(skillDir).isDirectory() || !existsSync(skillFile)) continue;
107
- const parsed = parseManualSkill(skillDir);
108
- if (parsed.userInvocable === false) throw new Error(`user-invocable: false is unsupported by OMP skill projection: ${skillFile}`);
109
- if (!parsed.manual) { stripOmpInvocationMetadata(join(destination, 'skills', entry, 'SKILL.md'), parsed.metadata); continue; }
110
- const commandName = `${options.namespace}:${parsed.name}`;
111
- writeOmpCommand(destination, commandName, parsed.description, parsed.argumentHint, rewriteOmpCommandReferences(parsed.body, commandNames, options.namespace),
112
- join(options.activeRoot, '.plgnz', 'source', 'skills', entry), projected);
113
- rmSync(join(destination, 'skills', entry), { recursive: true, force: true });
114
- }
115
- }
116
- writeFileSync(join(destination, 'package.json'), `${JSON.stringify({ name: options.packageName, version: options.version, omp: {} }, null, 2)}\n`);
117
- }
118
-
119
- function rewriteOmpCommandReferences(body: string, names: Set<string>, namespace: string): string {
120
- return body.replace(/(^|[^A-Za-z0-9_:-])\/([a-z0-9][a-z0-9-]*)(?=\s|$|[.,:;!?])/giu,
121
- (whole, prefix: string, name: string) => names.has(name) ? `${prefix}/${namespace}:${name}` : whole);
122
- }
123
-
124
- function relativeCommandBase(source: string, path: string): string {
125
- const parent = dirname(path);
126
- if (parent === source) return '';
127
- if (!parent.startsWith(`${source}/`)) throw new Error(`command source escapes plugin: ${path}`);
128
- return parent.slice(source.length + 1);
129
- }
130
-
131
- function writeOmpCommand(root: string, name: string, description: string, argumentHint: string | undefined, body: string, base: string, names: Set<string>): void {
132
- if (!/^[A-Za-z0-9][A-Za-z0-9._:-]*$/u.test(name)) throw new Error(`unsafe OMP command name: ${name}`);
133
- if (names.has(name)) throw new Error(`OMP command collision: ${name}`);
134
- names.add(name);
135
- const hint = argumentHint === undefined ? '' : `argument-hint: ${yamlString(argumentHint)}\n`;
136
- const preserved = body.endsWith('\n') ? body : `${body}\n`;
137
- mkdirSync(join(root, 'commands'), { recursive: true });
138
- writeFileSync(join(root, 'commands', `${name}.md`), `---\ndescription: ${yamlString(description)}\n${hint}---\n${preserved}\nBase directory for this command: ${base}\nRelative paths in this command are relative to this base directory.\n`);
139
- }
140
-
141
- function parseManualSkill(directory: string): { name: string; description: string; argumentHint?: string; userInvocable?: boolean; manual: boolean; body: string; metadata: Record<string, unknown> } {
142
- const path = join(directory, 'SKILL.md');
143
- const raw = readFileSync(path, 'utf8');
144
- const match = raw.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/);
145
- if (!match) throw new Error(`skill frontmatter is required: ${path}`);
146
- const metadata = parseYaml(match[1] ?? '', path);
147
- normalizeMetadata(metadata, path);
148
- const name = metadata['name'], description = metadata['description'];
149
- if (typeof name !== 'string' || !validSegment(name) || typeof description !== 'string' || !description.trim()) throw new Error(`invalid OMP skill identity: ${path}`);
150
- const manual = metadata['disable-model-invocation'];
151
- const userInvocable = metadata['user-invocable'];
152
- const hint = metadata['argument-hint'];
153
- if (manual !== undefined && typeof manual !== 'boolean') throw new Error(`disable-model-invocation must be boolean: ${path}`);
154
- if (userInvocable !== undefined && typeof userInvocable !== 'boolean') throw new Error(`user-invocable must be boolean: ${path}`);
155
- if (hint !== undefined && typeof hint !== 'string') throw new Error(`argument hint must be text: ${path}`);
156
- const sidecar = join(directory, 'agents', 'openai.yaml');
157
- const implicit = existsSync(sidecar) ? readImplicitPolicy(sidecar) : undefined;
158
- if (implicit !== undefined && typeof manual === 'boolean' && implicit === manual) throw new Error(`conflicting invocation policy for native skill ${name}`);
159
- return { name, description, ...(typeof hint === 'string' ? { argumentHint: hint } : {}), ...(typeof userInvocable === 'boolean' ? { userInvocable } : {}), manual: manual === true || implicit === false, body: match[2] ?? '', metadata };
160
- }
161
-
162
- function stripOmpInvocationMetadata(path: string, parsed: Record<string, unknown>): void {
163
- const raw = readFileSync(path, 'utf8'); const match = raw.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/);
164
- if (!match) throw new Error(`skill frontmatter is required: ${path}`);
165
- const kept = { ...parsed };
166
- for (const key of ['disable-model-invocation', 'disable_model_invocation', 'user-invocable', 'user_invocable', 'argument-hint', 'argument_hint']) delete kept[key];
167
- writeFileSync(path, `---\n${Bun.YAML.stringify(kept).replace(/\s*$/u, '')}\n---\n${match[2] ?? ''}`);
168
-
169
- }
170
-
171
- /**
172
- * Preserve the portable Agent Plugin as the primary package and, when prompt
173
- * commands exist, generate a sibling native directory plugin. The native
174
- * handler queues the expanded prompt through PluginContext.inject_message;
175
- * it never prints a prompt as if that had started a model turn.
176
- */
177
- export function projectPluginForHermes(sourceDir: string, destinationDir: string, companionDir?: string): void {
178
- const source = resolve(sourceDir);
179
- const destination = resolve(destinationDir);
180
- if (!existsSync(source) || !statSync(source).isDirectory()) throw new Error(`plugin source is not a directory: ${source}`);
181
- if (destination === source || destination.startsWith(`${source}/`)) throw new Error('destination must not be inside the plugin source');
182
- assertSafeTree(source);
183
- mkdirSync(destination, { recursive: true });
184
- if (readdirSync(destination).length !== 0) throw new Error(`Hermes projection destination must be empty: ${destination}`);
185
- const stage = mkdtempSync(join(destination, '.plgnz-hermes-stage-'));
186
- try {
187
- cpSync(source, stage, { recursive: true });
188
- const commands = discoverCommands(source);
189
- validateClaudeNativeSemantics(source, commands);
190
- const pluginId = readPackageName(source);
191
- const skills = projectHermesSkills(stage);
192
- if (commands.length > 0 || skills.length > 0) {
193
- if (companionDir === undefined) throw new Error('Hermes skills and prompt commands require a native companion destination');
194
- writeHermesCommandCompanion(companionDir, pluginId, commands, skills);
195
- }
196
- publishStage(stage, destination);
197
- } catch (error) {
198
- rmSync(stage, { recursive: true, force: true });
199
- throw error;
200
- }
201
- }
202
-
203
- function writeHermesCommandCompanion(destinationDir: string, pluginId: string, commands: Command[], skills: HermesSkill[]): void {
204
- const destination = resolve(destinationDir);
205
- mkdirSync(destination, { recursive: true });
206
- if (readdirSync(destination).length !== 0) throw new Error(`Hermes companion destination must be empty: ${destination}`);
207
- for (const command of commands) {
208
- if (command.userInvocable === false) throw new Error(`Hermes cannot preserve user-invocable: false for prompt command: ${command.source}`);
209
- if (command.allowImplicit) throw new Error(`Hermes cannot preserve model-invocable prompt command policy: ${command.source}`);
210
- }
211
- const companionId = hermesCommandCompanionId(pluginId);
212
- const commandNames = new Set(commands.map(command => command.name));
213
- const bridged = [...commands.map(command => ({ ...command,
214
- name: `${pluginId}:${command.name}`,
215
- body: command.body.replace(/(^|[^A-Za-z0-9_-])\/([a-z0-9][a-z0-9-]*)(?=\s|$|[.,:;!?])/giu, (whole, prefix: string, name: string) => commandNames.has(name) ? `${prefix}/${pluginId}:${name}` : whole),
216
- })), ...skills.filter(skill => skill.userOnly).map(skill => ({
217
- name: `${pluginId}:${skill.name}`,
218
- description: `User-only skill: ${skill.description}`,
219
- argumentHint: '[arguments]', userInvocable: true, allowImplicit: false,
220
- body: `Read and follow the user-invoked skill at .plgnz-user-skills/${skill.name}/SKILL.md relative to the plugin root above. Treat it as the governing instructions for this turn. User arguments: $ARGUMENTS`,
221
- source: `${pluginId}/skills/${skill.name}/SKILL.md`,
222
- }))];
223
- if (new Set(bridged.map(command => command.name)).size !== bridged.length) throw new Error(`Hermes command projection collides in ${pluginId}`);
224
- const rows = bridged.map(command => ` (${JSON.stringify(command.name)}, ${JSON.stringify(command.description)}, ${JSON.stringify(command.argumentHint ?? '')}, ${JSON.stringify(command.body)}),`).join('\n');
225
- const ordinary = skills.filter(skill => !skill.userOnly);
226
- const skillRows = ordinary.map(skill => ` (${JSON.stringify(skill.name)}, ${JSON.stringify(skill.description)}),`).join('\n');
227
- writeFileSync(join(destination, 'plugin.yaml'), `name: ${companionId}\nversion: 1\ndescription: Native prompt-command bridge for ${pluginId}\nrequires_plugins:\n - ${pluginId}\n`);
228
- writeFileSync(join(destination, '__init__.py'), `"""Generated by plgnz. Queues portable prompt commands into the interactive Hermes CLI."""\nfrom pathlib import Path\nimport re\nimport shlex\n\n_COMMANDS = [\n${rows}\n]\n\ndef _expand(template, raw_args):\n raw = raw_args or ""\n try:\n parts = shlex.split(raw)\n except ValueError:\n parts = raw.split()\n def replacement(match):\n if match.group(0) == "$ARGUMENTS":\n return raw\n index = int(match.group(1) or match.group(2))\n return parts[index - 1] if 0 < index <= len(parts) else ""\n return re.sub(r"\\$ARGUMENTS\\b|\\$(?:\\{([0-9]+)\\}|([0-9]+))", replacement, template)\n\ndef _handler(ctx, template):\n def run(raw_args):\n plugin_root = Path(__file__).resolve().parent.parent / ${JSON.stringify(pluginId)}\n prompt = _expand(template, raw_args)\n prompt = (f"Plugin root: {plugin_root}\\n"
229
- "Resolve relative skill, agent, and reference paths from that plugin root.\\n\\n" + prompt)\n if not ctx.inject_message(prompt):\n return "Unsupported outside an interactive Hermes CLI session; no model turn was started."\n return None\n return run\n\ndef register(ctx):\n for name, description, args_hint, template in _COMMANDS:\n ctx.register_command(name, _handler(ctx, template), description=description, args_hint=args_hint)\n`);
230
- if (ordinary.length > 0) {
231
- const init = join(destination, '__init__.py');
232
- writeFileSync(init, `${readFileSync(init, 'utf8')} import hashlib\n plugin_id = ${JSON.stringify(pluginId)}\n slug = "".join(ch if ch.isascii() and (ch.isalnum() or ch in "_-") else "-" for ch in plugin_id.lower()).strip("-_") or "plugin"\n namespace = f"agent-plugin-{slug}-{hashlib.sha256(plugin_id.encode()).hexdigest()[:8]}"\n skills = [\n${skillRows}\n ]\n lines = "\\n".join(f"- {namespace}:{name}: {description}" for name, description in skills)\n section = ${JSON.stringify(`Portable skills from ${pluginId} are available through Hermes skill tools:\n`)} + lines + ${JSON.stringify('\nUse skill_view with the exact qualified name when a listed skill matches the task.')}\n ctx.register_system_prompt_section(${JSON.stringify(`${companionId}.skills`)}, section, position="after_memory")\n`);
233
- }
234
- }
235
-
236
- function projectHermesSkills(stage: string): HermesSkill[] {
237
- const root = join(stage, 'skills');
238
- if (!existsSync(root)) return [];
239
- const result = readdirSync(root).sort().flatMap(entry => {
240
- const skill = join(root, entry, 'SKILL.md');
241
- if (!existsSync(skill)) return [];
242
- const match = readFileSync(skill, 'utf8').match(/^---\r?\n([\s\S]*?)\r?\n---/);
243
- if (match === null) return [];
244
- const metadata = parseYaml(match[1] ?? '', skill);
245
- return typeof metadata['description'] === 'string' ? [{ name: entry, description: metadata['description'], userOnly: hermesSkillUserOnly(skill, metadata) }] : [];
246
- });
247
- const userOnly = result.filter(skill => skill.userOnly);
248
- if (userOnly.length > 0) {
249
- const privateRoot = join(stage, '.plgnz-user-skills');
250
- cpSync(root, privateRoot, { recursive: true });
251
- for (const skill of userOnly) rmSync(join(root, skill.name), { recursive: true });
252
- }
253
- for (const skill of result.filter(item => !item.userOnly)) stripOrdinaryHermesPolicy(join(root, skill.name, 'SKILL.md'));
254
- return result;
255
- }
256
-
257
- function validateClaudeNativeSemantics(source: string, commands: Command[]): void {
258
- const file = join(source, '.claude-plugin', 'plugin.json');
259
- if (!existsSync(file)) return;
260
- const parsed: unknown = JSON.parse(readFileSync(file, 'utf8'));
261
- if (!isRecord(parsed)) throw new Error(`Claude native plugin manifest must be an object: ${file}`);
262
- for (const field of UNSUPPORTED_CLAUDE_COMPONENTS) {
263
- if (parsed[field] !== undefined) throw new Error(`Claude native ${field} semantics are unsupported for Codex conversion: ${file}`);
264
- }
265
- if (parsed['skills'] !== undefined && parsed['skills'] !== './skills' && parsed['skills'] !== './skills/') {
266
- throw new Error(`Claude native skills pointer is unsupported for Codex conversion: ${file}`);
267
- }
268
- if (parsed['skills'] !== undefined && (!existsSync(join(source, 'skills')) || !statSync(join(source, 'skills')).isDirectory())) {
269
- throw new Error(`Claude native skills pointer cannot be preserved for Codex conversion: ${file}`);
270
- }
271
- if (parsed['commands'] === undefined) return;
272
- const listed = Array.isArray(parsed['commands']) ? parsed['commands'] : [parsed['commands']];
273
- if (!listed.every(value => typeof value === 'string')) throw new Error(`Claude native commands pointer is invalid for Codex conversion: ${file}`);
274
- const preserved = new Set(commands.map(command => `./${command.source.slice(source.length + 1)}`));
275
- const requested = new Set(listed as string[]);
276
- if (requested.size !== preserved.size || [...requested].some(path => !preserved.has(path))) {
277
- throw new Error(`Claude native commands pointer is unsupported for Codex conversion: ${file}`);
278
- }
279
- }
280
-
281
- function assertSafeTree(root: string): void {
282
- // `statSync` follows links, so use find's physical link test before cpSync.
283
- // This avoids copying a resource that later resolves outside the package.
284
- const links = spawnSync('find', [root, '-type', 'l', '-print'], { encoding: 'utf8' });
285
- if (links.status !== 0) throw new Error(`could not inspect plugin resources: ${root}`);
286
- if (links.stdout.trim() !== '') throw new Error(`symlink is not supported in plugin resources: ${links.stdout.trim()}`);
287
- }
288
-
289
- function discoverCommands(source: string): Command[] {
290
- const claude = join(source, '.claude', 'commands');
291
- if (existsSync(claude)) return readCommandDirectory(claude, 'markdown');
292
- const root = join(source, 'commands');
293
- if (!existsSync(root)) return [];
294
- const markdown = readCommandDirectory(root, 'markdown');
295
- return markdown.length > 0 ? markdown : readCommandDirectory(root, 'toml');
296
- }
297
-
298
- export { discoverCommands };
299
-
300
- function readPackageName(source: string): string {
301
- const manifest = readPluginManifest(source);
302
- if (manifest === undefined) {
303
- throw new Error('Plugin manifest is required for command namespace mapping');
304
- }
305
- return manifest.name;
306
- }
307
-
308
- function readCommandDirectory(directory: string, kind: 'markdown' | 'toml'): Command[] {
309
- if (!statSync(directory).isDirectory()) throw new Error(`command path is not a directory: ${directory}`);
310
- return readdirSync(directory).sort().flatMap(file => {
311
- if (!file.endsWith(kind === 'markdown' ? '.md' : '.toml')) return [];
312
- const path = join(directory, file);
313
- if (!statSync(path).isFile()) throw new Error(`command must be a regular file: ${path}`);
314
- return [kind === 'markdown' ? parseMarkdownCommand(path) : parseTomlCommand(path)];
315
- });
316
- }
317
-
318
- function parseMarkdownCommand(path: string): Command {
319
- const raw = readFileSync(path, 'utf8');
320
- const match = raw.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/);
321
- if (match === null) throw new Error(`command frontmatter is required: ${path}`);
322
- const metadata = parseYaml(match[1] ?? '', path);
323
- validateMetadata(metadata, path);
324
- normalizeMetadata(metadata, path);
325
- const description = metadata['description'];
326
- if (typeof description !== 'string' || description.trim() === '') throw new Error(`command description is required: ${path}`);
327
- return commandFrom(path, description, metadata['argument-hint'], metadata['user-invocable'], metadata['disable-model-invocation'], match[2] ?? '');
328
- }
329
-
330
- function parseTomlCommand(path: string): Command {
331
- const parsed: unknown = parseToml(readFileSync(path, 'utf8'));
332
- if (!isRecord(parsed)) throw new Error(`invalid command TOML: ${path}`);
333
- validateMetadata(parsed, path);
334
- normalizeMetadata(parsed, path);
335
- const description = parsed['description'];
336
- const prompt = parsed['prompt'];
337
- if (typeof description !== 'string' || description.trim() === '' || typeof prompt !== 'string') throw new Error(`command TOML needs description and prompt: ${path}`);
338
- return commandFrom(path, description, parsed['argument-hint'], parsed['user-invocable'], parsed['disable-model-invocation'], prompt);
339
- }
340
-
341
- function normalizeMetadata(metadata: Record<string, unknown>, path: string): void {
342
- for (const [hyphen, underscore] of [['argument-hint', 'argument_hint'], ['user-invocable', 'user_invocable'], ['disable-model-invocation', 'disable_model_invocation']] as const) {
343
- if (metadata[hyphen] !== undefined && metadata[underscore] !== undefined) throw new Error(`conflicting command metadata spellings: ${path} (${hyphen}, ${underscore})`);
344
- if (metadata[hyphen] === undefined && metadata[underscore] !== undefined) metadata[hyphen] = metadata[underscore];
345
- }
346
- }
347
-
348
- function commandFrom(path: string, description: string, argumentHint: unknown, userInvocable: unknown, disableModelInvocation: unknown, body: string): Command { const name = basename(path).replace(/\.(md|toml)$/u, '');
349
- if (!validSegment(name)) throw new Error(`unsafe command name: ${name}`);
350
- if (argumentHint !== undefined && typeof argumentHint !== 'string') throw new Error(`argument hint must be text: ${path}`);
351
- if (userInvocable !== undefined && typeof userInvocable !== 'boolean') throw new Error(`user-invocable must be boolean: ${path}`);
352
- if (disableModelInvocation !== undefined && typeof disableModelInvocation !== 'boolean') throw new Error(`disable-model-invocation must be boolean: ${path}`);
353
- if (/!`[\s\S]*?`/u.test(body)) throw new Error(`shell preprocessing is unsupported: ${path}`);
354
- return { name, description, ...(typeof argumentHint === 'string' ? { argumentHint } : {}), ...(typeof userInvocable === 'boolean' ? { userInvocable } : {}), allowImplicit: disableModelInvocation === false, body, source: path };
355
- }
356
-
357
- /**
358
- * Normalize a staged plugin's command sources to the Markdown command form
359
- * every Markdown-commands host loads (spec §2: TOML prompt commands map into
360
- * the native Markdown form when semantics are equivalent). Directory
361
- * semantics match discoverCommands: any .md present wins and .toml files in
362
- * that directory are shadowed and removed; otherwise each .toml
363
- * (description + prompt) becomes <stem>.md. Invocation-policy metadata has
364
- * no Markdown equivalent and is refused rather than silently stripped.
365
- */
366
- export function normalizeCommandSources(root: string): void {
367
- const dir = join(root, 'commands');
368
- if (!existsSync(dir)) return;
369
- if (!statSync(dir).isDirectory()) throw new Error(`commands path is not a directory: ${dir}`);
370
- normalizeCommandTree(dir);
371
- }
372
-
373
- export function normalizeCommandTree(dir: string): void {
374
- const stats = readdirSync(dir).sort().map((name) => {
375
- const path = join(dir, name);
376
- const stat = lstatSync(path);
377
- if (stat.isSymbolicLink()) throw new Error(`command source contains symlink: ${path}`);
378
- return { name, path, isDirectory: stat.isDirectory(), isFile: stat.isFile() };
379
- });
380
- for (const entry of stats) if (entry.isDirectory) normalizeCommandTree(entry.path);
381
- const hasMarkdown = stats.some((entry) => entry.isFile && entry.name.endsWith('.md'));
382
- for (const entry of stats) {
383
- if (!entry.isFile || !entry.name.endsWith('.toml')) continue;
384
- if (hasMarkdown) { rmSync(entry.path); continue; }
385
- const projected = tomlCommandMarkdown(entry.path);
386
- writeFileSync(join(dir, `${projected.name}.md`), projected.text);
387
- rmSync(entry.path);
388
- }
389
- }
390
-
391
- /** The Markdown command text a TOML prompt command maps to (spec §2).
392
- * Invocation-policy metadata has no Markdown equivalent and is refused
393
- * rather than silently stripped. */
394
- export function tomlCommandMarkdown(path: string): { name: string; text: string } {
395
- const command = parseTomlCommand(path);
396
- if (command.userInvocable === false || command.allowImplicit) throw new Error(`command invocation policy is unsupported by Markdown projection: ${path}`);
397
- const hint = command.argumentHint === undefined ? '' : `argument-hint: ${yamlString(command.argumentHint)}\n`;
398
- const body = command.body.endsWith('\n') ? command.body : `${command.body}\n`;
399
- return { name: command.name, text: `---\ndescription: ${yamlString(command.description)}\n${hint}---\n${body}` };
400
- }
401
-
402
- /** Pi projector: reuse the shared command parser, but emit Pi's native skill form. */
403
- export function projectPluginForPi(sourceDir: string, destinationDir: string, namespace: string): void {
404
- const source = resolve(sourceDir); const destination = resolve(destinationDir);
405
- if (!existsSync(source) || !statSync(source).isDirectory()) throw new Error(`plugin source is not a directory: ${source}`);
406
- if (destination === source || destination.startsWith(`${source}/`)) throw new Error('destination must not be inside the plugin source');
407
- assertSafeTree(source); mkdirSync(destination, { recursive: true });
408
- if (readdirSync(destination).length !== 0) throw new Error(`Pi projection destination must be empty: ${destination}`);
409
- cpSync(source, destination, { recursive: true });
410
- const commands = discoverCommands(source); const names = new Set(commands.map(command => command.name));
411
- for (const command of commands) writePiCommandSkill(destination, command, namespace, names);
412
- }
413
-
414
- function writePiCommandSkill(stage: string, command: Command, namespace: string, names: Set<string>): void {
415
- if (command.userInvocable === false) throw new Error(`user-invocable: false has no proven Pi skill equivalent: ${command.source}`);
416
- const skillDir = join(stage, 'skills', command.name);
417
- if (existsSync(skillDir)) throw new Error(`collision: command ${command.name} would replace an existing skill`);
418
- const body = command.body
419
- .replace(/\$ARGUMENTS\b/gu, 'the text after this /skill invocation')
420
- .replace(/(^|[^A-Za-z0-9_-])\/([a-z0-9][a-z0-9-]*)(?=\s|$|[.,:;!?])/giu, (whole, prefix: string, name: string) => names.has(name) ? `${prefix}/skill:${namespace}-${name}` : whole);
421
- mkdirSync(skillDir, { recursive: true });
422
- writeFileSync(join(skillDir, 'SKILL.md'), `---\nname: ${namespace}-${command.name}\ndescription: ${yamlString(command.description)}\ndisable-model-invocation: true\n---\n\nUse the text after this /skill invocation as command arguments; no text means empty arguments. Relative paths remain relative to this skill directory.\n\n${body}`);
423
- }
424
-
425
- function validateMetadata(metadata: Record<string, unknown>, path: string): void {
426
- for (const [key, value] of Object.entries(metadata)) {
427
- if (UNSUPPORTED_FIELDS.has(key)) throw new Error(`permission semantics are unsupported for Codex conversion: ${path} (${key})`);
428
- if (!COMMAND_FIELDS.has(key) && key !== 'prompt') throw new Error(`unknown command metadata: ${path} (${key})`);
429
- if (key !== 'description' && key !== 'argument-hint' && key !== 'argument_hint' && key !== 'prompt' && typeof value !== 'boolean') {
430
- throw new Error(`command policy metadata must be boolean: ${path} (${key})`);
431
- }
432
- }
433
- }
434
-
435
- function writeCommandSkill(stage: string, command: Command, namespace: string, commandNames: Set<string>): void {
436
- const skillDir = join(stage, 'skills', command.name);
437
- if (existsSync(skillDir)) throw new Error(`collision: command ${command.name} would replace an existing skill`);
438
- const body = command.body.replace(/\$ARGUMENTS\b/gu, 'the invocation tail').replace(/(^|[^A-Za-z0-9_-])\/([a-z0-9][a-z0-9-]*)(?=\s|$|[.,:;!?])/giu, (whole, prefix: string, name: string) => commandNames.has(name) ? `${prefix}$${namespace}:${name}` : whole);
439
- const hint = command.argumentHint === undefined ? '' : `argument-hint: ${yamlString(command.argumentHint)}\n`;
440
- const userInvocable = command.userInvocable === undefined ? '' : `user-invocable: ${command.userInvocable}\n`;
441
- const preface = "Use the user's text after the skill invocation as command arguments; no text means empty arguments. Load referenced skills from sibling directories (../<skill>/SKILL.md). Plugin personas live at ../../agents/<persona>.md and shared references at ../../references/, relative to this file.\n\nFor persona work, read the persona file and use the current harness's available delegation facility with those instructions. Persona names are not assumed to be registered tools or agent types. If delegation is unavailable, perform the passes sequentially and report that limitation.\n\n";
442
- mkdirSync(join(skillDir, 'agents'), { recursive: true });
443
- writeFileSync(join(skillDir, 'SKILL.md'), `---\nname: ${command.name}\ndescription: ${yamlString(command.description)}\n${hint}${userInvocable}disable-model-invocation: ${!command.allowImplicit}\n---\n\n${preface}${body}`);
444
- writePolicy(join(skillDir, 'agents', 'openai.yaml'), command.allowImplicit);
445
- }
446
-
447
- function hermesSkillUserOnly(skill: string, metadata: Record<string, unknown>): boolean {
448
- const value = (hyphen: string, underscore: string): unknown => {
449
- if (metadata[hyphen] !== undefined && metadata[underscore] !== undefined) throw new Error(`conflicting Hermes skill invocation policy spellings: ${skill}`);
450
- return metadata[hyphen] ?? metadata[underscore];
451
- };
452
- const disable = value('disable-model-invocation', 'disable_model_invocation');
453
- const user = value('user-invocable', 'user_invocable');
454
- if (disable !== undefined && typeof disable !== 'boolean') throw new Error(`invalid Hermes skill invocation policy: ${skill}`);
455
- if (user !== undefined && typeof user !== 'boolean') throw new Error(`invalid Hermes skill invocation policy: ${skill}`);
456
- if (user === false) throw new Error(`Hermes cannot preserve user-invocable: false: ${skill}`);
457
- const sidecar = join(dirname(skill), 'agents', 'openai.yaml');
458
- const implicit = existsSync(sidecar) ? readImplicitPolicy(sidecar) : undefined;
459
- if (disable !== undefined && implicit !== undefined && implicit !== !disable) throw new Error(`conflicting invocation policy for Hermes skill ${skill}`);
460
- const userOnly = disable === true || implicit === false;
461
- return userOnly;
462
- }
463
-
464
- function stripOrdinaryHermesPolicy(skill: string): void {
465
- const raw = readFileSync(skill, 'utf8');
466
- const match = raw.match(/^---\r?\n([\s\S]*?)\r?\n---/);
467
- if (match === null) throw new Error(`Hermes skill frontmatter is required: ${skill}`);
468
- const metadata = parseYaml(match[1] ?? '', skill);
469
- let changed = false;
470
- for (const key of ['disable-model-invocation', 'disable_model_invocation', 'user-invocable', 'user_invocable']) {
471
- if (metadata[key] !== undefined) { delete metadata[key]; changed = true; }
472
- }
473
- if (changed) writeFileSync(skill, `---\n${Bun.YAML.stringify(metadata).trimEnd()}\n---${raw.slice(match[0].length)}`);
474
- }
475
-
476
- function translateNativeSkillPolicies(stage: string): void {
477
- const skills = join(stage, 'skills');
478
- if (!existsSync(skills)) return;
479
- for (const entry of readdirSync(skills)) {
480
- const skill = join(skills, entry);
481
- if (!statSync(skill).isDirectory()) continue;
482
- const file = join(skill, 'SKILL.md');
483
- if (!existsSync(file)) continue;
484
- const source = readFileSync(file, 'utf8');
485
- const match = source.match(/^---\r?\n([\s\S]*?)\r?\n---/);
486
- const metadata = match === null ? {} : parseYaml(match[1] ?? '', file);
487
- const manual = metadata['disable-model-invocation'] === true;
488
- const automatic = metadata['disable-model-invocation'] === false;
489
- const sidecar = join(skill, 'agents', 'openai.yaml');
490
- if (existsSync(sidecar)) {
491
- const policy = readImplicitPolicy(sidecar);
492
- if (policy !== undefined && ((manual && policy !== false) || (automatic && policy !== true))) {
493
- throw new Error(`conflicting invocation policy for native skill ${entry}`);
494
- } else if (policy === undefined && manual) writePolicy(sidecar, false);
495
- } else if (manual) {
496
- mkdirSync(dirname(sidecar), { recursive: true });
497
- writePolicy(sidecar, false);
498
- }
499
- }
500
- }
501
-
502
- function readImplicitPolicy(path: string): boolean | undefined {
503
- const parsed: unknown = Bun.YAML.parse(readFileSync(path, 'utf8'));
504
- if (!isRecord(parsed)) throw new Error(`invalid Codex sidecar: ${path}`);
505
- const policy = parsed['policy'];
506
- if (policy === undefined) return undefined;
507
- if (!isRecord(policy)) throw new Error(`invalid Codex invocation policy: ${path}`);
508
- if (policy['allow_implicit_invocation'] === undefined) throw new Error(`missing Codex invocation policy: ${path}`);
509
- if (typeof policy['allow_implicit_invocation'] !== 'boolean') throw new Error(`invalid Codex invocation policy: ${path}`);
510
- return policy['allow_implicit_invocation'];
511
- }
512
-
513
- function writePolicy(path: string, allowImplicit: boolean): void {
514
- const existing = existsSync(path) ? readFileSync(path, 'utf8').replace(/\s*$/u, '') : '';
515
- const suffix = `policy:\n allow_implicit_invocation: ${allowImplicit}\n`;
516
- writeFileSync(path, existing === '' ? suffix : `${existing}\n${suffix}`);
517
- }
518
-
519
- function publishStage(stage: string, destination: string): void {
520
- for (const entry of readdirSync(stage)) cpSync(join(stage, entry), join(destination, entry), { recursive: true });
521
- rmSync(stage, { recursive: true, force: true });
522
- }
523
-
524
- function parseYaml(text: string, path: string): Record<string, unknown> {
525
- try {
526
- const parsed: unknown = Bun.YAML.parse(text);
527
- if (!isRecord(parsed)) throw new Error('frontmatter must be a mapping');
528
- return parsed;
529
- } catch (error) {
530
- throw new Error(`invalid YAML frontmatter: ${path} (${error instanceof Error ? error.message : String(error)})`);
531
- }
532
- }
533
-
534
- function yamlString(value: string): string {
535
- return JSON.stringify(value);
536
- }
537
-
538
- function validSegment(value: string): boolean {
539
- return /^[a-z0-9][a-z0-9-]*$/u.test(value);
540
- }
541
-
542
- function isRecord(value: unknown): value is Record<string, unknown> {
543
- return typeof value === 'object' && value !== null && !Array.isArray(value);
544
- }