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,229 @@
1
+ import {mkdtempSync, rmSync, writeFileSync} from 'node:fs';
2
+ import {tmpdir} from 'node:os';
3
+ import {join} from 'node:path';
4
+ import {z} from 'incur';
5
+ import {afterEach, beforeEach, describe, expect, it} from 'vitest';
6
+ import {createCli} from './cli.js';
7
+ import {displayModeOf} from './commands/pico8-env.js';
8
+
9
+ /**
10
+ * The display mode (ADR-0017) is resolved ONCE, globally, by the root CLI:
11
+ * `--display` > `PICOPILOT_DISPLAY` > picopilot.json `display` > `auto`. A probe
12
+ * command mounted on the real CLI reports what a command handler sees.
13
+ */
14
+
15
+ let dir: string;
16
+ beforeEach(() => {
17
+ dir = mkdtempSync(join(tmpdir(), 'picopilot-cli-'));
18
+ });
19
+ afterEach(() => rmSync(dir, {recursive: true, force: true}));
20
+
21
+ async function probe(
22
+ argv: string[],
23
+ env: Record<string, string | undefined> = {},
24
+ config?: unknown,
25
+ ) {
26
+ const cli = createCli();
27
+ cli.command('probe-display', {
28
+ run: (c) => ({display: displayModeOf(c.var)}),
29
+ });
30
+ const args = [...argv];
31
+ if (config !== undefined) {
32
+ const path = join(dir, 'picopilot.json');
33
+ writeFileSync(path, JSON.stringify(config));
34
+ args.push('--config', path);
35
+ }
36
+ let stdout = '';
37
+ let exitCode = 0;
38
+ await cli.serve([...args, '--json'], {
39
+ stdout(s) {
40
+ stdout += s;
41
+ },
42
+ exit(code) {
43
+ exitCode = code;
44
+ },
45
+ env,
46
+ });
47
+ return {out: JSON.parse(stdout), exitCode};
48
+ }
49
+
50
+ describe('display mode resolution through the CLI', () => {
51
+ it('defaults to auto', async () => {
52
+ expect((await probe(['probe-display'])).out.display).toBe('auto');
53
+ });
54
+
55
+ it('reads picopilot.json "display"', async () => {
56
+ const {out} = await probe(['probe-display'], {}, {display: 'native'});
57
+ expect(out.display).toBe('native');
58
+ });
59
+
60
+ it('PICOPILOT_DISPLAY beats picopilot.json', async () => {
61
+ const {out} = await probe(
62
+ ['probe-display'],
63
+ {PICOPILOT_DISPLAY: 'xvfb'},
64
+ {display: 'native'},
65
+ );
66
+ expect(out.display).toBe('xvfb');
67
+ });
68
+
69
+ it('--display beats both, before or after the command', async () => {
70
+ for (const argv of [
71
+ ['--display', 'native', 'probe-display'],
72
+ ['probe-display', '--display', 'native'],
73
+ ['probe-display', '--display=native'],
74
+ ]) {
75
+ const {out} = await probe(
76
+ argv,
77
+ {PICOPILOT_DISPLAY: 'xvfb'},
78
+ {display: 'xvfb'},
79
+ );
80
+ expect(out.display).toBe('native');
81
+ }
82
+ });
83
+
84
+ it('an invalid PICOPILOT_DISPLAY is an error for a display command, not a silent auto', async () => {
85
+ const cli = createCli();
86
+ let stdout = '';
87
+ let exitCode = 0;
88
+ await cli.serve(['doctor', '--json'], {
89
+ stdout(s) {
90
+ stdout += s;
91
+ },
92
+ exit(code) {
93
+ exitCode = code;
94
+ },
95
+ env: {PICOPILOT_DISPLAY: 'xfvb'},
96
+ });
97
+ expect(exitCode).not.toBe(0);
98
+ const out = JSON.parse(stdout);
99
+ expect(out.code).toBe('invalid-display-mode');
100
+ expect(out.message).toContain('PICOPILOT_DISPLAY');
101
+ });
102
+
103
+ it('an invalid --display is an error for a display command', async () => {
104
+ const cli = createCli();
105
+ let stdout = '';
106
+ let exitCode = 0;
107
+ await cli.serve(['doctor', '--display', 'bogus', '--json'], {
108
+ stdout(s) {
109
+ stdout += s;
110
+ },
111
+ exit(code) {
112
+ exitCode = code;
113
+ },
114
+ env: {},
115
+ });
116
+ expect(exitCode).not.toBe(0);
117
+ expect(stdout).toContain('--display');
118
+ });
119
+
120
+ it('a --display with no value is rejected, and does not eat the next flag', async () => {
121
+ for (const argv of [
122
+ ['doctor', '--display', '--json'],
123
+ ['doctor', '--json', '--display'],
124
+ ]) {
125
+ const cli = createCli();
126
+ let stdout = '';
127
+ let exitCode = 0;
128
+ await cli.serve(argv, {
129
+ stdout(s) {
130
+ stdout += s;
131
+ },
132
+ exit(code) {
133
+ exitCode = code;
134
+ },
135
+ env: {},
136
+ });
137
+ expect(exitCode, argv.join(' ')).not.toBe(0);
138
+ // --json still applied: the error envelope is JSON.
139
+ expect(() => JSON.parse(stdout), argv.join(' ')).not.toThrow();
140
+ }
141
+ });
142
+
143
+ it('an invalid value does not break commands that never use the display', async () => {
144
+ const cli = createCli();
145
+ let stdout = '';
146
+ let exitCode = 0;
147
+ await cli.serve(['version', '--json'], {
148
+ stdout(s) {
149
+ stdout += s;
150
+ },
151
+ exit(code) {
152
+ exitCode = code;
153
+ },
154
+ env: {PICOPILOT_DISPLAY: 'xfvb'},
155
+ });
156
+ expect(exitCode).toBe(0);
157
+ expect(JSON.parse(stdout).name).toBe('picopilot');
158
+ });
159
+
160
+ it('--no-config ignores the picopilot.json display key', async () => {
161
+ const path = join(dir, 'picopilot.json');
162
+ writeFileSync(path, JSON.stringify({display: 'native'}));
163
+ // First load it (so a stale value could leak), then disable config.
164
+ const cli = createCli();
165
+ cli.command('probe-display', {
166
+ run: (c) => ({display: displayModeOf(c.var)}),
167
+ });
168
+ const outs: string[] = [];
169
+ for (const argv of [
170
+ ['probe-display', '--config', path, '--json'],
171
+ ['probe-display', '--no-config', '--json'],
172
+ ]) {
173
+ let stdout = '';
174
+ await cli.serve(argv, {
175
+ stdout(s) {
176
+ stdout += s;
177
+ },
178
+ exit() {},
179
+ env: {},
180
+ });
181
+ outs.push(JSON.parse(stdout).display);
182
+ }
183
+ expect(outs).toEqual(['native', 'auto']);
184
+ });
185
+
186
+ it('--config naming a missing file is an error', async () => {
187
+ const cli = createCli();
188
+ let stdout = '';
189
+ let exitCode = 0;
190
+ await cli.serve(['version', '--config', join(dir, 'nope.json'), '--json'], {
191
+ stdout(s) {
192
+ stdout += s;
193
+ },
194
+ exit(code) {
195
+ exitCode = code;
196
+ },
197
+ env: {},
198
+ });
199
+ expect(exitCode).not.toBe(0);
200
+ expect(stdout).toContain('not found');
201
+ });
202
+
203
+ it('a per-command config option still loads next to the display key', async () => {
204
+ // The custom config loader must keep incur's per-command option defaults.
205
+ const cli = createCli();
206
+ let seen: unknown;
207
+ cli.command('probe-opt', {
208
+ options: z.object({flag: z.boolean().default(false)}),
209
+ run: (c) => {
210
+ seen = c.options.flag;
211
+ return {};
212
+ },
213
+ });
214
+ const path = join(dir, 'picopilot.json');
215
+ writeFileSync(
216
+ path,
217
+ JSON.stringify({
218
+ display: 'native',
219
+ commands: {'probe-opt': {options: {flag: true}}},
220
+ }),
221
+ );
222
+ await cli.serve(['probe-opt', '--config', path, '--json'], {
223
+ stdout() {},
224
+ exit() {},
225
+ env: {},
226
+ });
227
+ expect(seen).toBe(true);
228
+ });
229
+ });
package/src/cli.ts CHANGED
@@ -1,5 +1,9 @@
1
- import {Cli} from 'incur';
1
+ import {existsSync, readFileSync} from 'node:fs';
2
+ import {homedir} from 'node:os';
3
+ import {join, resolve} from 'node:path';
4
+ import {Cli, z} from 'incur';
2
5
  import {registerAudio} from './commands/audio.js';
