picopilot 0.1.5 → 0.2.1

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 (126) hide show
  1. package/dist/cli.d.ts.map +1 -1
  2. package/dist/cli.js +205 -17
  3. package/dist/cli.js.map +1 -1
  4. package/dist/commands/audio.d.ts +3 -3
  5. package/dist/commands/audio.d.ts.map +1 -1
  6. package/dist/commands/audio.js +69 -44
  7. package/dist/commands/audio.js.map +1 -1
  8. package/dist/commands/doctor.d.ts +22 -0
  9. package/dist/commands/doctor.d.ts.map +1 -0
  10. package/dist/commands/doctor.js +226 -0
  11. package/dist/commands/doctor.js.map +1 -0
  12. package/dist/commands/export.d.ts +3 -4
  13. package/dist/commands/export.d.ts.map +1 -1
  14. package/dist/commands/export.js +4 -9
  15. package/dist/commands/export.js.map +1 -1
  16. package/dist/commands/gfx.d.ts +2 -2
  17. package/dist/commands/gfx.d.ts.map +1 -1
  18. package/dist/commands/gfx.js +14 -7
  19. package/dist/commands/gfx.js.map +1 -1
  20. package/dist/commands/lint.d.ts +2 -2
  21. package/dist/commands/lint.d.ts.map +1 -1
  22. package/dist/commands/lint.js +3 -2
  23. package/dist/commands/lint.js.map +1 -1
  24. package/dist/commands/minify.d.ts +2 -2
  25. package/dist/commands/minify.d.ts.map +1 -1
  26. package/dist/commands/minify.js +3 -2
  27. package/dist/commands/minify.js.map +1 -1
  28. package/dist/commands/pico8-env.d.ts +112 -0
  29. package/dist/commands/pico8-env.d.ts.map +1 -0
  30. package/dist/commands/pico8-env.js +118 -0
  31. package/dist/commands/pico8-env.js.map +1 -0
  32. package/dist/commands/playtest.d.ts +2 -2
  33. package/dist/commands/playtest.d.ts.map +1 -1
  34. package/dist/commands/playtest.js +4 -9
  35. package/dist/commands/playtest.js.map +1 -1
  36. package/dist/commands/run.d.ts +3 -4
  37. package/dist/commands/run.d.ts.map +1 -1
  38. package/dist/commands/run.js +52 -19
  39. package/dist/commands/run.js.map +1 -1
  40. package/dist/commands/serve.d.ts +2 -2
  41. package/dist/commands/serve.d.ts.map +1 -1
  42. package/dist/commands/serve.js +4 -9
  43. package/dist/commands/serve.js.map +1 -1
  44. package/dist/commands/shrinko-env.d.ts +22 -0
  45. package/dist/commands/shrinko-env.d.ts.map +1 -0
  46. package/dist/commands/shrinko-env.js +15 -0
  47. package/dist/commands/shrinko-env.js.map +1 -0
  48. package/dist/commands/tokens.d.ts +4 -4
  49. package/dist/commands/tokens.d.ts.map +1 -1
  50. package/dist/commands/tokens.js +3 -2
  51. package/dist/commands/tokens.js.map +1 -1
  52. package/dist/commands/verify.d.ts +2 -2
  53. package/dist/commands/verify.d.ts.map +1 -1
  54. package/dist/commands/verify.js +3 -2
  55. package/dist/commands/verify.js.map +1 -1
  56. package/dist/engine/child-env.d.ts +14 -0
  57. package/dist/engine/child-env.d.ts.map +1 -0
  58. package/dist/engine/child-env.js +21 -0
  59. package/dist/engine/child-env.js.map +1 -0
  60. package/dist/engine/pico8/adapter.d.ts +23 -3
  61. package/dist/engine/pico8/adapter.d.ts.map +1 -1
  62. package/dist/engine/pico8/adapter.js.map +1 -1
  63. package/dist/engine/pico8/cleanup.d.ts +16 -0
  64. package/dist/engine/pico8/cleanup.d.ts.map +1 -0
  65. package/dist/engine/pico8/cleanup.js +74 -0
  66. package/dist/engine/pico8/cleanup.js.map +1 -0
  67. package/dist/engine/pico8/display.d.ts +149 -0
  68. package/dist/engine/pico8/display.d.ts.map +1 -0
  69. package/dist/engine/pico8/display.js +311 -0
  70. package/dist/engine/pico8/display.js.map +1 -0
  71. package/dist/engine/pico8/drive.d.ts.map +1 -1
  72. package/dist/engine/pico8/drive.js +28 -2
  73. package/dist/engine/pico8/drive.js.map +1 -1
  74. package/dist/engine/pico8/harness.d.ts +8 -0
  75. package/dist/engine/pico8/harness.d.ts.map +1 -1
  76. package/dist/engine/pico8/harness.js +9 -1
  77. package/dist/engine/pico8/harness.js.map +1 -1
  78. package/dist/engine/pico8/index.d.ts +3 -0
  79. package/dist/engine/pico8/index.d.ts.map +1 -1
  80. package/dist/engine/pico8/index.js +3 -0
  81. package/dist/engine/pico8/index.js.map +1 -1
  82. package/dist/engine/pico8/shell.d.ts +56 -3
  83. package/dist/engine/pico8/shell.d.ts.map +1 -1
  84. package/dist/engine/pico8/shell.js +188 -24
  85. package/dist/engine/pico8/shell.js.map +1 -1
  86. package/dist/engine/shrinko/shell.d.ts +3 -3
  87. package/dist/engine/shrinko/shell.js +3 -3
  88. package/package.json +2 -2
  89. package/src/cli.test.ts +229 -0
  90. package/src/cli.ts +230 -18
  91. package/src/commands/audio.test.ts +64 -1
  92. package/src/commands/audio.ts +86 -57
  93. package/src/commands/doctor.test.ts +149 -0
  94. package/src/commands/doctor.ts +284 -0
  95. package/src/commands/export.ts +9 -13
  96. package/src/commands/gfx.ts +19 -15
  97. package/src/commands/lint.ts +7 -6
  98. package/src/commands/minify.ts +7 -6
  99. package/src/commands/pico8-env.ts +192 -0
  100. package/src/commands/playtest.ts +8 -11
  101. package/src/commands/run.test.ts +110 -2
  102. package/src/commands/run.ts +68 -24
  103. package/src/commands/serve.ts +8 -11
  104. package/src/commands/shrinko-env.test.ts +68 -0
  105. package/src/commands/shrinko-env.ts +40 -0
  106. package/src/commands/tokens.ts +9 -6
  107. package/src/commands/verify.ts +7 -4
  108. package/src/engine/child-env.ts +22 -0
  109. package/src/engine/pico8/adapter.ts +25 -3
  110. package/src/engine/pico8/cleanup.test.ts +28 -0
  111. package/src/engine/pico8/cleanup.ts +76 -0
  112. package/src/engine/pico8/display.test.ts +210 -0
  113. package/src/engine/pico8/display.ts +429 -0
  114. package/src/engine/pico8/drive.test.ts +20 -0
  115. package/src/engine/pico8/drive.ts +25 -2
  116. package/src/engine/pico8/fake-xvfb.test-support.ts +55 -0
  117. package/src/engine/pico8/harness.test.ts +26 -0
  118. package/src/engine/pico8/harness.ts +18 -2
  119. package/src/engine/pico8/index.ts +3 -0
  120. package/src/engine/pico8/shell.test.ts +448 -9
  121. package/src/engine/pico8/shell.ts +255 -26
  122. package/src/engine/pico8/virtual-display.process.test.ts +206 -0
  123. package/src/engine/shrinko/shell.ts +3 -3
  124. package/src/mcp.process.test.ts +219 -0
  125. package/src/skills/picopilot-audio/SKILL.md +4 -8
  126. package/src/skills/picopilot-overview/SKILL.md +9 -1
