@caperjs/core 0.5.1 → 0.6.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.
package/README.md CHANGED
@@ -8,7 +8,13 @@ It sits on top of PixiJS — a renderer, not a game engine — and adds the part
8
8
 
9
9
  ## Status
10
10
 
11
- This package is **not yet published to npm**. The recommended way to use it is to clone the [Caper monorepo](https://github.com/anthonysapp/caper), run the kitchen-sink, and either fork it as a starter or vendor `packages/core` into your own project. First publish lands in Phase 7.
11
+ Published on npm as `@caperjs/core`.
12
+
13
+ ## For AI agents
14
+
15
+ - `npx caper agent init` installs the `caper` skill + `AGENTS.md` pointers for this project.
16
+ - The reference is `node_modules/@caperjs/core/extras/llms.txt` — read it by section (`grep -n '^## '`), not whole.
17
+ - Engine source ships in the package under `src/`.
12
18
 
13
19
  ## Highlights
14
20
 
@@ -302,6 +302,12 @@ export function assetpackPlugin(manifestUrl = defaultManifestUrl, pixiPipesConfi
302
302
 
303
303
  return {
304
304
  name: 'vite-plugin-assetpack',
305
+ api: {
306
+ runOnce: async () => {
307
+ await getConfig();
308
+ await new AssetPack(apConfig).run();
309
+ },
310
+ },
305
311
  async configResolved(resolvedConfig) {
306
312
  mode = resolvedConfig.command;
307
313
  // Captured before getConfig() below, which is what consumes it.
@@ -398,8 +398,10 @@ export function assetTypesPlugin(manifestUrl = 'assets.json') {
398
398
  }
399
399
  const manifest = JSON.parse(await fs.promises.readFile(manifestPath, 'utf8'));
400
400
  await writeAssetTypes(manifest, path.dirname(manifestPath), path.join(publicDir, 'assets'), root);
401
- logger.info('Caper asset types plugin:: manifest changed, reloading browser...');
402
- viteServer?.ws?.send({ type: 'full-reload' });
401
+ if (viteServer) {
402
+ logger.info('Caper asset types plugin:: manifest changed, reloading browser...');
403
+ viteServer.ws.send({ type: 'full-reload' });
404
+ }
403
405
  } catch (error) {
404
406
  logger.error('Caper asset types plugin:: Error handling manifest change:', error);
405
407
  }
@@ -409,6 +411,9 @@ export function assetTypesPlugin(manifestUrl = 'assets.json') {
409
411
 
410
412
  return {
411
413
  name: 'vite-plugin-asset-types',
414
+ api: {
415
+ generateTypes: () => generate(manifestUrl),
416
+ },
412
417
  configResolved(config) {
413
418
  publicDir = config.publicDir;
414
419
  root = config.root;
@@ -418,6 +418,9 @@ declare module '@caperjs/core' {
418
418
 
419
419
  return {
420
420
  name: 'vite-plugin-caper-config',
421
+ api: {
422
+ generateTypes: () => build('Generating types from caper.config.ts'),
423
+ },
421
424
  configResolved(config) {
422
425
  publicDir = config.publicDir;
423
426
  root = config.root;
package/cli/agent.mjs ADDED
@@ -0,0 +1,112 @@
1
+ import { bgRed, bold, cyan, green, white } from 'kleur/colors';
2
+
3
+ import fs from 'node:fs';
4
+ import path from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
6
+ import { probe } from './probe.mjs';
7
+
8
+ /**
9
+ * `caper agent <subcommand>` — `init` installs the shipped `caper` agent skill
10
+ * into the current app; `probe` drives a running Caper app through the
11
+ * Playwright automation bridge.
12
+ */
13
+
14
+ const START_MARKER = '<!-- caper:agent-start -->';
15
+ const END_MARKER = '<!-- caper:agent-end -->';
16
+
17
+ // `.href` so this also works where the global URL is a DOM polyfill (vitest + happy-dom).
18
+ function resolveModuleRelative(rel) {
19
+ return fileURLToPath(new URL(rel, import.meta.url).href);
20
+ }
21
+
22
+ function readVersion() {
23
+ return JSON.parse(fs.readFileSync(resolveModuleRelative('../package.json'), 'utf-8')).version;
24
+ }
25
+
26
+ function makeBlock(skillPath, version) {
27
+ return `${START_MARKER}
28
+ ## Caper (engine) — agent pointers
29
+
30
+ This app runs on the Caper engine (\`@caperjs/core@${version}\`). Generated by \`caper agent init\`; re-run it after upgrading caper. Do not edit between these markers.
31
+
32
+ - Before engine-facing work (scenes, entities, popups, UI, plugins, \`caper.config.ts\`, assets, generated types), load the \`caper\` skill at \`${skillPath}\`.
33
+ - Read the reference by section, not whole: \`node_modules/@caperjs/core/extras/llms.txt\` (\`grep -n '^## '\` for the outline). Engine source ships at \`node_modules/@caperjs/core/src/\`.
34
+ - Verify: typecheck → build → drive the running app through \`window.Caper\` / \`Caper.automation[appId]\` (llms.txt §17). Never leave a dev server running in the foreground.
35
+ ${END_MARKER}`;
36
+ }
37
+
38
+ function upsertContext(cwd, dir) {
39
+ const candidates = ['AGENTS.md', 'CLAUDE.md'];
40
+ let contextFile = candidates.find((name) => fs.existsSync(path.join(cwd, name)));
41
+
42
+ if (!contextFile) {
43
+ contextFile = 'AGENTS.md';
44
+ }
45
+
46
+ const contextPath = path.resolve(cwd, contextFile);
47
+ const existing = fs.existsSync(contextPath) ? fs.readFileSync(contextPath, 'utf-8') : '';
48
+ const skillPath = path.posix.join(dir, 'caper/SKILL.md');
49
+ const version = readVersion();
50
+ const block = makeBlock(skillPath, version);
51
+
52
+ const startIndex = existing.indexOf(START_MARKER);
53
+ const endIndex = existing.indexOf(END_MARKER);
54
+
55
+ let contents;
56
+ if (startIndex !== -1 && endIndex !== -1 && endIndex > startIndex) {
57
+ const before = existing.slice(0, startIndex);
58
+ const after = existing.slice(endIndex + END_MARKER.length);
59
+ const trimmedBefore = before.replace(/\s+$/, '');
60
+ const trimmedAfter = after.replace(/^\s+/, '');
61
+ const spacerBefore = trimmedBefore ? '\n\n' : '';
62
+ const spacerAfter = trimmedAfter ? '\n\n' : '';
63
+ contents = `${trimmedBefore}${spacerBefore}${block}${spacerAfter}${trimmedAfter}`;
64
+ } else if (existing) {
65
+ const spacer = existing.endsWith('\n\n') ? '' : '\n\n';
66
+ contents = `${existing.replace(/\s+$/, '')}${spacer}${block}\n`;
67
+ } else {
68
+ contents = `${block}\n`;
69
+ }
70
+
71
+ fs.writeFileSync(contextPath, contents, 'utf-8');
72
+ return contextPath;
73
+ }
74
+
75
+ export async function agentInit(cwd, { dir = '.claude/skills' } = {}) {
76
+ const skillSource = resolveModuleRelative('../extras/skills/caper/SKILL.md');
77
+ const skillDir = path.resolve(cwd, dir, 'caper');
78
+ const skillFile = path.join(skillDir, 'SKILL.md');
79
+
80
+ fs.mkdirSync(skillDir, { recursive: true });
81
+ fs.copyFileSync(skillSource, skillFile);
82
+
83
+ const contextFile = upsertContext(cwd, dir);
84
+
85
+ console.log(green(bold('✓ Created caper skill')) + ` ${cyan(path.relative(cwd, skillFile).replace(/\\/g, '/'))}`);
86
+ console.log(green(bold('✓ Updated agent context')) + ` ${cyan(path.relative(cwd, contextFile).replace(/\\/g, '/'))}`);
87
+
88
+ return { skillFile, contextFile };
89
+ }
90
+
91
+ export async function agent(args) {
92
+ if (args[0] === 'init') {
93
+ let dir = '.claude/skills';
94
+ for (let i = 1; i < args.length; i++) {
95
+ if (args[i] === '--dir' && args[i + 1]) {
96
+ dir = args[i + 1];
97
+ i++;
98
+ }
99
+ }
100
+ await agentInit(process.cwd(), { dir });
101
+ return;
102
+ }
103
+
104
+ if (args[0] === 'probe') {
105
+ await probe(args.slice(1));
106
+ return;
107
+ }
108
+
109
+ const subcommand = args[0] ?? '(none)';
110
+ console.error(bold(bgRed(white(`Unknown agent command: "${subcommand}". Please use "init" or "probe".`))));
111
+ process.exit(1);
112
+ }
@@ -0,0 +1,104 @@
1
+ import { afterEach, describe, expect, it } from 'vitest';
2
+ import fs from 'node:fs';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import { agentInit } from './agent.mjs';
6
+
7
+ const shippedSkill = path.resolve(process.cwd(), 'extras/skills/caper/SKILL.md');
8
+
9
+ const START_MARKER = '<!-- caper:agent-start -->';
10
+
11
+ let tempDir = null;
12
+
13
+ afterEach(() => {
14
+ if (tempDir && fs.existsSync(tempDir)) {
15
+ fs.rmSync(tempDir, { recursive: true, force: true });
16
+ }
17
+ tempDir = null;
18
+ });
19
+
20
+ function makeTempDir() {
21
+ tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'caper-agent-'));
22
+ return tempDir;
23
+ }
24
+
25
+ function countMarkers(contents) {
26
+ return {
27
+ start: (contents.match(/<!-- caper:agent-start -->/g) || []).length,
28
+ end: (contents.match(/<!-- caper:agent-end -->/g) || []).length,
29
+ };
30
+ }
31
+
32
+ describe('agentInit', () => {
33
+ it('creates the default skill copy and AGENTS.md when no context file exists', async () => {
34
+ const cwd = makeTempDir();
35
+
36
+ const { skillFile, contextFile } = await agentInit(cwd);
37
+
38
+ expect(fs.existsSync(skillFile)).toBe(true);
39
+ expect(fs.readFileSync(skillFile, 'utf-8')).toBe(fs.readFileSync(shippedSkill, 'utf-8'));
40
+ expect(path.basename(contextFile)).toBe('AGENTS.md');
41
+
42
+ const context = fs.readFileSync(contextFile, 'utf-8');
43
+ expect(context).toContain('<!-- caper:agent-start -->');
44
+ expect(context).toContain('<!-- caper:agent-end -->');
45
+ expect(context).toContain('.claude/skills/caper/SKILL.md');
46
+ });
47
+
48
+ it('respects --dir and references the custom skill path', async () => {
49
+ const cwd = makeTempDir();
50
+
51
+ const { skillFile, contextFile } = await agentInit(cwd, { dir: 'skills' });
52
+
53
+ expect(skillFile).toBe(path.join(cwd, 'skills/caper/SKILL.md'));
54
+ expect(fs.existsSync(skillFile)).toBe(true);
55
+
56
+ const context = fs.readFileSync(contextFile, 'utf-8');
57
+ expect(context).toContain('skills/caper/SKILL.md');
58
+ expect(context).not.toContain('.claude/skills/caper/SKILL.md');
59
+ });
60
+
61
+ it('appends the block to CLAUDE.md when only CLAUDE.md exists', async () => {
62
+ const cwd = makeTempDir();
63
+ const original = '# Project CLAUDE.md\n\nSome existing guidance.\n';
64
+ fs.writeFileSync(path.join(cwd, 'CLAUDE.md'), original, 'utf-8');
65
+
66
+ const { skillFile, contextFile } = await agentInit(cwd);
67
+
68
+ expect(path.basename(contextFile)).toBe('CLAUDE.md');
69
+ expect(fs.existsSync(path.join(cwd, 'AGENTS.md'))).toBe(false);
70
+
71
+ const context = fs.readFileSync(contextFile, 'utf-8');
72
+ expect(context).toContain('Some existing guidance.');
73
+ expect(context).toContain('<!-- caper:agent-start -->');
74
+ expect(context).toContain('<!-- caper:agent-end -->');
75
+ expect(context.indexOf('Some existing guidance.')).toBeLessThan(context.indexOf(START_MARKER));
76
+ });
77
+
78
+ it('is idempotent and preserves user edits outside the markers', async () => {
79
+ const cwd = makeTempDir();
80
+
81
+ await agentInit(cwd);
82
+ const first = fs.readFileSync(path.join(cwd, 'AGENTS.md'), 'utf-8');
83
+
84
+ // User edits outside the markers
85
+ const edited = `# App pointers\n\n${first}\n\n## Custom section\n\nKeep me.\n`;
86
+ fs.writeFileSync(path.join(cwd, 'AGENTS.md'), edited, 'utf-8');
87
+
88
+ await agentInit(cwd);
89
+ const second = fs.readFileSync(path.join(cwd, 'AGENTS.md'), 'utf-8');
90
+
91
+ const markers = countMarkers(second);
92
+ expect(markers.start).toBe(1);
93
+ expect(markers.end).toBe(1);
94
+ expect(second).toContain('# App pointers');
95
+ expect(second).toContain('## Custom section');
96
+ expect(second).toContain('Keep me.');
97
+
98
+ // Replace the block with the freshly generated one so we can compare the rest
99
+ const start = second.indexOf('<!-- caper:agent-start -->');
100
+ const end = second.indexOf('<!-- caper:agent-end -->') + '<!-- caper:agent-end -->'.length;
101
+ const regenerated = second.slice(start, end);
102
+ expect(regenerated).toBe(first.trimEnd());
103
+ });
104
+ });
package/cli/create.mjs CHANGED
@@ -6,6 +6,7 @@ import path from 'node:path';
6
6
  import process from 'node:process';
7
7
  import { promisify } from 'node:util';
8
8
  import shell from 'shelljs';
9
+ import { agentInit } from './agent.mjs';
9
10
  import { copy, dist, mkdirp, package_manager } from './utils.mjs';
10
11
 
11
12
  let packageManager = package_manager;
@@ -150,7 +151,7 @@ export function injectPluginsIntoConfig(configContents, pluginConfigs) {
150
151
  * @param {string} cwd
151
152
  * @param {string[]} plugins
152
153
  */
153
- function write_template_files(cwd, template, applicationNameForPkg, applicationName, defaultName, plugins) {
154
+ async function write_template_files(cwd, template, applicationNameForPkg, applicationName, defaultName, plugins) {
154
155
  const dir = dist(`templates/${template}`);
155
156
  copy(`${dir}/package.template.json`, `${cwd}/package.json`);
156
157
 
@@ -209,6 +210,14 @@ function write_template_files(cwd, template, applicationNameForPkg, applicationN
209
210
  readme_contents = readme_contents.replace(/~PACKAGE_MANAGER~/g, mgr);
210
211
  fs.writeFileSync(readme_file, readme_contents, 'utf-8');
211
212
 
213
+ // Install the shipped caper agent skill and upsert pointers into AGENTS.md/CLAUDE.md.
214
+ // This is best-effort: scaffolding must not fail because the agent init step failed.
215
+ try {
216
+ await agentInit(cwd);
217
+ } catch (e) {
218
+ console.warn('Warning: failed to install the caper agent skill:', e.message);
219
+ }
220
+
212
221
  // find __APPLICATION_NAME__.ts and replace it with the application name, then delete ~Application.ts
213
222
  const app_file = `${cwd}/src/__APPLICATION_NAME__.ts`;
214
223
  const app_contents = fs.readFileSync(app_file, 'utf-8');
package/cli/doctor.mjs ADDED
@@ -0,0 +1,193 @@
1
+ import { dim, green, red, yellow } from 'kleur/colors';
2
+
3
+ import { execFile } from 'node:child_process';
4
+ import fs from 'node:fs';
5
+ import path from 'node:path';
6
+ import { fileURLToPath } from 'node:url';
7
+
8
+ /**
9
+ * `caper doctor` — one-shot health report for a Caper app.
10
+ *
11
+ * Answers the recurring agent questions: which caper is active, is it current,
12
+ * are generated types fresh, and are the agent pointers installed.
13
+ */
14
+
15
+ const START_MARKER = '<!-- caper:agent-start -->';
16
+ const END_MARKER = '<!-- caper:agent-end -->';
17
+
18
+ const readInstalledVersion = () => {
19
+ const url = new URL('../package.json', import.meta.url);
20
+ const pkgPath = url.protocol === 'file:' ? fileURLToPath(url.href) : path.resolve(process.cwd(), 'package.json');
21
+ return JSON.parse(fs.readFileSync(pkgPath, 'utf-8')).version;
22
+ };
23
+
24
+ const rel = (cwd, p) => {
25
+ const r = path.relative(cwd, p);
26
+ return r.startsWith('..') ? r : `.${path.sep}${r}`;
27
+ };
28
+
29
+ const newestMtime = (dirOrFile) => {
30
+ if (!fs.existsSync(dirOrFile)) return null;
31
+ const stat = fs.statSync(dirOrFile);
32
+ if (stat.isFile()) return stat.mtime;
33
+ if (!stat.isDirectory()) return null;
34
+ let newest = null;
35
+ for (const entry of fs.readdirSync(dirOrFile)) {
36
+ if (entry === 'node_modules' || entry === '.git') continue;
37
+ const childNewest = newestMtime(path.join(dirOrFile, entry));
38
+ if (childNewest && (!newest || childNewest > newest)) newest = childNewest;
39
+ }
40
+ return newest;
41
+ };
42
+
43
+ const compareVersions = (a, b) => {
44
+ const ap = a.split('.').map(Number);
45
+ const bp = b.split('.').map(Number);
46
+ for (let i = 0; i < Math.max(ap.length, bp.length); i++) {
47
+ const av = ap[i] ?? 0;
48
+ const bv = bp[i] ?? 0;
49
+ if (av < bv) return -1;
50
+ if (av > bv) return 1;
51
+ }
52
+ return 0;
53
+ };
54
+
55
+ const npmLatestVersion = (pkg, timeoutMs = 5000) =>
56
+ new Promise((resolve) => {
57
+ const timer = setTimeout(() => resolve(null), timeoutMs);
58
+ execFile('npm', ['view', pkg, 'version'], { timeout: timeoutMs }, (err, stdout) => {
59
+ clearTimeout(timer);
60
+ resolve(err || !stdout ? null : stdout.trim());
61
+ });
62
+ });
63
+
64
+ const push = (checks, id, status, label, hint) => {
65
+ checks.push({ id, status, label, ...(hint ? { hint } : {}) });
66
+ };
67
+
68
+ export async function runChecks(cwd, { online = true } = {}) {
69
+ const checks = [];
70
+ const installedVersion = readInstalledVersion();
71
+ const nodeModulesCaper = path.join(cwd, 'node_modules/@caperjs/core');
72
+
73
+ push(checks, 'version', 'ok', `@caperjs/core ${installedVersion}`);
74
+
75
+ if (online) {
76
+ const latest = await npmLatestVersion('@caperjs/core');
77
+ if (!latest) push(checks, 'npm', 'warn', 'latest on npm', 'lookup failed (offline?)');
78
+ else if (compareVersions(installedVersion, latest) < 0) {
79
+ push(checks, 'npm', 'warn', `latest on npm ${latest}`, 'pnpm update @caperjs/core@latest');
80
+ } else push(checks, 'npm', 'ok', `latest on npm ${latest}`);
81
+ }
82
+
83
+ if (!fs.existsSync(nodeModulesCaper)) {
84
+ push(checks, 'link', 'fail', '@caperjs/core not in node_modules', 'pnpm install');
85
+ } else {
86
+ let real;
87
+ try {
88
+ real = fs.realpathSync(nodeModulesCaper);
89
+ } catch {
90
+ push(checks, 'link', 'fail', 'node_modules/@caperjs/core is broken', 'pnpm install');
91
+ }
92
+ if (real) {
93
+ if (real.startsWith(path.join(cwd, 'node_modules') + path.sep)) {
94
+ push(checks, 'link', 'ok', 'registry build');
95
+ } else {
96
+ const buildFile = path.join(real, 'lib/caper.mjs');
97
+ const label = `linked from ${rel(cwd, real)}`;
98
+ if (!fs.existsSync(buildFile)) {
99
+ push(checks, 'link', 'fail', label, 'pnpm build in linked package');
100
+ } else {
101
+ const srcMtime = newestMtime(path.join(real, 'src'));
102
+ const stale = srcMtime && srcMtime > fs.statSync(buildFile).mtime;
103
+ push(checks, 'link', stale ? 'warn' : 'ok', label, stale ? 'engine source edited after last build; run pnpm build there' : undefined);
104
+ }
105
+ }
106
+ }
107
+ }
108
+
109
+ const appTypesFile = path.join(cwd, 'src/types/caper-app.d.ts');
110
+ if (!fs.existsSync(appTypesFile)) {
111
+ push(checks, 'app-types', 'fail', 'generated app types missing', 'npx caper types');
112
+ } else {
113
+ const sources = ['caper.config.ts', 'src/scenes', 'src/plugins', 'src/popups', 'src/entities', 'src/ui', 'src/locales'].map((s) => path.join(cwd, s));
114
+ const newestSource = sources.reduce((best, s) => {
115
+ const m = newestMtime(s);
116
+ return m && (!best || m > best) ? m : best;
117
+ }, null);
118
+ const stale = newestSource && newestSource > fs.statSync(appTypesFile).mtime;
119
+ push(checks, 'app-types', stale ? 'warn' : 'ok', stale ? 'generated app types stale' : 'generated app types fresh', stale ? 'npx caper types (or restart dev server)' : undefined);
120
+ }
121
+
122
+ const assetTypesFile = path.join(cwd, 'src/types/caper-assets.d.ts');
123
+ const assetsManifest = path.join(cwd, 'public/assets/assets.json');
124
+ if (fs.existsSync(assetTypesFile)) push(checks, 'asset-types', 'ok', 'generated asset types present');
125
+ else push(checks, 'asset-types', 'warn', 'generated asset types missing', 'npx caper types (app may have assets: false)');
126
+ if (fs.existsSync(assetsManifest)) push(checks, 'asset-manifest', 'ok', 'asset manifest present');
127
+ else push(checks, 'asset-manifest', 'warn', 'asset manifest missing', 'asset pipeline has not run; npx caper types or pnpm dev once');
128
+
129
+ const agentContext = ['AGENTS.md', 'CLAUDE.md'].find((name) => fs.existsSync(path.join(cwd, name)));
130
+ if (!agentContext) {
131
+ push(checks, 'agent', 'warn', 'agent context missing', 'npx caper agent init');
132
+ } else {
133
+ const contents = fs.readFileSync(path.join(cwd, agentContext), 'utf-8');
134
+ const start = contents.indexOf(START_MARKER);
135
+ const end = contents.indexOf(END_MARKER);
136
+ if (start === -1 || end === -1 || end <= start) {
137
+ push(checks, 'agent', 'warn', 'agent pointers not installed', 'npx caper agent init');
138
+ } else {
139
+ const block = contents.slice(start, end + END_MARKER.length);
140
+ const versionMatch = block.match(/@caperjs\/core@(\d+\.\d+\.\d+)/);
141
+ const skillMatch = block.match(/load the `caper` skill at `([^`]+)`/);
142
+ const hints = [];
143
+ if (versionMatch && versionMatch[1] !== installedVersion) {
144
+ hints.push(`pointer is @caperjs/core@${versionMatch[1]}; re-run npx caper agent init`);
145
+ }
146
+ if (skillMatch) {
147
+ if (!fs.existsSync(path.join(cwd, skillMatch[1].replace(/\//g, path.sep)))) {
148
+ hints.push(`skill file missing at ${skillMatch[1]}; npx caper agent init`);
149
+ }
150
+ } else hints.push('skill path not found in pointer block; npx caper agent init');
151
+ push(checks, 'agent', hints.length ? 'warn' : 'ok', 'agent pointers', hints.length ? hints.join('; ') : undefined);
152
+ }
153
+ }
154
+
155
+ const peerDeps = ['pixi.js', 'gsap', '@pixi/sound', 'vite'];
156
+ const required = new Set(['pixi.js', 'vite']);
157
+ let peerStatus = 'ok';
158
+ // Direct lookup rather than require.resolve: pixi.js's `exports` map does not
159
+ // expose ./package.json, so resolve() throws even when it is installed.
160
+ const peerResults = peerDeps.map((pkg) => {
161
+ try {
162
+ const pkgJsonPath = path.join(cwd, 'node_modules', pkg, 'package.json');
163
+ return `${pkg}@${JSON.parse(fs.readFileSync(pkgJsonPath, 'utf-8')).version}`;
164
+ } catch {
165
+ if (required.has(pkg)) peerStatus = 'fail';
166
+ else if (peerStatus === 'ok') peerStatus = 'warn';
167
+ return `${pkg} missing`;
168
+ }
169
+ });
170
+ push(checks, 'peers', peerStatus, `peer deps: ${peerResults.join(', ')}`, peerStatus !== 'ok' ? 'pnpm install' : undefined);
171
+
172
+ const caches = ['.assetpack', '.cache', 'dist'].filter((name) => fs.existsSync(path.join(cwd, name)));
173
+ push(checks, 'caches', 'ok', `caches${caches.length ? `: ${caches.join(', ')}` : ' clean'}`, caches.length ? 'rm -rf them on weird asset/name mismatches' : undefined);
174
+
175
+ return checks;
176
+ }
177
+
178
+ export async function doctor(args) {
179
+ const offline = args.includes('--offline');
180
+ const json = args.includes('--json');
181
+ const checks = await runChecks(process.cwd(), { online: !offline });
182
+
183
+ if (json) {
184
+ console.log(JSON.stringify(checks, null, 2));
185
+ } else {
186
+ for (const check of checks) {
187
+ const symbol = check.status === 'ok' ? green('✓') : check.status === 'warn' ? yellow('⚠') : red('✗');
188
+ console.log(`${symbol} ${check.label}${check.hint ? ` ${dim(check.hint)}` : ''}`);
189
+ }
190
+ }
191
+
192
+ if (checks.some((c) => c.status === 'fail')) process.exit(1);
193
+ }
@@ -0,0 +1,142 @@
1
+ import { afterEach, describe, expect, it } from 'vitest';
2
+ import fs from 'node:fs';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+
6
+ import { agentInit } from './agent.mjs';
7
+ import { runChecks } from './doctor.mjs';
8
+
9
+ const START_MARKER = '<!-- caper:agent-start -->';
10
+ const END_MARKER = '<!-- caper:agent-end -->';
11
+
12
+ const installedVersion = JSON.parse(fs.readFileSync(path.resolve(process.cwd(), 'package.json'), 'utf-8')).version;
13
+
14
+ let tempDir = null;
15
+
16
+ afterEach(() => {
17
+ if (tempDir && fs.existsSync(tempDir)) {
18
+ fs.rmSync(tempDir, { recursive: true, force: true });
19
+ }
20
+ tempDir = null;
21
+ });
22
+
23
+ function makeTempDir() {
24
+ tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'caper-doctor-'));
25
+ return tempDir;
26
+ }
27
+
28
+ function setMtime(file, secondsAgo) {
29
+ const t = Date.now() / 1000 - secondsAgo;
30
+ fs.utimesSync(file, t, t);
31
+ }
32
+
33
+ function find(checks, id) {
34
+ return checks.find((c) => c.id === id);
35
+ }
36
+
37
+ describe('runChecks', () => {
38
+ it('reports failures/warnings for an empty app directory', async () => {
39
+ const cwd = makeTempDir();
40
+
41
+ const checks = await runChecks(cwd, { online: false });
42
+
43
+ expect(find(checks, 'version').status).toBe('ok');
44
+ expect(find(checks, 'link').status).toBe('fail');
45
+ expect(find(checks, 'app-types').status).toBe('fail');
46
+ expect(find(checks, 'agent').status).toBe('warn');
47
+ expect(find(checks, 'asset-types').status).toBe('warn');
48
+ expect(find(checks, 'asset-manifest').status).toBe('warn');
49
+ expect(find(checks, 'caches').status).toBe('ok');
50
+ });
51
+
52
+ it('marks app types ok when fresh and warns after a newer source file', async () => {
53
+ const cwd = makeTempDir();
54
+
55
+ fs.mkdirSync(path.join(cwd, 'src/types'), { recursive: true });
56
+ fs.writeFileSync(path.join(cwd, 'caper.config.ts'), '// config', 'utf-8');
57
+ fs.writeFileSync(path.join(cwd, 'src/types/caper-app.d.ts'), '// types', 'utf-8');
58
+ setMtime(path.join(cwd, 'caper.config.ts'), 100);
59
+ setMtime(path.join(cwd, 'src/types/caper-app.d.ts'), 50);
60
+
61
+ const first = await runChecks(cwd, { online: false });
62
+ expect(find(first, 'app-types').status).toBe('ok');
63
+
64
+ fs.mkdirSync(path.join(cwd, 'src/scenes'), { recursive: true });
65
+ fs.writeFileSync(path.join(cwd, 'src/scenes/Example.ts'), '// scene', 'utf-8');
66
+ setMtime(path.join(cwd, 'src/scenes/Example.ts'), 10);
67
+
68
+ const second = await runChecks(cwd, { online: false });
69
+ expect(find(second, 'app-types').status).toBe('warn');
70
+ });
71
+
72
+ it('warns when the agent pointer version does not match the installed version', async () => {
73
+ const cwd = makeTempDir();
74
+
75
+ const block = `${START_MARKER}
76
+ ## Caper agent pointers
77
+
78
+ This app runs on \`@caperjs/core@0.0.1\`.
79
+
80
+ - Before engine-facing work, load the \`caper\` skill at \`.claude/skills/caper/SKILL.md\`.
81
+ ${END_MARKER}`;
82
+ fs.writeFileSync(path.join(cwd, 'AGENTS.md'), block, 'utf-8');
83
+ fs.mkdirSync(path.join(cwd, '.claude/skills/caper'), { recursive: true });
84
+ fs.writeFileSync(path.join(cwd, '.claude/skills/caper/SKILL.md'), '# skill', 'utf-8');
85
+
86
+ const checks = await runChecks(cwd, { online: false });
87
+ const agent = find(checks, 'agent');
88
+
89
+ expect(agent.status).toBe('warn');
90
+ expect(agent.hint).toContain('@caperjs/core@0.0.1');
91
+ });
92
+
93
+ it('reports agent pointers ok when the block and skill are current', async () => {
94
+ const cwd = makeTempDir();
95
+
96
+ await agentInit(cwd);
97
+
98
+ const checks = await runChecks(cwd, { online: false });
99
+ expect(find(checks, 'agent').status).toBe('ok');
100
+ });
101
+
102
+ it('reports a linked package missing its build', async () => {
103
+ const cwd = makeTempDir();
104
+ const checkout = path.join(cwd, 'checkout');
105
+ fs.mkdirSync(path.join(checkout, 'src'), { recursive: true });
106
+ fs.writeFileSync(path.join(checkout, 'src/a.ts'), '// a', 'utf-8');
107
+
108
+ const linkPath = path.join(cwd, 'node_modules/@caperjs/core');
109
+ fs.mkdirSync(path.dirname(linkPath), { recursive: true });
110
+ fs.symlinkSync(checkout, linkPath, 'dir');
111
+
112
+ const checks = await runChecks(cwd, { online: false });
113
+ const link = find(checks, 'link');
114
+
115
+ expect(link.status).toBe('fail');
116
+ expect(link.label).toContain('linked from');
117
+ expect(link.hint).toContain('pnpm build');
118
+ });
119
+
120
+ it('reports a linked package ok when the build is newer than source', async () => {
121
+ const cwd = makeTempDir();
122
+ const checkout = path.join(cwd, 'checkout');
123
+
124
+ fs.mkdirSync(path.join(checkout, 'src'), { recursive: true });
125
+ fs.writeFileSync(path.join(checkout, 'src/a.ts'), '// a', 'utf-8');
126
+ setMtime(path.join(checkout, 'src/a.ts'), 50);
127
+
128
+ fs.mkdirSync(path.join(checkout, 'lib'), { recursive: true });
129
+ fs.writeFileSync(path.join(checkout, 'lib/caper.mjs'), '// build', 'utf-8');
130
+ setMtime(path.join(checkout, 'lib/caper.mjs'), 10);
131
+
132
+ const linkPath = path.join(cwd, 'node_modules/@caperjs/core');
133
+ fs.mkdirSync(path.dirname(linkPath), { recursive: true });
134
+ fs.symlinkSync(checkout, linkPath, 'dir');
135
+
136
+ const checks = await runChecks(cwd, { online: false });
137
+ const link = find(checks, 'link');
138
+
139
+ expect(link.status).toBe('ok');
140
+ expect(link.label).toContain('linked from');
141
+ });
142
+ });