6
+ import {registerDoctor} from './commands/doctor.js';
3
7
  import {registerExport} from './commands/export.js';
4
8
  import {registerGfx} from './commands/gfx.js';
5
9
  import {registerInit} from './commands/init.js';
@@ -13,8 +17,133 @@ import {registerSfx} from './commands/sfx.js';
13
17
  import {registerTokens} from './commands/tokens.js';
14
18
  import {registerVerify} from './commands/verify.js';
15
19
  import {registerVersion} from './commands/version.js';
20
+ import {
21
+ DEFAULT_DISPLAY_MODE,
22
+ DISPLAY_CONFIG_KEY,
23
+ DISPLAY_ENV_VAR,
24
+ DISPLAY_MODES,
25
+ resolveDisplayMode,
26
+ } from './engine/pico8/display.js';
16
27
  import {VERSION} from './version.js';
17
28
 
29
+ /** The config files picopilot searches (first existing wins), relative to the cwd. */
30
+ const CONFIG_FILES = ['picopilot.json'];
31
+
32
+ /**
33
+ * What one `serve` invocation said at the top level, captured BEFORE incur sees
34
+ * the argv: the `--display` flag (taken out of argv, so it works before or after
35
+ * the command name and on `--mcp`), and for `--mcp` the config flags. incur's
36
+ * MCP transport passes no global flags to a tool call and loads no config file,
37
+ * so picopilot carries both itself.
38
+ */
39
+ interface Invocation {
40
+ display?: string;
41
+ mcp: boolean;
42
+ /** MCP only: the config tree picopilot loaded itself (undefined = none). */
43
+ mcpConfig?: Record<string, unknown>;
44
+ }
45
+
46
+ /**
47
+ * Removes every `--display <v>` / `--display=<v>` from argv and returns the last
48
+ * value. A `--display` with no value (last token, or followed by another flag)
49
+ * stays in argv, so incur rejects it instead of it silently eating the next flag.
50
+ */
51
+ function takeDisplayFlag(argv: string[]): {argv: string[]; display?: string} {
52
+ const rest: string[] = [];
53
+ let display: string | undefined;
54
+ for (let i = 0; i < argv.length; i++) {
55
+ const a = argv[i] as string;
56
+ const next = argv[i + 1];
57
+ if (a === '--display' && next !== undefined && !next.startsWith('-')) {
58
+ display = next;
59
+ i++;
60
+ } else if (a.startsWith('--display=') && a.length > '--display='.length) {
61
+ display = a.slice('--display='.length);
62
+ } else {
63
+ rest.push(a);
64
+ }
65
+ }
66
+ return {argv: rest, display};
67
+ }
68
+
69
+ /** Resolves a config path the way incur does: `~` is the home dir, else relative to the cwd. */
70
+ function resolveConfigPath(path: string): string {
71
+ return path === '~' || path.startsWith('~/')
72
+ ? join(homedir(), path.slice(1))
73
+ : resolve(path);
74
+ }
75
+
76
+ /**
77
+ * For `--mcp`: the config file incur would have loaded on the CLI. Mirrors
78
+ * incur's own parsing: `--config <p>` / `--config=<p>` / `--no-config`, the last
79
+ * one wins, a missing value is an error; else the first existing default file.
80
+ */
81
+ function loadMcpConfig(argv: string[]): Record<string, unknown> | undefined {
82
+ let explicit: string | undefined;
83
+ let disabled = false;
84
+ for (let i = 0; i < argv.length; i++) {
85
+ const a = argv[i] as string;
86
+ let value: string | undefined;
87
+ if (a === '--config') value = argv[++i];
88
+ else if (a.startsWith('--config=')) value = a.slice('--config='.length);
89
+ else if (a === '--no-config') {
90
+ explicit = undefined;
91
+ disabled = true;
92
+ continue;
93
+ } else continue;
94
+ if (value === undefined || value === '') {
95
+ throw new Error('Missing value for flag: --config');
96
+ }
97
+ explicit = value;
98
+ disabled = false;
99
+ }
100
+ if (disabled) return undefined;
101
+ const sink: {display?: unknown} = {};
102
+ if (explicit !== undefined) {
103
+ return loadConfig(resolveConfigPath(explicit), sink);
104
+ }
105
+ for (const file of CONFIG_FILES) {
106
+ const path = resolveConfigPath(file);
107
+ if (existsSync(path)) return loadConfig(path, sink);
108
+ }
109
+ return undefined;
110
+ }
111
+
112
+ /**
113
+ * Reads a picopilot.json for incur's config layer AND captures its top-level
114
+ * {@link DISPLAY_CONFIG_KEY}. incur only maps per-command `options` from the
115
+ * config file; the display mode is a GLOBAL setting, so the loader hands the
116
+ * value to the display middleware through `sink`.
117
+ */
118
+ function loadConfig(
119
+ path: string | undefined,
120
+ sink: {display?: unknown},
121
+ ): Record<string, unknown> | undefined {
122
+ sink.display = undefined;
123
+ if (path === undefined) return undefined;
124
+ let raw: string;
125
+ try {
126
+ raw = readFileSync(path, 'utf8');
127
+ } catch (e) {
128
+ // incur only hands the loader an existing file unless --config named one.
129
+ if ((e as NodeJS.ErrnoException).code === 'ENOENT') {
130
+ throw new Error(`Config file not found: ${path}`);
131
+ }
132
+ throw e;
133
+ }
134
+ let parsed: unknown;
135
+ try {
136
+ parsed = JSON.parse(raw);
137
+ } catch {
138
+ throw new Error(`Invalid JSON config file: ${path}`);
139
+ }
140
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
141
+ throw new Error(`Config file must hold a JSON object: ${path}`);
142
+ }
143
+ sink.display = (parsed as Record<string, unknown>)[DISPLAY_CONFIG_KEY];
144
+ return parsed as Record<string, unknown>;
145
+ }
146
+
18
147
  /**
19
148
  * Builds the root picopilot CLI.
20
149
  *
@@ -28,6 +157,7 @@ import {VERSION} from './version.js';
28
157
  * CLI and drive it with `cli.serve(argv, { stdout, exit, env })` DI overrides.
29
158
  */
