picopilot 0.0.0 → 0.1.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/LICENSE +661 -0
- package/dist/bin.d.ts +3 -0
- package/dist/bin.d.ts.map +1 -0
- package/dist/bin.js +4 -0
- package/dist/bin.js.map +1 -0
- package/dist/cli.d.ts +15 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +59 -0
- package/dist/cli.js.map +1 -0
- package/dist/commands/audio.d.ts +17 -0
- package/dist/commands/audio.d.ts.map +1 -0
- package/dist/commands/audio.js +315 -0
- package/dist/commands/audio.js.map +1 -0
- package/dist/commands/export.d.ts +36 -0
- package/dist/commands/export.d.ts.map +1 -0
- package/dist/commands/export.js +257 -0
- package/dist/commands/export.js.map +1 -0
- package/dist/commands/gfx.d.ts +28 -0
- package/dist/commands/gfx.d.ts.map +1 -0
- package/dist/commands/gfx.js +521 -0
- package/dist/commands/gfx.js.map +1 -0
- package/dist/commands/init.d.ts +34 -0
- package/dist/commands/init.d.ts.map +1 -0
- package/dist/commands/init.js +151 -0
- package/dist/commands/init.js.map +1 -0
- package/dist/commands/lint.d.ts +20 -0
- package/dist/commands/lint.d.ts.map +1 -0
- package/dist/commands/lint.js +90 -0
- package/dist/commands/lint.js.map +1 -0
- package/dist/commands/minify.d.ts +20 -0
- package/dist/commands/minify.d.ts.map +1 -0
- package/dist/commands/minify.js +129 -0
- package/dist/commands/minify.js.map +1 -0
- package/dist/commands/music.d.ts +13 -0
- package/dist/commands/music.d.ts.map +1 -0
- package/dist/commands/music.js +241 -0
- package/dist/commands/music.js.map +1 -0
- package/dist/commands/playtest.d.ts +29 -0
- package/dist/commands/playtest.d.ts.map +1 -0
- package/dist/commands/playtest.js +631 -0
- package/dist/commands/playtest.js.map +1 -0
- package/dist/commands/run.d.ts +30 -0
- package/dist/commands/run.d.ts.map +1 -0
- package/dist/commands/run.js +224 -0
- package/dist/commands/run.js.map +1 -0
- package/dist/commands/serve.d.ts +50 -0
- package/dist/commands/serve.d.ts.map +1 -0
- package/dist/commands/serve.js +213 -0
- package/dist/commands/serve.js.map +1 -0
- package/dist/commands/sfx.d.ts +13 -0
- package/dist/commands/sfx.d.ts.map +1 -0
- package/dist/commands/sfx.js +254 -0
- package/dist/commands/sfx.js.map +1 -0
- package/dist/commands/tokens.d.ts +36 -0
- package/dist/commands/tokens.d.ts.map +1 -0
- package/dist/commands/tokens.js +129 -0
- package/dist/commands/tokens.js.map +1 -0
- package/dist/commands/verify.d.ts +57 -0
- package/dist/commands/verify.d.ts.map +1 -0
- package/dist/commands/verify.js +213 -0
- package/dist/commands/verify.js.map +1 -0
- package/dist/commands/version.d.ts +12 -0
- package/dist/commands/version.d.ts.map +1 -0
- package/dist/commands/version.js +24 -0
- package/dist/commands/version.js.map +1 -0
- package/dist/engine/audio/index.d.ts +14 -0
- package/dist/engine/audio/index.d.ts.map +1 -0
- package/dist/engine/audio/index.js +14 -0
- package/dist/engine/audio/index.js.map +1 -0
- package/dist/engine/audio/music.d.ts +116 -0
- package/dist/engine/audio/music.d.ts.map +1 -0
- package/dist/engine/audio/music.js +138 -0
- package/dist/engine/audio/music.js.map +1 -0
- package/dist/engine/audio/section.d.ts +34 -0
- package/dist/engine/audio/section.d.ts.map +1 -0
- package/dist/engine/audio/section.js +67 -0
- package/dist/engine/audio/section.js.map +1 -0
- package/dist/engine/audio/sfx.d.ts +141 -0
- package/dist/engine/audio/sfx.d.ts.map +1 -0
- package/dist/engine/audio/sfx.js +506 -0
- package/dist/engine/audio/sfx.js.map +1 -0
- package/dist/engine/cart/cart.d.ts +75 -0
- package/dist/engine/cart/cart.d.ts.map +1 -0
- package/dist/engine/cart/cart.js +153 -0
- package/dist/engine/cart/cart.js.map +1 -0
- package/dist/engine/cart/errors.d.ts +29 -0
- package/dist/engine/cart/errors.d.ts.map +1 -0
- package/dist/engine/cart/errors.js +26 -0
- package/dist/engine/cart/errors.js.map +1 -0
- package/dist/engine/cart/gfx.d.ts +79 -0
- package/dist/engine/cart/gfx.d.ts.map +1 -0
- package/dist/engine/cart/gfx.js +171 -0
- package/dist/engine/cart/gfx.js.map +1 -0
- package/dist/engine/cart/index.d.ts +15 -0
- package/dist/engine/cart/index.d.ts.map +1 -0
- package/dist/engine/cart/index.js +15 -0
- package/dist/engine/cart/index.js.map +1 -0
- package/dist/engine/cart/map.d.ts +46 -0
- package/dist/engine/cart/map.d.ts.map +1 -0
- package/dist/engine/cart/map.js +118 -0
- package/dist/engine/cart/map.js.map +1 -0
- package/dist/engine/config/config.d.ts +74 -0
- package/dist/engine/config/config.d.ts.map +1 -0
- package/dist/engine/config/config.js +103 -0
- package/dist/engine/config/config.js.map +1 -0
- package/dist/engine/config/index.d.ts +7 -0
- package/dist/engine/config/index.d.ts.map +1 -0
- package/dist/engine/config/index.js +7 -0
- package/dist/engine/config/index.js.map +1 -0
- package/dist/engine/gfx/grid.d.ts +51 -0
- package/dist/engine/gfx/grid.d.ts.map +1 -0
- package/dist/engine/gfx/grid.js +116 -0
- package/dist/engine/gfx/grid.js.map +1 -0
- package/dist/engine/gfx/index.d.ts +17 -0
- package/dist/engine/gfx/index.d.ts.map +1 -0
- package/dist/engine/gfx/index.js +17 -0
- package/dist/engine/gfx/index.js.map +1 -0
- package/dist/engine/gfx/label.d.ts +34 -0
- package/dist/engine/gfx/label.d.ts.map +1 -0
- package/dist/engine/gfx/label.js +190 -0
- package/dist/engine/gfx/label.js.map +1 -0
- package/dist/engine/gfx/overlap.d.ts +42 -0
- package/dist/engine/gfx/overlap.d.ts.map +1 -0
- package/dist/engine/gfx/overlap.js +57 -0
- package/dist/engine/gfx/overlap.js.map +1 -0
- package/dist/engine/gfx/palette.d.ts +29 -0
- package/dist/engine/gfx/palette.d.ts.map +1 -0
- package/dist/engine/gfx/palette.js +46 -0
- package/dist/engine/gfx/palette.js.map +1 -0
- package/dist/engine/gfx/png.d.ts +31 -0
- package/dist/engine/gfx/png.d.ts.map +1 -0
- package/dist/engine/gfx/png.js +165 -0
- package/dist/engine/gfx/png.js.map +1 -0
- package/dist/engine/gfx/render.d.ts +42 -0
- package/dist/engine/gfx/render.d.ts.map +1 -0
- package/dist/engine/gfx/render.js +72 -0
- package/dist/engine/gfx/render.js.map +1 -0
- package/dist/engine/index.d.ts +22 -0
- package/dist/engine/index.d.ts.map +1 -0
- package/dist/engine/index.js +22 -0
- package/dist/engine/index.js.map +1 -0
- package/dist/engine/pico8/adapter.d.ts +267 -0
- package/dist/engine/pico8/adapter.d.ts.map +1 -0
- package/dist/engine/pico8/adapter.js +37 -0
- package/dist/engine/pico8/adapter.js.map +1 -0
- package/dist/engine/pico8/drive.d.ts +308 -0
- package/dist/engine/pico8/drive.d.ts.map +1 -0
- package/dist/engine/pico8/drive.js +581 -0
- package/dist/engine/pico8/drive.js.map +1 -0
- package/dist/engine/pico8/harness.d.ts +146 -0
- package/dist/engine/pico8/harness.d.ts.map +1 -0
- package/dist/engine/pico8/harness.js +251 -0
- package/dist/engine/pico8/harness.js.map +1 -0
- package/dist/engine/pico8/index.d.ts +21 -0
- package/dist/engine/pico8/index.d.ts.map +1 -0
- package/dist/engine/pico8/index.js +21 -0
- package/dist/engine/pico8/index.js.map +1 -0
- package/dist/engine/pico8/sentinel.d.ts +63 -0
- package/dist/engine/pico8/sentinel.d.ts.map +1 -0
- package/dist/engine/pico8/sentinel.js +86 -0
- package/dist/engine/pico8/sentinel.js.map +1 -0
- package/dist/engine/pico8/session-daemon-entry.d.ts +10 -0
- package/dist/engine/pico8/session-daemon-entry.d.ts.map +1 -0
- package/dist/engine/pico8/session-daemon-entry.js +11 -0
- package/dist/engine/pico8/session-daemon-entry.js.map +1 -0
- package/dist/engine/pico8/session-daemon-main.d.ts +44 -0
- package/dist/engine/pico8/session-daemon-main.d.ts.map +1 -0
- package/dist/engine/pico8/session-daemon-main.js +71 -0
- package/dist/engine/pico8/session-daemon-main.js.map +1 -0
- package/dist/engine/pico8/session-daemon.d.ts +68 -0
- package/dist/engine/pico8/session-daemon.d.ts.map +1 -0
- package/dist/engine/pico8/session-daemon.js +264 -0
- package/dist/engine/pico8/session-daemon.js.map +1 -0
- package/dist/engine/pico8/session.d.ts +140 -0
- package/dist/engine/pico8/session.d.ts.map +1 -0
- package/dist/engine/pico8/session.js +250 -0
- package/dist/engine/pico8/session.js.map +1 -0
- package/dist/engine/pico8/shell.d.ts +103 -0
- package/dist/engine/pico8/shell.d.ts.map +1 -0
- package/dist/engine/pico8/shell.js +324 -0
- package/dist/engine/pico8/shell.js.map +1 -0
- package/dist/engine/pico8/supervisor.d.ts +137 -0
- package/dist/engine/pico8/supervisor.d.ts.map +1 -0
- package/dist/engine/pico8/supervisor.js +140 -0
- package/dist/engine/pico8/supervisor.js.map +1 -0
- package/dist/engine/scaffold/index.d.ts +8 -0
- package/dist/engine/scaffold/index.d.ts.map +1 -0
- package/dist/engine/scaffold/index.js +8 -0
- package/dist/engine/scaffold/index.js.map +1 -0
- package/dist/engine/scaffold/scaffold.d.ts +49 -0
- package/dist/engine/scaffold/scaffold.d.ts.map +1 -0
- package/dist/engine/scaffold/scaffold.js +259 -0
- package/dist/engine/scaffold/scaffold.js.map +1 -0
- package/dist/engine/shrinko/adapter.d.ts +167 -0
- package/dist/engine/shrinko/adapter.d.ts.map +1 -0
- package/dist/engine/shrinko/adapter.js +107 -0
- package/dist/engine/shrinko/adapter.js.map +1 -0
- package/dist/engine/shrinko/index.d.ts +13 -0
- package/dist/engine/shrinko/index.d.ts.map +1 -0
- package/dist/engine/shrinko/index.js +13 -0
- package/dist/engine/shrinko/index.js.map +1 -0
- package/dist/engine/shrinko/shell.d.ts +69 -0
- package/dist/engine/shrinko/shell.d.ts.map +1 -0
- package/dist/engine/shrinko/shell.js +156 -0
- package/dist/engine/shrinko/shell.js.map +1 -0
- package/dist/engine/skills/index.d.ts +8 -0
- package/dist/engine/skills/index.d.ts.map +1 -0
- package/dist/engine/skills/index.js +8 -0
- package/dist/engine/skills/index.js.map +1 -0
- package/dist/engine/skills/skills.d.ts +104 -0
- package/dist/engine/skills/skills.d.ts.map +1 -0
- package/dist/engine/skills/skills.js +149 -0
- package/dist/engine/skills/skills.js.map +1 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +10 -0
- package/dist/index.js.map +1 -0
- package/dist/version.d.ts +2 -0
- package/dist/version.d.ts.map +1 -0
- package/dist/version.js +19 -0
- package/dist/version.js.map +1 -0
- package/package.json +48 -2
- package/src/bin.ts +4 -0
- package/src/cli.ts +61 -0
- package/src/commands/audio.test.ts +280 -0
- package/src/commands/audio.ts +410 -0
- package/src/commands/export.test.ts +310 -0
- package/src/commands/export.ts +316 -0
- package/src/commands/gfx-io.test.ts +178 -0
- package/src/commands/gfx.test.ts +470 -0
- package/src/commands/gfx.ts +645 -0
- package/src/commands/init.test.ts +482 -0
- package/src/commands/init.ts +193 -0
- package/src/commands/lint.test.ts +137 -0
- package/src/commands/lint.ts +117 -0
- package/src/commands/minify.test.ts +166 -0
- package/src/commands/minify.ts +156 -0
- package/src/commands/music.test.ts +272 -0
- package/src/commands/music.ts +288 -0
- package/src/commands/playtest.test.ts +317 -0
- package/src/commands/playtest.ts +773 -0
- package/src/commands/run.test.ts +264 -0
- package/src/commands/run.ts +285 -0
- package/src/commands/serve.test.ts +227 -0
- package/src/commands/serve.ts +272 -0
- package/src/commands/sfx.test.ts +349 -0
- package/src/commands/sfx.ts +300 -0
- package/src/commands/tokens.test.ts +212 -0
- package/src/commands/tokens.ts +168 -0
- package/src/commands/verify.test.ts +238 -0
- package/src/commands/verify.ts +256 -0
- package/src/commands/version.test.ts +70 -0
- package/src/commands/version.ts +24 -0
- package/src/engine/audio/index.ts +47 -0
- package/src/engine/audio/music.test.ts +188 -0
- package/src/engine/audio/music.ts +218 -0
- package/src/engine/audio/section.test.ts +124 -0
- package/src/engine/audio/section.ts +79 -0
- package/src/engine/audio/sfx.test.ts +410 -0
- package/src/engine/audio/sfx.ts +694 -0
- package/src/engine/cart/cart.test.ts +304 -0
- package/src/engine/cart/cart.ts +211 -0
- package/src/engine/cart/errors.ts +39 -0
- package/src/engine/cart/fixtures/crlf.p8 +6 -0
- package/src/engine/cart/fixtures/full.p8 +174 -0
- package/src/engine/cart/fixtures/header-only.p8 +2 -0
- package/src/engine/cart/fixtures/malformed-no-header.p8 +2 -0
- package/src/engine/cart/fixtures/minimal.p8 +10 -0
- package/src/engine/cart/fixtures/no-trailing-newline.p8 +6 -0
- package/src/engine/cart/gfx.ts +195 -0
- package/src/engine/cart/index.ts +27 -0
- package/src/engine/cart/map.ts +134 -0
- package/src/engine/config/config.ts +115 -0
- package/src/engine/config/index.ts +12 -0
- package/src/engine/gfx/grid.test.ts +128 -0
- package/src/engine/gfx/grid.ts +136 -0
- package/src/engine/gfx/index.ts +34 -0
- package/src/engine/gfx/label.test.ts +105 -0
- package/src/engine/gfx/label.ts +229 -0
- package/src/engine/gfx/overlap.test.ts +71 -0
- package/src/engine/gfx/overlap.ts +66 -0
- package/src/engine/gfx/palette.test.ts +35 -0
- package/src/engine/gfx/palette.ts +54 -0
- package/src/engine/gfx/png.test.ts +111 -0
- package/src/engine/gfx/png.ts +196 -0
- package/src/engine/gfx/render.test.ts +180 -0
- package/src/engine/gfx/render.ts +106 -0
- package/src/engine/index.ts +21 -0
- package/src/engine/pico8/adapter.ts +303 -0
- package/src/engine/pico8/drive.test.ts +415 -0
- package/src/engine/pico8/drive.ts +729 -0
- package/src/engine/pico8/harness.test.ts +181 -0
- package/src/engine/pico8/harness.ts +354 -0
- package/src/engine/pico8/index.ts +20 -0
- package/src/engine/pico8/sentinel.test.ts +80 -0
- package/src/engine/pico8/sentinel.ts +93 -0
- package/src/engine/pico8/session-daemon-entry.ts +12 -0
- package/src/engine/pico8/session-daemon-main.ts +98 -0
- package/src/engine/pico8/session-daemon.test.ts +212 -0
- package/src/engine/pico8/session-daemon.ts +306 -0
- package/src/engine/pico8/session.test.ts +239 -0
- package/src/engine/pico8/session.ts +321 -0
- package/src/engine/pico8/shell.test.ts +583 -0
- package/src/engine/pico8/shell.ts +412 -0
- package/src/engine/pico8/supervisor.test.ts +105 -0
- package/src/engine/pico8/supervisor.ts +197 -0
- package/src/engine/scaffold/index.ts +15 -0
- package/src/engine/scaffold/scaffold.ts +278 -0
- package/src/engine/shrinko/adapter.ts +241 -0
- package/src/engine/shrinko/fixtures/count-no-compressed.txt +2 -0
- package/src/engine/shrinko/fixtures/count-over-budget.txt +3 -0
- package/src/engine/shrinko/fixtures/count-under-budget.txt +3 -0
- package/src/engine/shrinko/index.ts +33 -0
- package/src/engine/shrinko/shell.ts +224 -0
- package/src/engine/shrinko/shrinko.test.ts +182 -0
- package/src/engine/skills/index.ts +14 -0
- package/src/engine/skills/skills.test.ts +376 -0
- package/src/engine/skills/skills.ts +205 -0
- package/src/index.ts +13 -0
- package/src/skills/game-design-reference/SKILL.md +70 -0
- package/src/skills/game-jam/SKILL.md +45 -0
- package/src/skills/picopilot-art/SKILL.md +62 -0
- package/src/skills/picopilot-audio/SKILL.md +194 -0
- package/src/skills/picopilot-code/SKILL.md +97 -0
- package/src/skills/picopilot-code/reference/README.md +42 -0
- package/src/skills/picopilot-code/reference/mode7-racing.md +95 -0
- package/src/skills/picopilot-code/reference/platformer.md +119 -0
- package/src/skills/picopilot-code/reference/puzzle-grid.md +106 -0
- package/src/skills/picopilot-code/reference/rpg-menus-dialog.md +122 -0
- package/src/skills/picopilot-code/reference/top-down-adventure.md +117 -0
- package/src/skills/picopilot-code/reference/twin-stick-arcade.md +128 -0
- package/src/skills/picopilot-debug/SKILL.md +195 -0
- package/src/skills/picopilot-overview/SKILL.md +52 -0
- package/src/version.ts +22 -0
|
@@ -0,0 +1,482 @@
|
|
|
1
|
+
import {execFileSync} from 'node:child_process';
|
|
2
|
+
import {
|
|
3
|
+
existsSync,
|
|
4
|
+
mkdtempSync,
|
|
5
|
+
readdirSync,
|
|
6
|
+
readFileSync,
|
|
7
|
+
writeFileSync,
|
|
8
|
+
} from 'node:fs';
|
|
9
|
+
import {tmpdir} from 'node:os';
|
|
10
|
+
import {join} from 'node:path';
|
|
11
|
+
import {Cli, z} from 'incur';
|
|
12
|
+
import {afterEach, beforeEach, describe, expect, it} from 'vitest';
|
|
13
|
+
import {Cart} from '../engine/cart/index.js';
|
|
14
|
+
import {readAllowMapOverlap} from '../engine/config/index.js';
|
|
15
|
+
import type {
|
|
16
|
+
InstallSkillsOptions,
|
|
17
|
+
InstallSkillsResult,
|
|
18
|
+
} from '../engine/skills/index.js';
|
|
19
|
+
import {createCli} from '../cli.js';
|
|
20
|
+
import {type SkillsInstaller, registerInit} from './init.js';
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Drives `picopilot init <dir>` through incur's `serve` DI, capturing stdout and
|
|
24
|
+
* the exit code without touching the real process env. The command writes only
|
|
25
|
+
* inside `dir` (a temp dir here), so this exercises the real scaffold I/O in
|
|
26
|
+
* isolation.
|
|
27
|
+
*/
|
|
28
|
+
async function runInit(argv: string[]) {
|
|
29
|
+
let stdout = '';
|
|
30
|
+
let exitCode = 0;
|
|
31
|
+
await createCli().serve(argv, {
|
|
32
|
+
stdout(s) {
|
|
33
|
+
stdout += s;
|
|
34
|
+
},
|
|
35
|
+
exit(code) {
|
|
36
|
+
exitCode = code;
|
|
37
|
+
},
|
|
38
|
+
// An EMPTY env: proves init needs nothing from the environment and, with a
|
|
39
|
+
// real command, that no shared-dir env (HOME/skills) is consulted.
|
|
40
|
+
env: {},
|
|
41
|
+
});
|
|
42
|
+
return {stdout, exitCode};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
let dir: string;
|
|
46
|
+
|
|
47
|
+
beforeEach(() => {
|
|
48
|
+
dir = mkdtempSync(join(tmpdir(), 'picopilot-init-'));
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
afterEach(() => {
|
|
52
|
+
// Best-effort cleanup; a leftover temp dir must never fail the suite.
|
|
53
|
+
try {
|
|
54
|
+
execFileSync('rm', ['-rf', dir]);
|
|
55
|
+
} catch {
|
|
56
|
+
// ignore
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
describe('picopilot init: scaffold output', () => {
|
|
61
|
+
it('writes EXACTLY the four scaffold files, nothing else', async () => {
|
|
62
|
+
const {exitCode} = await runInit(['init', dir]);
|
|
63
|
+
expect(exitCode).toBe(0);
|
|
64
|
+
expect(readdirSync(dir).sort()).toEqual(
|
|
65
|
+
['AGENTS.md', 'main.lua', 'main.p8', 'picopilot.json'].sort(),
|
|
66
|
+
);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('reports the written files and the absolute dir', async () => {
|
|
70
|
+
const {stdout} = await runInit(['init', dir]);
|
|
71
|
+
expect(stdout).toContain('main.p8');
|
|
72
|
+
expect(stdout).toContain('main.lua');
|
|
73
|
+
expect(stdout).toContain('AGENTS.md');
|
|
74
|
+
expect(stdout).toContain('picopilot.json');
|
|
75
|
+
expect(stdout).toContain(dir);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('main.p8 is a valid cart whose only code is `#include main.lua`', async () => {
|
|
79
|
+
await runInit(['init', dir]);
|
|
80
|
+
const text = readFileSync(join(dir, 'main.p8'), 'utf8');
|
|
81
|
+
const cart = Cart.parse(text);
|
|
82
|
+
// Round-trips through the cart model (identity).
|
|
83
|
+
expect(cart.serialize()).toBe(text);
|
|
84
|
+
// __lua__ holds exactly the include line and nothing else.
|
|
85
|
+
expect(cart.getSection('lua')?.trim()).toBe('#include main.lua');
|
|
86
|
+
// No hand-written binary sections in a fresh cart.
|
|
87
|
+
expect(cart.hasSection('gfx')).toBe(false);
|
|
88
|
+
expect(cart.hasSection('map')).toBe(false);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('main.lua is the plain-Lua edit surface with the _init/_update/_draw loop', async () => {
|
|
92
|
+
await runInit(['init', dir]);
|
|
93
|
+
const lua = readFileSync(join(dir, 'main.lua'), 'utf8');
|
|
94
|
+
expect(lua).toContain('function _init()');
|
|
95
|
+
expect(lua).toContain('function _update()');
|
|
96
|
+
expect(lua).toContain('function _draw()');
|
|
97
|
+
// main.lua must NOT be a .p8 (no cart header / section markers).
|
|
98
|
+
expect(lua).not.toContain('pico-8 cartridge');
|
|
99
|
+
expect(lua).not.toContain('__lua__');
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
describe('picopilot init: AGENTS.md carries the curated PICO-8 reference', () => {
|
|
104
|
+
let agents: string;
|
|
105
|
+
|
|
106
|
+
beforeEach(async () => {
|
|
107
|
+
await runInit(['init', dir]);
|
|
108
|
+
agents = readFileSync(join(dir, 'AGENTS.md'), 'utf8');
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('carries the execution loop', () => {
|
|
112
|
+
expect(agents).toContain('_init()');
|
|
113
|
+
expect(agents).toContain('_update()');
|
|
114
|
+
expect(agents).toContain('_draw()');
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('carries the build loop with a MANDATORY see-it-run step (not just static verify)', () => {
|
|
118
|
+
// The loop names verify AND run/playtest, and is explicit that a green
|
|
119
|
+
// static gate is not "done": the weak-model failure was shipping on verify.
|
|
120
|
+
expect(agents).toContain('picopilot verify');
|
|
121
|
+
expect(agents).toContain('picopilot run');
|
|
122
|
+
expect(agents).toContain('picopilot playtest run');
|
|
123
|
+
expect(agents.toLowerCase()).toContain('definition of done');
|
|
124
|
+
// It hands the model the concrete probe recipe so "see it" is actionable.
|
|
125
|
+
expect(agents).toContain('extcmd("screen")');
|
|
126
|
+
expect(agents).toContain('__PICOPILOT_DONE__');
|
|
127
|
+
// A bare timeout is explicitly NOT proof it works.
|
|
128
|
+
expect(agents.toLowerCase()).toContain('timeout');
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('hands the finished cart off via `pico8 -run`, NOT `serve`', () => {
|
|
132
|
+
// The user runs the cart with pico8 -run; serve is not the self-verify path.
|
|
133
|
+
expect(agents).toContain('pico8 -run');
|
|
134
|
+
// serve is called out as sharing-only (browser build), not proof-of-build.
|
|
135
|
+
expect(agents).toContain('picopilot serve');
|
|
136
|
+
expect(agents.toLowerCase()).toContain('do not emit extra deliverables');
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('carries the fixed 16-colour palette with real RGB values', () => {
|
|
140
|
+
// A palette entry the gfx render encoder depends on (red = 255,0,77).
|
|
141
|
+
expect(agents).toMatch(/red/);
|
|
142
|
+
expect(agents).toContain('255');
|
|
143
|
+
expect(agents).toContain('77');
|
|
144
|
+
// The transparent/index legend.
|
|
145
|
+
expect(agents).toMatch(/`\.`\s*=\s*transparent/);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it('carries the memory map AND the gfx/map overlap warning', () => {
|
|
149
|
+
expect(agents).toContain('0x1000');
|
|
150
|
+
expect(agents).toContain('0x6000');
|
|
151
|
+
expect(agents.toLowerCase()).toContain('overlap');
|
|
152
|
+
expect(agents).toContain('128..255');
|
|
153
|
+
expect(agents).toMatch(/__map__` rows 32\.\.63/);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it('carries the most-used, grouped API', () => {
|
|
157
|
+
expect(agents).toContain('spr(');
|
|
158
|
+
expect(agents).toContain('btn(');
|
|
159
|
+
expect(agents).toContain('sfx(');
|
|
160
|
+
expect(agents).toContain('printh');
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it('carries the silent-gotcha section (turns-not-radians, integer divide, draw-state persists)', () => {
|
|
164
|
+
// sin/cos take turns and sin is inverted (the #1 math trap).
|
|
165
|
+
expect(agents).toContain('TURNS');
|
|
166
|
+
expect(agents.toLowerCase()).toContain('inverted');
|
|
167
|
+
// Integer divide is backslash, not `/`.
|
|
168
|
+
expect(agents).toContain('5\\2');
|
|
169
|
+
// Draw state persists across frames.
|
|
170
|
+
expect(agents.toLowerCase()).toContain('persists');
|
|
171
|
+
// del-by-value vs deli-by-index.
|
|
172
|
+
expect(agents).toContain('deli(');
|
|
173
|
+
// Points the agent at the code skill's genre references.
|
|
174
|
+
expect(agents).toContain('picopilot-code');
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it('carries the token discipline (8192 budget + shorthands)', () => {
|
|
178
|
+
expect(agents).toContain('8192');
|
|
179
|
+
expect(agents).toContain('picopilot tokens');
|
|
180
|
+
expect(agents).toContain('picopilot minify');
|
|
181
|
+
// A PICO-8 shorthand the reference teaches.
|
|
182
|
+
expect(agents).toContain('+=');
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it('carries the `#include` discipline (edit main.lua, not the binary sections)', () => {
|
|
186
|
+
expect(agents).toContain('#include main.lua');
|
|
187
|
+
expect(agents).toContain('main.lua');
|
|
188
|
+
});
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
describe('picopilot init: INSTRUCT, not mutate (no VCS, prints tips)', () => {
|
|
192
|
+
it('does NOT run git init: no .git in the scaffolded folder', async () => {
|
|
193
|
+
await runInit(['init', dir]);
|
|
194
|
+
expect(existsSync(join(dir, '.git'))).toBe(false);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it('PRINTS a skills-discovery tip and a git init tip', async () => {
|
|
198
|
+
const {stdout} = await runInit(['init', dir]);
|
|
199
|
+
expect(stdout.toLowerCase()).toContain('skills');
|
|
200
|
+
expect(stdout).toContain('--install-skills');
|
|
201
|
+
expect(stdout).toContain('git init');
|
|
202
|
+
});
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
describe('picopilot init: shared-write isolation (default has no shared write)', () => {
|
|
206
|
+
it('writes NOTHING outside the target folder', async () => {
|
|
207
|
+
// Sentinels OUTSIDE the cart dir: a sibling temp dir standing in for the
|
|
208
|
+
// real home / a shared config location. Default `init` must not touch them.
|
|
209
|
+
const outside = mkdtempSync(join(tmpdir(), 'picopilot-outside-'));
|
|
210
|
+
const sentinel = join(outside, 'DO-NOT-TOUCH');
|
|
211
|
+
writeFileSync(sentinel, 'untouched');
|
|
212
|
+
const beforeOutside = readdirSync(outside).sort();
|
|
213
|
+
|
|
214
|
+
try {
|
|
215
|
+
await runInit(['init', dir]);
|
|
216
|
+
// The sentinel folder is byte-for-byte the same (no new files, sentinel intact).
|
|
217
|
+
expect(readdirSync(outside).sort()).toEqual(beforeOutside);
|
|
218
|
+
expect(readFileSync(sentinel, 'utf8')).toBe('untouched');
|
|
219
|
+
// And the only new entries are inside the cart dir.
|
|
220
|
+
expect(readdirSync(dir).sort()).toEqual(
|
|
221
|
+
['AGENTS.md', 'main.lua', 'main.p8', 'picopilot.json'].sort(),
|
|
222
|
+
);
|
|
223
|
+
} finally {
|
|
224
|
+
execFileSync('rm', ['-rf', outside]);
|
|
225
|
+
}
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it('refuses to overwrite an existing scaffold file (nonzero exit, no clobber)', async () => {
|
|
229
|
+
const existing = join(dir, 'main.lua');
|
|
230
|
+
writeFileSync(existing, 'MY WORK, do not clobber');
|
|
231
|
+
const {exitCode} = await runInit(['init', dir]);
|
|
232
|
+
expect(exitCode).not.toBe(0);
|
|
233
|
+
// The pre-existing file is untouched.
|
|
234
|
+
expect(readFileSync(existing, 'utf8')).toBe('MY WORK, do not clobber');
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it('DEFAULT init calls NO installer (the shared write is opt-in only)', async () => {
|
|
238
|
+
// The regression guard for the isolation scoping: the shared-write path must
|
|
239
|
+
// be reachable ONLY via --install-skills. A default `init` must never invoke
|
|
240
|
+
// the installer, so a spy installer that fails the test if called proves it.
|
|
241
|
+
let called = false;
|
|
242
|
+
const spy: SkillsInstaller = async () => {
|
|
243
|
+
called = true;
|
|
244
|
+
return {paths: [], agents: [], skills: []};
|
|
245
|
+
};
|
|
246
|
+
const {exitCode} = await runInitWith(['init', dir], spy);
|
|
247
|
+
expect(exitCode).toBe(0);
|
|
248
|
+
expect(called).toBe(false);
|
|
249
|
+
});
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Builds a picopilot-shaped CLI whose `init` uses an INJECTED installer, then
|
|
254
|
+
* drives it through incur's `serve` DI. This is how the --install-skills tests
|
|
255
|
+
* exercise the opt-in shared write without ever performing a real global
|
|
256
|
+
* install: the injected installer redirects the target (or records the call).
|
|
257
|
+
*/
|
|
258
|
+
async function runInitWith(argv: string[], installer: SkillsInstaller) {
|
|
259
|
+
const cli = Cli.create('picopilot', {version: '0.0.0', description: 'test'});
|
|
260
|
+
registerInit(cli, installer);
|
|
261
|
+
let stdout = '';
|
|
262
|
+
let exitCode = 0;
|
|
263
|
+
await cli.serve(argv, {
|
|
264
|
+
stdout(s) {
|
|
265
|
+
stdout += s;
|
|
266
|
+
},
|
|
267
|
+
exit(code) {
|
|
268
|
+
exitCode = code;
|
|
269
|
+
},
|
|
270
|
+
env: {},
|
|
271
|
+
});
|
|
272
|
+
return {stdout, exitCode};
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
describe('picopilot init --install-skills: the opt-in shared write', () => {
|
|
276
|
+
it('runs the installer and reports the installed skills + wired agents', async () => {
|
|
277
|
+
const calls: InstallSkillsOptions[] = [];
|
|
278
|
+
const installer: SkillsInstaller = async (
|
|
279
|
+
opts,
|
|
280
|
+
): Promise<InstallSkillsResult> => {
|
|
281
|
+
calls.push(opts);
|
|
282
|
+
return {
|
|
283
|
+
paths: [join(dir, '.agents', 'skills', 'picopilot-overview')],
|
|
284
|
+
agents: [
|
|
285
|
+
{
|
|
286
|
+
agent: 'Claude Code',
|
|
287
|
+
path: join(dir, '.claude', 'skills'),
|
|
288
|
+
mode: 'symlink',
|
|
289
|
+
},
|
|
290
|
+
],
|
|
291
|
+
skills: ['picopilot-overview', 'picopilot-art'],
|
|
292
|
+
};
|
|
293
|
+
};
|
|
294
|
+
|
|
295
|
+
const {stdout, exitCode} = await runInitWith(
|
|
296
|
+
['init', dir, '--install-skills', '--json'],
|
|
297
|
+
installer,
|
|
298
|
+
);
|
|
299
|
+
expect(exitCode).toBe(0);
|
|
300
|
+
// The installer WAS invoked (default is a GLOBAL install, so global=true).
|
|
301
|
+
expect(calls).toHaveLength(1);
|
|
302
|
+
expect(calls[0]?.global).toBe(true);
|
|
303
|
+
|
|
304
|
+
const out = JSON.parse(stdout) as {
|
|
305
|
+
installedSkills?: string[];
|
|
306
|
+
wiredAgents?: string[];
|
|
307
|
+
tips: string[];
|
|
308
|
+
};
|
|
309
|
+
expect(out.installedSkills).toEqual([
|
|
310
|
+
'picopilot-overview',
|
|
311
|
+
'picopilot-art',
|
|
312
|
+
]);
|
|
313
|
+
expect(out.wiredAgents).toEqual(['Claude Code']);
|
|
314
|
+
// The scaffold still happened alongside the install.
|
|
315
|
+
expect(readdirSync(dir).sort()).toEqual(
|
|
316
|
+
['AGENTS.md', 'main.lua', 'main.p8', 'picopilot.json'].sort(),
|
|
317
|
+
);
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
it('DEDUPES wiredAgents (incur returns one entry per skill x agent)', async () => {
|
|
321
|
+
// incur's install.agents has one entry per (skill, agent) symlink, so a
|
|
322
|
+
// multi-skill install repeats each agent name (see
|
|
323
|
+
// work/notes/observations/incur-install-agents-duplicated-per-skill.md).
|
|
324
|
+
// wiredAgents is the SET of agent names, so it must collapse duplicates.
|
|
325
|
+
const installer: SkillsInstaller = async () => ({
|
|
326
|
+
paths: [
|
|
327
|
+
join(dir, '.agents', 'skills', 'picopilot-overview'),
|
|
328
|
+
join(dir, '.agents', 'skills', 'picopilot-code'),
|
|
329
|
+
],
|
|
330
|
+
// Two skills x two agents = four entries, agent names repeated.
|
|
331
|
+
agents: [
|
|
332
|
+
{agent: 'Claude Code', path: join(dir, 'a1'), mode: 'symlink'},
|
|
333
|
+
{agent: 'Kilo', path: join(dir, 'a2'), mode: 'symlink'},
|
|
334
|
+
{agent: 'Claude Code', path: join(dir, 'a3'), mode: 'symlink'},
|
|
335
|
+
{agent: 'Kilo', path: join(dir, 'a4'), mode: 'symlink'},
|
|
336
|
+
],
|
|
337
|
+
skills: ['picopilot-overview', 'picopilot-code'],
|
|
338
|
+
});
|
|
339
|
+
const {stdout, exitCode} = await runInitWith(
|
|
340
|
+
['init', dir, '--install-skills', '--json'],
|
|
341
|
+
installer,
|
|
342
|
+
);
|
|
343
|
+
expect(exitCode).toBe(0);
|
|
344
|
+
const out = JSON.parse(stdout) as {wiredAgents?: string[]};
|
|
345
|
+
// Deduped to the unique set, order preserved.
|
|
346
|
+
expect(out.wiredAgents).toEqual(['Claude Code', 'Kilo']);
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
it('--no-global redirects the install into THIS project folder (the temp cart dir)', async () => {
|
|
350
|
+
const calls: InstallSkillsOptions[] = [];
|
|
351
|
+
const installer: SkillsInstaller = async (opts) => {
|
|
352
|
+
calls.push(opts);
|
|
353
|
+
return {paths: [], agents: [], skills: ['picopilot-overview']};
|
|
354
|
+
};
|
|
355
|
+
const {exitCode} = await runInitWith(
|
|
356
|
+
['init', dir, '--install-skills', '--no-global'],
|
|
357
|
+
installer,
|
|
358
|
+
);
|
|
359
|
+
expect(exitCode).toBe(0);
|
|
360
|
+
// --no-global → project install; cwd is the scaffolded cart folder, so a
|
|
361
|
+
// project install can never escape to a shared dir.
|
|
362
|
+
expect(calls[0]?.global).toBe(false);
|
|
363
|
+
expect(calls[0]?.cwd).toBe(dir);
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
it('prints the manual/symlink FALLBACK when no agent was wired', async () => {
|
|
367
|
+
const installer: SkillsInstaller = async () => ({
|
|
368
|
+
paths: [join(dir, '.agents', 'skills', 'picopilot-overview')],
|
|
369
|
+
agents: [],
|
|
370
|
+
skills: ['picopilot-overview'],
|
|
371
|
+
});
|
|
372
|
+
const {stdout, exitCode} = await runInitWith(
|
|
373
|
+
['init', dir, '--install-skills'],
|
|
374
|
+
installer,
|
|
375
|
+
);
|
|
376
|
+
expect(exitCode).toBe(0);
|
|
377
|
+
// No known agent detected → tell the user how to wire it manually (symlink),
|
|
378
|
+
// the printed-instructions fallback for unsupported agents.
|
|
379
|
+
expect(stdout.toLowerCase()).toContain('symlink');
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
it('surfaces a STRUCTURED failure (nonzero) when the install fails, not a crash', async () => {
|
|
383
|
+
const installer: SkillsInstaller = async () => {
|
|
384
|
+
throw new Error('boom from incur');
|
|
385
|
+
};
|
|
386
|
+
const {stdout, exitCode} = await runInitWith(
|
|
387
|
+
['init', dir, '--install-skills'],
|
|
388
|
+
installer,
|
|
389
|
+
);
|
|
390
|
+
expect(exitCode).not.toBe(0);
|
|
391
|
+
// The scaffold still succeeded and the message points at the manual path.
|
|
392
|
+
expect(readdirSync(dir)).toContain('main.p8');
|
|
393
|
+
expect(stdout.toLowerCase()).toMatch(/skills add|symlink/);
|
|
394
|
+
});
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
describe('picopilot.json: schema shape + incur config read (argv > config > default)', () => {
|
|
398
|
+
it('scaffolds a valid picopilot.json carrying allowMapOverlap = false', async () => {
|
|
399
|
+
await runInit(['init', dir]);
|
|
400
|
+
const raw = readFileSync(join(dir, 'picopilot.json'), 'utf8');
|
|
401
|
+
const parsed = JSON.parse(raw);
|
|
402
|
+
// The gfx/map-overlap authorisation key lives where incur's config layer
|
|
403
|
+
// reads a `gfx set` option default.
|
|
404
|
+
expect(parsed.commands.gfx.commands.set.options.allowMapOverlap).toBe(
|
|
405
|
+
false,
|
|
406
|
+
);
|
|
407
|
+
// The reader helper agrees.
|
|
408
|
+
expect(readAllowMapOverlap(parsed)).toBe(false);
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
it('feeds incur config → option default (config overrides zod default)', async () => {
|
|
412
|
+
await runInit(['init', dir]);
|
|
413
|
+
// Hand-set the config to true, mirroring an agent authorising overlap.
|
|
414
|
+
const cfgPath = join(dir, 'picopilot.json');
|
|
415
|
+
const cfg = JSON.parse(readFileSync(cfgPath, 'utf8'));
|
|
416
|
+
cfg.commands.gfx.commands.set.options.allowMapOverlap = true;
|
|
417
|
+
writeFileSync(cfgPath, `${JSON.stringify(cfg, null, 2)}\n`);
|
|
418
|
+
|
|
419
|
+
const seen = await readOverlapViaIncur(cfgPath);
|
|
420
|
+
// zod default is false; the config file wins.
|
|
421
|
+
expect(seen).toBe(true);
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
it('argv beats config (argv > config)', async () => {
|
|
425
|
+
await runInit(['init', dir]);
|
|
426
|
+
// Scaffolded config is false; --no-allow-map-overlap-style not needed:
|
|
427
|
+
// pass the flag true on argv while config stays false, argv must win... and
|
|
428
|
+
// the reverse: config true, flag false.
|
|
429
|
+
const cfgPath = join(dir, 'picopilot.json');
|
|
430
|
+
const cfg = JSON.parse(readFileSync(cfgPath, 'utf8'));
|
|
431
|
+
cfg.commands.gfx.commands.set.options.allowMapOverlap = true;
|
|
432
|
+
writeFileSync(cfgPath, `${JSON.stringify(cfg, null, 2)}\n`);
|
|
433
|
+
|
|
434
|
+
// config = true, but argv explicitly disables -> argv wins (false).
|
|
435
|
+
const seen = await readOverlapViaIncur(cfgPath, ['--no-allowMapOverlap']);
|
|
436
|
+
expect(seen).toBe(false);
|
|
437
|
+
});
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
/**
|
|
441
|
+
* Builds a throwaway CLI whose only command mirrors how `gfx set` will read
|
|
442
|
+
* `allowMapOverlap`: a boolean option defaulting to false, resolved through
|
|
443
|
+
* incur's config layer against the scaffolded `picopilot.json`. Proves the
|
|
444
|
+
* scaffolded file's SHAPE actually drives incur's `argv > config > default`
|
|
445
|
+
* precedence, not just that a helper can parse it.
|
|
446
|
+
*/
|
|
447
|
+
async function readOverlapViaIncur(
|
|
448
|
+
configPath: string,
|
|
449
|
+
extraArgv: string[] = [],
|
|
450
|
+
): Promise<boolean> {
|
|
451
|
+
const cli = Cli.create('picopilot', {
|
|
452
|
+
version: '0.0.0',
|
|
453
|
+
config: {flag: 'config', files: ['picopilot.json']},
|
|
454
|
+
});
|
|
455
|
+
// Mount `gfx set` so its config path is commands.gfx.commands.set.options.
|
|
456
|
+
const gfx = Cli.create('gfx');
|
|
457
|
+
gfx.command('set', {
|
|
458
|
+
description: 'test stand-in for the real gfx set',
|
|
459
|
+
options: z.object({
|
|
460
|
+
allowMapOverlap: z.boolean().default(false),
|
|
461
|
+
}),
|
|
462
|
+
output: z.object({allowMapOverlap: z.boolean()}),
|
|
463
|
+
run({options}) {
|
|
464
|
+
return {allowMapOverlap: options.allowMapOverlap};
|
|
465
|
+
},
|
|
466
|
+
});
|
|
467
|
+
cli.command(gfx as never);
|
|
468
|
+
|
|
469
|
+
let stdout = '';
|
|
470
|
+
await cli.serve(
|
|
471
|
+
['gfx', 'set', '--config', configPath, ...extraArgv, '--json'],
|
|
472
|
+
{
|
|
473
|
+
stdout(s) {
|
|
474
|
+
stdout += s;
|
|
475
|
+
},
|
|
476
|
+
exit() {},
|
|
477
|
+
env: {},
|
|
478
|
+
},
|
|
479
|
+
);
|
|
480
|
+
const parsed = JSON.parse(stdout) as {allowMapOverlap: boolean};
|
|
481
|
+
return parsed.allowMapOverlap;
|
|
482
|
+
}
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import {existsSync, mkdirSync, writeFileSync} from 'node:fs';
|
|
2
|
+
import {isAbsolute, join, resolve} from 'node:path';
|
|
3
|
+
import {type Cli, z} from 'incur';
|
|
4
|
+
import {scaffoldFiles} from '../engine/scaffold/index.js';
|
|
5
|
+
import {
|
|
6
|
+
type InstallSkillsOptions,
|
|
7
|
+
type InstallSkillsResult,
|
|
8
|
+
installSkills,
|
|
9
|
+
} from '../engine/skills/index.js';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Injects the skills installer `init --install-skills` calls (defaults to the
|
|
13
|
+
* real {@link installSkills}). This is the shared-write isolation SEAM, mirroring
|
|
14
|
+
* how `tokens`/`verify` inject their shrinko adapter: a test passes an installer
|
|
15
|
+
* that redirects the target to a temp dir (via `global: false` + a temp `cwd`),
|
|
16
|
+
* so the load-bearing install test can assert the real agent skill dirs are
|
|
17
|
+
* byte-untouched without ever performing a real global write.
|
|
18
|
+
*/
|
|
19
|
+
export type SkillsInstaller = (
|
|
20
|
+
options: InstallSkillsOptions,
|
|
21
|
+
) => Promise<InstallSkillsResult>;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Registers `picopilot init`, the scaffolder that turns a fresh folder into an
|
|
25
|
+
* agent-ready PICO-8 cart (US #1, #2).
|
|
26
|
+
*
|
|
27
|
+
* INSTRUCT-not-mutate (US #2): by default `init` writes ONLY inside the target
|
|
28
|
+
* cart folder, the `main.p8` (`__lua__` = `#include main.lua`), the editable
|
|
29
|
+
* `main.lua`, the curated `AGENTS.md` reference, and `picopilot.json`. It does
|
|
30
|
+
* NOT run `git init` and does NOT write to shared agent skill dirs; it PRINTS a
|
|
31
|
+
* skills-discovery tip and a `git init` tip instead. The global skills install
|
|
32
|
+
* is the opt-in `init --install-skills`, a SEPARATE task, so the default is a
|
|
33
|
+
* good citizen of `~/.claude` / agent dirs and of the user's VCS choice.
|
|
34
|
+
*
|
|
35
|
+
* The opt-in `init --install-skills` (US #2, #20) IS the shared write: it runs
|
|
36
|
+
* incur's skills install (which defaults to a GLOBAL write into `~/.agents/skills`
|
|
37
|
+
* + detected agent dirs) so the discipline skills are auto-discovered. It is the
|
|
38
|
+
* only picopilot path that touches a shared location, so the installer is an
|
|
39
|
+
* injected seam and its target is fully redirectable for isolation-testing.
|
|
40
|
+
*
|
|
41
|
+
* @param installer injects the skills installer (defaults to the real global
|
|
42
|
+
* install); tests pass one that redirects the target to a temp dir.
|
|
43
|
+
*/
|
|
44
|
+
export function registerInit(
|
|
45
|
+
cli: Cli.Cli,
|
|
46
|
+
installer: SkillsInstaller = installSkills,
|
|
47
|
+
): void {
|
|
48
|
+
cli.command('init', {
|
|
49
|
+
description:
|
|
50
|
+
'Scaffold an agent-ready PICO-8 cart (main.p8 + main.lua + AGENTS.md + picopilot.json).',
|
|
51
|
+
args: z.object({
|
|
52
|
+
dir: z
|
|
53
|
+
.string()
|
|
54
|
+
.default('.')
|
|
55
|
+
.describe(
|
|
56
|
+
'Target cart folder (created if missing). Defaults to the current directory.',
|
|
57
|
+
),
|
|
58
|
+
}),
|
|
59
|
+
options: z.object({
|
|
60
|
+
installSkills: z
|
|
61
|
+
.boolean()
|
|
62
|
+
.default(false)
|
|
63
|
+
.describe(
|
|
64
|
+
"Opt in to INSTALLING picopilot's skills into your agent's skill dirs (a GLOBAL write). Default off: init only PRINTS how to discover them.",
|
|
65
|
+
),
|
|
66
|
+
global: z
|
|
67
|
+
.boolean()
|
|
68
|
+
.default(true)
|
|
69
|
+
.describe(
|
|
70
|
+
'With --install-skills, install GLOBALLY (default). Pass --no-global to install into THIS project (./.agents/skills) instead.',
|
|
71
|
+
),
|
|
72
|
+
}),
|
|
73
|
+
output: z.object({
|
|
74
|
+
dir: z
|
|
75
|
+
.string()
|
|
76
|
+
.describe('The absolute path of the scaffolded cart folder.'),
|
|
77
|
+
files: z
|
|
78
|
+
.array(z.string())
|
|
79
|
+
.describe('The cart-relative names of the files written.'),
|
|
80
|
+
tips: z
|
|
81
|
+
.array(z.string())
|
|
82
|
+
.describe(
|
|
83
|
+
'One-line next-step tips (skills discovery + git init). NOT actions init took.',
|
|
84
|
+
),
|
|
85
|
+
installedSkills: z
|
|
86
|
+
.array(z.string())
|
|
87
|
+
.optional()
|
|
88
|
+
.describe(
|
|
89
|
+
'Skills installed by --install-skills (absent unless the flag was passed).',
|
|
90
|
+
),
|
|
91
|
+
wiredAgents: z
|
|
92
|
+
.array(z.string())
|
|
93
|
+
.optional()
|
|
94
|
+
.describe(
|
|
95
|
+
'Agents whose skill dirs --install-skills wired up (absent unless flagged).',
|
|
96
|
+
),
|
|
97
|
+
}),
|
|
98
|
+
examples: [
|
|
99
|
+
{description: 'Scaffold in the current folder'},
|
|
100
|
+
{description: 'Scaffold into a new folder', args: {dir: 'my-game'}},
|
|
101
|
+
{
|
|
102
|
+
description: "Scaffold and install picopilot's skills globally",
|
|
103
|
+
options: {installSkills: true},
|
|
104
|
+
},
|
|
105
|
+
],
|
|
106
|
+
async run({args, options, error}) {
|
|
107
|
+
const targetDir = isAbsolute(args.dir)
|
|
108
|
+
? args.dir
|
|
109
|
+
: resolve(process.cwd(), args.dir);
|
|
110
|
+
|
|
111
|
+
const files = scaffoldFiles();
|
|
112
|
+
|
|
113
|
+
// Refuse rather than clobber: `init` is for a fresh cart. Overwriting an
|
|
114
|
+
// existing main.p8/main.lua would silently destroy the agent's work, so a
|
|
115
|
+
// pre-existing scaffold file is a structured refusal, not a surprise write.
|
|
116
|
+
const clash = files.find((f) => existsSync(join(targetDir, f.name)));
|
|
117
|
+
if (clash !== undefined) {
|
|
118
|
+
return error({
|
|
119
|
+
code: 'already-initialised',
|
|
120
|
+
message: `refusing to overwrite existing ${clash.name} in ${targetDir}; init is for a fresh cart folder`,
|
|
121
|
+
exitCode: 1,
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
mkdirSync(targetDir, {recursive: true});
|
|
126
|
+
for (const f of files) {
|
|
127
|
+
writeFileSync(join(targetDir, f.name), f.content);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// INSTRUCT, do not mutate: these are printed tips, not actions init took.
|
|
131
|
+
const tips = [
|
|
132
|
+
"Skills: make your agent discover picopilot's skills with `npx picopilot skills add` (or symlink them yourself); run `picopilot init --install-skills` to install them globally.",
|
|
133
|
+
'Version control: this folder is not a git repo. Run `git init` yourself if you want one. `init` deliberately does not touch VCS.',
|
|
134
|
+
];
|
|
135
|
+
|
|
136
|
+
// Default init is INSTRUCT-not-mutate: it never touches a shared dir. Only
|
|
137
|
+
// the explicit --install-skills opt-in performs the shared write.
|
|
138
|
+
if (!options.installSkills) {
|
|
139
|
+
return {
|
|
140
|
+
dir: targetDir,
|
|
141
|
+
files: files.map((f) => f.name),
|
|
142
|
+
tips,
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// The one shared write: install picopilot's skills. A project install
|
|
147
|
+
// (--no-global) lands under this cart folder; a global install lands in
|
|
148
|
+
// the real agent skill dirs. Failures surface structured, not as a crash,
|
|
149
|
+
// so the scaffold that already succeeded is not masked by an install slip.
|
|
150
|
+
let install: InstallSkillsResult;
|
|
151
|
+
try {
|
|
152
|
+
install = await installer({
|
|
153
|
+
cli,
|
|
154
|
+
global: options.global,
|
|
155
|
+
cwd: options.global ? undefined : targetDir,
|
|
156
|
+
});
|
|
157
|
+
} catch (e) {
|
|
158
|
+
return error({
|
|
159
|
+
code: 'install-skills-failed',
|
|
160
|
+
message: `scaffold succeeded in ${targetDir}, but installing skills failed: ${
|
|
161
|
+
e instanceof Error ? e.message : String(e)
|
|
162
|
+
}. Discover them manually instead: run \`picopilot skills add\` or symlink the skills yourself.`,
|
|
163
|
+
exitCode: 1,
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Printed-instructions fallback (US #2): if no known non-universal agent
|
|
168
|
+
// was detected, the canonical `.agents/skills` install still happened (the
|
|
169
|
+
// universal-agent path), but a specific agent may not read it, so tell the
|
|
170
|
+
// user how to wire it manually rather than pretending every agent is set.
|
|
171
|
+
// Dedup: incur's install.agents has one entry per (skill, agent) symlink
|
|
172
|
+
// (see work/notes/observations/incur-install-agents-duplicated-per-skill.md),
|
|
173
|
+
// so N skills x M agents yields N*M entries. wiredAgents is the SET of
|
|
174
|
+
// agent names, so collapse duplicates here (order-preserving).
|
|
175
|
+
const wiredAgents = [...new Set(install.agents.map((a) => a.agent))];
|
|
176
|
+
const fallbackTip =
|
|
177
|
+
wiredAgents.length === 0
|
|
178
|
+
? `Installed to ${install.paths[0] ?? '.agents/skills'}. No known agent skill dir was detected to wire directly; if your agent does not auto-discover .agents/skills, symlink these skills into its skills dir.`
|
|
179
|
+
: `Wired agents: ${wiredAgents.join(', ')}.`;
|
|
180
|
+
|
|
181
|
+
return {
|
|
182
|
+
dir: targetDir,
|
|
183
|
+
files: files.map((f) => f.name),
|
|
184
|
+
tips: [
|
|
185
|
+
...tips,
|
|
186
|
+
`Skills installed: ${install.skills.join(', ')}. ${fallbackTip}`,
|
|
187
|
+
],
|
|
188
|
+
installedSkills: install.skills,
|
|
189
|
+
wiredAgents,
|
|
190
|
+
};
|
|
191
|
+
},
|
|
192
|
+
});
|
|
193
|
+
}
|