picopilot 0.0.0 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (334) hide show
  1. package/LICENSE +661 -0
  2. package/dist/bin.d.ts +3 -0
  3. package/dist/bin.d.ts.map +1 -0
  4. package/dist/bin.js +4 -0
  5. package/dist/bin.js.map +1 -0
  6. package/dist/cli.d.ts +15 -0
  7. package/dist/cli.d.ts.map +1 -0
  8. package/dist/cli.js +59 -0
  9. package/dist/cli.js.map +1 -0
  10. package/dist/commands/audio.d.ts +17 -0
  11. package/dist/commands/audio.d.ts.map +1 -0
  12. package/dist/commands/audio.js +315 -0
  13. package/dist/commands/audio.js.map +1 -0
  14. package/dist/commands/export.d.ts +36 -0
  15. package/dist/commands/export.d.ts.map +1 -0
  16. package/dist/commands/export.js +257 -0
  17. package/dist/commands/export.js.map +1 -0
  18. package/dist/commands/gfx.d.ts +28 -0
  19. package/dist/commands/gfx.d.ts.map +1 -0
  20. package/dist/commands/gfx.js +521 -0
  21. package/dist/commands/gfx.js.map +1 -0
  22. package/dist/commands/init.d.ts +34 -0
  23. package/dist/commands/init.d.ts.map +1 -0
  24. package/dist/commands/init.js +151 -0
  25. package/dist/commands/init.js.map +1 -0
  26. package/dist/commands/lint.d.ts +20 -0
  27. package/dist/commands/lint.d.ts.map +1 -0
  28. package/dist/commands/lint.js +90 -0
  29. package/dist/commands/lint.js.map +1 -0
  30. package/dist/commands/minify.d.ts +20 -0
  31. package/dist/commands/minify.d.ts.map +1 -0
  32. package/dist/commands/minify.js +129 -0
  33. package/dist/commands/minify.js.map +1 -0
  34. package/dist/commands/music.d.ts +13 -0
  35. package/dist/commands/music.d.ts.map +1 -0
  36. package/dist/commands/music.js +241 -0
  37. package/dist/commands/music.js.map +1 -0
  38. package/dist/commands/playtest.d.ts +29 -0
  39. package/dist/commands/playtest.d.ts.map +1 -0
  40. package/dist/commands/playtest.js +631 -0
  41. package/dist/commands/playtest.js.map +1 -0
  42. package/dist/commands/run.d.ts +30 -0
  43. package/dist/commands/run.d.ts.map +1 -0
  44. package/dist/commands/run.js +202 -0
  45. package/dist/commands/run.js.map +1 -0
  46. package/dist/commands/serve.d.ts +50 -0
  47. package/dist/commands/serve.d.ts.map +1 -0
  48. package/dist/commands/serve.js +213 -0
  49. package/dist/commands/serve.js.map +1 -0
  50. package/dist/commands/sfx.d.ts +13 -0
  51. package/dist/commands/sfx.d.ts.map +1 -0
  52. package/dist/commands/sfx.js +254 -0
  53. package/dist/commands/sfx.js.map +1 -0
  54. package/dist/commands/tokens.d.ts +36 -0
  55. package/dist/commands/tokens.d.ts.map +1 -0
  56. package/dist/commands/tokens.js +129 -0
  57. package/dist/commands/tokens.js.map +1 -0
  58. package/dist/commands/verify.d.ts +57 -0
  59. package/dist/commands/verify.d.ts.map +1 -0
  60. package/dist/commands/verify.js +213 -0
  61. package/dist/commands/verify.js.map +1 -0
  62. package/dist/commands/version.d.ts +12 -0
  63. package/dist/commands/version.d.ts.map +1 -0
  64. package/dist/commands/version.js +24 -0
  65. package/dist/commands/version.js.map +1 -0
  66. package/dist/engine/audio/index.d.ts +14 -0
  67. package/dist/engine/audio/index.d.ts.map +1 -0
  68. package/dist/engine/audio/index.js +14 -0
  69. package/dist/engine/audio/index.js.map +1 -0
  70. package/dist/engine/audio/music.d.ts +116 -0
  71. package/dist/engine/audio/music.d.ts.map +1 -0
  72. package/dist/engine/audio/music.js +138 -0
  73. package/dist/engine/audio/music.js.map +1 -0
  74. package/dist/engine/audio/section.d.ts +34 -0
  75. package/dist/engine/audio/section.d.ts.map +1 -0
  76. package/dist/engine/audio/section.js +67 -0
  77. package/dist/engine/audio/section.js.map +1 -0
  78. package/dist/engine/audio/sfx.d.ts +141 -0
  79. package/dist/engine/audio/sfx.d.ts.map +1 -0
  80. package/dist/engine/audio/sfx.js +506 -0
  81. package/dist/engine/audio/sfx.js.map +1 -0
  82. package/dist/engine/cart/cart.d.ts +75 -0
  83. package/dist/engine/cart/cart.d.ts.map +1 -0
  84. package/dist/engine/cart/cart.js +153 -0
  85. package/dist/engine/cart/cart.js.map +1 -0
  86. package/dist/engine/cart/errors.d.ts +29 -0
  87. package/dist/engine/cart/errors.d.ts.map +1 -0
  88. package/dist/engine/cart/errors.js +26 -0
  89. package/dist/engine/cart/errors.js.map +1 -0
  90. package/dist/engine/cart/gfx.d.ts +79 -0
  91. package/dist/engine/cart/gfx.d.ts.map +1 -0
  92. package/dist/engine/cart/gfx.js +171 -0
  93. package/dist/engine/cart/gfx.js.map +1 -0
  94. package/dist/engine/cart/index.d.ts +15 -0
  95. package/dist/engine/cart/index.d.ts.map +1 -0
  96. package/dist/engine/cart/index.js +15 -0
  97. package/dist/engine/cart/index.js.map +1 -0
  98. package/dist/engine/cart/map.d.ts +46 -0
  99. package/dist/engine/cart/map.d.ts.map +1 -0
  100. package/dist/engine/cart/map.js +118 -0
  101. package/dist/engine/cart/map.js.map +1 -0
  102. package/dist/engine/config/config.d.ts +74 -0
  103. package/dist/engine/config/config.d.ts.map +1 -0
  104. package/dist/engine/config/config.js +103 -0
  105. package/dist/engine/config/config.js.map +1 -0
  106. package/dist/engine/config/index.d.ts +7 -0
  107. package/dist/engine/config/index.d.ts.map +1 -0
  108. package/dist/engine/config/index.js +7 -0
  109. package/dist/engine/config/index.js.map +1 -0
  110. package/dist/engine/gfx/grid.d.ts +51 -0
  111. package/dist/engine/gfx/grid.d.ts.map +1 -0
  112. package/dist/engine/gfx/grid.js +116 -0
  113. package/dist/engine/gfx/grid.js.map +1 -0
  114. package/dist/engine/gfx/index.d.ts +17 -0
  115. package/dist/engine/gfx/index.d.ts.map +1 -0
  116. package/dist/engine/gfx/index.js +17 -0
  117. package/dist/engine/gfx/index.js.map +1 -0
  118. package/dist/engine/gfx/label.d.ts +34 -0
  119. package/dist/engine/gfx/label.d.ts.map +1 -0
  120. package/dist/engine/gfx/label.js +190 -0
  121. package/dist/engine/gfx/label.js.map +1 -0
  122. package/dist/engine/gfx/overlap.d.ts +42 -0
  123. package/dist/engine/gfx/overlap.d.ts.map +1 -0
  124. package/dist/engine/gfx/overlap.js +57 -0
  125. package/dist/engine/gfx/overlap.js.map +1 -0
  126. package/dist/engine/gfx/palette.d.ts +29 -0
  127. package/dist/engine/gfx/palette.d.ts.map +1 -0
  128. package/dist/engine/gfx/palette.js +46 -0
  129. package/dist/engine/gfx/palette.js.map +1 -0
  130. package/dist/engine/gfx/png.d.ts +31 -0
  131. package/dist/engine/gfx/png.d.ts.map +1 -0
  132. package/dist/engine/gfx/png.js +165 -0
  133. package/dist/engine/gfx/png.js.map +1 -0
  134. package/dist/engine/gfx/render.d.ts +42 -0
  135. package/dist/engine/gfx/render.d.ts.map +1 -0
  136. package/dist/engine/gfx/render.js +72 -0
  137. package/dist/engine/gfx/render.js.map +1 -0
  138. package/dist/engine/index.d.ts +22 -0
  139. package/dist/engine/index.d.ts.map +1 -0
  140. package/dist/engine/index.js +22 -0
  141. package/dist/engine/index.js.map +1 -0
  142. package/dist/engine/pico8/adapter.d.ts +267 -0
  143. package/dist/engine/pico8/adapter.d.ts.map +1 -0
  144. package/dist/engine/pico8/adapter.js +37 -0
  145. package/dist/engine/pico8/adapter.js.map +1 -0
  146. package/dist/engine/pico8/drive.d.ts +308 -0
  147. package/dist/engine/pico8/drive.d.ts.map +1 -0
  148. package/dist/engine/pico8/drive.js +581 -0
  149. package/dist/engine/pico8/drive.js.map +1 -0
  150. package/dist/engine/pico8/harness.d.ts +146 -0
  151. package/dist/engine/pico8/harness.d.ts.map +1 -0
  152. package/dist/engine/pico8/harness.js +251 -0
  153. package/dist/engine/pico8/harness.js.map +1 -0
  154. package/dist/engine/pico8/index.d.ts +21 -0
  155. package/dist/engine/pico8/index.d.ts.map +1 -0
  156. package/dist/engine/pico8/index.js +21 -0
  157. package/dist/engine/pico8/index.js.map +1 -0
  158. package/dist/engine/pico8/sentinel.d.ts +63 -0
  159. package/dist/engine/pico8/sentinel.d.ts.map +1 -0
  160. package/dist/engine/pico8/sentinel.js +86 -0
  161. package/dist/engine/pico8/sentinel.js.map +1 -0
  162. package/dist/engine/pico8/session-daemon-entry.d.ts +10 -0
  163. package/dist/engine/pico8/session-daemon-entry.d.ts.map +1 -0
  164. package/dist/engine/pico8/session-daemon-entry.js +11 -0
  165. package/dist/engine/pico8/session-daemon-entry.js.map +1 -0
  166. package/dist/engine/pico8/session-daemon-main.d.ts +44 -0
  167. package/dist/engine/pico8/session-daemon-main.d.ts.map +1 -0
  168. package/dist/engine/pico8/session-daemon-main.js +71 -0
  169. package/dist/engine/pico8/session-daemon-main.js.map +1 -0
  170. package/dist/engine/pico8/session-daemon.d.ts +68 -0
  171. package/dist/engine/pico8/session-daemon.d.ts.map +1 -0
  172. package/dist/engine/pico8/session-daemon.js +264 -0
  173. package/dist/engine/pico8/session-daemon.js.map +1 -0
  174. package/dist/engine/pico8/session.d.ts +140 -0
  175. package/dist/engine/pico8/session.d.ts.map +1 -0
  176. package/dist/engine/pico8/session.js +250 -0
  177. package/dist/engine/pico8/session.js.map +1 -0
  178. package/dist/engine/pico8/shell.d.ts +103 -0
  179. package/dist/engine/pico8/shell.d.ts.map +1 -0
  180. package/dist/engine/pico8/shell.js +324 -0
  181. package/dist/engine/pico8/shell.js.map +1 -0
  182. package/dist/engine/pico8/supervisor.d.ts +137 -0
  183. package/dist/engine/pico8/supervisor.d.ts.map +1 -0
  184. package/dist/engine/pico8/supervisor.js +140 -0
  185. package/dist/engine/pico8/supervisor.js.map +1 -0
  186. package/dist/engine/scaffold/index.d.ts +8 -0
  187. package/dist/engine/scaffold/index.d.ts.map +1 -0
  188. package/dist/engine/scaffold/index.js +8 -0
  189. package/dist/engine/scaffold/index.js.map +1 -0
  190. package/dist/engine/scaffold/scaffold.d.ts +49 -0
  191. package/dist/engine/scaffold/scaffold.d.ts.map +1 -0
  192. package/dist/engine/scaffold/scaffold.js +217 -0
  193. package/dist/engine/scaffold/scaffold.js.map +1 -0
  194. package/dist/engine/shrinko/adapter.d.ts +167 -0
  195. package/dist/engine/shrinko/adapter.d.ts.map +1 -0
  196. package/dist/engine/shrinko/adapter.js +107 -0
  197. package/dist/engine/shrinko/adapter.js.map +1 -0
  198. package/dist/engine/shrinko/index.d.ts +13 -0
  199. package/dist/engine/shrinko/index.d.ts.map +1 -0
  200. package/dist/engine/shrinko/index.js +13 -0
  201. package/dist/engine/shrinko/index.js.map +1 -0
  202. package/dist/engine/shrinko/shell.d.ts +69 -0
  203. package/dist/engine/shrinko/shell.d.ts.map +1 -0
  204. package/dist/engine/shrinko/shell.js +156 -0
  205. package/dist/engine/shrinko/shell.js.map +1 -0
  206. package/dist/engine/skills/index.d.ts +8 -0
  207. package/dist/engine/skills/index.d.ts.map +1 -0
  208. package/dist/engine/skills/index.js +8 -0
  209. package/dist/engine/skills/index.js.map +1 -0
  210. package/dist/engine/skills/skills.d.ts +104 -0
  211. package/dist/engine/skills/skills.d.ts.map +1 -0
  212. package/dist/engine/skills/skills.js +149 -0
  213. package/dist/engine/skills/skills.js.map +1 -0
  214. package/dist/index.d.ts +10 -0
  215. package/dist/index.d.ts.map +1 -0
  216. package/dist/index.js +10 -0
  217. package/dist/index.js.map +1 -0
  218. package/dist/version.d.ts +2 -0
  219. package/dist/version.d.ts.map +1 -0
  220. package/dist/version.js +19 -0
  221. package/dist/version.js.map +1 -0
  222. package/package.json +48 -2
  223. package/src/bin.ts +4 -0
  224. package/src/cli.ts +61 -0
  225. package/src/commands/audio.test.ts +280 -0
  226. package/src/commands/audio.ts +410 -0
  227. package/src/commands/export.test.ts +310 -0
  228. package/src/commands/export.ts +316 -0
  229. package/src/commands/gfx-io.test.ts +178 -0
  230. package/src/commands/gfx.test.ts +470 -0
  231. package/src/commands/gfx.ts +645 -0
  232. package/src/commands/init.test.ts +460 -0
  233. package/src/commands/init.ts +193 -0
  234. package/src/commands/lint.test.ts +137 -0
  235. package/src/commands/lint.ts +117 -0
  236. package/src/commands/minify.test.ts +166 -0
  237. package/src/commands/minify.ts +156 -0
  238. package/src/commands/music.test.ts +272 -0
  239. package/src/commands/music.ts +288 -0
  240. package/src/commands/playtest.test.ts +317 -0
  241. package/src/commands/playtest.ts +773 -0
  242. package/src/commands/run.test.ts +238 -0
  243. package/src/commands/run.ts +259 -0
  244. package/src/commands/serve.test.ts +218 -0
  245. package/src/commands/serve.ts +272 -0
  246. package/src/commands/sfx.test.ts +349 -0
  247. package/src/commands/sfx.ts +300 -0
  248. package/src/commands/tokens.test.ts +212 -0
  249. package/src/commands/tokens.ts +168 -0
  250. package/src/commands/verify.test.ts +238 -0
  251. package/src/commands/verify.ts +256 -0
  252. package/src/commands/version.test.ts +70 -0
  253. package/src/commands/version.ts +24 -0
  254. package/src/engine/audio/index.ts +47 -0
  255. package/src/engine/audio/music.test.ts +188 -0
  256. package/src/engine/audio/music.ts +218 -0
  257. package/src/engine/audio/section.test.ts +124 -0
  258. package/src/engine/audio/section.ts +79 -0
  259. package/src/engine/audio/sfx.test.ts +410 -0
  260. package/src/engine/audio/sfx.ts +694 -0
  261. package/src/engine/cart/cart.test.ts +304 -0
  262. package/src/engine/cart/cart.ts +211 -0
  263. package/src/engine/cart/errors.ts +39 -0
  264. package/src/engine/cart/fixtures/crlf.p8 +6 -0
  265. package/src/engine/cart/fixtures/full.p8 +174 -0
  266. package/src/engine/cart/fixtures/header-only.p8 +2 -0
  267. package/src/engine/cart/fixtures/malformed-no-header.p8 +2 -0
  268. package/src/engine/cart/fixtures/minimal.p8 +10 -0
  269. package/src/engine/cart/fixtures/no-trailing-newline.p8 +6 -0
  270. package/src/engine/cart/gfx.ts +195 -0
  271. package/src/engine/cart/index.ts +27 -0
  272. package/src/engine/cart/map.ts +134 -0
  273. package/src/engine/config/config.ts +115 -0
  274. package/src/engine/config/index.ts +12 -0
  275. package/src/engine/gfx/grid.test.ts +128 -0
  276. package/src/engine/gfx/grid.ts +136 -0
  277. package/src/engine/gfx/index.ts +34 -0
  278. package/src/engine/gfx/label.test.ts +105 -0
  279. package/src/engine/gfx/label.ts +229 -0
  280. package/src/engine/gfx/overlap.test.ts +71 -0
  281. package/src/engine/gfx/overlap.ts +66 -0
  282. package/src/engine/gfx/palette.test.ts +35 -0
  283. package/src/engine/gfx/palette.ts +54 -0
  284. package/src/engine/gfx/png.test.ts +111 -0
  285. package/src/engine/gfx/png.ts +196 -0
  286. package/src/engine/gfx/render.test.ts +180 -0
  287. package/src/engine/gfx/render.ts +106 -0
  288. package/src/engine/index.ts +21 -0
  289. package/src/engine/pico8/adapter.ts +303 -0
  290. package/src/engine/pico8/drive.test.ts +415 -0
  291. package/src/engine/pico8/drive.ts +729 -0
  292. package/src/engine/pico8/harness.test.ts +181 -0
  293. package/src/engine/pico8/harness.ts +354 -0
  294. package/src/engine/pico8/index.ts +20 -0
  295. package/src/engine/pico8/sentinel.test.ts +80 -0
  296. package/src/engine/pico8/sentinel.ts +93 -0
  297. package/src/engine/pico8/session-daemon-entry.ts +12 -0
  298. package/src/engine/pico8/session-daemon-main.ts +98 -0
  299. package/src/engine/pico8/session-daemon.test.ts +212 -0
  300. package/src/engine/pico8/session-daemon.ts +306 -0
  301. package/src/engine/pico8/session.test.ts +239 -0
  302. package/src/engine/pico8/session.ts +321 -0
  303. package/src/engine/pico8/shell.test.ts +583 -0
  304. package/src/engine/pico8/shell.ts +412 -0
  305. package/src/engine/pico8/supervisor.test.ts +105 -0
  306. package/src/engine/pico8/supervisor.ts +197 -0
  307. package/src/engine/scaffold/index.ts +15 -0
  308. package/src/engine/scaffold/scaffold.ts +236 -0
  309. package/src/engine/shrinko/adapter.ts +241 -0
  310. package/src/engine/shrinko/fixtures/count-no-compressed.txt +2 -0
  311. package/src/engine/shrinko/fixtures/count-over-budget.txt +3 -0
  312. package/src/engine/shrinko/fixtures/count-under-budget.txt +3 -0
  313. package/src/engine/shrinko/index.ts +33 -0
  314. package/src/engine/shrinko/shell.ts +224 -0
  315. package/src/engine/shrinko/shrinko.test.ts +182 -0
  316. package/src/engine/skills/index.ts +14 -0
  317. package/src/engine/skills/skills.test.ts +376 -0
  318. package/src/engine/skills/skills.ts +205 -0
  319. package/src/index.ts +13 -0
  320. package/src/skills/game-design-reference/SKILL.md +70 -0
  321. package/src/skills/game-jam/SKILL.md +45 -0
  322. package/src/skills/picopilot-art/SKILL.md +62 -0
  323. package/src/skills/picopilot-audio/SKILL.md +194 -0
  324. package/src/skills/picopilot-code/SKILL.md +97 -0
  325. package/src/skills/picopilot-code/reference/README.md +42 -0
  326. package/src/skills/picopilot-code/reference/mode7-racing.md +95 -0
  327. package/src/skills/picopilot-code/reference/platformer.md +119 -0
  328. package/src/skills/picopilot-code/reference/puzzle-grid.md +106 -0
  329. package/src/skills/picopilot-code/reference/rpg-menus-dialog.md +122 -0
  330. package/src/skills/picopilot-code/reference/top-down-adventure.md +117 -0
  331. package/src/skills/picopilot-code/reference/twin-stick-arcade.md +128 -0
  332. package/src/skills/picopilot-debug/SKILL.md +195 -0
  333. package/src/skills/picopilot-overview/SKILL.md +52 -0
  334. package/src/version.ts +22 -0
