@ikenga/contract 0.5.1 → 0.7.0

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 (53) hide show
  1. package/dist/engine/acp.d.ts +271 -0
  2. package/dist/engine/acp.d.ts.map +1 -0
  3. package/dist/engine/acp.js +13 -0
  4. package/dist/engine/acp.js.map +1 -0
  5. package/dist/{engine.d.ts → engine/adapter.d.ts} +60 -243
  6. package/dist/engine/adapter.d.ts.map +1 -0
  7. package/dist/{engine.js → engine/adapter.js} +14 -6
  8. package/dist/engine/adapter.js.map +1 -0
  9. package/dist/engine/errors.d.ts +17 -0
  10. package/dist/engine/errors.d.ts.map +1 -0
  11. package/dist/engine/errors.js +19 -0
  12. package/dist/engine/errors.js.map +1 -0
  13. package/dist/engine/index.d.ts +14 -0
  14. package/dist/engine/index.d.ts.map +1 -0
  15. package/dist/engine/index.js +14 -0
  16. package/dist/engine/index.js.map +1 -0
  17. package/dist/engine/portability.d.ts +113 -0
  18. package/dist/engine/portability.d.ts.map +1 -0
  19. package/dist/engine/portability.js +17 -0
  20. package/dist/engine/portability.js.map +1 -0
  21. package/dist/engine/subagent-transcoder.d.ts +24 -0
  22. package/dist/engine/subagent-transcoder.d.ts.map +1 -0
  23. package/dist/engine/subagent-transcoder.js +341 -0
  24. package/dist/engine/subagent-transcoder.js.map +1 -0
  25. package/dist/index.d.ts +1 -1
  26. package/dist/index.d.ts.map +1 -1
  27. package/dist/index.js +1 -1
  28. package/dist/index.js.map +1 -1
  29. package/dist/manifest.d.ts +147 -0
  30. package/dist/manifest.d.ts.map +1 -1
  31. package/dist/manifest.js +32 -1
  32. package/dist/manifest.js.map +1 -1
  33. package/dist/registry.d.ts +216 -0
  34. package/dist/registry.d.ts.map +1 -1
  35. package/dist/registry.js +23 -0
  36. package/dist/registry.js.map +1 -1
  37. package/dist/rpc.d.ts +1 -1
  38. package/dist/rpc.d.ts.map +1 -1
  39. package/package.json +3 -3
  40. package/src/{engine.ts → engine/acp.ts} +49 -198
  41. package/src/engine/adapter.ts +243 -0
  42. package/src/{engine.test.ts → engine/engine.test.ts} +33 -2
  43. package/src/engine/errors.ts +20 -0
  44. package/src/engine/index.ts +14 -0
  45. package/src/engine/portability.ts +123 -0
  46. package/src/engine/subagent-transcoder.test.ts +306 -0
  47. package/src/engine/subagent-transcoder.ts +333 -0
  48. package/src/index.ts +1 -1
  49. package/src/manifest.ts +35 -1
  50. package/src/registry.ts +25 -0
  51. package/src/rpc.ts +1 -1
  52. package/dist/engine.d.ts.map +0 -1
  53. package/dist/engine.js.map +0 -1
