@looop-games/cli 0.1.8 → 0.1.10
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/CHANGELOG.md +75 -0
- package/bin/looop.mjs +8 -0
- package/lib/agent-surface.mjs +14 -32
- package/lib/changelog.mjs +171 -0
- package/lib/create.mjs +132 -86
- package/lib/update.mjs +29 -2
- package/package.json +2 -2
- package/template/.claude/skills/build/SKILL.md +0 -266
- package/template/.claude/skills/engine/SKILL.md +0 -100
- package/template/.claude/skills/feedback/SKILL.md +0 -125
- package/template/.claude/skills/handbook/SKILL.md +0 -142
- package/template/.claude/skills/qa/SKILL.md +0 -53
- package/template/.claude/skills/todo/SKILL.md +0 -54
- package/template/.claude/skills/update-looop/SKILL.md +0 -74
- package/template/AGENTS.md +0 -93
- package/template/CLAUDE.md +0 -4
- package/template/GEMINI.md +0 -4
- package/template/boot.smoke.mjs +0 -21
- package/template/game.js +0 -53
- package/template/gitignore +0 -2
- package/template/handbook/design.md +0 -8
- package/template/handbook/feel.md +0 -8
- package/template/handbook/qa.md +0 -9
- package/template/index.html +0 -26
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# Changelog — @looop-games/cli
|
|
2
|
+
|
|
3
|
+
The `looop` command itself. This is the one piece of Looop you install from npm;
|
|
4
|
+
everything else (the component library, the room primitives, your game's skills)
|
|
5
|
+
lives in the engine and has [its own changelog](https://docs.looop.games), which
|
|
6
|
+
you can read with `looop changelog`.
|
|
7
|
+
|
|
8
|
+
Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
9
|
+
Versions: [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
10
|
+
|
|
11
|
+
> Entries for 0.1.9 and earlier were reconstructed from the release history after
|
|
12
|
+
> the fact, so they are short and may miss detail. Everything from 0.1.10 on is
|
|
13
|
+
> written as the change is made.
|
|
14
|
+
|
|
15
|
+
## [Unreleased]
|
|
16
|
+
|
|
17
|
+
## [0.1.10] - 2026-07-12
|
|
18
|
+
|
|
19
|
+
### Added
|
|
20
|
+
- `looop changelog` — what changed in the engine. With no arguments it shows
|
|
21
|
+
every release newer than the version your game is pinned to; `looop changelog
|
|
22
|
+
<version>` shows one release, `--all` shows the whole history.
|
|
23
|
+
- `looop update` now prints the changelog for every version it crosses, with
|
|
24
|
+
**BREAKING** entries called out separately, so you find out what has to change
|
|
25
|
+
in your game at the moment you take the update — not when something breaks.
|
|
26
|
+
|
|
27
|
+
## [0.1.9] - 2026-07-12
|
|
28
|
+
|
|
29
|
+
### Changed
|
|
30
|
+
- `looop create` now signs you in and downloads the engine, which carries the
|
|
31
|
+
starter game and your game's skills. The login used to happen at your first
|
|
32
|
+
`looop dev`, one command later.
|
|
33
|
+
- The published package is now just the command itself. Everything you receive as
|
|
34
|
+
a creator — the starter game, the skills, the shared libraries — comes from the
|
|
35
|
+
engine, so the two can no longer drift apart.
|
|
36
|
+
|
|
37
|
+
## [0.1.8] - 2026-07-12
|
|
38
|
+
|
|
39
|
+
### Added
|
|
40
|
+
- `looop feedback` sends the files a report attaches, so whoever reads it can see
|
|
41
|
+
the code you are talking about.
|
|
42
|
+
|
|
43
|
+
## [0.1.7] - 2026-07-11
|
|
44
|
+
|
|
45
|
+
### Changed
|
|
46
|
+
- `looop update` refreshes your game's skills and `AGENTS.md`, and reports every
|
|
47
|
+
file it touched. A file you edited yourself is kept, not overwritten.
|
|
48
|
+
|
|
49
|
+
## [0.1.6] - 2026-07-11
|
|
50
|
+
|
|
51
|
+
### Added
|
|
52
|
+
- `looop update` — move a game onto the latest engine release and re-pin it.
|
|
53
|
+
|
|
54
|
+
## [0.1.5] - 2026-07-11
|
|
55
|
+
|
|
56
|
+
### Added
|
|
57
|
+
- `looop feedback` — send the reports under `notes/feedback/` to the Looop team.
|
|
58
|
+
|
|
59
|
+
## [0.1.4] - 2026-07-11
|
|
60
|
+
|
|
61
|
+
### Fixed
|
|
62
|
+
- `looop create` installs cleanly on macOS (`fsevents` is pre-approved along with
|
|
63
|
+
the other install scripts).
|
|
64
|
+
|
|
65
|
+
## [0.1.3] - 2026-07-11
|
|
66
|
+
|
|
67
|
+
### Fixed
|
|
68
|
+
- `looop create` works on Windows.
|
|
69
|
+
|
|
70
|
+
## [0.1.2] - 2026-07-10
|
|
71
|
+
|
|
72
|
+
### Added
|
|
73
|
+
- First public release. `looop create` scaffolds a game, `looop dev` runs it with
|
|
74
|
+
multiplayer, `looop publish` puts it on play.looop.games, `looop login`
|
|
75
|
+
authenticates the machine.
|
package/bin/looop.mjs
CHANGED
|
@@ -9,6 +9,7 @@ import { publish } from '../lib/publish.mjs';
|
|
|
9
9
|
import { create } from '../lib/create.mjs';
|
|
10
10
|
import { testCmd } from '../lib/test-cmd.mjs';
|
|
11
11
|
import { update } from '../lib/update.mjs';
|
|
12
|
+
import { changelog } from '../lib/changelog.mjs';
|
|
12
13
|
import { sendFeedback } from '../lib/feedback.mjs';
|
|
13
14
|
import { clearToken } from '../lib/config.mjs';
|
|
14
15
|
|
|
@@ -25,6 +26,7 @@ Usage:
|
|
|
25
26
|
looop create <name> Bootstrap a new game folder (multiplayer works out of the box)
|
|
26
27
|
looop dev [--port <n>] Run the full dev stack (game + multiplayer + services)
|
|
27
28
|
looop test Run the game's tests (*.test.mjs) and smokes (*.smoke.mjs)
|
|
29
|
+
looop changelog [<v>] What changed in the engine (default: everything newer than your pin)
|
|
28
30
|
looop update Move this game to the latest engine release (re-pins looop.engine)
|
|
29
31
|
looop publish [--slug <s>] Publish this game to play.looop.games (--slug for an A/B copy)
|
|
30
32
|
looop feedback Send the unsent reports under notes/feedback/ to the Looop team
|
|
@@ -57,6 +59,12 @@ try {
|
|
|
57
59
|
const { ok } = await testCmd();
|
|
58
60
|
process.exit(ok ? 0 : 1);
|
|
59
61
|
}
|
|
62
|
+
case 'changelog':
|
|
63
|
+
await changelog({
|
|
64
|
+
version: rest.find((a) => !a.startsWith('--')),
|
|
65
|
+
all: rest.includes('--all'),
|
|
66
|
+
});
|
|
67
|
+
break;
|
|
60
68
|
case 'update':
|
|
61
69
|
await update();
|
|
62
70
|
break;
|
package/lib/agent-surface.mjs
CHANGED
|
@@ -1,15 +1,20 @@
|
|
|
1
1
|
// The agent surface — skills + the Layer-0 managed block — and how an update
|
|
2
2
|
// reaches a repo that already exists (creator-harness Slice 2, decision Q2).
|
|
3
3
|
//
|
|
4
|
-
// The surface ships INSIDE the versioned engine artifact,
|
|
5
|
-
// CLI
|
|
6
|
-
//
|
|
7
|
-
// artifact
|
|
8
|
-
//
|
|
4
|
+
// The surface ships INSIDE the versioned engine artifact, and ONLY there — the
|
|
5
|
+
// npm CLI carries no copy of it. That is what makes the ownership record below
|
|
6
|
+
// trustworthy: `create` places the surface by calling straight into this module
|
|
7
|
+
// (against the artifact it just installed), so the bytes on disk and the hashes
|
|
8
|
+
// we record come from the same place, and a fresh repo's first `update` is a
|
|
9
|
+
// genuine no-op. The CLI used to seed the surface from its own bundled
|
|
10
|
+
// `template/`, stamped with the ENGINE's version — a record that was false the
|
|
11
|
+
// moment npm and the engine registry shipped out of step, and that made the
|
|
12
|
+
// first `update` overwrite (or DELETE, via the orphan prune below) skills on a
|
|
13
|
+
// repo minutes old.
|
|
9
14
|
//
|
|
10
|
-
// **This runs
|
|
11
|
-
// or `publish`. It COULD run there safely (it can only ever converge a
|
|
12
|
-
// the engine version already pinned in its package.json, never a newer one),
|
|
15
|
+
// **This runs under `looop update` — and under `looop create`** — never on
|
|
16
|
+
// `dev` or `publish`. It COULD run there safely (it can only ever converge a
|
|
17
|
+
// repo to the engine version already pinned in its package.json, never a newer one),
|
|
13
18
|
// but a creator who ran `dev` and then found files they never wrote sitting in
|
|
14
19
|
// `git status` would be right to feel we'd gone through their pockets. Updates
|
|
15
20
|
// are something you ASK for. So this module writes nothing on its own schedule,
|
|
@@ -36,7 +41,7 @@
|
|
|
36
41
|
// everything outside them (their title, their instructions below the end
|
|
37
42
|
// marker) is theirs and is spliced back untouched.
|
|
38
43
|
import { createHash } from 'node:crypto';
|
|
39
|
-
import { cpSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync,
|
|
44
|
+
import { cpSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
|
|
40
45
|
import { dirname, join } from 'node:path';
|
|
41
46
|
|
|
42
47
|
export const SURFACE_DIR = 'agent-surface';
|
|
@@ -269,26 +274,3 @@ export function reconcileAgentSurface(projectDir, engineDir, { log = console.log
|
|
|
269
274
|
|
|
270
275
|
return result;
|
|
271
276
|
}
|
|
272
|
-
|
|
273
|
-
// Used by `looop create`: the template seed IS the surface for a brand-new
|
|
274
|
-
// repo, so record it as ours immediately. Without this the first `dev` would
|
|
275
|
-
// treat a two-minute-old scaffold as an unknown pre-existing repo and adopt it.
|
|
276
|
-
export function recordSeededSurface(projectDir, engineVersion) {
|
|
277
|
-
const placed = {};
|
|
278
|
-
const add = (rel) => {
|
|
279
|
-
const abs = join(projectDir, rel);
|
|
280
|
-
if (existsSync(abs)) placed[rel] = sha(readFileSync(abs));
|
|
281
|
-
};
|
|
282
|
-
const walk = (rel) => {
|
|
283
|
-
const abs = join(projectDir, rel);
|
|
284
|
-
if (!existsSync(abs)) return;
|
|
285
|
-
for (const entry of readdirSync(abs)) {
|
|
286
|
-
const child = join(rel, entry);
|
|
287
|
-
if (statSync(join(projectDir, child)).isDirectory()) walk(child);
|
|
288
|
-
else add(child);
|
|
289
|
-
}
|
|
290
|
-
};
|
|
291
|
-
walk(join('.claude', 'skills'));
|
|
292
|
-
add('AGENTS.md');
|
|
293
|
-
writeLocal(projectDir, engineVersion ?? null, placed);
|
|
294
|
-
}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
// `looop changelog` — what changed in the engine (project note azlqm2).
|
|
2
|
+
//
|
|
3
|
+
// The gap this closes: `looop update` used to say "Engine updated: 0.1.10 →
|
|
4
|
+
// 0.1.12" and then list the SKILL FILES it rewrote. It said nothing about what
|
|
5
|
+
// changed in the engine itself, so the agent crossed two versions of the shared
|
|
6
|
+
// library blind — it could not know a call it makes is now wrong, or that the
|
|
7
|
+
// thing it was about to hand-roll now exists.
|
|
8
|
+
//
|
|
9
|
+
// Two moments matter, and they are different:
|
|
10
|
+
// BEFORE an update — "what am I about to take?" → the default, vs your pin
|
|
11
|
+
// AFTER an update — "what do I have to change?" → the BREAKING callout
|
|
12
|
+
//
|
|
13
|
+
// Reading is NOT login-gated. The version list is public by design (a version
|
|
14
|
+
// number is not a secret — see builder/functions/api/creator/engine/index.ts);
|
|
15
|
+
// only the DOWNLOAD carries a token. Requiring a login to read what an update
|
|
16
|
+
// would do to you would be exactly backwards.
|
|
17
|
+
import { findProject } from './project.mjs';
|
|
18
|
+
import { readEnginePin } from './engine.mjs';
|
|
19
|
+
import { getApiBase } from './config.mjs';
|
|
20
|
+
import { DEFAULT_API_BASE } from './llm-shim.mjs';
|
|
21
|
+
|
|
22
|
+
const RULE = '─'.repeat(64);
|
|
23
|
+
|
|
24
|
+
// Semver by NUMBER. String order would put 0.1.9 after 0.1.10 and quietly show
|
|
25
|
+
// the wrong set — the kind of bug nobody notices until a release is missing
|
|
26
|
+
// from someone's update.
|
|
27
|
+
export function compareVersions(a, b) {
|
|
28
|
+
const pa = a.split('.').map(Number);
|
|
29
|
+
const pb = b.split('.').map(Number);
|
|
30
|
+
for (let i = 0; i < 3; i++) {
|
|
31
|
+
if ((pa[i] ?? 0) !== (pb[i] ?? 0)) return (pa[i] ?? 0) > (pb[i] ?? 0) ? 1 : -1;
|
|
32
|
+
}
|
|
33
|
+
return 0;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function selectReleases(releases, { pinned, version, all } = {}) {
|
|
37
|
+
const newestFirst = [...releases].sort((a, b) => compareVersions(b.version, a.version));
|
|
38
|
+
|
|
39
|
+
if (version) {
|
|
40
|
+
const one = newestFirst.find((r) => r.version === version);
|
|
41
|
+
if (!one) {
|
|
42
|
+
throw new Error(
|
|
43
|
+
`no engine release ${version}. Known: ${newestFirst.map((r) => r.version).join(', ')}`,
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
return [one];
|
|
47
|
+
}
|
|
48
|
+
if (all) return newestFirst;
|
|
49
|
+
// No pin means the game has never installed an engine (no `dev` yet). There is
|
|
50
|
+
// no "since" to work from, so show what it would get: the newest.
|
|
51
|
+
if (!pinned) return newestFirst.slice(0, 1);
|
|
52
|
+
return newestFirst.filter((r) => compareVersions(r.version, pinned) > 0);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// The actionable half. Everything else in a changelog is informative; a BREAKING
|
|
56
|
+
// entry means code in THIS game has to change, so it gets hoisted out of the
|
|
57
|
+
// prose and repeated on its own — with its migration line, which is the part the
|
|
58
|
+
// reader actually needs.
|
|
59
|
+
//
|
|
60
|
+
// The marker is POSITIONAL: a breaking change is a bullet that STARTS with it.
|
|
61
|
+
// Two false positives got here by loosening that, and both were found by reading
|
|
62
|
+
// real output rather than by any test:
|
|
63
|
+
//
|
|
64
|
+
// 1. matching the bare word case-insensitively flagged the ordinary sentence
|
|
65
|
+
// "when your game turns out to be breaking a Looop rule";
|
|
66
|
+
// 2. matching `**BREAKING**` anywhere on a line flagged a changelog entry that
|
|
67
|
+
// merely DESCRIBED the feature ("...calls out **BREAKING** entries
|
|
68
|
+
// separately...") — prose about breaking changes is not a breaking change.
|
|
69
|
+
//
|
|
70
|
+
// A callout that cries wolf is worse than no callout: it gets ignored, and the
|
|
71
|
+
// next real one is ignored with it.
|
|
72
|
+
// Bold form is case-insensitive (`**BREAKING**`, `**Breaking**` — the markup is
|
|
73
|
+
// the deliberate part). The bare-word form must be ALL CAPS with punctuation, so
|
|
74
|
+
// a bullet that merely opens with the ordinary word ("- breaking the sound
|
|
75
|
+
// barrier: …") can't pass as a marker.
|
|
76
|
+
const BREAKING_BOLD = /^\s*[-*]\s+\*\*breaking\*\*/i;
|
|
77
|
+
const BREAKING_CAPS = /^\s*[-*]\s+BREAKING\b\s*[:—–-]/;
|
|
78
|
+
const BREAKING_BULLET = { test: (l) => BREAKING_BOLD.test(l) || BREAKING_CAPS.test(l) };
|
|
79
|
+
|
|
80
|
+
// True for a line that continues the bullet above it: indented, non-blank, and
|
|
81
|
+
// not itself a new bullet or heading. That's how the migration line rides along.
|
|
82
|
+
const CONTINUATION = /^\s+\S/;
|
|
83
|
+
const NEW_BULLET_OR_HEADING = /^\s*(?:[-*]\s|#)/;
|
|
84
|
+
|
|
85
|
+
export function breakingLines(notes) {
|
|
86
|
+
const lines = (notes ?? '').split('\n');
|
|
87
|
+
const out = [];
|
|
88
|
+
for (let i = 0; i < lines.length; i++) {
|
|
89
|
+
if (!BREAKING_BULLET.test(lines[i])) continue;
|
|
90
|
+
const block = [lines[i].trim()];
|
|
91
|
+
for (let j = i + 1; j < lines.length; j++) {
|
|
92
|
+
const l = lines[j];
|
|
93
|
+
if (!CONTINUATION.test(l) || NEW_BULLET_OR_HEADING.test(l)) break;
|
|
94
|
+
block.push(l.trim());
|
|
95
|
+
i = j;
|
|
96
|
+
}
|
|
97
|
+
out.push(block.join(' '));
|
|
98
|
+
}
|
|
99
|
+
return out;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function renderReleases(selected, { pinned, latest } = {}) {
|
|
103
|
+
const out = [];
|
|
104
|
+
if (!selected.length) {
|
|
105
|
+
out.push(`Engine ${pinned ?? '(unpinned)'} — nothing new. This game is up to date with ${latest}.`);
|
|
106
|
+
return out.join('\n');
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const head =
|
|
110
|
+
selected.length === 1
|
|
111
|
+
? `Engine ${selected[0].version}`
|
|
112
|
+
: `${selected.length} engine releases newer than ${pinned}, newest first (latest is ${latest})`;
|
|
113
|
+
out.push(head, '');
|
|
114
|
+
|
|
115
|
+
for (const r of selected) {
|
|
116
|
+
out.push(RULE);
|
|
117
|
+
out.push(`## ${r.version}`);
|
|
118
|
+
out.push('');
|
|
119
|
+
out.push(r.notes?.trim() ? r.notes.trim() : ' (no notes recorded — this release predates the changelog)');
|
|
120
|
+
out.push('');
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const breaking = selected.flatMap((r) => breakingLines(r.notes).map((l) => ` ${r.version} ${l}`));
|
|
124
|
+
if (breaking.length) {
|
|
125
|
+
out.push(RULE);
|
|
126
|
+
out.push('');
|
|
127
|
+
out.push(
|
|
128
|
+
`⚠ ${breaking.length} BREAKING change${breaking.length === 1 ? '' : 's'} in this range.`,
|
|
129
|
+
' These are the entries that mean code in THIS game has to change — check each one',
|
|
130
|
+
' against the game before you call the update done.',
|
|
131
|
+
'',
|
|
132
|
+
...breaking,
|
|
133
|
+
'',
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
return out.join('\n');
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export async function fetchReleases({ apiBase, fetchImpl = fetch }) {
|
|
140
|
+
const res = await fetchImpl(`${apiBase}/api/creator/engine`);
|
|
141
|
+
if (!res.ok) throw new Error(`could not read the engine changelog (HTTP ${res.status})`);
|
|
142
|
+
const body = await res.json();
|
|
143
|
+
// An older platform has no `releases` key. Printing "nothing new" there would
|
|
144
|
+
// be a lie in the most dangerous direction — it reads as "safe to update".
|
|
145
|
+
//
|
|
146
|
+
// Do NOT tell them to upgrade the CLI: the missing half is the PLATFORM's, and
|
|
147
|
+
// a creator cannot deploy it. Advice they can't act on is worse than none.
|
|
148
|
+
if (!Array.isArray(body.releases)) {
|
|
149
|
+
throw new Error(
|
|
150
|
+
'this Looop platform is not serving the engine changelog yet. Nothing is wrong with your ' +
|
|
151
|
+
'game — try again later, and `npx looop update` still works in the meantime.',
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
return body;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export async function changelog({
|
|
158
|
+
cwd = process.cwd(),
|
|
159
|
+
apiBase = getApiBase(DEFAULT_API_BASE),
|
|
160
|
+
log = console.log,
|
|
161
|
+
fetchImpl = fetch,
|
|
162
|
+
readPin,
|
|
163
|
+
version,
|
|
164
|
+
all = false,
|
|
165
|
+
} = {}) {
|
|
166
|
+
const pinned = readPin ? readPin() : readEnginePin(findProject(cwd).dir);
|
|
167
|
+
const { releases, latest } = await fetchReleases({ apiBase, fetchImpl });
|
|
168
|
+
const selected = selectReleases(releases, { pinned, version, all });
|
|
169
|
+
log(renderReleases(selected, { pinned, latest }));
|
|
170
|
+
return { pinned, latest, selected };
|
|
171
|
+
}
|
package/lib/create.mjs
CHANGED
|
@@ -2,26 +2,45 @@
|
|
|
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
|
|
5
|
-
// (the platform is not optional — the
|
|
5
|
+
// (the platform is not optional — the scaffold joins a room on line one, no
|
|
6
6
|
// "add multiplayer later" tier). The folder name is the slug.
|
|
7
7
|
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
8
|
+
// ── Where the creator's files come from ─────────────────────────────────────
|
|
9
|
+
//
|
|
10
|
+
// EVERYTHING a creator receives comes from the versioned engine artifact: the
|
|
11
|
+
// starter game (`scaffold/`) and the agent surface (`agent-surface/` — skills
|
|
12
|
+
// plus the Layer-0 managed block). This npm package ships CODE ONLY.
|
|
13
|
+
//
|
|
14
|
+
// It used to ship a `template/` folder, and that was a bug with a long fuse.
|
|
15
|
+
// npm and the engine registry are two independent, immutable release lanes, so
|
|
16
|
+
// the two copies of the surface drifted the moment one shipped without the
|
|
17
|
+
// other — and `create` seeded from the CLI while stamping the ownership record
|
|
18
|
+
// with the ENGINE's version, so that record was a lie the day it was written.
|
|
19
|
+
// The creator paid for it on their first `looop update`: skills they had never
|
|
20
|
+
// touched changed under them, and a skill the CLI shipped but the engine didn't
|
|
21
|
+
// was silently DELETED (agent-surface.mjs prunes files it owns that the
|
|
22
|
+
// artifact no longer lists). On a repo minutes old.
|
|
23
|
+
//
|
|
24
|
+
// One authored copy, one release lane. A skill or scaffold change now ships
|
|
25
|
+
// with an engine release alone; the CLI is released only when the CLI's own
|
|
26
|
+
// code changes.
|
|
27
|
+
//
|
|
28
|
+
// The consequence, and it is deliberate: `create` needs the engine, and the
|
|
29
|
+
// engine download is login-gated — so CREATE SIGNS YOU IN. That gate existed
|
|
30
|
+
// anyway, one command later, at first `dev`. This moves it to where the creator
|
|
31
|
+
// is already waiting and leaves `dev` instant.
|
|
15
32
|
import { execFileSync } from 'node:child_process';
|
|
16
|
-
import { existsSync, mkdirSync, writeFileSync, readdirSync, readFileSync, symlinkSync } from 'node:fs';
|
|
33
|
+
import { existsSync, mkdirSync, writeFileSync, readdirSync, readFileSync, rmSync, symlinkSync } from 'node:fs';
|
|
17
34
|
import { createRequire } from 'node:module';
|
|
18
35
|
import { join, relative, dirname } from 'node:path';
|
|
19
36
|
import { getApiBase } from './config.mjs';
|
|
20
37
|
import { DEFAULT_API_BASE } from './llm-shim.mjs';
|
|
21
38
|
import { runNpm } from './npm.mjs';
|
|
22
|
-
import {
|
|
39
|
+
import { ensureEngine } from './engine.mjs';
|
|
40
|
+
import { reconcileAgentSurface } from './agent-surface.mjs';
|
|
23
41
|
|
|
24
|
-
|
|
42
|
+
// The starter game, inside the installed engine artifact.
|
|
43
|
+
export const SCAFFOLD_DIR = 'scaffold';
|
|
25
44
|
|
|
26
45
|
const NAME_OK = /^[a-z0-9][a-z0-9-]{0,40}$/;
|
|
27
46
|
|
|
@@ -48,69 +67,37 @@ const ALLOW_SCRIPTS = { esbuild: true, workerd: true, fsevents: true };
|
|
|
48
67
|
// what create-time provisioning exists to prevent.
|
|
49
68
|
const PLAYWRIGHT_SPEC = '^1.61.1';
|
|
50
69
|
|
|
51
|
-
//
|
|
52
|
-
//
|
|
53
|
-
//
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
//
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
log(`⚠️ Could not resolve the engine version (${err.message}) — the first \`looop dev\` will pin it instead.`);
|
|
67
|
-
return null;
|
|
70
|
+
// Copy the starter game out of the artifact. One rename: `gitignore` →
|
|
71
|
+
// `.gitignore` (npm pack silently strips .gitignore files from a package, so
|
|
72
|
+
// the artifact stores it dot-less).
|
|
73
|
+
function copyScaffold(engineDir, dir, name, version) {
|
|
74
|
+
const root = join(engineDir, SCAFFOLD_DIR);
|
|
75
|
+
if (!existsSync(root)) {
|
|
76
|
+
// RELEASE ORDER: the engine must ship the scaffold BEFORE a CLI that expects
|
|
77
|
+
// it. If this CLI ever reaches a creator ahead of that engine, `create`
|
|
78
|
+
// resolves the latest engine, finds no starter game, and every create on
|
|
79
|
+
// earth fails — so say exactly what is wrong, and never blame the creator's
|
|
80
|
+
// setup for our release ordering.
|
|
81
|
+
throw new Error(
|
|
82
|
+
`Looop engine ${version} ships no starter game — it predates engine-owned scaffolding. ` +
|
|
83
|
+
'This is a platform-side issue, not something you did: please report it (`npx looop feedback`) or retry shortly.',
|
|
84
|
+
);
|
|
68
85
|
}
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
const entries = readdirSync(TEMPLATE_DIR, { withFileTypes: true, recursive: true }).filter((e) => e.isFile());
|
|
73
|
-
for (const entry of entries) {
|
|
74
|
-
const rel = relative(TEMPLATE_DIR, join(entry.parentPath, entry.name));
|
|
86
|
+
for (const entry of readdirSync(root, { withFileTypes: true, recursive: true })) {
|
|
87
|
+
if (!entry.isFile()) continue;
|
|
88
|
+
const rel = relative(root, join(entry.parentPath, entry.name));
|
|
75
89
|
const dest = join(dir, rel === 'gitignore' ? '.gitignore' : rel);
|
|
76
90
|
mkdirSync(dirname(dest), { recursive: true });
|
|
77
|
-
writeFileSync(dest, readFileSync(join(
|
|
91
|
+
writeFileSync(dest, readFileSync(join(root, rel), 'utf8').replaceAll('{{name}}', name));
|
|
78
92
|
}
|
|
79
93
|
}
|
|
80
94
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
log = console.log,
|
|
88
|
-
} = {}) {
|
|
89
|
-
if (!name || !NAME_OK.test(name)) {
|
|
90
|
-
throw new Error(
|
|
91
|
-
`"${name ?? ''}" won't work as a game name — use lowercase letters, digits, and dashes (it becomes the URL: play.looop.games/g/<name>).`,
|
|
92
|
-
);
|
|
93
|
-
}
|
|
94
|
-
const dir = join(cwd, name);
|
|
95
|
-
if (existsSync(dir)) throw new Error(`${dir} already exists — pick another name or remove it first.`);
|
|
96
|
-
|
|
97
|
-
mkdirSync(dir, { recursive: true });
|
|
98
|
-
copyTemplate(dir, name);
|
|
99
|
-
// Non-Claude agent CLIs (Codex, Gemini/Antigravity, Cursor, opencode)
|
|
100
|
-
// discover project skills at .agents/skills/ — alias it to the same files.
|
|
101
|
-
// Created here, not shipped in the template: npm pack can't carry symlinks.
|
|
102
|
-
try {
|
|
103
|
-
mkdirSync(join(dir, '.agents'), { recursive: true });
|
|
104
|
-
symlinkSync(join('..', '.claude', 'skills'), join(dir, '.agents', 'skills'), 'dir');
|
|
105
|
-
} catch {
|
|
106
|
-
// e.g. Windows without symlink rights — Claude Code (and any tool that
|
|
107
|
-
// reads .claude/skills/ directly) still works.
|
|
108
|
-
}
|
|
109
|
-
// The engine is deliberately NOT a dependency (Q4 revision): it installs
|
|
110
|
-
// from the platform's login-gated registry on first `looop dev`. The
|
|
111
|
-
// VERSION is pinned here at create time (public lookup, warn-fallback) so
|
|
112
|
-
// the initial commit already carries it — see resolveEnginePin above.
|
|
113
|
-
const enginePin = await resolveEnginePin(apiBase, log);
|
|
95
|
+
async function scaffold({ dir, name, install, cliSpec, apiBase, log, ensure, reconcile }) {
|
|
96
|
+
// Written FIRST, and without the engine pin: npm needs a package.json to
|
|
97
|
+
// install into, and `ensureEngine` writes the pin itself once it knows which
|
|
98
|
+
// version it actually installed. ONE writer for the pin — the old code had
|
|
99
|
+
// `create` resolve it and `ensureEngine` resolve it again, which is precisely
|
|
100
|
+
// how they came to disagree.
|
|
114
101
|
writeFileSync(
|
|
115
102
|
join(dir, 'package.json'),
|
|
116
103
|
JSON.stringify(
|
|
@@ -121,28 +108,50 @@ export async function create({
|
|
|
121
108
|
scripts: { dev: 'looop dev', publish: 'looop publish', test: 'looop test' },
|
|
122
109
|
devDependencies: { '@looop-games/cli': cliSpec, playwright: PLAYWRIGHT_SPEC },
|
|
123
110
|
allowScripts: ALLOW_SCRIPTS,
|
|
124
|
-
...(enginePin ? { looop: { engine: enginePin } } : {}),
|
|
125
111
|
},
|
|
126
112
|
null,
|
|
127
113
|
2,
|
|
128
114
|
) + '\n',
|
|
129
115
|
);
|
|
130
116
|
|
|
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
|
-
|
|
137
117
|
if (install) {
|
|
138
118
|
log(`Installing the CLI (npm install in ${name}/)…`);
|
|
139
119
|
runNpm(['install', '--no-audit', '--no-fund'], { cwd: dir, stdio: 'pipe' });
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// The engine — and with it the starter game and the agent skills. Signs the
|
|
123
|
+
// creator in via the device flow if this machine has no token yet, and writes
|
|
124
|
+
// the `looop.engine` pin.
|
|
125
|
+
const engine = await ensure(dir, { apiBase, log });
|
|
126
|
+
|
|
127
|
+
copyScaffold(engine.dir, dir, name, engine.version);
|
|
128
|
+
|
|
129
|
+
// The agent surface is PLATFORM-owned and reconciled on every `looop update`,
|
|
130
|
+
// so it is placed here by the very code that will later update it — reading
|
|
131
|
+
// the artifact, recording the real hash of what landed. That identity is the
|
|
132
|
+
// whole point: a fresh repo's first `update` is now a genuine no-op, because
|
|
133
|
+
// the bytes on disk and the bytes in the ownership record came from the same
|
|
134
|
+
// artifact.
|
|
135
|
+
reconcile(dir, engine.dir, { log });
|
|
136
|
+
|
|
137
|
+
// Non-Claude agent CLIs (Codex, Gemini/Antigravity, Cursor, opencode)
|
|
138
|
+
// discover project skills at .agents/skills/ — alias it to the same files.
|
|
139
|
+
// After reconcile, so the link is never dangling.
|
|
140
|
+
try {
|
|
141
|
+
mkdirSync(join(dir, '.agents'), { recursive: true });
|
|
142
|
+
symlinkSync(join('..', '.claude', 'skills'), join(dir, '.agents', 'skills'), 'dir');
|
|
143
|
+
} catch {
|
|
144
|
+
// e.g. Windows without symlink rights — Claude Code (and any tool that
|
|
145
|
+
// reads .claude/skills/ directly) still works.
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (install) {
|
|
140
149
|
// Fetch the smoke-test browser NOW (cached machine-wide per version) so
|
|
141
150
|
// the first `looop test` never stalls on a surprise download mid-build.
|
|
142
151
|
// Resolved + run via node directly — NEVER a nested `npx`: when create
|
|
143
|
-
// itself runs under `npm exec --package=<tarball>` (the stranger
|
|
144
|
-
//
|
|
145
|
-
//
|
|
152
|
+
// itself runs under `npm exec --package=<tarball>` (the stranger gesture),
|
|
153
|
+
// the inherited npm_config_* env makes an inner npx try to re-install the
|
|
154
|
+
// tarball and cancel (caught live, 2026-07-10).
|
|
146
155
|
try {
|
|
147
156
|
log('Fetching the test browser (Chromium — one-time download, cached for every game)…');
|
|
148
157
|
const req = createRequire(join(dir, 'package.json'));
|
|
@@ -152,12 +161,14 @@ export async function create({
|
|
|
152
161
|
log('⚠️ Could not fetch Chromium (offline?) — `looop test` will fetch it when first needed.');
|
|
153
162
|
}
|
|
154
163
|
}
|
|
164
|
+
|
|
155
165
|
try {
|
|
156
166
|
execFileSync('git', ['init', '-q'], { cwd: dir, stdio: 'pipe' });
|
|
157
|
-
//
|
|
158
|
-
//
|
|
159
|
-
//
|
|
160
|
-
//
|
|
167
|
+
// The initial commit is the first revert baseline (rejecting milestone 1
|
|
168
|
+
// resets to it) and what lets `git worktree add` work — on a never-committed
|
|
169
|
+
// repo it silently creates an empty orphan lane instead. It carries the
|
|
170
|
+
// engine pin AND the true agent surface, so a reset to baseline cannot
|
|
171
|
+
// silently change either.
|
|
161
172
|
execFileSync('git', ['add', '-A'], { cwd: dir, stdio: 'pipe' });
|
|
162
173
|
const msg = ['commit', '-q', '-m', 'initial scaffold (looop create)'];
|
|
163
174
|
try {
|
|
@@ -175,8 +186,43 @@ export async function create({
|
|
|
175
186
|
}
|
|
176
187
|
|
|
177
188
|
log('');
|
|
178
|
-
log(`✅ ${name} is ready.`);
|
|
179
|
-
log(` cd ${
|
|
180
|
-
|
|
181
|
-
|
|
189
|
+
log(`✅ ${name} is ready (Looop engine ${engine.version}).`);
|
|
190
|
+
log(` cd ${name} && npx looop dev`);
|
|
191
|
+
return { dir, engineVersion: engine.version };
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export async function create({
|
|
195
|
+
name,
|
|
196
|
+
cwd = process.cwd(),
|
|
197
|
+
install = true,
|
|
198
|
+
cliSpec = process.env.LOOOP_CREATE_CLI_SPEC || '^0.1.0',
|
|
199
|
+
apiBase = getApiBase(DEFAULT_API_BASE),
|
|
200
|
+
log = console.log,
|
|
201
|
+
// Seams, mirroring `looop update` — the tests drive a real artifact on disk
|
|
202
|
+
// through the real reconciler, with no network and no account.
|
|
203
|
+
ensure = ensureEngine,
|
|
204
|
+
reconcile = reconcileAgentSurface,
|
|
205
|
+
} = {}) {
|
|
206
|
+
if (!name || !NAME_OK.test(name)) {
|
|
207
|
+
throw new Error(
|
|
208
|
+
`"${name ?? ''}" won't work as a game name — use lowercase letters, digits, and dashes (it becomes the URL: play.looop.games/g/<name>).`,
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
const dir = join(cwd, name);
|
|
212
|
+
if (existsSync(dir)) throw new Error(`${dir} already exists — pick another name or remove it first.`);
|
|
213
|
+
|
|
214
|
+
mkdirSync(dir, { recursive: true });
|
|
215
|
+
try {
|
|
216
|
+
return await scaffold({ dir, name, install, cliSpec, apiBase, log, ensure, reconcile });
|
|
217
|
+
} catch (err) {
|
|
218
|
+
// ATOMIC. Everything a creator receives now arrives over the network behind
|
|
219
|
+
// a login, so a failure anywhere leaves a folder with no game, no skills and
|
|
220
|
+
// no initial commit — one that `looop dev` cannot run, and that `create`
|
|
221
|
+
// would refuse to retry into ("already exists"). A folder that looks like a
|
|
222
|
+
// game and isn't one is worse than no folder at all: it fails later,
|
|
223
|
+
// somewhere more confusing. Take it back, and say so.
|
|
224
|
+
rmSync(dir, { recursive: true, force: true });
|
|
225
|
+
log(`\n⚠️ Could not finish creating ${name} — removed the incomplete folder so you can retry cleanly.`);
|
|
226
|
+
throw err;
|
|
227
|
+
}
|
|
182
228
|
}
|