picopilot 0.2.0 → 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.
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +129 -36
- package/dist/cli.js.map +1 -1
- package/dist/commands/audio.d.ts.map +1 -1
- package/dist/commands/audio.js +11 -3
- package/dist/commands/audio.js.map +1 -1
- package/dist/commands/doctor.d.ts.map +1 -1
- package/dist/commands/doctor.js +7 -3
- package/dist/commands/doctor.js.map +1 -1
- package/dist/commands/gfx.d.ts.map +1 -1
- package/dist/commands/gfx.js +11 -5
- package/dist/commands/gfx.js.map +1 -1
- package/dist/commands/pico8-env.d.ts +39 -3
- package/dist/commands/pico8-env.d.ts.map +1 -1
- package/dist/commands/pico8-env.js +51 -4
- package/dist/commands/pico8-env.js.map +1 -1
- package/dist/commands/run.d.ts.map +1 -1
- package/dist/commands/run.js +8 -2
- package/dist/commands/run.js.map +1 -1
- package/package.json +2 -2
- package/src/cli.test.ts +42 -1
- package/src/cli.ts +143 -37
- package/src/commands/audio.ts +10 -3
- package/src/commands/doctor.ts +7 -3
- package/src/commands/gfx.ts +12 -9
- package/src/commands/pico8-env.ts +71 -3
- package/src/commands/run.ts +8 -2
- package/src/mcp.process.test.ts +219 -0
- package/src/skills/picopilot-overview/SKILL.md +1 -1
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import {z} from 'incur';
|
|
2
2
|
import {
|
|
3
3
|
DEFAULT_DISPLAY_MODE,
|
|
4
|
+
DISPLAY_MODES,
|
|
4
5
|
type DisplayMode,
|
|
5
6
|
isDisplayMode,
|
|
6
7
|
type Pico8Adapter,
|
|
@@ -72,9 +73,23 @@ export function createShellAdapterFactory(
|
|
|
72
73
|
}
|
|
73
74
|
|
|
74
75
|
/**
|
|
75
|
-
* The display
|
|
76
|
-
*
|
|
77
|
-
*
|
|
76
|
+
* The `display` option of the commands that use the display mode (`run`,
|
|
77
|
+
* `audio record`, `audio render`, `doctor`). A per-command option, not an incur
|
|
78
|
+
* global, because incur's MCP transport passes no globals: as an option it is
|
|
79
|
+
* also an argument of the MCP tool. Unset = the invocation-level value.
|
|
80
|
+
*/
|
|
81
|
+
export const DISPLAY_OPTION = z
|
|
82
|
+
.enum(DISPLAY_MODES)
|
|
83
|
+
.optional()
|
|
84
|
+
.describe(
|
|
85
|
+
'How a PICO-8 -run session (audio recording) gets a display: auto (default: a private virtual display only when there is none), xvfb (always virtual), native (as-is). Else PICOPILOT_DISPLAY, else picopilot.json "display".',
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* The display mode the root middleware in `createCli` resolved for this
|
|
90
|
+
* invocation (top-level `--display` > PICOPILOT_DISPLAY > picopilot.json >
|
|
91
|
+
* auto). A command mounted on a bare CLI (some tests) has no var and gets the
|
|
92
|
+
* default.
|
|
78
93
|
*/
|
|
79
94
|
export function displayModeOf(vars: unknown): DisplayMode {
|
|
80
95
|
const value =
|
|
@@ -84,6 +99,59 @@ export function displayModeOf(vars: unknown): DisplayMode {
|
|
|
84
99
|
return isDisplayMode(value) ? value : DEFAULT_DISPLAY_MODE;
|
|
85
100
|
}
|
|
86
101
|
|
|
102
|
+
/**
|
|
103
|
+
* The display mode a command uses: its own `display` option (a per-call MCP
|
|
104
|
+
* argument) wins, else the invocation-level value. An invalid invocation-level
|
|
105
|
+
* value (a typo in PICOPILOT_DISPLAY, say) is an `invalid-display-mode` error
|
|
106
|
+
* here, so only the commands that use the mode fail on it.
|
|
107
|
+
*/
|
|
108
|
+
export function resolveCommandDisplay(
|
|
109
|
+
option: DisplayMode | undefined,
|
|
110
|
+
vars: unknown,
|
|
111
|
+
):
|
|
112
|
+
| {ok: true; mode: DisplayMode}
|
|
113
|
+
| {
|
|
114
|
+
ok: false;
|
|
115
|
+
error: {code: 'invalid-display-mode'; message: string; exitCode: number};
|
|
116
|
+
} {
|
|
117
|
+
if (option !== undefined) return {ok: true, mode: option};
|
|
118
|
+
const problem =
|
|
119
|
+
vars !== null && typeof vars === 'object'
|
|
120
|
+
? (vars as {displayError?: unknown}).displayError
|
|
121
|
+
: undefined;
|
|
122
|
+
if (typeof problem === 'string') {
|
|
123
|
+
return {
|
|
124
|
+
ok: false,
|
|
125
|
+
error: {code: 'invalid-display-mode', message: problem, exitCode: 1},
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
return {ok: true, mode: displayModeOf(vars)};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Under `--mcp`, incur loads no config file, so picopilot loads picopilot.json
|
|
133
|
+
* itself and hands the tree to commands as the `mcpConfig` var. A command whose
|
|
134
|
+
* option can come from the config reads its fallback here (CLI runs get it from
|
|
135
|
+
* incur as the option's default, so this is undefined there).
|
|
136
|
+
*/
|
|
137
|
+
export function mcpConfigOption(
|
|
138
|
+
vars: unknown,
|
|
139
|
+
commandPath: readonly string[],
|
|
140
|
+
key: string,
|
|
141
|
+
): unknown {
|
|
142
|
+
let node: unknown =
|
|
143
|
+
vars !== null && typeof vars === 'object'
|
|
144
|
+
? (vars as {mcpConfig?: unknown}).mcpConfig
|
|
145
|
+
: undefined;
|
|
146
|
+
for (const part of commandPath) {
|
|
147
|
+
node = (node as {commands?: Record<string, unknown>} | undefined)
|
|
148
|
+
?.commands?.[part];
|
|
149
|
+
}
|
|
150
|
+
return (node as {options?: Record<string, unknown>} | undefined)?.options?.[
|
|
151
|
+
key
|
|
152
|
+
];
|
|
153
|
+
}
|
|
154
|
+
|
|
87
155
|
/**
|
|
88
156
|
* The `error(...)` payload for a `display-unavailable` result (ADR-0017): the
|
|
89
157
|
* session needs a display, there is none, and no Xvfb is installed to provide a
|
package/src/commands/run.ts
CHANGED
|
@@ -16,10 +16,11 @@ import {
|
|
|
16
16
|
import {makeReadInclude} from './cart-includes.js';
|
|
17
17
|
import {
|
|
18
18
|
createShellAdapterFactory,
|
|
19
|
-
|
|
19
|
+
DISPLAY_OPTION,
|
|
20
20
|
displayUnavailableError,
|
|
21
21
|
PICO8_ENV,
|
|
22
22
|
pico8NotFoundError,
|
|
23
|
+
resolveCommandDisplay,
|
|
23
24
|
type Pico8AdapterFactory,
|
|
24
25
|
} from './pico8-env.js';
|
|
25
26
|
|
|
@@ -96,6 +97,7 @@ export function registerRun(
|
|
|
96
97
|
.describe(
|
|
97
98
|
'One-shot scripted input passed as `-p` (the cart reads it via stat(6)) for automated playtests.',
|
|
98
99
|
),
|
|
100
|
+
display: DISPLAY_OPTION,
|
|
99
101
|
recordAudio: z
|
|
100
102
|
.boolean()
|
|
101
103
|
.default(false)
|
|
@@ -183,8 +185,12 @@ export function registerRun(
|
|
|
183
185
|
: mkdtempSync(join(tmpdir(), 'picopilot-run-'));
|
|
184
186
|
if (!existsSync(shotDir)) mkdirSync(shotDir, {recursive: true});
|
|
185
187
|
|
|
188
|
+
// The display mode only matters to the --record-audio (-run) pass, so a
|
|
189
|
+
// bad PICOPILOT_DISPLAY only fails a run that records.
|
|
190
|
+
const display = resolveCommandDisplay(options.display, vars);
|
|
191
|
+
if (options.recordAudio && !display.ok) return error(display.error);
|
|
186
192
|
const adapter = adapterFactory(env as NodeJS.ProcessEnv, {
|
|
187
|
-
display:
|
|
193
|
+
display: display.ok ? display.mode : 'auto',
|
|
188
194
|
});
|
|
189
195
|
const result = await adapter.run({
|
|
190
196
|
cartPath,
|
|
@@ -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
|
+
});
|
|
@@ -19,7 +19,7 @@ Run `picopilot doctor` before anything else on a machine you have not used yet.
|
|
|
19
19
|
|
|
20
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
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
|
|
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
23
|
|
|
24
24
|
## The #include discipline (how every picopilot cart is laid out)
|
|
25
25
|
|