30
159
  export function createCli(): Cli.Cli {
160
+ const configSink: {display?: unknown} = {};
31
161
  const cli = Cli.create('picopilot', {
32
162
  version: VERSION,
33
163
  description: 'An agent-first toolchain for PICO-8 game development.',
@@ -38,24 +168,106 @@ export function createCli(): Cli.Cli {
38
168
  // Per-cart policy config (e.g. `allowMapOverlap`); precedence argv > config > defaults.
39
169
  config: {
40
170
  flag: 'config',
41
- files: ['picopilot.json'],
171
+ files: CONFIG_FILES,
172
+ loader: async (path) => loadConfig(path, configSink),
42
173
  },
174
+ // The display mode (ADR-0017): `--display` (a per-command option on the
175
+ // commands that use it, also accepted anywhere on the command line and on
176
+ // `--mcp`) > PICOPILOT_DISPLAY > picopilot.json `display` > auto.
177
+ env: z.object({
178
+ [DISPLAY_ENV_VAR]: z
179
+ .string()
180
+ .optional()
181
+ .describe('Display mode: auto | xvfb | native (below --display).'),
182
+ }),
183
+ vars: z.object({
184
+ display: z.enum(DISPLAY_MODES).default(DEFAULT_DISPLAY_MODE),
185
+ displayError: z.string().optional(),
186
+ mcpConfig: z.record(z.string(), z.unknown()).optional(),
187
+ }),
188
+ mcp: {
189
+ // One MCP tool per command (incur's default is progressive discovery
190
+ // behind search/call meta-tools); picopilot's surface is small, and the
191
+ // skills name commands directly.
192
+ tools: {discovery: 'direct'},
193
+ },
194
+ });
195
+
196
+ const invocation: Invocation = {mcp: false};
197
+
198
+ cli.use(async (c, next) => {
199
+ const configured = invocation.mcp
200
+ ? invocation.mcpConfig?.[DISPLAY_CONFIG_KEY]
201
+ : configSink.display;
202
+ configSink.display = undefined; // never leak into a later invocation
203
+ const resolved = resolveDisplayMode({
204
+ flag: invocation.display,
205
+ env: (c.env as Record<string, string | undefined>)[DISPLAY_ENV_VAR],
206
+ config: configured,
207
+ });
208
+ if (resolved.ok) {
209
+ c.set('display', resolved.mode);
210
+ } else {
211
+ // Not an error here: only the commands that USE the display mode fail
212
+ // on a bad value (see resolveCommandDisplay); the rest run as usual.
213
+ const where =
214
+ resolved.source === 'flag'
215
+ ? '--display'
216
+ : resolved.source === 'env'
217
+ ? DISPLAY_ENV_VAR
218
+ : `picopilot.json "${DISPLAY_CONFIG_KEY}"`;
219
+ c.set(
220
+ 'displayError',
221
+ `${where} is "${resolved.value}"; expected one of: ${DISPLAY_MODES.join(', ')}.`,
222
+ );
223
+ }
224
+ if (invocation.mcp && invocation.mcpConfig !== undefined) {
225
+ c.set('mcpConfig', invocation.mcpConfig);
226
+ }
227
+ await next();
43
228
  });
44
229
 
45
- registerVersion(cli);
46
- registerInit(cli);
47
- registerGfx(cli);
48
- registerTokens(cli);
49
- registerLint(cli);
50
- registerMinify(cli);
51
- registerVerify(cli);
52
- registerRun(cli);
53
- registerExport(cli);
54
- registerServe(cli);
55
- registerPlaytest(cli);
56
- registerSfx(cli);
57
- registerMusic(cli);
58
- registerAudio(cli);
59
-
60
- return cli;
230
+ // Capture the top-level `--display` (and, for `--mcp`, the config) before
231
+ // incur parses argv; see {@link Invocation}.
232
+ const serve = cli.serve.bind(cli);
233
+ cli.serve = async (argv = process.argv.slice(2), options) => {
234
+ const taken = takeDisplayFlag(argv);
235
+ invocation.display = taken.display;
236
+ invocation.mcp = taken.argv.includes('--mcp');
237
+ invocation.mcpConfig = undefined;
238
+ if (invocation.mcp) {
239
+ try {
240
+ invocation.mcpConfig = loadMcpConfig(taken.argv);
241
+ } catch (e) {
242
+ // A broken config must stop the server visibly, not serve without it.
243
+ process.stderr.write(
244
+ `picopilot --mcp: ${e instanceof Error ? e.message : String(e)}\n`,
245
+ );
246
+ process.exitCode = 1;
247
+ return;
248
+ }
249
+ }
250
+ return serve(taken.argv, options);
251
+ };
252
+
253
+ // The register* helpers take the untyped Cli: the display var is read through
254
+ // `displayModeOf`, so they do not depend on this CLI's generics.
255
+ const root = cli as unknown as Cli.Cli;
256
+ registerVersion(root);
257
+ registerDoctor(root);
258
+ registerInit(root);
259
+ registerGfx(root);
260
+ registerTokens(root);
261
+ registerLint(root);
262
+ registerMinify(root);
263
+ registerVerify(root);
264
+ registerRun(root);
265
+ registerExport(root);
266
+ registerServe(root);
267
+ registerPlaytest(root);
268
+ registerSfx(root);
269
+ registerMusic(root);
270
+ registerAudio(root);
271
+
272
+ return root;
61
273
  }
@@ -19,7 +19,7 @@ import type {
19
19
  RecordOptions,
20
20
  RunOptions,
21
21
  } from '../engine/pico8/index.js';
22
- import {pico8NotFound} from '../engine/pico8/index.js';
22
+ import {displayUnavailable, pico8NotFound} from '../engine/pico8/index.js';
23
23
  import {type Pico8AdapterFactory, registerAudio} from './audio.js';
24
24
 
25
25
  const HEADER = 'pico-8 cartridge // http://www.pico-8.com\nversion 42\n';
@@ -290,3 +290,66 @@ describe('audio: honest help text (record-based, NOT an offline export)', () =>
290
290
  expect(lower).toContain('offline export');
291
291
  });