@@ -0,0 +1,3 @@
1
+ tokens: 8053 98%
2
+ chars: 30320 46%
3
+ compressed: 12176 77%
@@ -0,0 +1,3 @@
1
+ tokens: 512 6%
2
+ chars: 1840 2%
3
+ compressed: 980 6%
@@ -0,0 +1,33 @@
1
+ /**
2
+ * The shrinko adapter seam: how every shrinko-backed command reaches shrinko8.
3
+ *
4
+ * `adapter.ts` is the command-facing CONTRACT (the {@link ShrinkoAdapter}
5
+ * interface, the {@link ShrinkoResult} discriminated union, the structured
6
+ * {@link ShrinkoNotFound} value, and the `--count` parser). `shell.ts` is the v1
7
+ * IMPLEMENTATION that shells out to a user-installed Python `shrinko8`
8
+ * (ADR-0001). A future native-TS implementation implements the same interface
9
+ * and drops in without touching the command layer.
10
+ */
11
+ export {
12
+ type CountReport,
13
+ type LintFinding,
14
+ type LintReport,
15
+ type MinifyReport,
16
+ parseCount,
17
+ parseLint,
18
+ SHRINKO_NEEDS,
19
+ SHRINKO_REMEDY,
20
+ type ShrinkoAdapter,
21
+ type ShrinkoNotFound,
22
+ shrinkoNotFound,
23
+ type ShrinkoOk,
24
+ ShrinkoParseError,
25
+ type ShrinkoResult,
26
+ } from './adapter.js';
27
+ export {
28
+ type ChildResult,
29
+ type ChildRunner,
30
+ execFileRunner,
31
+ ShellShrinkoAdapter,
32
+ type ShellShrinkoOptions,
33
+ } from './shell.js';
@@ -0,0 +1,224 @@
1
+ import {execFile} from 'node:child_process';
2
+
3
+ import {
4
+ type CountReport,
5
+ type LintReport,
6
+ type MinifyReport,
7
+ parseCount,
8
+ parseLint,
9
+ type ShrinkoAdapter,
10
+ type ShrinkoResult,
11
+ shrinkoNotFound,
12
+ } from './adapter.js';
13
+
14
+ /**
15
+ * The result of running one child process: its exit `code` (null when killed by
16
+ * a signal), captured `stdout`/`stderr`, and `spawnError` set when the binary
17
+ * could not be launched AT ALL (ENOENT etc.) rather than launched-and-exited.
18
+ * `spawnError` is how the shell adapter distinguishes "shrinko is not installed"
19
+ * from "shrinko ran and failed".
20
+ */
21
+ export interface ChildResult {
22
+ readonly code: number | null;
23
+ readonly stdout: string;
24
+ readonly stderr: string;
25
+ readonly spawnError: NodeJS.ErrnoException | undefined;
26
+ }
27
+
28
+ /**
29
+ * The child-process seam. The real implementation shells out with the CALLER's
30
+ * `env` (so a test overrides the child's `PATH` and nothing else). Tests inject
31
+ * a stub runner to simulate shrinko present/absent WITHOUT ever requiring the
32
+ * real binary, and to assert exactly what argv/env the adapter would spawn.
33
+ */
34
+ export type ChildRunner = (
35
+ file: string,
36
+ args: string[],
37
+ env: NodeJS.ProcessEnv,
38
+ ) => Promise<ChildResult>;
39
+
40
+ /**
41
+ * The default {@link ChildRunner}: an `execFile` that captures stdout/stderr and
42
+ * NEVER rejects. A failed spawn (ENOENT) becomes `spawnError`; a nonzero exit
43
+ * becomes a `code`, so both outcomes are data the adapter maps to a structured
44
+ * result. The child inherits ONLY the `env` it is handed (the command layer
45
+ * passes the incur-resolved env, whose `PATH` a test can override) so there is
46
+ * no ambient-environment leak.
47
+ */
48
+ export const execFileRunner: ChildRunner = (file, args, env) =>
49
+ new Promise((resolve) => {
50
+ execFile(file, args, {env, encoding: 'utf8'}, (error, stdout, stderr) => {
51
+ const err = error as NodeJS.ErrnoException | null;
52
+ resolve({
53
+ // `execFile` sets error.code to the string 'ENOENT' on spawn failure and
54
+ // to the numeric exit code on a nonzero exit; separate the two.
55
+ code:
56
+ err !== null && typeof err.code === 'number'
57
+ ? err.code
58
+ : err === null
59
+ ? 0
60
+ : null,
61
+ stdout,
62
+ stderr,
63
+ spawnError:
64
+ err !== null && typeof err.code === 'string' ? err : undefined,
65
+ });
66
+ });
67
+ });
68
+
69
+ /**
70
+ * The candidate ways to invoke shrinko, tried in order. First the `shrinko8`
71
+ * entry-point on `PATH`, then `python -m shrinko8` / `python3 -m shrinko8` as
72
+ * fallbacks (the module is `shrinko8`; the pip package is `shrinko`). The first
73
+ * candidate that SPAWNS (does not ENOENT) is the one shrinko is reached through.
74
+ */
75
+ const SHRINKO_INVOCATIONS: readonly (readonly string[])[] = [
76
+ ['shrinko8'],
77
+ ['python', '-m', 'shrinko8'],
78
+ ['python3', '-m', 'shrinko8'],
79
+ ];
80
+
81
+ /** Options for {@link ShellShrinkoAdapter}. */
82
+ export interface ShellShrinkoOptions {
83
+ /**
84
+ * The environment handed to the child process. The command layer passes the
85
+ * incur-resolved env here; its `PATH` is the isolation lever a test overrides
86
+ * (an empty/`PATH`-less env makes every invocation ENOENT ⇒ shrinko-absent).
87
+ */
88
+ readonly env: NodeJS.ProcessEnv;
89
+ /** The child runner (defaults to {@link execFileRunner}); tests inject a stub. */
90
+ readonly run?: ChildRunner;
91
+ }
92
+
93
+ /**
94
+ * The v1 {@link ShrinkoAdapter}: shells out to a user-installed Python
95
+ * `shrinko8` (ADR-0001). It is the ONLY place that knows shrinko is an external
96
+ * process; every command talks to the {@link ShrinkoAdapter} interface, so a
97
+ * future native-TS adapter replaces this class without touching a command.
98
+ *
99
+ * Presence detection and invocation are the SAME act: the adapter tries each
100
+ * {@link SHRINKO_INVOCATIONS} candidate and uses the first that spawns. If every
101
+ * candidate ENOENTs, shrinko is absent and the method returns the structured
102
+ * {@link shrinkoNotFound} value (never a throw, never a hollow success).
103
+ */
104
+ export class ShellShrinkoAdapter implements ShrinkoAdapter {
105
+ private readonly env: NodeJS.ProcessEnv;
106
+ private readonly run: ChildRunner;
107
+
108
+ constructor(options: ShellShrinkoOptions) {
109
+ this.env = options.env;
110
+ this.run = options.run ?? execFileRunner;
111
+ }
112
+
113
+ async count(cartPath: string): Promise<ShrinkoResult<CountReport>> {
114
+ const outcome = await this.invoke([cartPath, '--count']);
115
+ if (outcome === undefined) {
116
+ return shrinkoNotFound();
117
+ }
118
+ // shrinko prints the count report; some builds send it to stderr, so parse
119
+ // the combined stream. parseCount throws on unparseable output (ShrinkoParseError).
120
+ return {
121
+ ok: true,
122
+ value: parseCount(`${outcome.stdout}\n${outcome.stderr}`),
123
+ };
124
+ }
125
+
126
+ async lint(cartPath: string): Promise<ShrinkoResult<LintReport>> {
127
+ // `-l` lints; it exits nonzero (2) WHEN it finds warnings, which is a normal
128
+ // result (findings are data), NOT a spawn failure. Only a spawnError means
129
+ // shrinko is absent, so we key absence off `outcome === undefined`, and parse
130
+ // the warnings regardless of the (0-or-2) exit code.
131
+ const outcome = await this.invoke([cartPath, '-l']);
132
+ if (outcome === undefined) {
133
+ return shrinkoNotFound();
134
+ }
135
+ return {
136
+ ok: true,
137
+ value: parseLint(`${outcome.stdout}\n${outcome.stderr}`),
138
+ };
139
+ }
140
+
141
+ async minify(
142
+ cartPath: string,
143
+ outPath: string,
144
+ ): Promise<ShrinkoResult<MinifyReport>> {
145
+ // Count the source first (the "before"): if shrinko is absent, this returns
146
+ // the not-found value and we never write anything.
147
+ const before = await this.count(cartPath);
148
+ if (!before.ok) return before;
149
+
150
+ // Minify SAFE-only (ADR-0007): the three safe-only knobs keep rename/reorder/
151
+ // builtins behaviour-preserving. Output goes to a SEPARATE cart (outPath).
152
+ const out = await this.invoke([
153
+ cartPath,
154
+ '-m',
155
+ '--rename-safe-only',
156
+ '--reorder-safe-only',
157
+ '--builtins-safe-only',
158
+ outPath,
159
+ ]);
160
+ if (out === undefined) return shrinkoNotFound();
161
+
162
+ // Count the artifact (the "after"). It exists now, so this is a normal count.
163
+ const after = await this.count(outPath);
164
+ if (!after.ok) return after;
165
+
166
+ return {
167
+ ok: true,
168
+ value: {
169
+ beforeTokens: before.value.tokens,
170
+ afterTokens: after.value.tokens,
171
+ outPath,
172
+ },
173
+ };
174
+ }
175
+
176
+ async exportSpritesheet(
177
+ cartPath: string,
178
+ pngPath: string,
179
+ ): Promise<ShrinkoResult<void>> {
180
+ // `<cart> <out.png> -f spritesheet` writes the 128x128 spritesheet PNG.
181
+ const outcome = await this.invoke([cartPath, pngPath, '-f', 'spritesheet']);
182
+ if (outcome === undefined) return shrinkoNotFound();
183
+ return {ok: true, value: undefined};
184
+ }
185
+
186
+ async importSpritesheet(
187
+ srcCartPath: string,
188
+ pngPath: string,
189
+ outCartPath: string,
190
+ ): Promise<ShrinkoResult<void>> {
191
+ // `<cart> <out.p8> --merge <png> gfx spritesheet` merges ONLY the gfx section
192
+ // from the PNG into the source cart, preserving code/map/sfx/music. (A plain
193
+ // `-F spritesheet` would drop everything but gfx, wiping the user's code.)
194
+ const outcome = await this.invoke([
195
+ srcCartPath,
196
+ outCartPath,
197
+ '--merge',
198
+ pngPath,
199
+ 'gfx',
200
+ 'spritesheet',
201
+ ]);
202
+ if (outcome === undefined) return shrinkoNotFound();
203
+ return {ok: true, value: undefined};
204
+ }
205
+
206
+ /**
207
+ * Runs shrinko with the given trailing args, trying each invocation candidate
208
+ * until one SPAWNS. Returns the {@link ChildResult} of the first candidate
209
+ * that launched, or `undefined` when every candidate ENOENTs (shrinko absent).
210
+ */
211
+ private async invoke(args: string[]): Promise<ChildResult | undefined> {
212
+ for (const inv of SHRINKO_INVOCATIONS) {
213
+ const [file, ...prefix] = inv as [string, ...string[]];
214
+ const result = await this.run(file, [...prefix, ...args], this.env);
215
+ if (result.spawnError === undefined) {
216
+ return result;
217
+ }
218
+ // ENOENT on this candidate: fall through to the next. Any other spawn
219
+ // error (EACCES etc.) also falls through, so a broken candidate never
220
+ // masks a working one.
221
+ }
222
+ return undefined;
223
+ }
224
+ }
@@ -0,0 +1,182 @@
1
+ import {readFileSync} from 'node:fs';
2
+ import {fileURLToPath} from 'node:url';
3
+ import {describe, expect, it} from 'vitest';
4
+
5
+ import {
6
+ type ChildResult,
7
+ type ChildRunner,
8
+ parseCount,
9
+ SHRINKO_REMEDY,
10
+ ShellShrinkoAdapter,
11
+ ShrinkoParseError,
12
+ } from './index.js';
13
+
14
+ /** Reads a captured `--count` sample as raw text. */
15
+ function sample(name: string): string {
16
+ return readFileSync(
17
+ fileURLToPath(new URL(`./fixtures/${name}`, import.meta.url)),
18
+ 'utf8',
19
+ );
20
+ }
21
+
22
+ /** A recorded spawn, so tests can assert the exact argv + env the adapter used. */
23
+ interface Spawn {
24
+ file: string;
25
+ args: string[];
26
+ env: NodeJS.ProcessEnv;
27
+ }
28
+
29
+ /**
30
+ * Builds a stub {@link ChildRunner} that returns a fixed {@link ChildResult} for
31
+ * a chosen `file`, and ENOENT for every other candidate. Records each spawn so a
32
+ * test can assert which invocation was used and with what env. This is how every
33
+ * test drives shrinko present/absent WITHOUT the real binary.
34
+ */
35
+ function stubRunner(opts: {
36
+ presentFile?: string;
37
+ stdout?: string;
38
+ stderr?: string;
39
+ code?: number;
40
+ }): {run: ChildRunner; spawns: Spawn[]} {
41
+ const spawns: Spawn[] = [];
42
+ const run: ChildRunner = async (file, args, env) => {
43
+ spawns.push({file, args, env});
44
+ if (opts.presentFile !== undefined && file === opts.presentFile) {
45
+ return {
46
+ code: opts.code ?? 0,
47
+ stdout: opts.stdout ?? '',
48
+ stderr: opts.stderr ?? '',
49
+ spawnError: undefined,
50
+ } satisfies ChildResult;
51
+ }
52
+ // Not the present binary → simulate ENOENT (not installed on PATH).
53
+ const err = Object.assign(new Error(`spawn ${file} ENOENT`), {
54
+ code: 'ENOENT',
55
+ }) as NodeJS.ErrnoException;
56
+ return {code: null, stdout: '', stderr: '', spawnError: err};
57
+ };
58
+ return {run, spawns};
59
+ }
60
+
61
+ describe('parseCount: parses shrinko --count output', () => {
62
+ it('parses the documented over-budget sample', () => {
63
+ const report = parseCount(sample('count-over-budget.txt'));
64
+ expect(report).toEqual({
65
+ tokens: 8053,
66
+ tokensPct: 98,
67
+ chars: 30320,
68
+ charsPct: 46,
69
+ compressed: 12176,
70
+ compressedPct: 77,
71
+ });
72
+ });
73
+
74
+ it('parses a sample with no compressed line (compressed undefined)', () => {
75
+ const report = parseCount(sample('count-no-compressed.txt'));
76
+ expect(report.tokens).toBe(300);
77
+ expect(report.chars).toBe(1200);
78
+ expect(report.compressed).toBeUndefined();
79
+ expect(report.compressedPct).toBeUndefined();
80
+ });
81
+
82
+ it('parses the count even when embedded in surrounding output/whitespace', () => {
83
+ const noisy = `some banner line\n\n tokens: 8053 98%\n chars: 30320 46%\n compressed: 12176 77%\ntrailing\n`;
84
+ const report = parseCount(noisy);
85
+ expect(report.tokens).toBe(8053);
86
+ expect(report.chars).toBe(30320);
87
+ expect(report.compressed).toBe(12176);
88
+ });
89
+
90
+ it('throws ShrinkoParseError when the required lines are absent', () => {
91
+ expect(() => parseCount('unexpected shrinko output\n')).toThrow(
92
+ ShrinkoParseError,
93
+ );
94
+ });
95
+ });
96
+
97
+ describe('ShellShrinkoAdapter.count: present', () => {
98
+ it('parses --count from the shrinko8 entry-point on PATH', async () => {
99
+ const {run, spawns} = stubRunner({
100
+ presentFile: 'shrinko8',
101
+ stdout: sample('count-over-budget.txt'),
102
+ });
103
+ const adapter = new ShellShrinkoAdapter({env: {PATH: '/fake/bin'}, run});
104
+ const result = await adapter.count('/carts/main.p8');
105
+
106
+ expect(result.ok).toBe(true);
107
+ if (result.ok) {
108
+ expect(result.value.tokens).toBe(8053);
109
+ expect(result.value.chars).toBe(30320);
110
+ expect(result.value.compressed).toBe(12176);
111
+ }
112
+ // It spawned `shrinko8 /carts/main.p8 --count` first and stopped there.
113
+ expect(spawns[0]?.file).toBe('shrinko8');
114
+ expect(spawns[0]?.args).toEqual(['/carts/main.p8', '--count']);
115
+ });
116
+
117
+ it('parses --count even when shrinko prints to stderr', async () => {
118
+ const {run} = stubRunner({
119
+ presentFile: 'shrinko8',
120
+ stdout: '',
121
+ stderr: sample('count-under-budget.txt'),
122
+ });
123
+ const adapter = new ShellShrinkoAdapter({env: {PATH: '/fake/bin'}, run});
124
+ const result = await adapter.count('/carts/main.p8');
125
+ expect(result.ok).toBe(true);
126
+ if (result.ok) expect(result.value.tokens).toBe(512);
127
+ });
128
+
129
+ it('falls back to `python -m shrinko8` when shrinko8 is not on PATH', async () => {
130
+ const {run, spawns} = stubRunner({
131
+ presentFile: 'python',
132
+ stdout: sample('count-over-budget.txt'),
133
+ });
134
+ const adapter = new ShellShrinkoAdapter({env: {PATH: '/fake/bin'}, run});
135
+ const result = await adapter.count('/carts/main.p8');
136
+
137
+ expect(result.ok).toBe(true);
138
+ // First tried shrinko8 (ENOENT), then python -m shrinko8 (spawned).
139
+ expect(spawns.map((s) => s.file)).toEqual(['shrinko8', 'python']);
140
+ expect(spawns[1]?.args).toEqual([
141
+ '-m',
142
+ 'shrinko8',
143
+ '/carts/main.p8',
144
+ '--count',
145
+ ]);
146
+ });
147
+ });
148
+
149
+ describe('ShellShrinkoAdapter.count: absent (structured, never a crash)', () => {
150
+ it('returns shrinko-not-found with the EXACT remedy when nothing spawns', async () => {
151
+ // Every candidate ENOENTs (an empty PATH is the isolation lever).
152
+ const {run, spawns} = stubRunner({presentFile: undefined});
153
+ const adapter = new ShellShrinkoAdapter({env: {}, run});
154
+ const result = await adapter.count('/carts/main.p8');
155
+
156
+ expect(result.ok).toBe(false);
157
+ if (!result.ok) {
158
+ expect(result.reason).toBe('shrinko-not-found');
159
+ expect(result.remedy).toBe(SHRINKO_REMEDY);
160
+ expect(result.remedy).toBe('uv pip install shrinko');
161
+ expect(result.needs).toEqual(['python>=3.8']);
162
+ }
163
+ // It tried every invocation candidate before giving up.
164
+ expect(spawns.map((s) => s.file)).toEqual([
165
+ 'shrinko8',
166
+ 'python',
167
+ 'python3',
168
+ ]);
169
+ });
170
+
171
+ it('passes ONLY the given env to the child (the PATH isolation lever)', async () => {
172
+ const {run, spawns} = stubRunner({
173
+ presentFile: 'shrinko8',
174
+ stdout: sample('count-over-budget.txt'),
175
+ });
176
+ const env = {PATH: '/only/this/bin', SOME_OTHER: 'x'};
177
+ const adapter = new ShellShrinkoAdapter({env, run});
178
+ await adapter.count('/carts/main.p8');
179
+ // The child sees exactly the env we handed the adapter, nothing ambient.
180
+ expect(spawns[0]?.env).toEqual(env);
181
+ });
182
+ });
@@ -0,0 +1,14 @@
1
+ /**
2
+ * The skills-install engine behind `picopilot init --install-skills`, the one
3
+ * picopilot path that writes to a shared/global location. Every install-target
4
+ * knob is a parameter here so tests redirect it to a temp dir and the real agent
5
+ * skill dirs stay byte-untouched. See `skills.ts` for the isolation contract.
6
+ */
7
+ export {
8
+ installSkills,
9
+ type InstallSkillsOptions,
10
+ type InstallSkillsResult,
11
+ SKILL_NAMES,
12
+ skillsSourceDir,
13
+ type WiredAgent,
14
+ } from './skills.js';