@looop-games/cli 0.1.4 → 0.1.6
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/bin/looop.mjs +1 -1
- package/lib/agent-surface.mjs +251 -0
- package/lib/bundle-primitives.mjs +2 -2
- package/lib/create.mjs +8 -1
- package/lib/dev.mjs +3 -5
- package/lib/feedback.mjs +1 -1
- package/lib/inject.mjs +1 -2
- package/lib/llm-shim.mjs +1 -1
- package/lib/login.mjs +1 -2
- package/lib/ports.mjs +1 -1
- package/lib/publish.mjs +6 -7
- package/lib/static-server.mjs +2 -3
- package/lib/test-cmd.mjs +3 -4
- package/lib/update.mjs +74 -15
- package/package.json +3 -2
- package/template/.claude/skills/engine/SKILL.md +1 -1
- package/template/.claude/skills/update-looop/SKILL.md +74 -0
- package/template/AGENTS.md +1 -1
- package/lib/bundle-primitives.test.mjs +0 -95
- package/lib/config.test.mjs +0 -37
- package/lib/create.test.mjs +0 -433
- package/lib/dev.test.mjs +0 -52
- package/lib/engine.test.mjs +0 -176
- package/lib/feedback.test.mjs +0 -144
- package/lib/inject.test.mjs +0 -87
- package/lib/llm-shim.test.mjs +0 -90
- package/lib/login.test.mjs +0 -89
- package/lib/npm.test.mjs +0 -61
- package/lib/project.test.mjs +0 -88
- package/lib/publish.test.mjs +0 -148
- package/lib/static-server.test.mjs +0 -161
- package/lib/test-cmd.test.mjs +0 -118
- package/lib/update.test.mjs +0 -98
package/bin/looop.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
// looop — the Looop game-development CLI
|
|
2
|
+
// looop — the Looop game-development CLI.
|
|
3
3
|
//
|
|
4
4
|
// Runs inside a standalone game folder (its own repo). The agent is the
|
|
5
5
|
// primary user: keep output plain, actionable, and machine-legible.
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
// The agent surface — skills + the Layer-0 managed block — and how an update
|
|
2
|
+
// reaches a repo that already exists (creator-harness Slice 2, decision Q2).
|
|
3
|
+
//
|
|
4
|
+
// The surface ships INSIDE the versioned engine artifact, not the npm CLI: the
|
|
5
|
+
// CLI's template is only a create-time SEED (the engine is login-gated, so a
|
|
6
|
+
// fresh folder can't download one before its first `dev`). From then on the
|
|
7
|
+
// artifact is the source of truth. Without this, a creator's skills are frozen
|
|
8
|
+
// at the version they created the game with — the exact freeze Q2 exists to end.
|
|
9
|
+
//
|
|
10
|
+
// **This runs ONLY under `looop update`** (2026-07-11) — never on `dev`
|
|
11
|
+
// or `publish`. It COULD run there safely (it can only ever converge a repo to
|
|
12
|
+
// the engine version already pinned in its package.json, never a newer one),
|
|
13
|
+
// but a creator who ran `dev` and then found files they never wrote sitting in
|
|
14
|
+
// `git status` would be right to feel we'd gone through their pockets. Updates
|
|
15
|
+
// are something you ASK for. So this module writes nothing on its own schedule,
|
|
16
|
+
// and it narrates nothing either: it returns what it did, and `looop update`
|
|
17
|
+
// tells the creator, who decides whether to commit it.
|
|
18
|
+
//
|
|
19
|
+
// The one contract everything here serves:
|
|
20
|
+
//
|
|
21
|
+
// an update reaches a pre-existing repo without touching anything
|
|
22
|
+
// user-owned.
|
|
23
|
+
//
|
|
24
|
+
// So ownership is tracked explicitly, in `.looop/agent-surface.json` — the
|
|
25
|
+
// hash of every file WE wrote. On each run a file is one of:
|
|
26
|
+
//
|
|
27
|
+
// ours, unmodified (hash matches what we recorded) → replace with the new
|
|
28
|
+
// ours, edited (hash differs) → KEEP theirs, warn
|
|
29
|
+
// never ours (no record, but on disk) → KEEP theirs, warn
|
|
30
|
+
// not on disk → place ours
|
|
31
|
+
// retired upstream → remove, if still ours
|
|
32
|
+
//
|
|
33
|
+
// A skill we never placed is never even enumerated, so a creator's own skills
|
|
34
|
+
// are invisible to this code by construction. `AGENTS.md` is the exception that
|
|
35
|
+
// proves the rule: the managed block between the markers is ours to rewrite;
|
|
36
|
+
// everything outside them (their title, their instructions below the end
|
|
37
|
+
// marker) is theirs and is spliced back untouched.
|
|
38
|
+
import { createHash } from 'node:crypto';
|
|
39
|
+
import { cpSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
40
|
+
import { dirname, join } from 'node:path';
|
|
41
|
+
|
|
42
|
+
export const SURFACE_DIR = 'agent-surface';
|
|
43
|
+
export const LOCAL_MANIFEST = '.looop/agent-surface.json';
|
|
44
|
+
|
|
45
|
+
const START = '<!-- looop:managed:start -->';
|
|
46
|
+
const END = '<!-- looop:managed:end -->';
|
|
47
|
+
|
|
48
|
+
const sha = (buf) => createHash('sha256').update(buf).digest('hex');
|
|
49
|
+
|
|
50
|
+
// Where each artifact path lands in the game repo. `skills/**` is the whole
|
|
51
|
+
// surface today; AGENTS.md is spliced rather than written whole.
|
|
52
|
+
function target(rel) {
|
|
53
|
+
if (rel === 'AGENTS.md') return 'AGENTS.md';
|
|
54
|
+
if (rel.startsWith('skills/')) return join('.claude', rel);
|
|
55
|
+
return null; // unknown surface entry from a newer engine — ignore, don't guess
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function projectName(projectDir) {
|
|
59
|
+
try {
|
|
60
|
+
return JSON.parse(readFileSync(join(projectDir, 'package.json'), 'utf8')).name || '';
|
|
61
|
+
} catch {
|
|
62
|
+
return '';
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function readLocal(projectDir) {
|
|
67
|
+
try {
|
|
68
|
+
const m = JSON.parse(readFileSync(join(projectDir, LOCAL_MANIFEST), 'utf8'));
|
|
69
|
+
return { engineVersion: m.engineVersion ?? null, placed: m.placed ?? {} };
|
|
70
|
+
} catch {
|
|
71
|
+
return null; // no manifest = a pre-Slice-2 repo; see `adopt` below
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function writeLocal(projectDir, engineVersion, placed) {
|
|
76
|
+
const p = join(projectDir, LOCAL_MANIFEST);
|
|
77
|
+
mkdirSync(dirname(p), { recursive: true });
|
|
78
|
+
writeFileSync(p, JSON.stringify({ engineVersion, placed }, null, 2) + '\n');
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Splice the artifact's managed block into the repo's AGENTS.md, preserving
|
|
82
|
+
// everything outside the markers. Returns null when the creator's file has no
|
|
83
|
+
// markers — they rewrote Layer 0 themselves, so it is theirs now.
|
|
84
|
+
function spliceManaged(current, incoming) {
|
|
85
|
+
const cs = current.indexOf(START);
|
|
86
|
+
const ce = current.indexOf(END);
|
|
87
|
+
if (cs === -1 || ce === -1 || ce < cs) return null;
|
|
88
|
+
|
|
89
|
+
const is = incoming.indexOf(START);
|
|
90
|
+
const ie = incoming.indexOf(END);
|
|
91
|
+
if (is === -1 || ie === -1) return null;
|
|
92
|
+
|
|
93
|
+
const block = incoming.slice(is, ie + END.length);
|
|
94
|
+
return current.slice(0, cs) + block + current.slice(ce + END.length);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Reconcile the game repo's agent surface against the installed engine artifact.
|
|
99
|
+
* Never throws: a malformed or absent surface must not block `looop dev`.
|
|
100
|
+
*/
|
|
101
|
+
export function reconcileAgentSurface(projectDir, engineDir, { log = console.log } = {}) {
|
|
102
|
+
const result = {
|
|
103
|
+
placed: [], removed: [], shadowed: [], backups: [],
|
|
104
|
+
adopted: false, skipped: false, engineVersion: null, backupDir: null,
|
|
105
|
+
};
|
|
106
|
+
const surface = join(engineDir, SURFACE_DIR);
|
|
107
|
+
|
|
108
|
+
let manifest;
|
|
109
|
+
try {
|
|
110
|
+
if (!existsSync(join(surface, 'manifest.json'))) {
|
|
111
|
+
result.skipped = true; // engine older than Slice 2 — it carries no surface
|
|
112
|
+
return result;
|
|
113
|
+
}
|
|
114
|
+
manifest = JSON.parse(readFileSync(join(surface, 'manifest.json'), 'utf8'));
|
|
115
|
+
} catch (err) {
|
|
116
|
+
log(`⚠️ Could not read the engine's agent surface (${err.message}) — skills left as they are.`);
|
|
117
|
+
result.skipped = true;
|
|
118
|
+
return result;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
try {
|
|
122
|
+
const name = projectName(projectDir);
|
|
123
|
+
const render = (buf) => Buffer.from(buf.toString('utf8').replaceAll('{{name}}', name), 'utf8');
|
|
124
|
+
|
|
125
|
+
const prior = readLocal(projectDir);
|
|
126
|
+
// Adoption: a repo scaffolded before this mechanism existed has our files on
|
|
127
|
+
// disk but no record of them. We take ownership so it can ever be updated —
|
|
128
|
+
// and back up anything whose bytes diverge, so an edit we can't distinguish
|
|
129
|
+
// from ours is preserved rather than destroyed.
|
|
130
|
+
const adopt = prior === null;
|
|
131
|
+
result.adopted = adopt;
|
|
132
|
+
const placed = adopt ? {} : { ...prior.placed };
|
|
133
|
+
|
|
134
|
+
const backupRoot = join(projectDir, '.looop', `backup-${manifest.engineVersion}`);
|
|
135
|
+
const backups = [];
|
|
136
|
+
const backup = (rel) => {
|
|
137
|
+
const from = join(projectDir, rel);
|
|
138
|
+
if (!existsSync(from)) return;
|
|
139
|
+
const to = join(backupRoot, rel);
|
|
140
|
+
mkdirSync(dirname(to), { recursive: true });
|
|
141
|
+
cpSync(from, to, { recursive: true });
|
|
142
|
+
backups.push(rel);
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
// ── retired entries: remove what upstream dropped. This is the half that
|
|
146
|
+
// makes a RENAME possible on a repo that already exists — without it a
|
|
147
|
+
// replaced skill would linger next to its replacement forever. ─────────
|
|
148
|
+
for (const rel of manifest.retired ?? []) {
|
|
149
|
+
const dest = target(rel);
|
|
150
|
+
if (!dest) continue;
|
|
151
|
+
const abs = join(projectDir, dest);
|
|
152
|
+
if (!existsSync(abs)) continue;
|
|
153
|
+
|
|
154
|
+
const owned = Object.keys(placed).some((p) => p === dest || p.startsWith(`${dest}/`));
|
|
155
|
+
if (!owned && !adopt) {
|
|
156
|
+
result.shadowed.push(dest);
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
if (adopt) backup(dest);
|
|
160
|
+
rmSync(abs, { recursive: true, force: true });
|
|
161
|
+
for (const p of Object.keys(placed)) {
|
|
162
|
+
if (p === dest || p.startsWith(`${dest}/`)) delete placed[p];
|
|
163
|
+
}
|
|
164
|
+
result.removed.push(dest);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// ── the live surface ────────────────────────────────────────────────────
|
|
168
|
+
for (const rel of Object.keys(manifest.files ?? {})) {
|
|
169
|
+
const dest = target(rel);
|
|
170
|
+
if (!dest) continue;
|
|
171
|
+
const src = join(surface, rel);
|
|
172
|
+
if (!existsSync(src)) continue;
|
|
173
|
+
|
|
174
|
+
const abs = join(projectDir, dest);
|
|
175
|
+
const desired = render(readFileSync(src));
|
|
176
|
+
const exists = existsSync(abs);
|
|
177
|
+
const current = exists ? readFileSync(abs) : null;
|
|
178
|
+
const recorded = placed[dest];
|
|
179
|
+
|
|
180
|
+
if (dest === 'AGENTS.md') {
|
|
181
|
+
// The markers ARE the contract: inside is ours, outside is theirs.
|
|
182
|
+
const next = exists
|
|
183
|
+
? spliceManaged(current.toString('utf8'), desired.toString('utf8'))
|
|
184
|
+
: desired.toString('utf8');
|
|
185
|
+
if (next === null) {
|
|
186
|
+
result.shadowed.push(dest);
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
if (exists && current.toString('utf8') === next) continue;
|
|
190
|
+
writeFileSync(abs, next);
|
|
191
|
+
placed[dest] = sha(Buffer.from(next, 'utf8'));
|
|
192
|
+
result.placed.push(dest);
|
|
193
|
+
continue;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
if (exists) {
|
|
197
|
+
const now = sha(current);
|
|
198
|
+
if (now === recorded) {
|
|
199
|
+
if (now === sha(desired)) continue; // already current
|
|
200
|
+
} else if (recorded === undefined && adopt) {
|
|
201
|
+
if (now !== sha(desired)) backup(dest);
|
|
202
|
+
} else {
|
|
203
|
+
// Either they edited ours, or it was never ours. Both are theirs.
|
|
204
|
+
result.shadowed.push(dest);
|
|
205
|
+
placed[dest] = recorded ?? now;
|
|
206
|
+
continue;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
mkdirSync(dirname(abs), { recursive: true });
|
|
211
|
+
writeFileSync(abs, desired);
|
|
212
|
+
placed[dest] = sha(desired);
|
|
213
|
+
result.placed.push(dest);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
writeLocal(projectDir, manifest.engineVersion, placed);
|
|
217
|
+
result.engineVersion = manifest.engineVersion;
|
|
218
|
+
result.backups = backups;
|
|
219
|
+
result.backupDir = backups.length
|
|
220
|
+
? `${LOCAL_MANIFEST.replace('agent-surface.json', '')}backup-${manifest.engineVersion}/`
|
|
221
|
+
: null;
|
|
222
|
+
} catch (err) {
|
|
223
|
+
// A surface bug must never be the reason a creator can't run their game.
|
|
224
|
+
log(`⚠️ Could not sync the agent skills (${err.message}) — continuing; your game is unaffected.`);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
return result;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// Used by `looop create`: the template seed IS the surface for a brand-new
|
|
231
|
+
// repo, so record it as ours immediately. Without this the first `dev` would
|
|
232
|
+
// treat a two-minute-old scaffold as an unknown pre-existing repo and adopt it.
|
|
233
|
+
export function recordSeededSurface(projectDir, engineVersion) {
|
|
234
|
+
const placed = {};
|
|
235
|
+
const add = (rel) => {
|
|
236
|
+
const abs = join(projectDir, rel);
|
|
237
|
+
if (existsSync(abs)) placed[rel] = sha(readFileSync(abs));
|
|
238
|
+
};
|
|
239
|
+
const walk = (rel) => {
|
|
240
|
+
const abs = join(projectDir, rel);
|
|
241
|
+
if (!existsSync(abs)) return;
|
|
242
|
+
for (const entry of readdirSync(abs)) {
|
|
243
|
+
const child = join(rel, entry);
|
|
244
|
+
if (statSync(join(projectDir, child)).isDirectory()) walk(child);
|
|
245
|
+
else add(child);
|
|
246
|
+
}
|
|
247
|
+
};
|
|
248
|
+
walk(join('.claude', 'skills'));
|
|
249
|
+
add('AGENTS.md');
|
|
250
|
+
writeLocal(projectDir, engineVersion ?? null, placed);
|
|
251
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
1
|
+
// Bundle a server-backed game's primitives on the CREATOR's machine, with the
|
|
2
|
+
// engine marked external.
|
|
3
3
|
//
|
|
4
4
|
// The publish endpoint is a Pages Function — a Worker with no filesystem and no
|
|
5
5
|
// npm — so it cannot run a bundler. But real server primitives import npm
|
package/lib/create.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// `looop create <name>` — bootstrap a standalone Looop game
|
|
1
|
+
// `looop create <name>` — bootstrap a standalone Looop game.
|
|
2
2
|
//
|
|
3
3
|
// The create-next-app gesture: one command → a complete folder that is its
|
|
4
4
|
// own repo, where `looop dev` immediately serves a WORKING multiplayer game
|
|
@@ -19,6 +19,7 @@ import { join, relative, dirname } from 'node:path';
|
|
|
19
19
|
import { getApiBase } from './config.mjs';
|
|
20
20
|
import { DEFAULT_API_BASE } from './llm-shim.mjs';
|
|
21
21
|
import { runNpm } from './npm.mjs';
|
|
22
|
+
import { recordSeededSurface } from './agent-surface.mjs';
|
|
22
23
|
|
|
23
24
|
export const TEMPLATE_DIR = join(import.meta.dirname, '..', 'template');
|
|
24
25
|
|
|
@@ -127,6 +128,12 @@ export async function create({
|
|
|
127
128
|
) + '\n',
|
|
128
129
|
);
|
|
129
130
|
|
|
131
|
+
// The template we just copied IS this repo's agent surface, so record it as
|
|
132
|
+
// ours now (Slice 2 / Q2). Without this, the first `looop dev` would meet a
|
|
133
|
+
// two-minute-old scaffold with no ownership record, treat it as a legacy
|
|
134
|
+
// pre-Slice-2 repo, and noisily "adopt" it. From here the artifact drives it.
|
|
135
|
+
recordSeededSurface(dir, enginePin);
|
|
136
|
+
|
|
130
137
|
if (install) {
|
|
131
138
|
log(`Installing the CLI (npm install in ${name}/)…`);
|
|
132
139
|
runNpm(['install', '--no-audit', '--no-fund'], { cwd: dir, stdio: 'pipe' });
|
package/lib/dev.mjs
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
|
-
// `looop dev` — the standalone three-service dev stack
|
|
2
|
-
// cuqfzo), same shape the monorepo's dev.sh proved:
|
|
1
|
+
// `looop dev` — the standalone three-service dev stack:
|
|
3
2
|
//
|
|
4
3
|
// static :8000 game folder at /games/<slug>/, /shared/ → engine bundle,
|
|
5
4
|
// head injection + auto-reload
|
|
@@ -37,12 +36,11 @@ export function partykitBin() {
|
|
|
37
36
|
}
|
|
38
37
|
}
|
|
39
38
|
|
|
40
|
-
// Per-game server parity
|
|
39
|
+
// Per-game server parity: a game shipping its own
|
|
41
40
|
// `partykit.json` runs its OWN server in dev — partykit gets the game folder
|
|
42
41
|
// as cwd, resolving the game's `main` entry (which imports the room server
|
|
43
42
|
// from the installed engine). Without one, the bundle's shared room server
|
|
44
|
-
// runs, exactly as before.
|
|
45
|
-
// vendor-flipped game behaves identically standalone. A config whose `main`
|
|
43
|
+
// runs, exactly as before. A config whose `main`
|
|
46
44
|
// doesn't exist is ignored LOUDLY — a dead file must not take dev's
|
|
47
45
|
// multiplayer down with it.
|
|
48
46
|
export function partykitCwdFor(project, engine, warn = console.warn) {
|
package/lib/feedback.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// `looop feedback` — send the reports under notes/feedback/ to the Looop team
|
|
2
|
-
// (
|
|
2
|
+
// (the transport behind the template's /feedback skill).
|
|
3
3
|
//
|
|
4
4
|
// Target: /api/creator/feedback — the token-authenticated intake beside
|
|
5
5
|
// creator/publish. Each unsent notes/feedback/*.md ships VERBATIM as the
|
package/lib/inject.mjs
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
|
-
// HTML/JS dev-serving transforms
|
|
2
|
-
// looop-core tools/game/dev_server.py, kept behaviour-identical:
|
|
1
|
+
// HTML/JS dev-serving transforms:
|
|
3
2
|
//
|
|
4
3
|
// * Head injection mirrors the production /g/<slug> serve path
|
|
5
4
|
// (builder/functions/g/[[path]].ts): window.GAME_SLUG + window.LOOOP_IDENTITY
|
package/lib/llm-shim.mjs
CHANGED
package/lib/login.mjs
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
|
-
// `looop login` — device-flow authentication (
|
|
2
|
-
// Q2/Q6, the `gh auth login` shape):
|
|
1
|
+
// `looop login` — device-flow authentication (the `gh auth login` shape):
|
|
3
2
|
//
|
|
4
3
|
// 1. ask the platform for a pairing (device_code for us, user_code for the
|
|
5
4
|
// human),
|
package/lib/ports.mjs
CHANGED
package/lib/publish.mjs
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
// `looop publish` — ship the game to play.looop.games
|
|
1
|
+
// `looop publish` — ship the game to play.looop.games.
|
|
2
2
|
//
|
|
3
3
|
// Server-resolved: we send ONLY the game's own files plus the engine version
|
|
4
4
|
// the installed bundle declares; the platform resolves /shared/... from its
|
|
5
|
-
// release registry.
|
|
6
|
-
//
|
|
5
|
+
// release registry. The client never stages the shared library itself — that
|
|
6
|
+
// trust hole is what this design replaces.
|
|
7
7
|
//
|
|
8
8
|
// Target: /api/creator/publish — the token-authenticated creator lane, the
|
|
9
9
|
// one publish route reachable on the public play host (the legacy
|
|
@@ -21,9 +21,8 @@ import { bundlePrimitives, PRIMITIVES_ENTRY } from './bundle-primitives.mjs';
|
|
|
21
21
|
export const PLAY_BASE = 'https://play.looop.games';
|
|
22
22
|
|
|
23
23
|
// Never shipped: tooling, VCS, agent workspace, notes + handbook + agent
|
|
24
|
-
// instructions (repo knowledge travels with the repo, not the catalog —
|
|
25
|
-
//
|
|
26
|
-
// public URL), tests/smokes.
|
|
24
|
+
// instructions (repo knowledge travels with the repo, not the catalog — a
|
|
25
|
+
// published game must not expose it on a public URL), tests/smokes.
|
|
27
26
|
const SKIP_DIRS = new Set(['node_modules', 'notes', 'handbook', '.git', '.looop', '.claude', '__pycache__']);
|
|
28
27
|
const SKIP_FILES = [
|
|
29
28
|
/^package(-lock)?\.json$/,
|
|
@@ -69,7 +68,7 @@ export async function publish({
|
|
|
69
68
|
throw new Error('this game has no index.html at its root — publish needs an entry page.');
|
|
70
69
|
}
|
|
71
70
|
|
|
72
|
-
// Server-backed game
|
|
71
|
+
// Server-backed game: its primitives can
|
|
73
72
|
// import npm packages + WASM the platform's bundler-less endpoint can't
|
|
74
73
|
// resolve, so we bundle them HERE (engine marked external) and send the
|
|
75
74
|
// single self-contained module in place of the raw barrel. The endpoint
|
package/lib/static-server.mjs
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
|
-
// The standalone dev static server —
|
|
2
|
-
//
|
|
3
|
-
// bundle behind the same URL shape production uses:
|
|
1
|
+
// The standalone dev static server — serves a single game + the installed
|
|
2
|
+
// engine bundle behind the same URL shape production uses:
|
|
4
3
|
//
|
|
5
4
|
// /games/<slug>/… → the game folder
|
|
6
5
|
// /shared/… → the engine bundle's shared/ tree
|
package/lib/test-cmd.mjs
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
|
-
// `looop test` — run the game's own verification gate
|
|
2
|
-
// Slice 1; the successor to the monorepo's `gamedev <name> test`).
|
|
1
|
+
// `looop test` — run the game's own verification gate.
|
|
3
2
|
//
|
|
4
3
|
// Discovers the game's test files — `*.test.mjs` (unit, run under
|
|
5
4
|
// `node --test`) and `*.smoke.mjs` (integration, run against a REAL dev
|
|
@@ -115,8 +114,8 @@ export async function testCmd({ cwd = process.cwd(), log = console.log, devFn =
|
|
|
115
114
|
env: {
|
|
116
115
|
...env,
|
|
117
116
|
LOOOP_TEST_GAME_URL: handle.url,
|
|
118
|
-
//
|
|
119
|
-
//
|
|
117
|
+
// A legacy alias for the same URL, exported so smokes written
|
|
118
|
+
// against the older convention keep running unedited.
|
|
120
119
|
GAMEDEV_TEST_GAME_URL: handle.url,
|
|
121
120
|
URL: handle.url,
|
|
122
121
|
},
|
package/lib/update.mjs
CHANGED
|
@@ -1,15 +1,53 @@
|
|
|
1
|
-
// `looop update` — move this game to the latest
|
|
2
|
-
// the
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
// (
|
|
6
|
-
//
|
|
7
|
-
|
|
1
|
+
// `looop update` — move this game to the latest Looop: the engine bundle AND
|
|
2
|
+
// the agent surface (skills + the Layer-0 managed block) that rides it.
|
|
3
|
+
//
|
|
4
|
+
// This is the ONLY command that writes to `.claude/skills/` or `AGENTS.md`
|
|
5
|
+
// (creator-harness Slice 2 / Q2, scoped to update-only, 2026-07-11).
|
|
6
|
+
// `dev` and `publish` never touch them: an update is something a creator asks
|
|
7
|
+
// for, not something that happens to them while they were doing something else.
|
|
8
|
+
//
|
|
9
|
+
// Because it writes into files the creator has in git, it OWES them a full
|
|
10
|
+
// account of what it changed — every path, and whose it was. The `/update-looop`
|
|
11
|
+
// skill wraps this to walk them through the diff and offer the commit.
|
|
12
|
+
import { findProject, resolveEngine } from './project.mjs';
|
|
8
13
|
import { ensureEngine, readEnginePin, writeEnginePin } from './engine.mjs';
|
|
14
|
+
import { reconcileAgentSurface } from './agent-surface.mjs';
|
|
9
15
|
import { getToken, getApiBase } from './config.mjs';
|
|
10
16
|
import { login } from './login.mjs';
|
|
11
17
|
import { DEFAULT_API_BASE } from './llm-shim.mjs';
|
|
12
18
|
|
|
19
|
+
// The report is the point. A creator reading this must be able to answer, with
|
|
20
|
+
// no further digging: what changed, was any of it mine, and what do I do now.
|
|
21
|
+
function report(log, engineVersion, surface) {
|
|
22
|
+
const { placed = [], removed = [], shadowed = [], backups = [], backupDir } = surface;
|
|
23
|
+
if (!placed.length && !removed.length && !shadowed.length) {
|
|
24
|
+
log(' Your skills and instructions were already current.');
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
log('');
|
|
29
|
+
log(`Looop also updated this game's skills and instructions to match engine ${engineVersion}:`);
|
|
30
|
+
for (const f of placed) log(` updated ${f}`);
|
|
31
|
+
for (const f of removed) log(` removed ${f} (retired by Looop)`);
|
|
32
|
+
for (const f of shadowed) log(` KEPT YOURS ${f} (you changed it — we left it alone)`);
|
|
33
|
+
|
|
34
|
+
if (backups.length) {
|
|
35
|
+
// On adoption we cannot tell an untouched old platform file from one the
|
|
36
|
+
// creator edited — there is no ownership record to compare against, which
|
|
37
|
+
// is precisely why we're adopting. Most of these are just the previous
|
|
38
|
+
// version's files. Claiming "you edited these" would be a lie that alarms;
|
|
39
|
+
// saying nothing would risk a silent overwrite. State only what is true.
|
|
40
|
+
log('');
|
|
41
|
+
log(` This game predates Looop's skill updates, so those files had no`);
|
|
42
|
+
log(` ownership record. Their previous contents are saved in ${backupDir}`);
|
|
43
|
+
log(` — if you had edited any of them, your version is in there.`);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
log('');
|
|
47
|
+
log(" These are Looop's files inside your repo, so they show up in git as");
|
|
48
|
+
log(" changes you didn't make. Review them and save them with your next commit.");
|
|
49
|
+
}
|
|
50
|
+
|
|
13
51
|
export async function update({
|
|
14
52
|
cwd = process.cwd(),
|
|
15
53
|
apiBase = getApiBase(DEFAULT_API_BASE),
|
|
@@ -17,6 +55,7 @@ export async function update({
|
|
|
17
55
|
fetchImpl = fetch,
|
|
18
56
|
loginFn = login,
|
|
19
57
|
ensure = ensureEngine,
|
|
58
|
+
reconcile = reconcileAgentSurface,
|
|
20
59
|
} = {}) {
|
|
21
60
|
const project = findProject(cwd);
|
|
22
61
|
const from = readEnginePin(project.dir);
|
|
@@ -36,16 +75,36 @@ export async function update({
|
|
|
36
75
|
const { latest } = await res.json();
|
|
37
76
|
if (!latest) throw new Error('the platform has no downloadable engine releases yet.');
|
|
38
77
|
|
|
78
|
+
let engineDir = null;
|
|
79
|
+
let updated = false;
|
|
80
|
+
|
|
39
81
|
if (from === latest) {
|
|
40
82
|
log(`✅ Engine ${latest} — already up to date.`);
|
|
41
|
-
|
|
83
|
+
// Reconcile anyway. A repo can sit on the latest engine and STILL have an
|
|
84
|
+
// out-of-date surface: one scaffolded before this mechanism existed has
|
|
85
|
+
// never had its skills adopted, and would otherwise wait forever for a
|
|
86
|
+
// release it already has.
|
|
87
|
+
try {
|
|
88
|
+
engineDir = resolveEngine(project.dir).dir;
|
|
89
|
+
} catch {
|
|
90
|
+
engineDir = null; // engine not installed yet — the next `dev` fetches it
|
|
91
|
+
}
|
|
92
|
+
} else {
|
|
93
|
+
// Rewrite the pin first; ensureEngine honors it (download → install → pin).
|
|
94
|
+
writeEnginePin(project.dir, latest);
|
|
95
|
+
const engine = await ensure(project.dir, { apiBase, log, fetchImpl });
|
|
96
|
+
engineDir = engine.dir ?? null;
|
|
97
|
+
updated = true;
|
|
98
|
+
log('');
|
|
99
|
+
log(`✅ Engine updated: ${from ?? '(none)'} → ${engine.version}`);
|
|
42
100
|
}
|
|
43
101
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
102
|
+
const surface = engineDir ? reconcile(project.dir, engineDir, { log }) : { skipped: true };
|
|
103
|
+
if (!surface.skipped) report(log, surface.engineVersion ?? latest, surface);
|
|
104
|
+
|
|
105
|
+
if (updated) {
|
|
106
|
+
log('');
|
|
107
|
+
log(' Republish (`npx looop publish`) when you want the live game on it.');
|
|
108
|
+
}
|
|
109
|
+
return { from, to: latest, updated, surface };
|
|
51
110
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@looop-games/cli",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.6",
|
|
4
4
|
"description": "Looop game development CLI — dev server, login, and publishing for standalone Looop games.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -10,7 +10,8 @@
|
|
|
10
10
|
"files": [
|
|
11
11
|
"bin",
|
|
12
12
|
"lib",
|
|
13
|
-
"template"
|
|
13
|
+
"template",
|
|
14
|
+
"!lib/**/*.test.mjs"
|
|
14
15
|
],
|
|
15
16
|
"engines": {
|
|
16
17
|
"node": ">=20.11"
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: engine
|
|
3
|
-
description: Discover what the Looop engine already provides before building something from scratch —
|
|
3
|
+
description: Discover what the Looop engine already provides before building something from scratch — its component library, craft docs, and how to customize engine behaviour. Read the index rather than assuming what exists. Use when the creator asks "can it do X?", when you're about to write a system a game engine would normally provide, or when tuning game feel.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# /engine — what Looop already gives you
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: update-looop
|
|
3
|
+
description: Update Looop itself — the engine, and the skills and instructions this repo runs on. Use when the creator asks for the latest Looop ("is there an update?", "update looop", "get the newest version"), when a Looop feature they've heard about is missing, or when something looks like a Looop bug that may already be fixed. Not for updating their GAME — that's just building.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# /update-looop — get the latest Looop, and explain what changed
|
|
7
|
+
|
|
8
|
+
`npx looop update` moves this game to the newest Looop release. It is the **only**
|
|
9
|
+
command that writes to Looop's files inside this repo — `dev` and `publish` never
|
|
10
|
+
do. That is deliberate: an update is something the creator *asks* for.
|
|
11
|
+
|
|
12
|
+
But it does write into **their** repo. After it runs, `git status` shows changes
|
|
13
|
+
they did not make. **Your job is to make sure that is never a surprise.**
|
|
14
|
+
|
|
15
|
+
## Before
|
|
16
|
+
|
|
17
|
+
Tell them what an update touches, in one or two sentences:
|
|
18
|
+
|
|
19
|
+
- **The engine** — the game code they import from (`/shared/...`). Their game
|
|
20
|
+
keeps running on the old engine until they publish again, so an update is safe
|
|
21
|
+
to take at any time.
|
|
22
|
+
- **The skills and instructions** — the files in `.claude/skills/` and the Looop
|
|
23
|
+
section of `AGENTS.md`. These are Looop's, living in their repo.
|
|
24
|
+
- **Never their game.** Not `index.html`, not `game.js`, not `handbook/`, not
|
|
25
|
+
`notes/`. And never a skill they wrote, or an edit they made to one of ours —
|
|
26
|
+
where they've changed something, their version wins and the update says so.
|
|
27
|
+
|
|
28
|
+
If they have uncommitted work, say so first — the update's changes will land
|
|
29
|
+
alongside it, and a mixed diff is harder to read. Offer to save theirs first.
|
|
30
|
+
|
|
31
|
+
## Run it
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
npx looop update
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
It prints exactly what it did: every file updated, every file removed, and every
|
|
38
|
+
file it **kept because the creator had changed it**. Read that output — it is the
|
|
39
|
+
source of truth for the next step, not your assumptions about what a release
|
|
40
|
+
contains.
|
|
41
|
+
|
|
42
|
+
## After — the part that matters
|
|
43
|
+
|
|
44
|
+
**Report the change in their terms, not ours.** Don't paste the file list back at
|
|
45
|
+
them; tell them what is now different about what you and they can do:
|
|
46
|
+
|
|
47
|
+
> *"Looop updated to engine 0.1.8. The `/update-handbook` skill was replaced by a
|
|
48
|
+
> broader `/handbook` — same job, plus it now keeps your game's vision and design
|
|
49
|
+
> pillars. Your own `/my-thing` skill was left alone."*
|
|
50
|
+
|
|
51
|
+
Then **offer the commit** — never run it unasked:
|
|
52
|
+
|
|
53
|
+
> *"That changed 3 of Looop's files in your repo. Want me to save them?
|
|
54
|
+
> (`git add -A && git commit -m "update Looop to engine 0.1.8"`)"*
|
|
55
|
+
|
|
56
|
+
Two things to volunteer without being asked:
|
|
57
|
+
|
|
58
|
+
- **If they'd edited a Looop file**, the update kept *their* version and did not
|
|
59
|
+
apply ours. Say which file, and that they may want to re-apply their change on
|
|
60
|
+
top of the new one — otherwise they'll silently miss the update to that file
|
|
61
|
+
forever.
|
|
62
|
+
- **If the update mentions a backup folder** (`.looop/backup-<version>/`), that
|
|
63
|
+
is the pre-update copy of files whose history we couldn't be sure of. Tell them
|
|
64
|
+
it exists. They can delete it once they're happy.
|
|
65
|
+
|
|
66
|
+
## Rules
|
|
67
|
+
|
|
68
|
+
- **Never run `looop update` as a side effect of something else.** It is its own
|
|
69
|
+
act, with its own consent. If a bug looks like it might already be fixed
|
|
70
|
+
upstream, *propose* the update — don't just do it mid-build.
|
|
71
|
+
- **Never commit on their behalf** without asking, even when the only changes are
|
|
72
|
+
Looop's own files. It is their repo and their history.
|
|
73
|
+
- The live game at `play.looop.games` does **not** change until they run
|
|
74
|
+
`npx looop publish`. Say so — creators reasonably assume an update is live.
|
package/template/AGENTS.md
CHANGED
|
@@ -19,7 +19,7 @@ applies. This table routes only what has no skill:
|
|
|
19
19
|
| The creator wants… | Do this |
|
|
20
20
|
|---|---|
|
|
21
21
|
| To "save my work" | `git add -A && git commit` (and push, if the repo has a remote). Saving is local; it is **not** publishing. |
|
|
22
|
-
| To update Looop / the engine |
|
|
22
|
+
| To update Looop / the engine | `/update-looop`. It is the ONLY thing that writes Looop's files into this repo (`.claude/skills/`, the managed block of this file) — `dev` and `publish` never do, so an update is never a surprise in their git. The live game changes only on the next publish. |
|
|
23
23
|
| Better game feel (tuning speeds, jumps, timings) | The engine's **tweaks** library (`node_modules/@looop-games/engine/shared/ui/tweaks/`) + `shared/practices/feel.md`. |
|
|
24
24
|
| To change how an engine file behaves | Copy it to `overrides/shared/<same path>` and edit the copy — dev and publish serve your override instead of the official module. Imports stay `/shared/...`; never rewrite them. An override is a fork: that file stops receiving engine updates until you re-port it. |
|
|
25
25
|
|