@@ -0,0 +1,123 @@
1
+ /**
2
+ * Portability surface for engine pkgs — ADR-012.
3
+ *
4
+ * Engine pkgs implement this in parallel to the runtime adapter
5
+ * (`AcpEngine` in `./acp.ts`). The kernel's `engine_assets` registry calls
6
+ * these methods at pkg install / uninstall time, fanning the operation out
7
+ * to every installed `EngineAdapter` so a user's pkg investment (skills,
8
+ * commands, agents, MCP servers) survives an engine swap.
9
+ *
10
+ * No manifest schema changes — pkgs continue to declare `skills`,
11
+ * `commands`, `agents` as folder paths and `mcp[]` as inline entries.
12
+ * This file is a runtime interface; it has no Zod schema and no
13
+ * compile-time dependency on the manifest schema beyond reusing
14
+ * `McpServer` for the spec passed to `registerMcpServer`.
15
+ */
16
+
17
+ import type { McpServer } from '../manifest.js';
18
+
19
+ /**
20
+ * Result of an install / register operation. Per ADR §2 the kernel's pkg
21
+ * manager UI surfaces these so the user can see exactly what each engine
22
+ * adapter wrote.
23
+ */
24
+ export interface InstallReport {
25
+ /** Absolute paths the adapter wrote or symlinked. */
26
+ wrote: string[];
27
+ /** Targets that were already correct — idempotent no-op. */
28
+ skipped: string[];
29
+ /** Non-fatal notes (e.g. "commands not supported on Codex; skipped"). */
30
+ warnings: string[];
31
+ }
32
+
33
+ /**
34
+ * Dry-run output for `EngineAdapter.plan()`. Shape mirrors `InstallReport`
35
+ * plus the `(engineId, pkgId)` tuple so the pkg manager UI can render a
36
+ * per-engine breakdown ("this pkg will install assets into 3 engines").
37
+ */
38
+ export interface InstallPlan extends InstallReport {
39
+ /** Engine the plan was computed against — matches `EngineAdapter.id`. */
40
+ engineId: string;
41
+ /** Pkg the plan was computed for — matches the manifest `id` field. */
42
+ pkgId: string;
43
+ }
44
+
45
+ /**
46
+ * Minimal pkg manifest slice the adapter needs to compute a plan. Carved
47
+ * narrow on purpose: `plan()` runs before install, so it shouldn't load
48
+ * disk content the adapter wouldn't otherwise touch. Folder paths are
49
+ * relative to the pkg root (same convention as the on-disk manifest).
50
+ */
51
+ export interface ManifestSnapshot {
52
+ /** Pkg id (e.g. `com.ikenga.studio`). */
53
+ id: string;
54
+ /** Pkg slug used to namespace materialized dirs (id with `.` → `-`). */
55
+ slug: string;
56
+ /** Relative folder for `skills/<name>/SKILL.md` files, if any. */
57
+ skills?: string;
58
+ /** Relative folder for `commands/<name>.md` files, if any. */
59
+ commands?: string;
60
+ /** Relative folder for `agents/<name>.md` files, if any. */
61
+ agents?: string;
62
+ /** Inline MCP server specs from the manifest's `mcp[]` block. */
63
+ mcp: McpServer[];
64
+ }
65
+
66
+ /**
67
+ * Portability adapter exported by every engine pkg alongside its runtime
68
+ * `AcpEngine`. The kernel resolves both at load time (ADR §2).
69
+ *
70
+ * Folder-level methods. Each `install*` call is the adapter's chance to
71
+ * symlink, copy, or walk-and-transcode the whole folder — the choice
72
+ * depends on what the engine accepts natively. Idempotent by contract:
73
+ * re-running with unchanged inputs is a no-op.
74
+ *
75
+ * `registerMcpServer` writes a single entry into the engine's external
76
+ * settings file (`~/.claude/settings.json`, `~/.gemini/settings.json`,
77
+ * `~/.codex/config.toml`). Entries are keyed `ikenga.<pkg-slug>.<name>`
78
+ * per ADR §7 so they never collide with user-authored entries.
79
+ *
80
+ * Inverses tear down only what this pkg owned — user content in the same
81
+ * dir / settings file is left untouched.
82
+ */
83
+ export interface EngineAdapter {
84
+ /** Engine identifier — matches `engine.agentId` in the pkg manifest. */
85
+ readonly id: string;
86
+
87
+ /**
88
+ * Materialize the pkg's skills folder into the engine's recognized
89
+ * location. Idempotent.
90
+ */
91
+ installSkills(folder: string, pkgId: string, pkgSlug: string): Promise<InstallReport>;
92
+
93
+ /**
94
+ * Materialize the pkg's commands folder. Engines without a first-class
95
+ * commands primitive (Codex) may emit warnings and skip per-file
96
+ * instead of erroring — see ADR §5.
97
+ */
98
+ installCommands(folder: string, pkgId: string, pkgSlug: string): Promise<InstallReport>;
99
+
100
+ /** Materialize the pkg's agents folder. Codex transcodes MD→TOML. */
101
+ installAgents(folder: string, pkgId: string, pkgSlug: string): Promise<InstallReport>;
102
+
103
+ /**
104
+ * Register one MCP server in the engine's external settings file.
105
+ * Idempotent on the `(pkgSlug, server name)` key.
106
+ */
107
+ registerMcpServer(spec: McpServer, pkgId: string, pkgSlug: string): Promise<InstallReport>;
108
+
109
+ /** Inverse of `installSkills`. Removes only entries this pkg owned. */
110
+ uninstallSkills(pkgId: string, pkgSlug: string): Promise<void>;
111
+ /** Inverse of `installCommands`. */
112
+ uninstallCommands(pkgId: string, pkgSlug: string): Promise<void>;
113
+ /** Inverse of `installAgents`. */
114
+ uninstallAgents(pkgId: string, pkgSlug: string): Promise<void>;
115
+ /** Inverse of `registerMcpServer`. */
116
+ unregisterMcpServer(serverName: string, pkgId: string, pkgSlug: string): Promise<void>;
117
+
118
+ /**
119
+ * Dry-run used by the pkg manager UI. Returns the same shape as a real
120
+ * install would produce, without touching the filesystem.
121
+ */
122
+ plan(pkgId: string, pkgSlug: string, manifestSnapshot: ManifestSnapshot): Promise<InstallPlan>;
123
+ }
@@ -0,0 +1,306 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import {
4
+ codexTomlToMd,
5
+ mdToCodexToml,
6
+ mdToGeminiCommandToml,
7
+ } from './subagent-transcoder.js';
8
+
9
+ // Tiny TOML parser shared between tests — independent of the transcoder's
10
+ // internal parser so round-trip checks have something to compare against.
11
+ // Only handles the keys ADR §5 lists, which is what the transcoder emits.
12
+ function parseSimpleToml(toml: string): Record<string, unknown> {
13
+ const out: Record<string, unknown> = {};
14
+ const lines = toml.split('\n');
15
+ let i = 0;
16
+ let table: Record<string, unknown> = out;
17
+ while (i < lines.length) {
18
+ const raw = lines[i] ?? '';
19
+ const t = raw.trim();
20
+ if (t === '' || t.startsWith('#')) { i++; continue; }
21
+ const tableMatch = /^\[([^\]]+)\]$/.exec(t);
22
+ if (tableMatch) {
23
+ const name = (tableMatch[1] ?? '').trim();
24
+ const sub: Record<string, unknown> = {};
25
+ out[name] = sub;
26
+ table = sub;
27
+ i++;
28
+ continue;
29
+ }
30
+ const eq = raw.indexOf('=');
31
+ const key = raw.slice(0, eq).trim();
32
+ const restRaw = raw.slice(eq + 1);
33
+ const rest = restRaw.trim();
34
+ if (rest.startsWith('"""')) {
35
+ const afterOpen = rest.slice(3);
36
+ if (afterOpen.endsWith('"""') && afterOpen.length >= 3) {
37
+ table[key] = unescape(afterOpen.slice(0, -3));
38
+ i++;
39
+ continue;
40
+ }
41
+ const buf: string[] = [];
42
+ if (afterOpen !== '') buf.push(afterOpen);
43
+ i++;
44
+ while (i < lines.length) {
45
+ const ln = lines[i] ?? '';
46
+ if (ln.endsWith('"""')) {
47
+ const trailing = ln.slice(0, -3);
48
+ if (trailing === '' && buf.length > 0) {
49
+ table[key] = unescape(buf.join('\n') + '\n');
50
+ } else {
51
+ buf.push(trailing);
52
+ table[key] = unescape(buf.join('\n'));
53
+ }
54
+ i++;
55
+ break;
56
+ }
57
+ buf.push(ln);
58
+ i++;
59
+ }
60
+ continue;
61
+ }
62
+ if (rest.startsWith('"') && rest.endsWith('"')) {
63
+ table[key] = JSON.parse(rest);
64
+ } else if (rest.startsWith('[') && rest.endsWith(']')) {
65
+ const inner = rest.slice(1, -1).trim();
66
+ if (inner === '') {
67
+ table[key] = [];
68
+ } else {
69
+ table[key] = inner.split(',').map((p) => {
70
+ const v = p.trim();
71
+ if (v.startsWith('"') && v.endsWith('"')) return JSON.parse(v);
72
+ if (/^-?\d+(\.\d+)?$/.test(v)) return Number(v);
73
+ return v;
74
+ });
75
+ }
76
+ } else if (rest === 'true') table[key] = true;
77
+ else if (rest === 'false') table[key] = false;
78
+ else if (/^-?\d+(\.\d+)?$/.test(rest)) table[key] = Number(rest);
79
+ else table[key] = rest;
80
+ i++;
81
+ }
82
+ return out;
83
+ }
84
+
85
+ function unescape(s: string): string {
86
+ return s.replace(/"\\""\\""/g, '"""');
87
+ }
88
+
89
+ function deepEqualKeys(a: Record<string, unknown>, b: Record<string, unknown>): boolean {
90
+ const ak = Object.keys(a).sort();
91
+ const bk = Object.keys(b).sort();
92
+ if (ak.join(',') !== bk.join(',')) return false;
93
+ for (const k of ak) {
94
+ const va = a[k];
95
+ const vb = b[k];
96
+ if (Array.isArray(va) && Array.isArray(vb)) {
97
+ if (va.length !== vb.length) return false;
98
+ for (let i = 0; i < va.length; i++) {
99
+ if (JSON.stringify(va[i]) !== JSON.stringify(vb[i])) return false;
100
+ }
101
+ continue;
102
+ }
103
+ if (typeof va === 'object' && va !== null && typeof vb === 'object' && vb !== null) {
104
+ if (!deepEqualKeys(va as Record<string, unknown>, vb as Record<string, unknown>)) return false;
105
+ continue;
106
+ }
107
+ if (va !== vb) return false;
108
+ }
109
+ return true;
110
+ }
111
+
112
+ // ---------------- TOML → MD → TOML round-trip ----------------
113
+
114
+ test('TOML → MD → TOML round-trip preserves canonical keys', () => {
115
+ const original = [
116
+ 'name = "my-agent"',
117
+ 'description = "Does the thing"',
118
+ 'tools = ["bash", "read"]',
119
+ 'model = "claude-sonnet-4"',
120
+ 'system_prompt = """',
121
+ 'You are a helpful agent.',
122
+ 'Be terse.',
123
+ '"""',
124
+ ].join('\n') + '\n';
125
+ const md = codexTomlToMd(original);
126
+ const reemitted = mdToCodexToml(md);
127
+ const a = parseSimpleToml(original);
128
+ const b = parseSimpleToml(reemitted);
129
+ assert.ok(deepEqualKeys(a, b), `mismatch:\n${JSON.stringify(a)}\nvs\n${JSON.stringify(b)}`);
130
+ });
131
+
132
+ // ---------------- MD → TOML → MD round-trip with every ADR §5 key ----------------
133
+
134
+ test('MD → TOML → MD round-trip preserves every ADR §5 key', () => {
135
+ const md = [
136
+ '---',
137
+ 'name: kitchen-sink',
138
+ 'description: Every key in ADR §5',
139
+ 'tools: [bash, read, write]',
140
+ 'model: claude-opus-4-7',
141
+ 'developer_instructions: Internal notes for Codex',
142
+ 'sandbox_mode: workspace-write',
143
+ 'temperature: 0.7',
144
+ 'max_turns: 20',
145
+ 'timeout_mins: 5',
146
+ '---',
147
+ '',
148
+ 'You are an agent that does everything.',
149
+ '',
150
+ 'Be concise.',
151
+ ].join('\n');
152
+ const toml = mdToCodexToml(md);
153
+ const back = codexTomlToMd(toml);
154
+ // Re-parse the returned MD's frontmatter into TOML again so we can
155
+ // diff structurally — frontmatter ordering is allowed to drift.
156
+ const reToml = mdToCodexToml(back);
157
+ const a = parseSimpleToml(toml);
158
+ const b = parseSimpleToml(reToml);
159
+ for (const k of [
160
+ 'name', 'description', 'tools', 'model',
161
+ 'developer_instructions', 'sandbox_mode',
162
+ 'temperature', 'max_turns', 'timeout_mins',
163
+ 'system_prompt',
164
+ ]) {
165
+ assert.ok(k in a, `lost key ${k} in first emit`);
166
+ assert.ok(k in b, `lost key ${k} in re-emit`);
167
+ assert.equal(JSON.stringify(a[k]), JSON.stringify(b[k]), `key ${k} drifted`);
168
+ }
169
+ });
170
+
171
+ // ---------------- Gemini command round-trip ----------------
172
+
173
+ test('mdToGeminiCommandToml round-trips body byte-equal modulo trailing newline', () => {
174
+ const body = 'Summarize the diff in 3 bullets.\nThen suggest tests.';
175
+ const md = [
176
+ '---',
177
+ 'name: summarize-diff',
178
+ 'description: Slash command for PR summaries',
179
+ '---',
180
+ '',
181
+ body,
182
+ ].join('\n');
183
+ const toml = mdToGeminiCommandToml(md);
184
+ const parsed = parseSimpleToml(toml);
185
+ assert.equal(parsed.name, 'summarize-diff');
186
+ assert.equal(parsed.description, 'Slash command for PR summaries');
187
+ assert.equal(parsed.prompt, body);
188
+ });
189
+
190
+ // ---------------- Empty frontmatter ----------------
191
+
192
+ test('mdToCodexToml handles empty frontmatter (no `---` block)', () => {
193
+ const md = 'Just a body, no frontmatter.';
194
+ const toml = mdToCodexToml(md);
195
+ const parsed = parseSimpleToml(toml);
196
+ assert.equal(parsed.system_prompt, 'Just a body, no frontmatter.');
197
+ });
198
+
199
+ // ---------------- Multi-line description ----------------
200
+
201
+ test('mdToCodexToml handles multi-line YAML body becoming system_prompt', () => {
202
+ const md = [
203
+ '---',
204
+ 'name: multi',
205
+ 'description: short',
206
+ '---',
207
+ '',
208
+ 'Line one.',
209
+ 'Line two.',
210
+ 'Line three.',
211
+ ].join('\n');
212
+ const toml = mdToCodexToml(md);
213
+ const parsed = parseSimpleToml(toml);
214
+ assert.equal(parsed.name, 'multi');
215
+ assert.equal(parsed.description, 'short');
216
+ assert.equal(parsed.system_prompt, 'Line one.\nLine two.\nLine three.');
217
+ });
218
+
219
+ // ---------------- Inline list values ----------------
220
+
221
+ test('inline list value (tools: [bash, read]) round-trips', () => {
222
+ const md = [
223
+ '---',
224
+ 'name: t',
225
+ 'description: d',
226
+ 'tools: [bash, read, write]',
227
+ '---',
228
+ '',
229
+ 'body',
230
+ ].join('\n');
231
+ const toml = mdToCodexToml(md);
232
+ const parsed = parseSimpleToml(toml);
233
+ assert.deepEqual(parsed.tools, ['bash', 'read', 'write']);
234
+ });
235
+
236
+ // ---------------- Block-style list values ----------------
237
+
238
+ test('block-style YAML list (`- a` lines) parses identically to inline', () => {
239
+ const md = [
240
+ '---',
241
+ 'name: t',
242
+ 'description: d',
243
+ 'tools:',
244
+ ' - bash',
245
+ ' - read',
246
+ '---',
247
+ '',
248
+ 'body',
249
+ ].join('\n');
250
+ const toml = mdToCodexToml(md);
251
+ const parsed = parseSimpleToml(toml);
252
+ assert.deepEqual(parsed.tools, ['bash', 'read']);
253
+ });
254
+
255
+ // ---------------- Markdown body containing triple quotes ----------------
256
+
257
+ test('body containing triple quotes survives MD → TOML → MD', () => {
258
+ const body = 'Use """fenced""" blocks for code in the response.';
259
+ const md = [
260
+ '---',
261
+ 'name: q',
262
+ 'description: d',
263
+ '---',
264
+ '',
265
+ body,
266
+ ].join('\n');
267
+ const toml = mdToCodexToml(md);
268
+ const parsed = parseSimpleToml(toml);
269
+ assert.equal(parsed.system_prompt, body);
270
+ // And full round-trip
271
+ const back = codexTomlToMd(toml);
272
+ const toml2 = mdToCodexToml(back);
273
+ const parsed2 = parseSimpleToml(toml2);
274
+ assert.equal(parsed2.system_prompt, body);
275
+ });
276
+
277
+ // ---------------- Codex extras preserved ----------------
278
+
279
+ test('Codex extras (developer_instructions, sandbox_mode) survive round-trip', () => {
280
+ const original = [
281
+ 'name = "codex-only"',
282
+ 'description = "uses extras"',
283
+ 'sandbox_mode = "read-only"',
284
+ 'developer_instructions = """',
285
+ 'Use the existing helper module.',
286
+ 'Do not network.',
287
+ '"""',
288
+ 'system_prompt = """',
289
+ 'You are Codex.',
290
+ '"""',
291
+ ].join('\n') + '\n';
292
+ const md = codexTomlToMd(original);
293
+ const reemitted = mdToCodexToml(md);
294
+ const a = parseSimpleToml(original);
295
+ const b = parseSimpleToml(reemitted);
296
+ assert.equal(a.sandbox_mode, b.sandbox_mode);
297
+ assert.equal(a.developer_instructions, b.developer_instructions);
298
+ assert.equal(a.system_prompt, b.system_prompt);
299
+ });
300
+
301
+ // ---------------- Unterminated frontmatter rejected ----------------
302
+
303
+ test('unterminated YAML frontmatter throws', () => {
304
+ const md = '---\nname: bad\n';
305
+ assert.throws(() => mdToCodexToml(md), /unterminated/);
306
+ });