@@ -0,0 +1,219 @@
1
+ import {type ChildProcess, spawn} from 'node:child_process';
2
+ import {mkdtempSync, rmSync, writeFileSync} from 'node:fs';
3
+ import {createRequire} from 'node:module';
4
+ import {tmpdir} from 'node:os';
5
+ import {join} from 'node:path';
6
+ import {afterEach, beforeEach, describe, expect, it} from 'vitest';
7
+
8
+ /**
9
+ * picopilot as an MCP server (`picopilot --mcp`), end to end over stdio. incur's
10
+ * MCP transport passes no global flags to a tool call and loads no config file,
11
+ * so these prove picopilot closes those gaps itself: the server's own
12
+ * `--display` / `--config`, picopilot.json in the server's cwd, and a per-call
13
+ * `display` argument (ADR-0017).
14
+ */
15
+
16
+ const tsx = createRequire(import.meta.url).resolve('tsx');
17
+ const bin = new URL('./bin.ts', import.meta.url).pathname;
18
+
19
+ let dir: string;
20
+ let child: ChildProcess | undefined;
21
+
22
+ beforeEach(() => {
23
+ dir = mkdtempSync(join(tmpdir(), 'picopilot-mcp-'));
24
+ });
25
+ afterEach(() => {
26
+ child?.kill('SIGKILL');
27
+ child = undefined;
28
+ rmSync(dir, {recursive: true, force: true});
29
+ });
30
+
31
+ /** Starts `picopilot --mcp ...` in `dir` and returns a JSON-RPC caller. */
32
+ async function mcp(argv: string[] = [], env: NodeJS.ProcessEnv = {}) {
33
+ const proc = spawn(
34
+ process.execPath,
35
+ ['--import', tsx, bin, '--mcp', ...argv],
36
+ {
37
+ cwd: dir,
38
+ // No PICO-8 / shrinko on PATH: doctor's probes fail fast.
39
+ env: {PATH: '/nonexistent', HOME: dir, ...env},
40
+ stdio: ['pipe', 'pipe', 'pipe'],
41
+ },
42
+ );
43
+ child = proc;
44
+ const waiting = new Map<number, (msg: any) => void>();
45
+ let buf = '';
46
+ proc.stdout!.setEncoding('utf8');
47
+ proc.stdout!.on('data', (chunk: string) => {
48
+ buf += chunk;
49
+ let nl: number;
50
+ while ((nl = buf.indexOf('\n')) >= 0) {
51
+ const line = buf.slice(0, nl).trim();
52
+ buf = buf.slice(nl + 1);
53
+ if (line === '') continue;
54
+ const msg = JSON.parse(line);
55
+ if (typeof msg.id === 'number') waiting.get(msg.id)?.(msg);
56
+ }
57
+ });
58
+ let nextId = 1;
59
+ const request = (method: string, params: unknown) =>
60
+ new Promise<any>((resolve) => {
61
+ const id = nextId++;
62
+ waiting.set(id, resolve);
63
+ proc.stdin!.write(
64
+ `${JSON.stringify({jsonrpc: '2.0', id, method, params})}\n`,
65
+ );
66
+ });
67
+ await request('initialize', {
68
+ protocolVersion: '2025-06-18',
69
+ capabilities: {},
70
+ clientInfo: {name: 'test', version: '0'},
71
+ });
72
+ proc.stdin!.write(
73
+ `${JSON.stringify({jsonrpc: '2.0', method: 'notifications/initialized'})}\n`,
74
+ );
75
+ const call = async (name: string, args: Record<string, unknown> = {}) => {
76
+ const res = await request('tools/call', {name, arguments: args});
77
+ const text = res.result?.content?.[0]?.text ?? '';
78
+ return {isError: res.result?.isError === true, text};
79
+ };
80
+ return {request, call};
81
+ }
82
+
83
+ async function doctorMode(
84
+ session: Awaited<ReturnType<typeof mcp>>,
85
+ args: Record<string, unknown> = {},
86
+ ): Promise<string> {
87
+ const {text, isError} = await session.call('doctor', args);
88
+ expect(isError).toBe(false);
89
+ return JSON.parse(text).display.mode;
90
+ }
91
+
92
+ describe('picopilot --mcp: the display mode reaches tool calls', () => {
93
+ it('defaults to auto', async () => {
94
+ expect(await doctorMode(await mcp())).toBe('auto');
95
+ }, 30_000);
96
+
97
+ it('the server flag `--mcp --display xvfb` applies to every call', async () => {
98
+ expect(await doctorMode(await mcp(['--display', 'xvfb']))).toBe('xvfb');
99
+ }, 30_000);
100
+
101
+ it('picopilot.json "display" in the server cwd applies (incur loads no config under --mcp)', async () => {
102
+ writeFileSync(
103
+ join(dir, 'picopilot.json'),
104
+ JSON.stringify({display: 'native'}),
105
+ );
106
+ expect(await doctorMode(await mcp())).toBe('native');
107
+ }, 30_000);
108
+
109
+ it('the server flag beats PICOPILOT_DISPLAY, which beats picopilot.json', async () => {
110
+ writeFileSync(
111
+ join(dir, 'picopilot.json'),
112
+ JSON.stringify({display: 'native'}),
113
+ );
114
+ expect(await doctorMode(await mcp([], {PICOPILOT_DISPLAY: 'xvfb'}))).toBe(
115
+ 'xvfb',
116
+ );
117
+ child?.kill('SIGKILL');
118
+ expect(
119
+ await doctorMode(
120
+ await mcp(['--display', 'auto'], {PICOPILOT_DISPLAY: 'xvfb'}),
121
+ ),
122
+ ).toBe('auto');
123
+ }, 30_000);
124
+
125
+ it('--no-config on the server ignores picopilot.json', async () => {
126
+ writeFileSync(
127
+ join(dir, 'picopilot.json'),
128
+ JSON.stringify({display: 'native'}),
129
+ );
130
+ expect(await doctorMode(await mcp(['--no-config']))).toBe('auto');
131
+ }, 30_000);
132
+
133
+ it('--config resolves like incur: `~` is the home dir, the last config flag wins', async () => {
134
+ writeFileSync(
135
+ join(dir, 'custom.json'),
136
+ JSON.stringify({display: 'native'}),
137
+ );
138
+ expect(await doctorMode(await mcp(['--config', '~/custom.json']))).toBe(
139
+ 'native',
140
+ );
141
+ child?.kill('SIGKILL');
142
+ expect(
143
+ await doctorMode(await mcp(['--no-config', '--config=custom.json'])),
144
+ ).toBe('native');
145
+ }, 30_000);
146
+
147
+ it('a broken config stops the server visibly instead of serving without it', async () => {
148
+ writeFileSync(join(dir, 'picopilot.json'), '{not json');
149
+ const proc = spawn(process.execPath, ['--import', tsx, bin, '--mcp'], {
150
+ cwd: dir,
151
+ env: {PATH: '/nonexistent', HOME: dir},
152
+ stdio: ['pipe', 'pipe', 'pipe'],
153
+ });
154
+ child = proc;
155
+ let stderr = '';
156
+ proc.stderr!.setEncoding('utf8');
157
+ proc.stderr!.on('data', (s: string) => {
158
+ stderr += s;
159
+ });
160
+ const code = await new Promise<number | null>((resolve) =>
161
+ proc.once('exit', resolve),
162
+ );
163
+ expect(code).toBe(1);
164
+ expect(stderr).toContain('Invalid JSON config file');
165
+ }, 30_000);
166
+
167
+ it('a per-call `display` argument beats the server flag', async () => {
168
+ const session = await mcp(['--display', 'xvfb']);
169
+ expect(await doctorMode(session, {display: 'native'})).toBe('native');
170
+ }, 30_000);
171
+
172
+ it('the recording tools expose `display` as an input', async () => {
173
+ const session = await mcp();
174
+ const list = await session.request('tools/list', {});
175
+ for (const name of ['audio_record', 'audio_render', 'run', 'doctor']) {
176
+ const tool = list.result.tools.find(
177
+ (t: {name: string}) => t.name === name,
178
+ );
179
+ expect(tool?.inputSchema?.properties?.display, name).toBeDefined();
180
+ }
181
+ }, 30_000);
182
+ });
183
+
184
+ describe('picopilot --mcp: per-command picopilot.json options still apply', () => {
185
+ /** A cart whose sprite 130 (shared map region) already holds a pixel. */
186
+ function cartWithSprite130Data(): string {
187
+ const rows = Array.from({length: 128}, () => '0'.repeat(128));
188
+ rows[64] = `${'0'.repeat(16)}7${'0'.repeat(111)}`;
189
+ const path = join(dir, 'main.p8');
190
+ writeFileSync(
191
+ path,
192
+ `pico-8 cartridge // http://www.pico-8.com\nversion 42\n__lua__\n__gfx__\n${rows.join('\n')}\n`,
193
+ );
194
+ return path;
195
+ }
196
+ const grid = Array.from({length: 8}, () => '33333333').join('\n');
197
+
198
+ it('gfx set is refused without authorisation, allowed by allowMapOverlap in picopilot.json', async () => {
199
+ const cart = cartWithSprite130Data();
200
+ const refused = await (
201
+ await mcp()
202
+ ).call('gfx_set', {sprite: 130, grid, cart});
203
+ expect(refused.isError).toBe(true);
204
+ expect(refused.text).toContain('refusing');
205
+ child?.kill('SIGKILL');
206
+
207
+ writeFileSync(
208
+ join(dir, 'picopilot.json'),
209
+ JSON.stringify({
210
+ commands: {gfx: {commands: {set: {options: {allowMapOverlap: true}}}}},
211
+ }),
212
+ );
213
+ const allowed = await (
214
+ await mcp()
215
+ ).call('gfx_set', {sprite: 130, grid, cart});
216
+ expect(allowed.isError).toBe(false);
217
+ expect(JSON.parse(allowed.text).overwroteSharedData).toBe(true);
218
+ }, 30_000);
219
+ });
@@ -46,11 +46,7 @@ These steps are wired as CTAs (a merged SFX or song points you at `verify` then
46
46
 
47
47
  ## `audio render` / `audio record`: get a WAV by RECORDING (ADR-0009)
48
48
 
49
- PICO-8 has NO working offline WAV export (it SIGFPEs upstream), so picopilot gets
50
- audio-to-WAV the only way that works: by RECORDING a running session
51
- (`extcmd("audio_rec")`/`audio_end`). Two commands, both PICO-8-gated and both
52
- REAL-TIME recordings that need a real audio+video session (a developer machine,
53
- NOT headless CI), NOT deterministic offline exports:
49
+ PICO-8 has NO working offline WAV export (it SIGFPEs upstream), so picopilot gets audio-to-WAV the only way that works: by RECORDING a running session (`extcmd("audio_rec")`/`audio_end`). Two commands, both PICO-8-gated and both REAL-TIME recordings of playback, NOT deterministic offline exports. They work headless (no display, no sound card): PICO-8 records its own mixer, not the audio device, and when there is no display picopilot runs the session on a private virtual display by itself (ADR-0017). You do not need to do anything for that.
54
50
 
55
51
  - **`picopilot audio render`** injects a THROWAWAY play-harness for a chosen
56
52
  target and records it, so you write NO playback code. Pick the target with
@@ -68,9 +64,9 @@ The WAV goes to an isolated dir (`--wav-dir`, default a temp dir), never
68
64
  `~/Desktop` or the carts root (the `audio_end(1)`-saves-to-the-current-folder
69
65
  trap; picopilot steers it with PICO-8's `root_path`). PICO-8 absent -> a
70
66
  structured `pico8-not-found` (remedy `set PICO8_PATH or install PICO-8`), never a
71
- crash. On a headless machine the recording succeeds but captures NO audio
72
- (`captured:false` + a nudge to run on a machine with a display + audio device):
73
- that is honest, not a failure of the recipe.
67
+ crash.
68
+
69
+ When a recording produces no WAV (`captured:false`), the result says why: `noAudioReason` (the backstop fired before the recorder finished, PICO-8 exited early, or the capture was empty) and `pico8LogTail` (the end of PICO-8's own log, where load errors show up). Read both before retrying; a cart that fails to load never reaches the end of the recording, so run `picopilot verify` on it. If the machine has no display AND no Xvfb, the command fails up front with `display-unavailable`, whose message names the package to install; `picopilot doctor` reports the same thing before you try.
74
70
 
75
71
  ## `sfx from-mml`: one MML string -> one SFX slot
76
72
 
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: picopilot-overview
3
- description: Start here for any PICO-8 cart built with picopilot. The #include discipline (edit main.lua, never the .p8 binary sections), the per-iteration verify loop, and which deeper skill to load for code, art, audio, or debugging. Use when you open a picopilot cart folder or run picopilot init.
3
+ description: Start here for any PICO-8 cart built with picopilot. Call picopilot doctor first to learn what this machine can do (everything works headless), then the #include discipline (edit main.lua, never the .p8 binary sections), the per-iteration verify loop, and which deeper skill to load for code, art, audio, or debugging. Use when you open a picopilot cart folder or run picopilot init.
4
4
  ---
5
5
 
6
6
  # picopilot overview
@@ -13,6 +13,14 @@ acceptance gate, so you can build a PICO-8 game and self-correct.
13
13
  Load this skill first. Then load the group skill for whatever you are doing:
14
14
  `picopilot-code`, `picopilot-art`, `picopilot-audio`, or `picopilot-debug`.
15
15
 
16
+ ## First on a new machine: `picopilot doctor`
17
+
18
+ Run `picopilot doctor` before anything else on a machine you have not used yet. It reports what THIS machine can do: whether PICO-8 is found and actually runs (with its version), whether shrinko8 is found, how a PICO-8 audio session would get a display, and the resulting `available` / `unavailable` command lists (each unavailable command says what it is missing). Plan from that list instead of discovering limits by failing.
19
+
20
+ Every picopilot command works on a machine with no display and no sound card (a server, CI, an agent sandbox). Screenshots, `playtest` and `export` run PICO-8 headless. Audio recording (`audio render`, `audio record`, `run --record-audio`) needs a real PICO-8 session with a window, and picopilot provides one by itself: with the default display mode `auto`, when there is no display it starts a private virtual display for that recording and removes it afterwards. You do not need to set anything up. Only if `doctor` lists the audio commands as unavailable with `missing: display` (or a recording fails with `display-unavailable`) does the machine lack Xvfb; the error names the install command to suggest to the human.
21
+
22
+ The display mode can be forced with `--display xvfb` (always virtual) or `--display native` (use the current display as-is), also settable as `PICOPILOT_DISPLAY` or `"display"` in `picopilot.json`. Through MCP, the same `display` is an argument of the `run`, `audio_record`, `audio_render` and `doctor` tools. Leave it at `auto` unless a human asks otherwise.
23
+
16
24
  ## The #include discipline (how every picopilot cart is laid out)
17
25
 
18
26
  - `main.p8` is the cart. Its `__lua__` section is a single line: `#include main.lua`.