292
292
  });
293
+
294
+ describe('audio record/render: headless (ADR-0017)', () => {
295
+ it('record inlines `#include main.lua` so the harness loads from the temp wavDir', async () => {
296
+ writeFileSync(cartPath, `${HEADER}__lua__\n#include main.lua\n`);
297
+ writeFileSync(
298
+ join(dir, 'main.lua'),
299
+ 'function _update() from_main=1 end\n',
300
+ );
301
+ const seen: {record?: RecordOptions} = {};
302
+ await runAudio(
303
+ () => stubAdapter(recorded(undefined), seen),
304
+ ['audio', 'record', cartPath, '--json'],
305
+ );
306
+ const harness = readFileSync(seen.record!.cartPath, 'utf8');
307
+ expect(harness).not.toMatch(/#include/);
308
+ expect(harness).toContain('from_main=1');
309
+ });
310
+
311
+ for (const sub of [
312
+ ['audio', 'record'],
313
+ ['audio', 'render', '--sfx', '0'],
314
+ ]) {
315
+ it(`${sub.join(' ')}: display-unavailable is a structured error naming the fix`, async () => {
316
+ const {stdout, exitCode} = await runAudio(
317
+ () => stubAdapter(displayUnavailable('no display, no Xvfb')),
318
+ [
319
+ ...sub.slice(0, 2),
320
+ cartPath,
321
+ ...sub.slice(2),
322
+ '--json',
323
+ '--full-output',
324
+ ],
325
+ );
326
+ expect(exitCode).not.toBe(0);
327
+ const out = JSON.parse(stdout);
328
+ expect(out.error.code).toBe('display-unavailable');
329
+ expect(out.meta.cta.description).toMatch(/install Xvfb/);
330
+ });
331
+ }
332
+
333
+ it('a run with no WAV reports noAudioReason, pico8LogTail and the display used', async () => {
334
+ const {stdout} = await runAudio(
335
+ () =>
336
+ stubAdapter({
337
+ ok: true,
338
+ value: {
339
+ wavPath: undefined,
340
+ printh: '',
341
+ exitReason: 'timeout',
342
+ display: 'Xvfb',
343
+ noWavReason: 'the backstop fired',
344
+ pico8LogTail: 'could not include main.lua',
345
+ },
346
+ }),
347
+ ['audio', 'record', cartPath, '--json'],
348
+ );
349
+ const out = JSON.parse(stdout);
350
+ expect(out.captured).toBe(false);
351
+ expect(out.display).toBe('Xvfb');
352
+ expect(out.noAudioReason).toBe('the backstop fired');
353
+ expect(out.pico8LogTail).toContain('main.lua');
354
+ });
355
+ });