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.
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +205 -17
- package/dist/cli.js.map +1 -1
- package/dist/commands/audio.d.ts +3 -3
- package/dist/commands/audio.d.ts.map +1 -1
- package/dist/commands/audio.js +69 -44
- package/dist/commands/audio.js.map +1 -1
- package/dist/commands/doctor.d.ts +22 -0
- package/dist/commands/doctor.d.ts.map +1 -0
- package/dist/commands/doctor.js +226 -0
- package/dist/commands/doctor.js.map +1 -0
- package/dist/commands/export.d.ts +3 -4
- package/dist/commands/export.d.ts.map +1 -1
- package/dist/commands/export.js +4 -9
- package/dist/commands/export.js.map +1 -1
- package/dist/commands/gfx.d.ts +2 -2
- package/dist/commands/gfx.d.ts.map +1 -1
- package/dist/commands/gfx.js +14 -7
- package/dist/commands/gfx.js.map +1 -1
- package/dist/commands/lint.d.ts +2 -2
- package/dist/commands/lint.d.ts.map +1 -1
- package/dist/commands/lint.js +3 -2
- package/dist/commands/lint.js.map +1 -1
- package/dist/commands/minify.d.ts +2 -2
- package/dist/commands/minify.d.ts.map +1 -1
- package/dist/commands/minify.js +3 -2
- package/dist/commands/minify.js.map +1 -1
- package/dist/commands/pico8-env.d.ts +112 -0
- package/dist/commands/pico8-env.d.ts.map +1 -0
- package/dist/commands/pico8-env.js +118 -0
- package/dist/commands/pico8-env.js.map +1 -0
- package/dist/commands/playtest.d.ts +2 -2
- package/dist/commands/playtest.d.ts.map +1 -1
- package/dist/commands/playtest.js +4 -9
- package/dist/commands/playtest.js.map +1 -1
- package/dist/commands/run.d.ts +3 -4
- package/dist/commands/run.d.ts.map +1 -1
- package/dist/commands/run.js +52 -19
- package/dist/commands/run.js.map +1 -1
- package/dist/commands/serve.d.ts +2 -2
- package/dist/commands/serve.d.ts.map +1 -1
- package/dist/commands/serve.js +4 -9
- package/dist/commands/serve.js.map +1 -1
- package/dist/commands/shrinko-env.d.ts +22 -0
- package/dist/commands/shrinko-env.d.ts.map +1 -0
- package/dist/commands/shrinko-env.js +15 -0
- package/dist/commands/shrinko-env.js.map +1 -0
- package/dist/commands/tokens.d.ts +4 -4
- package/dist/commands/tokens.d.ts.map +1 -1
- package/dist/commands/tokens.js +3 -2
- package/dist/commands/tokens.js.map +1 -1
- package/dist/commands/verify.d.ts +2 -2
- package/dist/commands/verify.d.ts.map +1 -1
- package/dist/commands/verify.js +3 -2
- package/dist/commands/verify.js.map +1 -1
- package/dist/engine/child-env.d.ts +14 -0
- package/dist/engine/child-env.d.ts.map +1 -0
- package/dist/engine/child-env.js +21 -0
- package/dist/engine/child-env.js.map +1 -0
- package/dist/engine/pico8/adapter.d.ts +23 -3
- package/dist/engine/pico8/adapter.d.ts.map +1 -1
- package/dist/engine/pico8/adapter.js.map +1 -1
- package/dist/engine/pico8/cleanup.d.ts +16 -0
- package/dist/engine/pico8/cleanup.d.ts.map +1 -0
- package/dist/engine/pico8/cleanup.js +74 -0
- package/dist/engine/pico8/cleanup.js.map +1 -0
- package/dist/engine/pico8/display.d.ts +149 -0
- package/dist/engine/pico8/display.d.ts.map +1 -0
- package/dist/engine/pico8/display.js +311 -0
- package/dist/engine/pico8/display.js.map +1 -0
- package/dist/engine/pico8/drive.d.ts.map +1 -1
- package/dist/engine/pico8/drive.js +28 -2
- package/dist/engine/pico8/drive.js.map +1 -1
- package/dist/engine/pico8/harness.d.ts +8 -0
- package/dist/engine/pico8/harness.d.ts.map +1 -1
- package/dist/engine/pico8/harness.js +9 -1
- package/dist/engine/pico8/harness.js.map +1 -1
- package/dist/engine/pico8/index.d.ts +3 -0
- package/dist/engine/pico8/index.d.ts.map +1 -1
- package/dist/engine/pico8/index.js +3 -0
- package/dist/engine/pico8/index.js.map +1 -1
- package/dist/engine/pico8/shell.d.ts +56 -3
- package/dist/engine/pico8/shell.d.ts.map +1 -1
- package/dist/engine/pico8/shell.js +188 -24
- package/dist/engine/pico8/shell.js.map +1 -1
- package/dist/engine/shrinko/shell.d.ts +3 -3
- package/dist/engine/shrinko/shell.js +3 -3
- package/package.json +2 -2
- package/src/cli.test.ts +229 -0
- package/src/cli.ts +230 -18
- package/src/commands/audio.test.ts +64 -1
- package/src/commands/audio.ts +86 -57
- package/src/commands/doctor.test.ts +149 -0
- package/src/commands/doctor.ts +284 -0
- package/src/commands/export.ts +9 -13
- package/src/commands/gfx.ts +19 -15
- package/src/commands/lint.ts +7 -6
- package/src/commands/minify.ts +7 -6
- package/src/commands/pico8-env.ts +192 -0
- package/src/commands/playtest.ts +8 -11
- package/src/commands/run.test.ts +110 -2
- package/src/commands/run.ts +68 -24
- package/src/commands/serve.ts +8 -11
- package/src/commands/shrinko-env.test.ts +68 -0
- package/src/commands/shrinko-env.ts +40 -0
- package/src/commands/tokens.ts +9 -6
- package/src/commands/verify.ts +7 -4
- package/src/engine/child-env.ts +22 -0
- package/src/engine/pico8/adapter.ts +25 -3
- package/src/engine/pico8/cleanup.test.ts +28 -0
- package/src/engine/pico8/cleanup.ts +76 -0
- package/src/engine/pico8/display.test.ts +210 -0
- package/src/engine/pico8/display.ts +429 -0
- package/src/engine/pico8/drive.test.ts +20 -0
- package/src/engine/pico8/drive.ts +25 -2
- package/src/engine/pico8/fake-xvfb.test-support.ts +55 -0
- package/src/engine/pico8/harness.test.ts +26 -0
- package/src/engine/pico8/harness.ts +18 -2
- package/src/engine/pico8/index.ts +3 -0
- package/src/engine/pico8/shell.test.ts +448 -9
- package/src/engine/pico8/shell.ts +255 -26
- package/src/engine/pico8/virtual-display.process.test.ts +206 -0
- package/src/engine/shrinko/shell.ts +3 -3
- package/src/mcp.process.test.ts +219 -0
- package/src/skills/picopilot-audio/SKILL.md +4 -8
- package/src/skills/picopilot-overview/SKILL.md +9 -1
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
import {mkdtempSync, realpathSync, rmSync, writeFileSync} from 'node:fs';
|
|
2
|
+
import {tmpdir} from 'node:os';
|
|
3
|
+
import {join} from 'node:path';
|
|
4
|
+
import {type Cli, z} from 'incur';
|
|
5
|
+
import {
|
|
6
|
+
DISPLAY_MODES,
|
|
7
|
+
type DisplayMode,
|
|
8
|
+
detectVirtualDisplayBackend,
|
|
9
|
+
findExecutable,
|
|
10
|
+
hasNativeDisplay,
|
|
11
|
+
needsVirtualDisplay,
|
|
12
|
+
pico8Candidates,
|
|
13
|
+
pico8ChildEnv,
|
|
14
|
+
type VirtualDisplayBackend,
|
|
15
|
+
} from '../engine/pico8/index.js';
|
|
16
|
+
import {
|
|
17
|
+
createShellAdapterFactory,
|
|
18
|
+
DISPLAY_OPTION,
|
|
19
|
+
PICO8_ENV,
|
|
20
|
+
resolveCommandDisplay,
|
|
21
|
+
type Pico8AdapterFactory,
|
|
22
|
+
} from './pico8-env.js';
|
|
23
|
+
import {
|
|
24
|
+
createShellShrinkoFactory,
|
|
25
|
+
type ShrinkoAdapterFactory,
|
|
26
|
+
} from './shrinko-env.js';
|
|
27
|
+
|
|
28
|
+
/** The marker the version-probe cart prints in front of `stat(5)`. */
|
|
29
|
+
const VERSION_MARKER = '__PICOPILOT_PICO8_VERSION__';
|
|
30
|
+
|
|
31
|
+
/** The probe cart: print PICO-8's version id, then the done-sentinel. */
|
|
32
|
+
const PROBE_SENTINEL = '__PICOPILOT_DOCTOR_DONE__';
|
|
33
|
+
const PROBE_CART = `pico-8 cartridge // http://www.pico-8.com
|
|
34
|
+
version 42
|
|
35
|
+
__lua__
|
|
36
|
+
printh("${VERSION_MARKER}"..stat(5))
|
|
37
|
+
printh("${PROBE_SENTINEL}")
|
|
38
|
+
`;
|
|
39
|
+
|
|
40
|
+
/** A tiny cart for the shrinko probe (a real `--count` run). */
|
|
41
|
+
const SHRINKO_PROBE_CART = `pico-8 cartridge // http://www.pico-8.com
|
|
42
|
+
version 42
|
|
43
|
+
__lua__
|
|
44
|
+
print("hi")
|
|
45
|
+
`;
|
|
46
|
+
|
|
47
|
+
/** The doctor's backstop for the headless PICO-8 probe. */
|
|
48
|
+
const PROBE_BACKSTOP_MS = 15_000;
|
|
49
|
+
|
|
50
|
+
/** What a command needs to run (beyond picopilot itself). */
|
|
51
|
+
type Need = 'pico8' | 'shrinko8' | 'display';
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The capability table: every command (or command mode) and what it needs.
|
|
55
|
+
* `display` means a PICO-8 `-run` session: a native display or a virtual one.
|
|
56
|
+
* The `-x` paths (run screenshots, playtest, export, serve) are headless and
|
|
57
|
+
* need PICO-8 only.
|
|
58
|
+
*/
|
|
59
|
+
const COMMAND_NEEDS: readonly {command: string; needs: readonly Need[]}[] = [
|
|
60
|
+
{command: 'init', needs: []},
|
|
61
|
+
{command: 'gfx show / set / render', needs: []},
|
|
62
|
+
{command: 'sfx', needs: []},
|
|
63
|
+
{command: 'music', needs: []},
|
|
64
|
+
{command: 'gfx import / export', needs: ['shrinko8']},
|
|
65
|
+
{command: 'tokens', needs: ['shrinko8']},
|
|
66
|
+
{command: 'lint', needs: ['shrinko8']},
|
|
67
|
+
{command: 'minify', needs: ['shrinko8']},
|
|
68
|
+
{command: 'verify', needs: ['shrinko8']},
|
|
69
|
+
{command: 'run', needs: ['pico8']},
|
|
70
|
+
{command: 'playtest', needs: ['pico8']},
|
|
71
|
+
{command: 'export', needs: ['pico8']},
|
|
72
|
+
{command: 'serve', needs: ['pico8']},
|
|
73
|
+
{command: 'run --record-audio', needs: ['pico8', 'display']},
|
|
74
|
+
{command: 'audio record', needs: ['pico8', 'display']},
|
|
75
|
+
{command: 'audio render', needs: ['pico8', 'display']},
|
|
76
|
+
];
|
|
77
|
+
|
|
78
|
+
/** The PICO-8 version from its install path (e.g. `.../pico-8-0.2.7/...`), if it names one. */
|
|
79
|
+
function versionFromPath(path: string): string | undefined {
|
|
80
|
+
const m = /pico-?8[-_ ]v?(\d+\.\d+\.\d+[a-z]?)/i.exec(path);
|
|
81
|
+
return m?.[1];
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** How a `-run` session would get its display here. */
|
|
85
|
+
type RunSessionDisplay = 'native' | 'xvfb' | 'none';
|
|
86
|
+
|
|
87
|
+
/** Dependencies for {@link registerDoctor}, injectable for tests. */
|
|
88
|
+
export interface DoctorDeps {
|
|
89
|
+
readonly pico8?: Pico8AdapterFactory;
|
|
90
|
+
readonly shrinko?: ShrinkoAdapterFactory;
|
|
91
|
+
/** The parent environment (defaults to `process.env`): DISPLAY etc. are read from it. */
|
|
92
|
+
readonly parentEnv?: NodeJS.ProcessEnv;
|
|
93
|
+
/** Executable lookup (defaults to a PATH search). */
|
|
94
|
+
readonly find?: (name: string, env: NodeJS.ProcessEnv) => string | undefined;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Registers `picopilot doctor`: what can THIS machine do? It reports whether
|
|
99
|
+
* PICO-8 is found (and runs headless, with its version), whether shrinko8 is
|
|
100
|
+
* found, how a PICO-8 `-run` session would get a display (native, a private
|
|
101
|
+
* virtual one via Xvfb, or none), and therefore which commands are available.
|
|
102
|
+
* An agent calls it first instead of discovering limits by failing. It never
|
|
103
|
+
* fails itself: a missing dependency is data in the report.
|
|
104
|
+
*/
|
|
105
|
+
export function registerDoctor(cli: Cli.Cli, deps: DoctorDeps = {}): void {
|
|
106
|
+
const pico8Factory =
|
|
107
|
+
deps.pico8 ?? createShellAdapterFactory({parentEnv: deps.parentEnv});
|
|
108
|
+
const shrinkoFactory: ShrinkoAdapterFactory =
|
|
109
|
+
deps.shrinko ?? createShellShrinkoFactory({parentEnv: deps.parentEnv});
|
|
110
|
+
const find = deps.find ?? findExecutable;
|
|
111
|
+
|
|
112
|
+
cli.command('doctor', {
|
|
113
|
+
description:
|
|
114
|
+
'Report what this machine can do: PICO-8 (found, runs headless, version), shrinko8, the display a PICO-8 -run session would use (native / virtual Xvfb / none), and which picopilot commands are therefore available. Call this first on a new machine.',
|
|
115
|
+
env: PICO8_ENV,
|
|
116
|
+
options: z.object({display: DISPLAY_OPTION}),
|
|
117
|
+
output: z.object({
|
|
118
|
+
pico8: z.object({
|
|
119
|
+
found: z.boolean().describe('Whether the PICO-8 binary was found.'),
|
|
120
|
+
path: z.string().optional().describe('The resolved binary path.'),
|
|
121
|
+
runs: z
|
|
122
|
+
.boolean()
|
|
123
|
+
.describe('Whether a headless -x probe run actually worked.'),
|
|
124
|
+
version: z
|
|
125
|
+
.string()
|
|
126
|
+
.optional()
|
|
127
|
+
.describe('The PICO-8 version, when its install path names it.'),
|
|
128
|
+
versionId: z
|
|
129
|
+
.number()
|
|
130
|
+
.optional()
|
|
131
|
+
.describe("PICO-8's internal version number (stat(5))."),
|
|
132
|
+
}),
|
|
133
|
+
shrinko8: z.object({
|
|
134
|
+
found: z
|
|
135
|
+
.boolean()
|
|
136
|
+
.describe('Whether shrinko8 ran (token count on a probe cart).'),
|
|
137
|
+
}),
|
|
138
|
+
display: z.object({
|
|
139
|
+
mode: z
|
|
140
|
+
.enum(DISPLAY_MODES)
|
|
141
|
+
.describe('The resolved display mode (--display / env / config).'),
|
|
142
|
+
native: z
|
|
143
|
+
.boolean()
|
|
144
|
+
.describe('Whether DISPLAY or WAYLAND_DISPLAY is set.'),
|
|
145
|
+
virtual: z
|
|
146
|
+
.enum(['Xvfb', 'xvfb-run'])
|
|
147
|
+
.optional()
|
|
148
|
+
.describe('The virtual-display backend available, if any.'),
|
|
149
|
+
runSessions: z
|
|
150
|
+
.enum(['native', 'xvfb', 'none'])
|
|
151
|
+
.describe(
|
|
152
|
+
'How a PICO-8 -run session (audio record/render, run --record-audio) gets a display here.',
|
|
153
|
+
),
|
|
154
|
+
}),
|
|
155
|
+
available: z
|
|
156
|
+
.array(z.string())
|
|
157
|
+
.describe('Commands that work on this machine.'),
|
|
158
|
+
unavailable: z
|
|
159
|
+
.array(
|
|
160
|
+
z.object({
|
|
161
|
+
command: z.string(),
|
|
162
|
+
missing: z.array(z.string()),
|
|
163
|
+
}),
|
|
164
|
+
)
|
|
165
|
+
.describe('Commands that do not, and what they are missing.'),
|
|
166
|
+
}),
|
|
167
|
+
async run({env, ok, error, options, var: vars}) {
|
|
168
|
+
const resolved = resolveCommandDisplay(options.display, vars);
|
|
169
|
+
if (!resolved.ok) return error(resolved.error);
|
|
170
|
+
const display: DisplayMode = resolved.mode;
|
|
171
|
+
const childEnv = pico8ChildEnv(
|
|
172
|
+
env as Record<string, string | undefined>,
|
|
173
|
+
deps.parentEnv ?? process.env,
|
|
174
|
+
);
|
|
175
|
+
|
|
176
|
+
// --- PICO-8: locate, then a real headless probe for the version.
|
|
177
|
+
const [pico8File] = pico8Candidates(childEnv) as [string, ...string[]];
|
|
178
|
+
const pico8Path = find(pico8File, childEnv);
|
|
179
|
+
let runs = false;
|
|
180
|
+
let versionId: number | undefined;
|
|
181
|
+
let version: string | undefined;
|
|
182
|
+
const probeDir = mkdtempSync(join(tmpdir(), 'picopilot-doctor-'));
|
|
183
|
+
try {
|
|
184
|
+
const cartPath = join(probeDir, 'probe.p8');
|
|
185
|
+
writeFileSync(cartPath, PROBE_CART);
|
|
186
|
+
const result = await pico8Factory(env as NodeJS.ProcessEnv, {
|
|
187
|
+
display,
|
|
188
|
+
}).run({
|
|
189
|
+
cartPath,
|
|
190
|
+
shotDir: probeDir,
|
|
191
|
+
sentinel: PROBE_SENTINEL,
|
|
192
|
+
backstopMs: PROBE_BACKSTOP_MS,
|
|
193
|
+
});
|
|
194
|
+
if (result.ok) {
|
|
195
|
+
const m = new RegExp(`${VERSION_MARKER}(\\d+)`).exec(
|
|
196
|
+
result.value.printh,
|
|
197
|
+
);
|
|
198
|
+
if (m !== null) {
|
|
199
|
+
runs = true;
|
|
200
|
+
versionId = Number(m[1]);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
} finally {
|
|
204
|
+
rmSync(probeDir, {recursive: true, force: true});
|
|
205
|
+
}
|
|
206
|
+
if (pico8Path !== undefined) {
|
|
207
|
+
let real = pico8Path;
|
|
208
|
+
try {
|
|
209
|
+
real = realpathSync(pico8Path);
|
|
210
|
+
} catch {
|
|
211
|
+
// keep the unresolved path
|
|
212
|
+
}
|
|
213
|
+
version = versionFromPath(real) ?? versionFromPath(pico8Path);
|
|
214
|
+
}
|
|
215
|
+
const pico8Found = pico8Path !== undefined || runs;
|
|
216
|
+
|
|
217
|
+
// --- shrinko8: a real --count on a tiny cart.
|
|
218
|
+
let shrinkoFound = false;
|
|
219
|
+
const shrinkoDir = mkdtempSync(join(tmpdir(), 'picopilot-doctor-'));
|
|
220
|
+
try {
|
|
221
|
+
const cartPath = join(shrinkoDir, 'probe.p8');
|
|
222
|
+
writeFileSync(cartPath, SHRINKO_PROBE_CART);
|
|
223
|
+
try {
|
|
224
|
+
const count = await shrinkoFactory(env as NodeJS.ProcessEnv).count(
|
|
225
|
+
cartPath,
|
|
226
|
+
);
|
|
227
|
+
shrinkoFound = count.ok;
|
|
228
|
+
} catch {
|
|
229
|
+
// ran but did not parse: present, yet not usable for us
|
|
230
|
+
shrinkoFound = false;
|
|
231
|
+
}
|
|
232
|
+
} finally {
|
|
233
|
+
rmSync(shrinkoDir, {recursive: true, force: true});
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// --- display: how a -run session would get one here.
|
|
237
|
+
const native = hasNativeDisplay(childEnv);
|
|
238
|
+
const virtual: VirtualDisplayBackend | undefined =
|
|
239
|
+
detectVirtualDisplayBackend(childEnv, find);
|
|
240
|
+
const runSessions: RunSessionDisplay = needsVirtualDisplay(
|
|
241
|
+
display,
|
|
242
|
+
childEnv,
|
|
243
|
+
)
|
|
244
|
+
? virtual !== undefined
|
|
245
|
+
? 'xvfb'
|
|
246
|
+
: 'none'
|
|
247
|
+
: native
|
|
248
|
+
? 'native'
|
|
249
|
+
: 'none';
|
|
250
|
+
|
|
251
|
+
const have: Record<Need, boolean> = {
|
|
252
|
+
pico8: runs,
|
|
253
|
+
shrinko8: shrinkoFound,
|
|
254
|
+
display: runSessions !== 'none',
|
|
255
|
+
};
|
|
256
|
+
const available: string[] = [];
|
|
257
|
+
const unavailable: {command: string; missing: string[]}[] = [];
|
|
258
|
+
for (const {command, needs} of COMMAND_NEEDS) {
|
|
259
|
+
const missing = needs.filter((n) => !have[n]);
|
|
260
|
+
if (missing.length === 0) available.push(command);
|
|
261
|
+
else unavailable.push({command, missing});
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
return ok({
|
|
265
|
+
pico8: {
|
|
266
|
+
found: pico8Found,
|
|
267
|
+
...(pico8Path !== undefined ? {path: pico8Path} : {}),
|
|
268
|
+
runs,
|
|
269
|
+
...(version !== undefined ? {version} : {}),
|
|
270
|
+
...(versionId !== undefined ? {versionId} : {}),
|
|
271
|
+
},
|
|
272
|
+
shrinko8: {found: shrinkoFound},
|
|
273
|
+
display: {
|
|
274
|
+
mode: display,
|
|
275
|
+
native,
|
|
276
|
+
...(virtual !== undefined ? {virtual} : {}),
|
|
277
|
+
runSessions,
|
|
278
|
+
},
|
|
279
|
+
available,
|
|
280
|
+
unavailable,
|
|
281
|
+
});
|
|
282
|
+
},
|
|
283
|
+
});
|
|
284
|
+
}
|
package/src/commands/export.ts
CHANGED
|
@@ -6,20 +6,22 @@ import {
|
|
|
6
6
|
EXPORT_HTML_NAME,
|
|
7
7
|
type Pico8Adapter,
|
|
8
8
|
type Pico8ExportResult,
|
|
9
|
-
ShellPico8Adapter,
|
|
10
9
|
} from '../engine/pico8/index.js';
|
|
10
|
+
import {
|
|
11
|
+
createShellAdapterFactory,
|
|
12
|
+
PICO8_ENV,
|
|
13
|
+
type Pico8AdapterFactory,
|
|
14
|
+
} from './pico8-env.js';
|
|
11
15
|
import {prepareLabeledCart} from './label-cart.js';
|
|
12
16
|
|
|
13
17
|
/**
|
|
14
18
|
* Injects the PICO-8 adapter `export` uses (defaults to the shell adapter over
|
|
15
19
|
* native `pico8 <cart> -export <name>.html -x`). Mirrors the `run` seam: a test
|
|
16
|
-
* passes a stub to drive the present/absent paths without the paid binary,
|
|
17
|
-
* to isolate the child `env` (its `PICO8_PATH`/`PATH`).
|
|
20
|
+
* passes a stub to drive the present/absent paths without the paid binary, (the child env is the parent env + PICO8_PATH/PATH).
|
|
18
21
|
*/
|
|
19
|
-
export type Pico8AdapterFactory
|
|
22
|
+
export type {Pico8AdapterFactory} from './pico8-env.js';
|
|
20
23
|
|
|
21
|
-
const defaultAdapterFactory: Pico8AdapterFactory = (
|
|
22
|
-
new ShellPico8Adapter({env});
|
|
24
|
+
const defaultAdapterFactory: Pico8AdapterFactory = createShellAdapterFactory();
|
|
23
25
|
|
|
24
26
|
/** The default backstop: kill an export that never completes/exits after this many ms. */
|
|
25
27
|
const DEFAULT_BACKSTOP_MS = 30_000;
|
|
@@ -102,13 +104,7 @@ export function registerExport(
|
|
|
102
104
|
}),
|
|
103
105
|
// incur resolves these from the (test-overridable) env source and hands
|
|
104
106
|
// them to the child, so PICO8_PATH/PATH locate the binary in isolation.
|
|
105
|
-
env:
|
|
106
|
-
PICO8_PATH: z
|
|
107
|
-
.string()
|
|
108
|
-
.optional()
|
|
109
|
-
.describe('Explicit path to the PICO-8 binary (else `pico8` on PATH).'),
|
|
110
|
-
PATH: z.string().optional().describe('Used to locate `pico8`.'),
|
|
111
|
-
}),
|
|
107
|
+
env: PICO8_ENV,
|
|
112
108
|
output: z.object({
|
|
113
109
|
outDir: z
|
|
114
110
|
.string()
|
package/src/commands/gfx.ts
CHANGED
|
@@ -20,16 +20,18 @@ import {
|
|
|
20
20
|
SHEET_RENDER_SCALE,
|
|
21
21
|
SPRITE_RENDER_SCALE,
|
|
22
22
|
} from '../engine/gfx/index.js';
|
|
23
|
+
import {type ShrinkoAdapter} from '../engine/shrinko/index.js';
|
|
23
24
|
import {
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
} from '
|
|
25
|
+
createShellShrinkoFactory,
|
|
26
|
+
type ShrinkoAdapterFactory,
|
|
27
|
+
} from './shrinko-env.js';
|
|
28
|
+
import {mcpConfigOption} from './pico8-env.js';
|
|
27
29
|
|
|
28
30
|
/** Injects the shrinko adapter `gfx import`/`gfx export` use (the spritesheet round-trip). */
|
|
29
|
-
export type ShrinkoAdapterFactory
|
|
31
|
+
export type {ShrinkoAdapterFactory} from './shrinko-env.js';
|
|
30
32
|
|
|
31
|
-
const defaultShrinkoFactory: ShrinkoAdapterFactory =
|
|
32
|
-
|
|
33
|
+
const defaultShrinkoFactory: ShrinkoAdapterFactory =
|
|
34
|
+
createShellShrinkoFactory();
|
|
33
35
|
|
|
34
36
|
/**
|
|
35
37
|
* The exit code for the `map-overlap` refusal. Chosen as 1 (a plain failure),
|
|
@@ -198,11 +200,13 @@ function buildGfxGroup(shrinkoFactory: ShrinkoAdapterFactory): Cli.Cli {
|
|
|
198
200
|
),
|
|
199
201
|
}),
|
|
200
202
|
options: z.object({
|
|
203
|
+
// No zod default: an absent value must stay distinguishable, so that under
|
|
204
|
+
// `--mcp` (where incur loads no config) picopilot.json still applies.
|
|
201
205
|
allowMapOverlap: z
|
|
202
206
|
.boolean()
|
|
203
|
-
.
|
|
207
|
+
.optional()
|
|
204
208
|
.describe(
|
|
205
|
-
'Authorise overwriting existing shared-region data when writing sprites 128-255 (ADR-0004). Also settable as allowMapOverlap in picopilot.json.',
|
|
209
|
+
'Authorise overwriting existing shared-region data when writing sprites 128-255 (ADR-0004). Also settable as allowMapOverlap in picopilot.json. Default false.',
|
|
206
210
|
),
|
|
207
211
|
}),
|
|
208
212
|
output: z.object({
|
|
@@ -236,7 +240,7 @@ function buildGfxGroup(shrinkoFactory: ShrinkoAdapterFactory): Cli.Cli {
|
|
|
236
240
|
},
|
|
237
241
|
},
|
|
238
242
|
],
|
|
239
|
-
run({args, options, error, ok}) {
|
|
243
|
+
run({args, options, error, ok, var: vars}) {
|
|
240
244
|
const cartPath = resolveCart(args.cart);
|
|
241
245
|
|
|
242
246
|
// Decode the incoming grid FIRST: a malformed grid is a caller error and
|
|
@@ -266,12 +270,12 @@ function buildGfxGroup(shrinkoFactory: ShrinkoAdapterFactory): Cli.Cli {
|
|
|
266
270
|
// (the shared-bank bytes at risk), NOT __map__ (ADR-0004). The flag and
|
|
267
271
|
// the picopilot.json config both feed `options.allowMapOverlap` (incur
|
|
268
272
|
// resolves argv > config > default), so both authorisation paths converge
|
|
269
|
-
// here.
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
);
|
|
273
|
+
// here. Under `--mcp` incur loads no config, so the picopilot.json value
|
|
274
|
+
// comes from the `mcpConfig` var instead.
|
|
275
|
+
const allowMapOverlap =
|
|
276
|
+
options.allowMapOverlap ??
|
|
277
|
+
mcpConfigOption(vars, ['gfx', 'set'], 'allowMapOverlap') === true;
|
|
278
|
+
const decision = decideOverlap(sheet, args.sprite, allowMapOverlap);
|
|
275
279
|
|
|
276
280
|
if (decision.kind === 'refused') {
|
|
277
281
|
// The silent-corruption guard: REFUSE and DO NOT write. The cart file is
|
package/src/commands/lint.ts
CHANGED
|
@@ -1,16 +1,17 @@
|
|
|
1
1
|
import {existsSync} from 'node:fs';
|
|
2
2
|
import {isAbsolute, resolve} from 'node:path';
|
|
3
3
|
import {type Cli, z} from 'incur';
|
|
4
|
+
import {type ShrinkoAdapter} from '../engine/shrinko/index.js';
|
|
4
5
|
import {
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
} from '
|
|
6
|
+
createShellShrinkoFactory,
|
|
7
|
+
type ShrinkoAdapterFactory,
|
|
8
|
+
} from './shrinko-env.js';
|
|
8
9
|
|
|
9
10
|
/** See {@link import('./tokens.js').ShrinkoAdapterFactory}: lint reuses the same seam. */
|
|
10
|
-
export type ShrinkoAdapterFactory
|
|
11
|
+
export type {ShrinkoAdapterFactory} from './shrinko-env.js';
|
|
11
12
|
|
|
12
|
-
const defaultAdapterFactory: ShrinkoAdapterFactory =
|
|
13
|
-
|
|
13
|
+
const defaultAdapterFactory: ShrinkoAdapterFactory =
|
|
14
|
+
createShellShrinkoFactory();
|
|
14
15
|
|
|
15
16
|
/**
|
|
16
17
|
* Registers `picopilot lint` (US #4): surface PICO-8-specific issues (undefined
|
package/src/commands/minify.ts
CHANGED
|
@@ -1,16 +1,17 @@
|
|
|
1
1
|
import {existsSync} from 'node:fs';
|
|
2
2
|
import {dirname, isAbsolute, join, resolve} from 'node:path';
|
|
3
3
|
import {type Cli, z} from 'incur';
|
|
4
|
+
import {type ShrinkoAdapter} from '../engine/shrinko/index.js';
|
|
4
5
|
import {
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
} from '
|
|
6
|
+
createShellShrinkoFactory,
|
|
7
|
+
type ShrinkoAdapterFactory,
|
|
8
|
+
} from './shrinko-env.js';
|
|
8
9
|
|
|
9
10
|
/** See {@link import('./tokens.js').ShrinkoAdapterFactory}: minify reuses the same seam. */
|
|
10
|
-
export type ShrinkoAdapterFactory
|
|
11
|
+
export type {ShrinkoAdapterFactory} from './shrinko-env.js';
|
|
11
12
|
|
|
12
|
-
const defaultAdapterFactory: ShrinkoAdapterFactory =
|
|
13
|
-
|
|
13
|
+
const defaultAdapterFactory: ShrinkoAdapterFactory =
|
|
14
|
+
createShellShrinkoFactory();
|
|
14
15
|
|
|
15
16
|
/**
|
|
16
17
|
* Derives the default output path `<name>.min.p8` next to the source cart:
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import {z} from 'incur';
|
|
2
|
+
import {
|
|
3
|
+
DEFAULT_DISPLAY_MODE,
|
|
4
|
+
DISPLAY_MODES,
|
|
5
|
+
type DisplayMode,
|
|
6
|
+
isDisplayMode,
|
|
7
|
+
type Pico8Adapter,
|
|
8
|
+
pico8ChildEnv,
|
|
9
|
+
ShellPico8Adapter,
|
|
10
|
+
type SpawnRunner,
|
|
11
|
+
type VirtualDisplayDeps,
|
|
12
|
+
} from '../engine/pico8/index.js';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The env schema every PICO-8-gated command declares. These are the values
|
|
16
|
+
* picopilot itself RESOLVES (incur reads them from its env source, which tests
|
|
17
|
+
* override); they are NOT the whole child environment. The child gets the
|
|
18
|
+
* parent environment with these overlaid ({@link pico8ChildEnv}), so PICO-8
|
|
19
|
+
* still sees DISPLAY, HOME, XDG_RUNTIME_DIR, SDL_* and the rest.
|
|
20
|
+
*/
|
|
21
|
+
export const PICO8_ENV = z.object({
|
|
22
|
+
PICO8_PATH: z
|
|
23
|
+
.string()
|
|
24
|
+
.optional()
|
|
25
|
+
.describe('Explicit path to the PICO-8 binary (else `pico8` on PATH).'),
|
|
26
|
+
PATH: z.string().optional().describe('Used to locate `pico8`.'),
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
/** What a command knows beyond the env when it builds an adapter. */
|
|
30
|
+
export interface Pico8AdapterContext {
|
|
31
|
+
/** The resolved display mode for `-run` sessions (ADR-0017). */
|
|
32
|
+
readonly display: DisplayMode;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Injects the PICO-8 adapter a command uses. A test passes a stub to drive the
|
|
37
|
+
* sentinel/backstop/absent paths without the paid binary; the default is
|
|
38
|
+
* {@link createShellAdapterFactory}.
|
|
39
|
+
*/
|
|
40
|
+
export type Pico8AdapterFactory = (
|
|
41
|
+
env: NodeJS.ProcessEnv,
|
|
42
|
+
context?: Pico8AdapterContext,
|
|
43
|
+
) => Pico8Adapter;
|
|
44
|
+
|
|
45
|
+
/** Dependencies for {@link createShellAdapterFactory}, injectable for tests. */
|
|
46
|
+
export interface ShellAdapterFactoryDeps {
|
|
47
|
+
/** The parent environment PICO-8 inherits (defaults to `process.env`). */
|
|
48
|
+
readonly parentEnv?: NodeJS.ProcessEnv;
|
|
49
|
+
/** The PICO-8 spawn seam (defaults to the real spawn). */
|
|
50
|
+
readonly spawn?: SpawnRunner;
|
|
51
|
+
/** The virtual-display seam (Xvfb lookup + spawn). */
|
|
52
|
+
readonly virtualDisplay?: VirtualDisplayDeps;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* The default adapter factory: the shell adapter over the user's PICO-8, with
|
|
57
|
+
* the child env = parent env + the command's resolved values, and the display
|
|
58
|
+
* mode from the context.
|
|
59
|
+
*/
|
|
60
|
+
export function createShellAdapterFactory(
|
|
61
|
+
deps: ShellAdapterFactoryDeps = {},
|
|
62
|
+
): Pico8AdapterFactory {
|
|
63
|
+
return (env, context) =>
|
|
64
|
+
new ShellPico8Adapter({
|
|
65
|
+
env: pico8ChildEnv(
|
|
66
|
+
env as Record<string, string | undefined>,
|
|
67
|
+
deps.parentEnv ?? process.env,
|
|
68
|
+
),
|
|
69
|
+
spawn: deps.spawn,
|
|
70
|
+
display: context?.display ?? DEFAULT_DISPLAY_MODE,
|
|
71
|
+
virtualDisplay: deps.virtualDisplay,
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
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.
|
|
93
|
+
*/
|
|
94
|
+
export function displayModeOf(vars: unknown): DisplayMode {
|
|
95
|
+
const value =
|
|
96
|
+
vars !== null && typeof vars === 'object'
|
|
97
|
+
? (vars as {display?: unknown}).display
|
|
98
|
+
: undefined;
|
|
99
|
+
return isDisplayMode(value) ? value : DEFAULT_DISPLAY_MODE;
|
|
100
|
+
}
|
|
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
|
+
|
|
155
|
+
/**
|
|
156
|
+
* The `error(...)` payload for a `display-unavailable` result (ADR-0017): the
|
|
157
|
+
* session needs a display, there is none, and no Xvfb is installed to provide a
|
|
158
|
+
* virtual one. The message and CTA name the fix, so the agent never has to
|
|
159
|
+
* discover it by a silent timeout.
|
|
160
|
+
*/
|
|
161
|
+
export function displayUnavailableError(
|
|
162
|
+
what: string,
|
|
163
|
+
result: {detail: string; remedy: string},
|
|
164
|
+
) {
|
|
165
|
+
return {
|
|
166
|
+
code: 'display-unavailable' as const,
|
|
167
|
+
message: `${what} needs a display (a real audio+video -run session): ${result.detail}. Remedy: ${result.remedy}.`,
|
|
168
|
+
exitCode: 1,
|
|
169
|
+
cta: {
|
|
170
|
+
description: `No display and no Xvfb on this machine. Fix: ${result.remedy}. Then re-run; picopilot starts a private virtual display by itself (--display auto). Check what this machine can do:`,
|
|
171
|
+
commands: [
|
|
172
|
+
{
|
|
173
|
+
command: 'doctor',
|
|
174
|
+
description:
|
|
175
|
+
'Report PICO-8, shrinko8 and display capabilities, and which commands are available here.',
|
|
176
|
+
},
|
|
177
|
+
],
|
|
178
|
+
},
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** The `error(...)` payload for a `pico8-not-found` result (the exact remedy + needs). */
|
|
183
|
+
export function pico8NotFoundError(result: {
|
|
184
|
+
remedy: string;
|
|
185
|
+
needs: readonly string[];
|
|
186
|
+
}) {
|
|
187
|
+
return {
|
|
188
|
+
code: 'pico8-not-found' as const,
|
|
189
|
+
message: `PICO-8 is not installed. ${result.remedy} (needs: ${result.needs.join(', ')})`,
|
|
190
|
+
exitCode: 1,
|
|
191
|
+
};
|
|
192
|
+
}
|
package/src/commands/playtest.ts
CHANGED
|
@@ -22,12 +22,16 @@ import {
|
|
|
22
22
|
SessionRegistry,
|
|
23
23
|
sessionExists,
|
|
24
24
|
SHOT_BASENAME,
|
|
25
|
-
ShellPico8Adapter,
|
|
26
25
|
spawnSessionDaemon,
|
|
27
26
|
type SessionRequest,
|
|
28
27
|
type SessionResponse,
|
|
29
28
|
type SessionResponseValue,
|
|
30
29
|
} from '../engine/pico8/index.js';
|
|
30
|
+
import {
|
|
31
|
+
createShellAdapterFactory,
|
|
32
|
+
PICO8_ENV,
|
|
33
|
+
type Pico8AdapterFactory,
|
|
34
|
+
} from './pico8-env.js';
|
|
31
35
|
import {makeReadInclude} from './cart-includes.js';
|
|
32
36
|
|
|
33
37
|
/**
|
|
@@ -36,10 +40,9 @@ import {makeReadInclude} from './cart-includes.js';
|
|
|
36
40
|
* stub to drive the drive-orchestration + the absent path WITHOUT the paid
|
|
37
41
|
* binary, and to isolate the child `env` (its `PICO8_PATH`/`PATH`).
|
|
38
42
|
*/
|
|
39
|
-
export type Pico8AdapterFactory
|
|
43
|
+
export type {Pico8AdapterFactory} from './pico8-env.js';
|
|
40
44
|
|
|
41
|
-
const defaultAdapterFactory: Pico8AdapterFactory = (
|
|
42
|
-
new ShellPico8Adapter({env});
|
|
45
|
+
const defaultAdapterFactory: Pico8AdapterFactory = createShellAdapterFactory();
|
|
43
46
|
|
|
44
47
|
/**
|
|
45
48
|
* The default backstop: kill a driven cart that never signals/exits after this
|
|
@@ -219,13 +222,7 @@ function buildPlaytestGroup(adapterFactory: Pico8AdapterFactory): Cli.Cli {
|
|
|
219
222
|
}
|
|
220
223
|
|
|
221
224
|
/** The env schema the whole `playtest` group shares (locates the binary, isolated). */
|
|
222
|
-
const PLAYTEST_ENV =
|
|
223
|
-
PICO8_PATH: z
|
|
224
|
-
.string()
|
|
225
|
-
.optional()
|
|
226
|
-
.describe('Explicit path to the PICO-8 binary (else `pico8` on PATH).'),
|
|
227
|
-
PATH: z.string().optional().describe('Used to locate `pico8`.'),
|
|
228
|
-
});
|
|
225
|
+
const PLAYTEST_ENV = PICO8_ENV;
|
|
229
226
|
|
|
230
227
|
/**
|
|
231
228
|
* The ONE-SHOT root handler (unchanged behaviour from the one-shot task): build
|