@pixelvibing/cli 0.56.2 → 0.57.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
@@ -10,13 +10,21 @@ cd my-game
10
10
  pixel-vibing-cli setup
11
11
  ```
12
12
 
13
- `setup` signs you in in the browser, makes the directory a local project, which means a
14
- `pixelvibing.json` and a `pixelvibing/` folder, links it to one of your platform
15
- projects, and installs the Skills into `.claude/skills/` or `.codex/skills/`: a hub, the
16
- style guide's, and one for each kind of art. A block in `AGENTS.md`, and in `CLAUDE.md` when
17
- there is one, names which Skill to load for which request. It also
18
- configures the remote MCP server in `.mcp.json` or `.codex/config.toml`, by a command
19
- that prints the token's header when the harness connects, so the token is in no file.
13
+ `setup` goes a step at a time: it signs you in in the browser, makes the directory a local
14
+ project, which means a `pixelvibing.json` and a `pixelvibing/` folder, links it to one of your
15
+ platform projects — picked from a list, or made there from an idea — keeps that project's own
16
+ token, the one its Install tab in the web app shows, and installs the Skills — a hub, the style
17
+ guide's, and one for each kind of art — into the coding assistants you mark, as many as you like:
18
+ Claude Code, Codex, Cursor, Gemini CLI, OpenCode and GitHub Copilot. `--harness` names them instead; without a terminal it takes those the repository has,
19
+ else Claude Code. It then asks whether the Skills are yours, for every project, in each harness's
20
+ directory in your home (`~/.claude/skills/`, `~/.agents/skills/`, `~/.cursor/skills/`,
21
+ `~/.gemini/skills/`, `~/.config/opencode/skills/`, `~/.copilot/skills/`), which is the default, or
22
+ this repository's alone (`.claude/skills/`, `.agents/skills/`, `.cursor/skills/`, `.gemini/skills/`,
23
+ `.opencode/skills/`, `.github/skills/`); `--scope user|project` answers it. A block in `AGENTS.md`, and in `CLAUDE.md` when
24
+ there is one, names which Skill to load for which request. A coding assistant reaches the
25
+ platform through the CLI, so `setup` writes no MCP entry. A client that needs the remote MCP
26
+ server copies its entry, with the project's token, from the project's Install tab in the web app,
27
+ into a file kept out of git; `logout` removes the token from such entries.
20
28
  Each step it finds done already is skipped.
21
29
 
22
30
  | Command | What it does |
@@ -29,8 +37,8 @@ Each step it finds done already is skipped.
29
37
  | `guide pull\|push\|revise\|show\|versions` | Read and write the project's style guide |
30
38
  | `seal` | Seal `pixelvibing/out/` into `pixelvibing/build/assets.eac` |
31
39
  | `export default\|pixi\|phaser\|godot` | Pack the art into sprite sheets and the map into placed sprites, for an engine |
32
- | `update` | Replace the installed Skills with the ones this version ships, add those it ships since, rewrite their block in `AGENTS.md`, and bring the MCP server's entry to it |
33
- | `mcp headers` | Print the header a harness sends the remote MCP server, from the kept token |
40
+ | `update` | Replace the installed Skills with the ones this version ships, add those it ships since, rewrite their block in `AGENTS.md`, leaving any MCP server entry as it is |
41
+ | `mcp headers` | Print the header for the remote MCP server, from the kept token, for an entry written by hand |
34
42
  | `editor` | Open the PixelVibing desktop app on the project |
35
43
 
36
44
  Every command takes `--help`, and `--json` for an answer a script reads. On a machine with
@@ -1,15 +1,16 @@
1
1
  #!/usr/bin/env node
2
- import { stat, readFile, writeFile, mkdir, readdir, rename, realpath as realpath$1, lstat, rm } from "node:fs/promises";
2
+ import { readdir, readFile, mkdir, writeFile, stat, rename, realpath as realpath$1, lstat, rm } from "node:fs/promises";
3
3
  import { join, dirname, resolve, relative, sep, basename, isAbsolute } from "node:path";
4
+ import { createHash, randomBytes } from "node:crypto";
5
+ import { fileURLToPath } from "node:url";
4
6
  import { realpath, promises } from "node:fs";
5
7
  import { promisify, parseArgs } from "node:util";
6
- import { randomBytes, createHash } from "node:crypto";
7
8
  import { createServer } from "node:http";
8
9
  import { inflate, deflate, deflateSync, inflateSync } from "node:zlib";
9
- import { fileURLToPath } from "node:url";
10
10
  import { spawn } from "node:child_process";
11
- import { hostname, homedir } from "node:os";
11
+ import { homedir } from "node:os";
12
12
  import { createInterface } from "node:readline";
13
+ import { intro, outro, log, select, multiselect, isCancel } from "@clack/prompts";
13
14
  const API_BASE = typeof __PIXELVIBING_API__ === "string" && __PIXELVIBING_API__ !== "" ? __PIXELVIBING_API__ : "/api";
14
15
  class SignedOut extends Error {
15
16
  constructor() {
@@ -76,6 +77,7 @@ function createAccountApi(fetcher = (input, init2) => fetch(input, init2), base
76
77
  me: async () => (await call("/me")).json(),
77
78
  tokens: async () => (await (await call("/tokens")).json()).tokens,
78
79
  createToken: async (name, project) => (await call("/tokens", { method: "POST", body: { name, project } })).json(),
80
+ projectToken: async (project) => (await (await call(`/projects/${encodeURIComponent(project)}/token`)).json()).token,
79
81
  projects: async () => (await (await call("/projects")).json()).projects.map(({ id: id2, name }) => ({ id: id2, name })),
80
82
  revokeToken: async (id2) => {
81
83
  await call(`/tokens/${encodeURIComponent(id2)}`, { method: "DELETE" });
@@ -158,8 +160,239 @@ async function tokenFor(store, origin, env) {
158
160
  throw error;
159
161
  }
160
162
  }
163
+ const HARNESSES = {
164
+ claude: { name: "Claude Code", directory: ".claude", userDirectory: ".claude", found: [".claude"] },
165
+ codex: { name: "Codex", directory: ".agents", userDirectory: ".agents", found: [".codex"] },
166
+ cursor: { name: "Cursor", directory: ".cursor", userDirectory: ".cursor", found: [".cursor"] },
167
+ gemini: { name: "Gemini CLI", directory: ".gemini", userDirectory: ".gemini", found: [".gemini"] },
168
+ opencode: { name: "OpenCode", directory: ".opencode", userDirectory: ".config/opencode", found: [".opencode", "opencode.json"] },
169
+ copilot: {
170
+ name: "GitHub Copilot",
171
+ directory: ".github",
172
+ userDirectory: ".copilot",
173
+ found: [".github/skills", ".github/copilot-instructions.md", ".vscode/mcp.json"]
174
+ }
175
+ };
176
+ const HARNESS_NAMES = Object.keys(HARNESSES);
177
+ const directoryOf = (harness, scope) => scope === "user" ? HARNESSES[harness].userDirectory : HARNESSES[harness].directory;
178
+ const skillsDirectory = (harness, scope = "project") => `${scope === "user" ? "~/" : ""}${directoryOf(harness, scope)}/skills`;
179
+ const SKILLS_MANIFEST = "pixelvibing-skills.json";
180
+ const sha256$1 = (text2) => createHash("sha256").update(text2).digest("hex");
181
+ function skillHash(text2, files = []) {
182
+ if (files.length === 0) return sha256$1(text2);
183
+ const hash = createHash("sha256").update(text2);
184
+ for (const file of files) hash.update(`\0${file.path}\0`).update(file.text);
185
+ return hash.digest("hex");
186
+ }
187
+ async function referencesOf(directory) {
188
+ let names;
189
+ try {
190
+ names = (await readdir(join(directory, "references"), { withFileTypes: true })).filter((entry) => entry.isFile()).map((entry) => entry.name);
191
+ } catch {
192
+ return [];
193
+ }
194
+ const files = [];
195
+ for (const name of names.sort()) files.push({ path: `references/${name}`, text: await readFile(join(directory, "references", name), "utf8") });
196
+ return files;
197
+ }
198
+ const SHIPPED_DIRECTORY = fileURLToPath(new URL("../skills/", import.meta.url));
199
+ async function shippedSkills(directory = SHIPPED_DIRECTORY) {
200
+ const names = (await readdir(directory, { withFileTypes: true })).filter((entry) => entry.isDirectory()).map((entry) => entry.name);
201
+ const skills = [];
202
+ for (const name of names.sort()) {
203
+ const text2 = await readFile(join(directory, name, "SKILL.md"), "utf8");
204
+ const files = await referencesOf(join(directory, name));
205
+ skills.push({ name, text: text2, ...files.length > 0 ? { files } : {}, sha256: skillHash(text2, files) });
206
+ }
207
+ return skills;
208
+ }
209
+ async function exists$2(path) {
210
+ try {
211
+ await stat(path);
212
+ return true;
213
+ } catch {
214
+ return false;
215
+ }
216
+ }
217
+ function harnessesNamed(named) {
218
+ const unknown = named.filter((name) => !Object.hasOwn(HARNESSES, name));
219
+ if (unknown.length > 0) throw new CliError(`--harness is ${HARNESS_NAMES.join(", ")}, not ${unknown.join(", ")}`);
220
+ return [...new Set(named)];
221
+ }
222
+ async function harnessesFound(root) {
223
+ const found = [];
224
+ for (const harness of HARNESS_NAMES) {
225
+ for (const path of HARNESSES[harness].found) {
226
+ if (await exists$2(join(root, path))) {
227
+ found.push(harness);
228
+ break;
229
+ }
230
+ }
231
+ }
232
+ return found;
233
+ }
234
+ async function harnessesFor(root, named) {
235
+ if (named.length > 0) return harnessesNamed(named);
236
+ const found = await harnessesFound(root);
237
+ return found.length > 0 ? found : ["claude"];
238
+ }
239
+ const manifestPath = (root, harness, scope) => join(root, directoryOf(harness, scope), SKILLS_MANIFEST);
240
+ const skillPath = (root, harness, scope, name) => join(root, directoryOf(harness, scope), "skills", name, "SKILL.md");
241
+ async function readManifest(root, harness, scope = "project") {
242
+ let parsed2;
243
+ try {
244
+ parsed2 = JSON.parse(await readFile(manifestPath(root, harness, scope), "utf8"));
245
+ } catch (error) {
246
+ if (error.code === "ENOENT") return null;
247
+ throw new CliError(`${manifestPath(root, harness, scope)} cannot be read: ${error.message}`);
248
+ }
249
+ const skills = parsed2?.skills;
250
+ if (typeof skills !== "object" || skills === null) throw new CliError(`${manifestPath(root, harness, scope)} records no skills`);
251
+ return Object.fromEntries(Object.entries(skills).filter((entry) => typeof entry[1] === "string"));
252
+ }
253
+ async function writeManifest(root, harness, scope, skills) {
254
+ const sorted = Object.fromEntries(Object.entries(skills).sort(([left], [right]) => left < right ? -1 : 1));
255
+ await writeFile(manifestPath(root, harness, scope), `${JSON.stringify({ format: 1, skills: sorted }, null, 2)}
256
+ `);
257
+ }
258
+ async function writeSkill(root, harness, scope, skill) {
259
+ const path = skillPath(root, harness, scope, skill.name);
260
+ await mkdir(join(path, ".."), { recursive: true });
261
+ await writeFile(path, skill.text);
262
+ for (const file of skill.files ?? []) {
263
+ const at = join(path, "..", file.path);
264
+ await mkdir(join(at, ".."), { recursive: true });
265
+ await writeFile(at, file.text);
266
+ }
267
+ }
268
+ async function textAt(path) {
269
+ try {
270
+ return await readFile(path, "utf8");
271
+ } catch {
272
+ return null;
273
+ }
274
+ }
275
+ async function hashThere(root, harness, scope, skill) {
276
+ const path = skillPath(root, harness, scope, skill.name);
277
+ const text2 = await textAt(path);
278
+ if (text2 === null) return null;
279
+ const files = [];
280
+ for (const file of skill.files ?? []) files.push({ path: file.path, text: await textAt(join(path, "..", file.path)) ?? "" });
281
+ return skillHash(text2, files);
282
+ }
283
+ async function installMissing(root, harness, skills, scope = "project") {
284
+ await mkdir(join(root, directoryOf(harness, scope)), { recursive: true });
285
+ const manifest2 = await readManifest(root, harness, scope) ?? {};
286
+ const installed = [];
287
+ const left = [];
288
+ for (const skill of skills) {
289
+ const there = await hashThere(root, harness, scope, skill);
290
+ if (there === null) {
291
+ await writeSkill(root, harness, scope, skill);
292
+ manifest2[skill.name] = skill.sha256;
293
+ installed.push(skill.name);
294
+ } else if (there === skill.sha256) manifest2[skill.name] = skill.sha256;
295
+ else if (manifest2[skill.name] === void 0) left.push(skill.name);
296
+ }
297
+ await writeManifest(root, harness, scope, manifest2);
298
+ return { harness, scope, installed, left };
299
+ }
300
+ async function updateSkills(root, skills, scope = "project") {
301
+ const updated = [];
302
+ for (const harness of HARNESS_NAMES) {
303
+ const manifest2 = await readManifest(root, harness, scope);
304
+ if (manifest2 === null) continue;
305
+ const replaced = [];
306
+ const installed = [];
307
+ for (const skill of skills) {
308
+ const recorded = manifest2[skill.name];
309
+ if (recorded === skill.sha256 && await textAt(skillPath(root, harness, scope, skill.name)) !== null) continue;
310
+ await writeSkill(root, harness, scope, skill);
311
+ (recorded === void 0 ? installed : replaced).push(skill.name);
312
+ manifest2[skill.name] = skill.sha256;
313
+ }
314
+ await writeManifest(root, harness, scope, manifest2);
315
+ updated.push({ harness, scope, replaced, installed });
316
+ }
317
+ return updated;
318
+ }
319
+ const SERVER_NAME = "pixelvibing";
320
+ const HARNESS_FILES = {
321
+ claude: ".mcp.json",
322
+ codex: ".codex/config.toml",
323
+ cursor: ".cursor/mcp.json",
324
+ gemini: ".gemini/settings.json",
325
+ opencode: "opencode.json",
326
+ copilot: ".vscode/mcp.json"
327
+ };
328
+ const SERVERS = {
329
+ claude: "mcpServers",
330
+ cursor: "mcpServers",
331
+ gemini: "mcpServers",
332
+ opencode: "mcp",
333
+ copilot: "servers"
334
+ };
335
+ async function textOf$1(path) {
336
+ try {
337
+ return await readFile(path, "utf8");
338
+ } catch (error) {
339
+ if (error.code === "ENOENT") return null;
340
+ throw error;
341
+ }
342
+ }
343
+ function isObject(value) {
344
+ return typeof value === "object" && value !== null && !Array.isArray(value);
345
+ }
346
+ const TABLE = `[mcp_servers.${SERVER_NAME}]`;
347
+ const isHeader = (line) => /^\s*\[/.test(line);
348
+ function tableOf(lines) {
349
+ const start = lines.findIndex((line) => line.trim() === TABLE);
350
+ if (start === -1) return null;
351
+ let end = start + 1;
352
+ while (end < lines.length && !isHeader(lines[end])) end += 1;
353
+ return { start, end };
354
+ }
355
+ function jsonWithout(harness, text2) {
356
+ const servers = SERVERS[harness];
357
+ let parsed2;
358
+ try {
359
+ parsed2 = JSON.parse(text2);
360
+ } catch {
361
+ return null;
362
+ }
363
+ if (!isObject(parsed2) || !isObject(parsed2[servers])) return null;
364
+ const ours = parsed2[servers][SERVER_NAME];
365
+ if (!isObject(ours) || !isObject(ours.headers) || ours.headers.Authorization === void 0) return null;
366
+ const { Authorization: _token, ...others } = ours.headers;
367
+ const { headers: _headers, ...rest } = ours;
368
+ const kept = Object.keys(others).length > 0 ? { ...rest, headers: others } : rest;
369
+ return `${JSON.stringify({ ...parsed2, [servers]: { ...parsed2[servers], [SERVER_NAME]: kept } }, null, 2)}
370
+ `;
371
+ }
372
+ function codexWithout(text2) {
373
+ const lines = text2.split("\n");
374
+ const at = tableOf(lines);
375
+ if (at === null) return null;
376
+ const header = lines.findIndex((line, index) => index > at.start && index < at.end && /^\s*http_headers\s*=.*\bAuthorization\s*=/.test(line));
377
+ if (header === -1) return null;
378
+ const others = lines[header].replace(/\s*,?\s*Authorization\s*=\s*"(?:[^"\\]|\\.)*"\s*,?/, " ").replace(/\{\s+\}/, "{}");
379
+ const kept = /\{\s*\}/.test(others) ? null : others.replace(/\{\s+/, "{ ").replace(/\s+\}/, " }");
380
+ return lines.flatMap((line, index) => index !== header ? [line] : kept === null ? [] : [kept]).join("\n");
381
+ }
382
+ async function forgetToken(root) {
383
+ const changed2 = [];
384
+ for (const harness of HARNESS_NAMES) {
385
+ const path = join(root, HARNESS_FILES[harness]);
386
+ const text2 = await textOf$1(path);
387
+ if (text2 === null) continue;
388
+ const next = harness === "codex" ? codexWithout(text2) : jsonWithout(harness, text2);
389
+ if (next === null) continue;
390
+ await writeFile(path, next);
391
+ changed2.push(HARNESS_FILES[harness]);
392
+ }
393
+ return changed2;
394
+ }
161
395
  const DEFAULT_API_URL = "https://api.pixelvibing.com";
162
- const DEFAULT_MCP_URL = "https://mcp.pixelvibing.com";
163
396
  const DEFAULT_WEB_URL = "https://app.pixelvibing.com/";
164
397
  const LOOPBACK = /* @__PURE__ */ new Set(["localhost", "127.0.0.1", "[::1]"]);
165
398
  const PLAIN_PATH = /^[A-Za-z0-9._~/-]*$/;
@@ -215,6 +448,10 @@ function createWebApi(fetcher = (input, init2) => fetch(input, init2), base = AP
215
448
  }
216
449
  };
217
450
  return {
451
+ /** The API's own address, whole, for the MCP server's next to it (specs/web-app/ R2.14). */
452
+ address: new URL(base, globalThis.location?.origin ?? "http://localhost").href.replace(/\/+$/, ""),
453
+ /** The project's own token, to the signed-in person alone (specs/accounts-and-sign-in/ R3.12). */
454
+ projectToken: (projectId) => json2(`/projects/${id(projectId)}/token`),
218
455
  projects: (before = null) => json2(`/projects${before === null ? "" : `?before=${id(before)}`}`),
219
456
  /** A new project from its name and idea, with its settings and its first map's size when chosen (specs/project-settings/ R1.2). */
220
457
  createProject: (name, idea, settings, map) => paying2(
@@ -461,7 +698,7 @@ function settingsPath(platform, env, home) {
461
698
  const config = env.XDG_CONFIG_HOME?.trim();
462
699
  return join(config !== void 0 && config !== "" ? config : join(home, ".config"), "pixelvibing", "cli.json");
463
700
  }
464
- const isCame = (value) => value === "login" || value === "given";
701
+ const isCame = (value) => value === "login" || value === "given" || value === "project";
465
702
  async function readSettings(path) {
466
703
  let parsed2;
467
704
  try {
@@ -814,11 +1051,7 @@ class DesktopSession {
814
1051
  });
815
1052
  }
816
1053
  }
817
- const TOKEN_NAME_CHARS = 64;
818
- function tokenName(host) {
819
- const name = `pixel-vibing-cli on ${host.trim() === "" ? "this machine" : host.trim()}`;
820
- return name.slice(0, TOKEN_NAME_CHARS);
821
- }
1054
+ const PROJECT_TOKEN_NAME = "Project token";
822
1055
  async function storeReady(context, key) {
823
1056
  try {
824
1057
  return await context.services.store.get(key);
@@ -884,24 +1117,24 @@ async function signInInBrowser(context, named, choose) {
884
1117
  if (outcome === "failed") throw new CliError("the sign-in did not finish: nothing was kept");
885
1118
  const jwt = await session.jwt();
886
1119
  if (jwt === null) throw new CliError("the sign-in did not finish: nothing was kept");
887
- let made;
1120
+ let token;
888
1121
  let account;
889
1122
  let project;
890
1123
  try {
891
1124
  const signed = platformWith(context.services.fetch, apiUrl, jwt);
892
1125
  account = (await signed.account.me()).name;
893
1126
  project = await choose(signed);
894
- made = await signed.account.createToken(tokenName(context.services.hostname()), project.id);
1127
+ token = await signed.account.projectToken(project.id);
895
1128
  } finally {
896
1129
  await session.signOut();
897
1130
  }
898
1131
  const key = tokenKey(apiUrl, project.id);
899
1132
  const before = await storeReady(context, key);
900
1133
  const previous = (await readSettings(context.services.settingsFile)).tokens[key]?.came;
901
- await keep(context, key, made.token, "login");
902
- if (before !== null && before !== made.token && previous === "login") await revoke(context, apiUrl, before).catch(() => {
1134
+ await keep(context, key, token, "project");
1135
+ if (before !== null && before !== token && previous === "login") await revoke(context, apiUrl, before).catch(() => {
903
1136
  });
904
- return { account, token: made.name, project };
1137
+ return { account, token: PROJECT_TOKEN_NAME, project };
905
1138
  }
906
1139
  const SECTOR_SIDE$1 = 128;
907
1140
  const SECTOR_CELLS = SECTOR_SIDE$1 * SECTOR_SIDE$1;
@@ -2667,18 +2900,34 @@ async function projectNamed(platform, wanted) {
2667
2900
  seen.length === 0 ? `this account has no project yet: make one with \`pixel-vibing-cli link --idea "<what the game is>"\`` : `this account has no project named ${asked}: ${seen.join(", ")}`
2668
2901
  );
2669
2902
  }
2670
- async function chosenProject(context, platform) {
2903
+ async function newProjectAsked(context, local) {
2904
+ const name = (await context.io.ask(`Name the new project [${local.manifest.name}]: `)).trim() || local.manifest.name;
2905
+ const idea = (await context.io.ask("What is the game, in a few sentences? ")).trim();
2906
+ if (idea === "") throw new CliError("a project starts from an idea: nothing was made");
2907
+ return { name, idea };
2908
+ }
2909
+ async function chosenProject(context, platform, local) {
2671
2910
  const { projects } = await platform.web.projects();
2672
- if (projects.length === 0) throw new CliError(`this account has no project yet: make one with \`pixel-vibing-cli link --idea "<what the game is>"\``);
2673
2911
  if (!context.io.interactive) {
2912
+ if (projects.length === 0) throw new CliError(`this account has no project yet: make one with \`pixel-vibing-cli link --idea "<what the game is>"\``);
2674
2913
  throw new CliError(`name one of your projects, by id or name: ${projects.map((project2) => project2.name).join(", ")}; or make one with --idea`);
2675
2914
  }
2676
- context.io.warn(`${projects.map((project2, index) => ` ${index + 1}. ${project2.name}`).join("\n")}
2677
- `);
2678
- const picked = Number((await context.io.ask("Link to which project (number): ")).trim());
2679
- const project = Number.isInteger(picked) ? projects[picked - 1] : void 0;
2680
- if (project === void 0) throw new CliError("no project has that number");
2681
- return project;
2915
+ context.io.step("Project");
2916
+ let picked;
2917
+ try {
2918
+ picked = await context.io.pick("Link this folder to which project?", [
2919
+ ...projects.map((project2) => ({ label: project2.name })),
2920
+ { label: "Make a new project", hint: "from an idea" }
2921
+ ]);
2922
+ } catch (error) {
2923
+ if (error.message === "cancelled") throw new CliError("no project was chosen: nothing was linked");
2924
+ throw error;
2925
+ }
2926
+ const project = projects[picked];
2927
+ if (project !== void 0) return { id: project.id, name: project.name };
2928
+ const { name, idea } = await newProjectAsked(context, local);
2929
+ const made = await paying(platform, () => platform.web.createProject(name, idea));
2930
+ return { id: made.id, name };
2682
2931
  }
2683
2932
  async function projectAskedFor(context, session, local, wanted, idea) {
2684
2933
  if (idea !== void 0) {
@@ -2686,7 +2935,8 @@ async function projectAskedFor(context, session, local, wanted, idea) {
2686
2935
  const made = await paying(session, () => session.web.createProject(name, idea));
2687
2936
  return { id: made.id, name };
2688
2937
  }
2689
- const project = wanted === void 0 ? await chosenProject(context, session) : await projectNamed(session, wanted);
2938
+ if (wanted === void 0) return chosenProject(context, session, local);
2939
+ const project = await projectNamed(session, wanted);
2690
2940
  return { id: project.id, name: project.name };
2691
2941
  }
2692
2942
  async function linkHere(context, local, wanted, idea, provider) {
@@ -2785,7 +3035,7 @@ async function projectAsked(context, named) {
2785
3035
  }
2786
3036
  const login = {
2787
3037
  name: "login",
2788
- summary: "Sign in in the browser, and keep an account token for this machine and the folder's platform project",
3038
+ summary: "Sign in in the browser, and keep the folder's platform project's token on this machine",
2789
3039
  usage: "[--provider <name>] [--project <id|name>] | --token [<token> | -]",
2790
3040
  options: {
2791
3041
  provider: { type: "string", value: "<name>", description: "the provider to sign in with, such as github" },
@@ -2805,8 +3055,8 @@ const login = {
2805
3055
  const { account, token, project } = await signInInBrowser(context, stringOf(args, "provider"), (session) => projectNamed(session, wanted));
2806
3056
  answer(
2807
3057
  context,
2808
- { signedIn: true, account, came: "login", token, project },
2809
- `Signed in as ${account}. This machine's token for ${project.name}, "${token}", is kept in this system's credential store.`
3058
+ { signedIn: true, account, came: "project", token, project },
3059
+ `Signed in as ${account}. ${project.name}'s token is kept in this system's credential store.`
2810
3060
  );
2811
3061
  }
2812
3062
  if (context.io.env[TOKEN_VARIABLE] !== void 0) context.io.warn(`${TOKEN_VARIABLE} is set, and is used before the kept token while it is.
@@ -2815,7 +3065,7 @@ const login = {
2815
3065
  };
2816
3066
  const logout = {
2817
3067
  name: "logout",
2818
- summary: "Remove the token kept for the folder's platform project, revoking it when login issued it",
3068
+ summary: "Remove the token kept for the folder's platform project, revoking it only when an earlier login made it for this machine",
2819
3069
  usage: "[--project <id>]",
2820
3070
  options: { project: { type: "string", value: "<id>", description: "the platform project whose token to remove, when not the folder's" } },
2821
3071
  run: async (context, args) => {
@@ -2832,10 +3082,13 @@ const logout = {
2832
3082
  if (came === "login") await revoke(context, apiUrl, token);
2833
3083
  await context.services.store.remove(key);
2834
3084
  await recordToken(context.services.settingsFile, key, null);
3085
+ const local = await findProject(context.io.cwd);
3086
+ const forgotten = local !== null && local.manifest.project?.toLowerCase() === project.toLowerCase() ? await forgetToken(local.root) : [];
3087
+ const entries = forgotten.length === 0 ? "" : ` It was also removed from ${forgotten.join(", ")}.`;
2835
3088
  answer(
2836
3089
  context,
2837
- { signedOut: true, revoked: came === "login" },
2838
- came === "login" ? "Signed out: the token was revoked and removed from this machine." : "Signed out: the token was removed from this machine. It was given with --token, so it still works wherever else it is kept: revoke it in the dashboard if it should not."
3090
+ { signedOut: true, revoked: came === "login", entries: forgotten },
3091
+ came === "login" ? `Signed out: the token was revoked and removed from this machine.${entries}` : came === "project" ? `Signed out: the project's token was removed from this machine.${entries} It is the project's own, so it still works wherever else it is kept: revoke it in the dashboard to stop it, and the project gets a new one.` : `Signed out: the token was removed from this machine.${entries} It was given with --token, so it still works wherever else it is kept: revoke it in the dashboard if it should not.`
2839
3092
  );
2840
3093
  if (context.io.env[TOKEN_VARIABLE] !== void 0) context.io.warn(`${TOKEN_VARIABLE} is still set, and this shell still signs in with it.
2841
3094
  `);
@@ -3040,8 +3293,8 @@ function artChange(name, summary, route, method = "POST", key) {
3040
3293
  const platform = await signedIn(context);
3041
3294
  const base = `/projects/${encodeURIComponent(project)}`;
3042
3295
  const path = key === void 0 ? `${base}/art/${route}` : `${base}/art/${route}/${encodeURIComponent(args.positionals[0])}`;
3043
- const answered = await changed(context, platform, args, path, `${base}/art`, body, method);
3044
- answer(context, answered, `Changed the art: it is at revision ${String(answered.revision)}.`);
3296
+ const answered2 = await changed(context, platform, args, path, `${base}/art`, body, method);
3297
+ answer(context, answered2, `Changed the art: it is at revision ${String(answered2.revision)}.`);
3045
3298
  }
3046
3299
  };
3047
3300
  }
@@ -3087,8 +3340,8 @@ const artRestore = {
3087
3340
  const project = await linkedHere(context);
3088
3341
  const platform = await signedIn(context);
3089
3342
  const base = `/projects/${encodeURIComponent(project)}`;
3090
- const answered = await changed(context, platform, args, `${base}/art/restore`, `${base}/art`, { draft: args.positionals[0] });
3091
- answer(context, answered, `Restored it: the art is at revision ${String(answered.revision)}.`);
3343
+ const answered2 = await changed(context, platform, args, `${base}/art/restore`, `${base}/art`, { draft: args.positionals[0] });
3344
+ answer(context, answered2, `Restored it: the art is at revision ${String(answered2.revision)}.`);
3092
3345
  }
3093
3346
  };
3094
3347
  const artReference = {
@@ -3102,8 +3355,8 @@ const artReference = {
3102
3355
  const platform = await signedIn(context);
3103
3356
  const base = `/projects/${encodeURIComponent(project)}`;
3104
3357
  const [name, image] = args.positionals;
3105
- const answered = await changed(context, platform, args, `${base}/art/references`, `${base}/art`, { name, image });
3106
- answer(context, answered, `Kept ${name} as a reference: the art is at revision ${String(answered.revision)}.`);
3358
+ const answered2 = await changed(context, platform, args, `${base}/art/references`, `${base}/art`, { name, image });
3359
+ answer(context, answered2, `Kept ${name} as a reference: the art is at revision ${String(answered2.revision)}.`);
3107
3360
  }
3108
3361
  };
3109
3362
  const artTileset = {
@@ -3117,8 +3370,8 @@ const artTileset = {
3117
3370
  const platform = await signedIn(context);
3118
3371
  const base = `/projects/${encodeURIComponent(project)}`;
3119
3372
  const [run, tileset] = args.positionals;
3120
- const answered = await changed(context, platform, args, `${base}/art/records/${encodeURIComponent(run)}/tileset`, `${base}/art`, { tileset });
3121
- answer(context, answered, `Named ${tileset} for ${run}: the art is at revision ${String(answered.revision)}.`);
3373
+ const answered2 = await changed(context, platform, args, `${base}/art/records/${encodeURIComponent(run)}/tileset`, `${base}/art`, { tileset });
3374
+ answer(context, answered2, `Named ${tileset} for ${run}: the art is at revision ${String(answered2.revision)}.`);
3122
3375
  }
3123
3376
  };
3124
3377
  const LEVEL = { z: { type: "string", value: "<level>", description: "the map level, 0 to 14" } };
@@ -3162,8 +3415,8 @@ const mapWrite = {
3162
3415
  const body = await bodyOf(context, args);
3163
3416
  const platform = await signedIn(context);
3164
3417
  const base = `/projects/${encodeURIComponent(project)}`;
3165
- const answered = await changed(context, platform, args, `${base}/map/sectors`, `${base}/map`, body, "PUT");
3166
- answer(context, answered, `Wrote the sectors: the map is at revision ${String(answered.revision)}.`);
3418
+ const answered2 = await changed(context, platform, args, `${base}/map/sectors`, `${base}/map`, body, "PUT");
3419
+ answer(context, answered2, `Wrote the sectors: the map is at revision ${String(answered2.revision)}.`);
3167
3420
  }
3168
3421
  };
3169
3422
  const mapSize = {
@@ -3178,8 +3431,8 @@ const mapSize = {
3178
3431
  const project = await linkedHere(context);
3179
3432
  const platform = await signedIn(context);
3180
3433
  const base = `/projects/${encodeURIComponent(project)}`;
3181
- const answered = await changed(context, platform, args, `${base}/map/size`, `${base}/map`, { width, height }, "PUT");
3182
- answer(context, answered, `Grew the map to ${width} × ${height} tiles: it is at revision ${String(answered.revision)}.`);
3434
+ const answered2 = await changed(context, platform, args, `${base}/map/size`, `${base}/map`, { width, height }, "PUT");
3435
+ answer(context, answered2, `Grew the map to ${width} × ${height} tiles: it is at revision ${String(answered2.revision)}.`);
3183
3436
  }
3184
3437
  };
3185
3438
  const ICON_SIZES = [16, 32, 64];
@@ -4299,7 +4552,7 @@ function pngCodec(zlib) {
4299
4552
  }
4300
4553
  const EXPORT_MARK = ".pixelvibing-export";
4301
4554
  const codec$1 = pngCodec({ inflate: (data, maxOutputLength) => inflateSync(data, { maxOutputLength }), deflate: (data) => deflateSync(data) });
4302
- async function isDirectory$1(path) {
4555
+ async function isDirectory(path) {
4303
4556
  try {
4304
4557
  return (await lstat(path)).isDirectory();
4305
4558
  } catch {
@@ -4339,7 +4592,7 @@ async function writeWhole(out, files, engine) {
4339
4592
  });
4340
4593
  throw error;
4341
4594
  }
4342
- const had = await isDirectory$1(out);
4595
+ const had = await isDirectory(out);
4343
4596
  if (had) await rename(out, old);
4344
4597
  try {
4345
4598
  await rename(staging, out);
@@ -6166,7 +6419,7 @@ const mcpHeaders = {
6166
6419
  }
6167
6420
  };
6168
6421
  const GUIDE_KIND = "style-guide";
6169
- const sha256$1 = (text2) => createHash("sha256").update(text2).digest("hex");
6422
+ const sha256 = (text2) => createHash("sha256").update(text2).digest("hex");
6170
6423
  const guidePath = (local) => join(local.root, ...STYLE_GUIDE_FILE.split("/"));
6171
6424
  async function localGuide(local) {
6172
6425
  const boundary = artBoundary(local);
@@ -6176,7 +6429,7 @@ async function unpushed(local) {
6176
6429
  const text2 = await localGuide(local);
6177
6430
  if (text2 === null) return false;
6178
6431
  const pulled = (await readRecord(local)).guide;
6179
- return pulled === null ? text2 !== starterGuide(local.manifest.name) : sha256$1(text2) !== pulled.sha256;
6432
+ return pulled === null ? text2 !== starterGuide(local.manifest.name) : sha256(text2) !== pulled.sha256;
6180
6433
  }
6181
6434
  async function noGuide(platform, project) {
6182
6435
  const { guide } = await platform.web.project(project);
@@ -6194,7 +6447,7 @@ async function currentGuide(platform, project) {
6194
6447
  async function writeGuide(local, guide) {
6195
6448
  await artBoundary(local).writeFileReplacing(guidePath(local), guide.text);
6196
6449
  const record = await readRecord(local);
6197
- await writeRecord(local, { ...record, guide: { version: guide.version, sha256: sha256$1(guide.text) } });
6450
+ await writeRecord(local, { ...record, guide: { version: guide.version, sha256: sha256(guide.text) } });
6198
6451
  }
6199
6452
  const CHANGED = "pixelvibing/style-guide.md has changes that were never pushed: push them with `pixel-vibing-cli guide push`, or pass --force to replace them with the platform's";
6200
6453
  const guidePull = {
@@ -6208,7 +6461,7 @@ const guidePull = {
6208
6461
  const guide = await currentGuide(platform, project);
6209
6462
  const record = await readRecord(local);
6210
6463
  const text2 = await localGuide(local);
6211
- if (record.guide?.version === guide.version && text2 !== null && sha256$1(text2) === record.guide.sha256) {
6464
+ if (record.guide?.version === guide.version && text2 !== null && sha256(text2) === record.guide.sha256) {
6212
6465
  answer(context, { version: guide.version, current: true }, `The style guide is current: version ${guide.version}.`);
6213
6466
  return;
6214
6467
  }
@@ -6226,7 +6479,7 @@ const guidePush = {
6226
6479
  const text2 = await localGuide(local);
6227
6480
  if (text2 === null) throw new CliError(`${STYLE_GUIDE_FILE} is not there: \`pixel-vibing-cli guide pull\` writes it`);
6228
6481
  const pulled = (await readRecord(local)).guide;
6229
- if (pulled !== null && sha256$1(text2) === pulled.sha256) {
6482
+ if (pulled !== null && sha256(text2) === pulled.sha256) {
6230
6483
  answer(context, { version: pulled.version, pushed: false }, `Nothing to push: ${STYLE_GUIDE_FILE} is version ${pulled.version} as it was pulled.`);
6231
6484
  return;
6232
6485
  }
@@ -6249,7 +6502,7 @@ const guidePush = {
6249
6502
  throw error;
6250
6503
  }
6251
6504
  const record = await readRecord(local);
6252
- await writeRecord(local, { ...record, guide: { version: version2, sha256: sha256$1(text2) } });
6505
+ await writeRecord(local, { ...record, guide: { version: version2, sha256: sha256(text2) } });
6253
6506
  answer(context, { version: version2, pushed: true }, `Kept ${STYLE_GUIDE_FILE} as version ${version2} of the style guide.`);
6254
6507
  }
6255
6508
  };
@@ -6610,210 +6863,6 @@ const move = {
6610
6863
  );
6611
6864
  }
6612
6865
  };
6613
- const version = "0.56.2";
6614
- const manifest = {
6615
- version
6616
- };
6617
- const VERSION = manifest.version;
6618
- const PINNED_PACKAGE = `@pixelvibing/cli@${VERSION}`;
6619
- const SERVER_NAME = "pixelvibing";
6620
- const PLAIN_ADDRESS = /^https?:\/\/[A-Za-z0-9.-]+(:[0-9]+)?(\/[A-Za-z0-9._~/-]*)?$/;
6621
- function mcpEntry(apiUrl) {
6622
- if (!PLAIN_ADDRESS.test(apiUrl)) {
6623
- throw new CliError(`${apiUrl} cannot be written into a harness's command: an address there holds only letters, digits and . - : / _ ~`);
6624
- }
6625
- if (apiUrl === DEFAULT_API_URL) return { url: DEFAULT_MCP_URL, headers: `npx --yes ${PINNED_PACKAGE} mcp headers` };
6626
- return { url: `${apiUrl}/mcp`, headers: `npx --yes ${PINNED_PACKAGE} mcp headers --api ${apiUrl}` };
6627
- }
6628
- async function textOf$1(path) {
6629
- try {
6630
- return await readFile(path, "utf8");
6631
- } catch (error) {
6632
- if (error.code === "ENOENT") return null;
6633
- throw error;
6634
- }
6635
- }
6636
- const CLAUDE_FILE = ".mcp.json";
6637
- const CODEX_FILE = join(".codex", "config.toml");
6638
- const TABLE = `[mcp_servers.${SERVER_NAME}]`;
6639
- const isHeader = (line) => /^\s*\[/.test(line);
6640
- function claudeWith(text2, entry, onlyWhereConfigured) {
6641
- let parsed2 = {};
6642
- if (text2 !== null) {
6643
- try {
6644
- parsed2 = JSON.parse(text2);
6645
- } catch (error) {
6646
- throw new CliError(`${CLAUDE_FILE} is not valid JSON, so it was left as it is: ${error.message}`);
6647
- }
6648
- if (typeof parsed2 !== "object" || parsed2 === null || Array.isArray(parsed2)) throw new CliError(`${CLAUDE_FILE} is not an object, so it was left as it is`);
6649
- }
6650
- const servers = parsed2.mcpServers;
6651
- if (servers !== void 0 && (typeof servers !== "object" || servers === null || Array.isArray(servers))) {
6652
- throw new CliError(`${CLAUDE_FILE}'s mcpServers is not an object, so it was left as it is`);
6653
- }
6654
- const current = servers ?? {};
6655
- if (onlyWhereConfigured && current[SERVER_NAME] === void 0) return null;
6656
- const next = { ...parsed2, mcpServers: { ...current, [SERVER_NAME]: { type: "http", url: entry.url, headersHelper: entry.headers } } };
6657
- return `${JSON.stringify(next, null, 2)}
6658
- `;
6659
- }
6660
- function codexWith(text2, entry, onlyWhereConfigured) {
6661
- const table = [TABLE, `url = ${JSON.stringify(entry.url)}`, `http_headers_helper = ${JSON.stringify(entry.headers)}`];
6662
- const lines = (text2 ?? "").split("\n");
6663
- const start = lines.findIndex((line) => line.trim() === TABLE);
6664
- if (start === -1) {
6665
- if (onlyWhereConfigured) return null;
6666
- const before = (text2 ?? "").replace(/\n*$/, "");
6667
- return `${before === "" ? "" : `${before}
6668
-
6669
- `}${table.join("\n")}
6670
- `;
6671
- }
6672
- let end = start + 1;
6673
- while (end < lines.length && !isHeader(lines[end])) end += 1;
6674
- const kept = lines.slice(start + 1, end).filter((line) => line.trim() !== "" && !/^\s*(url|http_headers_helper)\s*=/.test(line));
6675
- const rest = lines.slice(end);
6676
- return [...lines.slice(0, start), ...table, ...kept, ...rest.length > 0 ? ["", ...rest] : [""]].join("\n").replace(/\n{3,}/g, "\n\n");
6677
- }
6678
- async function configureHarness(root, harness, entry, onlyWhereConfigured = false) {
6679
- const file = harness === "claude" ? CLAUDE_FILE : CODEX_FILE;
6680
- const path = join(root, file);
6681
- const text2 = await textOf$1(path);
6682
- const next = harness === "claude" ? claudeWith(text2, entry, onlyWhereConfigured) : codexWith(text2, entry, onlyWhereConfigured);
6683
- if (next === null) return "absent";
6684
- if (next === text2) return "current";
6685
- if (harness === "codex") await mkdir(join(root, ".codex"), { recursive: true });
6686
- await writeFile(path, next);
6687
- return "written";
6688
- }
6689
- const HARNESS_FILES = { claude: ".mcp.json", codex: ".codex/config.toml" };
6690
- const HARNESSES = { claude: ".claude", codex: ".codex" };
6691
- const SKILLS_MANIFEST = "pixelvibing-skills.json";
6692
- const sha256 = (text2) => createHash("sha256").update(text2).digest("hex");
6693
- function skillHash(text2, files = []) {
6694
- if (files.length === 0) return sha256(text2);
6695
- const hash = createHash("sha256").update(text2);
6696
- for (const file of files) hash.update(`\0${file.path}\0`).update(file.text);
6697
- return hash.digest("hex");
6698
- }
6699
- async function referencesOf(directory) {
6700
- let names;
6701
- try {
6702
- names = (await readdir(join(directory, "references"), { withFileTypes: true })).filter((entry) => entry.isFile()).map((entry) => entry.name);
6703
- } catch {
6704
- return [];
6705
- }
6706
- const files = [];
6707
- for (const name of names.sort()) files.push({ path: `references/${name}`, text: await readFile(join(directory, "references", name), "utf8") });
6708
- return files;
6709
- }
6710
- const SHIPPED_DIRECTORY = fileURLToPath(new URL("../skills/", import.meta.url));
6711
- async function shippedSkills(directory = SHIPPED_DIRECTORY) {
6712
- const names = (await readdir(directory, { withFileTypes: true })).filter((entry) => entry.isDirectory()).map((entry) => entry.name);
6713
- const skills = [];
6714
- for (const name of names.sort()) {
6715
- const text2 = await readFile(join(directory, name, "SKILL.md"), "utf8");
6716
- const files = await referencesOf(join(directory, name));
6717
- skills.push({ name, text: text2, ...files.length > 0 ? { files } : {}, sha256: skillHash(text2, files) });
6718
- }
6719
- return skills;
6720
- }
6721
- async function isDirectory(path) {
6722
- try {
6723
- return (await stat(path)).isDirectory();
6724
- } catch {
6725
- return false;
6726
- }
6727
- }
6728
- async function harnessesFor(root, named) {
6729
- if (named.length > 0) {
6730
- const unknown = named.filter((name) => !(name in HARNESSES));
6731
- if (unknown.length > 0) throw new CliError(`--harness is ${Object.keys(HARNESSES).join(" or ")}, not ${unknown.join(", ")}`);
6732
- return [...new Set(named)];
6733
- }
6734
- const present = [];
6735
- for (const harness of Object.keys(HARNESSES)) if (await isDirectory(join(root, HARNESSES[harness]))) present.push(harness);
6736
- return present.length > 0 ? present : ["claude"];
6737
- }
6738
- const manifestPath = (root, harness) => join(root, HARNESSES[harness], SKILLS_MANIFEST);
6739
- const skillPath = (root, harness, name) => join(root, HARNESSES[harness], "skills", name, "SKILL.md");
6740
- async function readManifest(root, harness) {
6741
- let parsed2;
6742
- try {
6743
- parsed2 = JSON.parse(await readFile(manifestPath(root, harness), "utf8"));
6744
- } catch (error) {
6745
- if (error.code === "ENOENT") return null;
6746
- throw new CliError(`${manifestPath(root, harness)} cannot be read: ${error.message}`);
6747
- }
6748
- const skills = parsed2?.skills;
6749
- if (typeof skills !== "object" || skills === null) throw new CliError(`${manifestPath(root, harness)} records no skills`);
6750
- return Object.fromEntries(Object.entries(skills).filter((entry) => typeof entry[1] === "string"));
6751
- }
6752
- async function writeManifest(root, harness, skills) {
6753
- const sorted = Object.fromEntries(Object.entries(skills).sort(([left], [right]) => left < right ? -1 : 1));
6754
- await writeFile(manifestPath(root, harness), `${JSON.stringify({ format: 1, skills: sorted }, null, 2)}
6755
- `);
6756
- }
6757
- async function writeSkill(root, harness, skill) {
6758
- const path = skillPath(root, harness, skill.name);
6759
- await mkdir(join(path, ".."), { recursive: true });
6760
- await writeFile(path, skill.text);
6761
- for (const file of skill.files ?? []) {
6762
- const at = join(path, "..", file.path);
6763
- await mkdir(join(at, ".."), { recursive: true });
6764
- await writeFile(at, file.text);
6765
- }
6766
- }
6767
- async function textAt(path) {
6768
- try {
6769
- return await readFile(path, "utf8");
6770
- } catch {
6771
- return null;
6772
- }
6773
- }
6774
- async function hashThere(root, harness, skill) {
6775
- const path = skillPath(root, harness, skill.name);
6776
- const text2 = await textAt(path);
6777
- if (text2 === null) return null;
6778
- const files = [];
6779
- for (const file of skill.files ?? []) files.push({ path: file.path, text: await textAt(join(path, "..", file.path)) ?? "" });
6780
- return skillHash(text2, files);
6781
- }
6782
- async function installMissing(root, harness, skills) {
6783
- const manifest2 = await readManifest(root, harness) ?? {};
6784
- const installed = [];
6785
- const left = [];
6786
- for (const skill of skills) {
6787
- const there = await hashThere(root, harness, skill);
6788
- if (there === null) {
6789
- await writeSkill(root, harness, skill);
6790
- manifest2[skill.name] = skill.sha256;
6791
- installed.push(skill.name);
6792
- } else if (there === skill.sha256) manifest2[skill.name] = skill.sha256;
6793
- else if (manifest2[skill.name] === void 0) left.push(skill.name);
6794
- }
6795
- await writeManifest(root, harness, manifest2);
6796
- return { harness, installed, left };
6797
- }
6798
- async function updateSkills(root, skills) {
6799
- const updated = [];
6800
- for (const harness of Object.keys(HARNESSES)) {
6801
- const manifest2 = await readManifest(root, harness);
6802
- if (manifest2 === null) continue;
6803
- const replaced = [];
6804
- const installed = [];
6805
- for (const skill of skills) {
6806
- const recorded = manifest2[skill.name];
6807
- if (recorded === skill.sha256 && await textAt(skillPath(root, harness, skill.name)) !== null) continue;
6808
- await writeSkill(root, harness, skill);
6809
- (recorded === void 0 ? installed : replaced).push(skill.name);
6810
- manifest2[skill.name] = skill.sha256;
6811
- }
6812
- await writeManifest(root, harness, manifest2);
6813
- updated.push({ harness, replaced, installed });
6814
- }
6815
- return updated;
6816
- }
6817
6866
  const BLOCK_OPEN = "<!-- pixelvibing:skills -->";
6818
6867
  const BLOCK_CLOSE = "<!-- /pixelvibing:skills -->";
6819
6868
  const ROUTED_FILES = [
@@ -6830,7 +6879,8 @@ function routingBlock(skills) {
6830
6879
  "## PixelVibing art",
6831
6880
  "",
6832
6881
  "This game's art is made on PixelVibing. Before making, finding, pulling or exporting any of it,",
6833
- "load the Skill for the request from `.claude/skills/` or `.codex/skills/` and follow it,",
6882
+ "load the Skill for the request from this harness's Skills folder, such as `.claude/skills/` or `.agents/skills/`,",
6883
+ "and follow it,",
6834
6884
  "starting with `pixelvibing`. Never write a PixelVibing token into a file or a command.",
6835
6885
  "",
6836
6886
  ...lines,
@@ -6932,16 +6982,57 @@ function routingWords(routed) {
6932
6982
  })
6933
6983
  };
6934
6984
  }
6985
+ async function harnessesChosen(context, root, named) {
6986
+ if (named.length > 0 || !context.io.interactive || context.json) return harnessesFor(root, named);
6987
+ const found = await harnessesFound(root);
6988
+ context.io.step("Harnesses");
6989
+ let marks;
6990
+ try {
6991
+ marks = await context.io.choose(
6992
+ "Install the Skills into which coding assistants?",
6993
+ HARNESS_NAMES.map((harness) => ({ label: HARNESSES[harness].name, marked: found.includes(harness) }))
6994
+ );
6995
+ } catch (error) {
6996
+ if (error.message === "cancelled") throw new CliError("no harness was chosen: nothing was installed");
6997
+ throw error;
6998
+ }
6999
+ const chosen = HARNESS_NAMES.filter((_, index) => marks[index] === true);
7000
+ if (chosen.length === 0) throw new CliError("no harness was marked: nothing was installed");
7001
+ return chosen;
7002
+ }
7003
+ async function scopeChosen(context, given2) {
7004
+ if (given2 !== void 0) {
7005
+ if (given2 !== "user" && given2 !== "project") throw new CliError(`--scope is user or project, not ${given2}`);
7006
+ return given2;
7007
+ }
7008
+ if (!context.io.interactive || context.json) return "user";
7009
+ let picked;
7010
+ try {
7011
+ picked = await context.io.pick("Install the Skills for which projects?", [
7012
+ { label: "Every project of yours", hint: "each assistant's own directory" },
7013
+ { label: "This repository alone" }
7014
+ ]);
7015
+ } catch (error) {
7016
+ if (error.message === "cancelled") throw new CliError("no scope was chosen: nothing was installed");
7017
+ throw error;
7018
+ }
7019
+ return picked === 1 ? "project" : "user";
7020
+ }
6935
7021
  const setup = {
6936
7022
  name: "setup",
6937
7023
  summary: "Sign in, make and link the local project, install the Skills and name them in AGENTS.md, doing only what is missing",
6938
- usage: "[--harness claude|codex]… [--project <id|name> | --idea <text>] [--name <name>] [--provider <name>]",
7024
+ usage: "[--harness <name>]… [--scope user|project] [--project <id|name> | --idea <text>] [--name <name>] [--provider <name>]",
6939
7025
  options: {
6940
7026
  harness: {
6941
7027
  type: "string",
6942
7028
  multiple: true,
6943
- value: "<claude|codex>",
6944
- description: "the harness to install the Skills into; those this repository has, else Claude Code"
7029
+ value: `<${HARNESS_NAMES.join("|")}>`,
7030
+ description: "a harness to install the Skills into, without the list; with none and no terminal, those this repository has, else Claude Code"
7031
+ },
7032
+ scope: {
7033
+ type: "string",
7034
+ value: "<user|project>",
7035
+ description: "install the Skills for every project of yours (the default) or for this repository alone"
6945
7036
  },
6946
7037
  project: { type: "string", value: "<id|name>", description: "the platform project to link, when the local project names none" },
6947
7038
  idea: { type: "string", value: "<text>", description: "make the platform project from this idea, when the local project names none" },
@@ -6950,14 +7041,18 @@ const setup = {
6950
7041
  },
6951
7042
  run: async (context, args) => {
6952
7043
  const done = [];
7044
+ const steps = context.io.interactive && !context.json;
7045
+ if (steps) context.io.step("PixelVibing setup", "start");
6953
7046
  if (await findProject(context.io.cwd) === null) {
6954
7047
  const made = await initProject(context.io.cwd, stringOf(args, "name"));
6955
7048
  done.push(`made ${made.root} the local project ${made.name}`);
6956
7049
  }
6957
7050
  const local = await projectHere(context.io.cwd);
6958
7051
  let linked = local.manifest.project ?? null;
6959
- const kept = linked === null ? null : await tokenFor(context.services.store, tokenKey(apiUrlOf(context), linked), context.io.env);
7052
+ const apiUrl = apiUrlOf(context);
7053
+ const kept = linked === null ? null : await tokenFor(context.services.store, tokenKey(apiUrl, linked), context.io.env);
6960
7054
  if (linked === null || kept === null) {
7055
+ if (steps) context.io.step("Sign in");
6961
7056
  const { project, account } = await linkHere(
6962
7057
  context,
6963
7058
  local,
@@ -6970,17 +7065,16 @@ const setup = {
6970
7065
  linked = project.id;
6971
7066
  }
6972
7067
  const skills = await shippedSkills();
6973
- const harnesses = await harnessesFor(local.root, stringsOf(args, "harness"));
6974
- const entry = mcpEntry(apiUrlOf(context));
7068
+ const harnesses = await harnessesChosen(context, local.root, stringsOf(args, "harness"));
7069
+ const scope = await scopeChosen(context, stringOf(args, "scope"));
7070
+ const base = scope === "user" ? context.services.home : local.root;
6975
7071
  const installed = [];
6976
7072
  for (const harness of harnesses) {
6977
- const result = await installMissing(local.root, harness, skills);
6978
- const mcp = await configureHarness(local.root, harness, entry);
6979
- installed.push({ ...result, mcp });
6980
- if (result.installed.length > 0) done.push(`installed ${result.installed.join(", ")} into ${HARNESSES[result.harness]}/skills/`);
6981
- if (mcp === "written") done.push(`configured the MCP server in ${HARNESS_FILES[harness]}`);
7073
+ const result = await installMissing(base, harness, skills, scope);
7074
+ installed.push(result);
7075
+ if (result.installed.length > 0) done.push(`installed ${result.installed.join(", ")} into ${skillsDirectory(result.harness, scope)}/`);
6982
7076
  for (const name of result.left) {
6983
- context.io.warn(`${HARNESSES[result.harness]}/skills/${name}/ is already there and is not the one this CLI ships: it was left as it is.
7077
+ context.io.warn(`${skillsDirectory(result.harness, scope)}/${name}/ is already there and is not the one this CLI ships: it was left as it is.
6984
7078
  `);
6985
7079
  }
6986
7080
  }
@@ -6988,11 +7082,9 @@ const setup = {
6988
7082
  const said = routingWords(routing);
6989
7083
  done.push(...said.done);
6990
7084
  for (const warning of said.warnings) context.io.warn(warning);
6991
- answer(
6992
- context,
6993
- { root: local.root, project: linked, harnesses: installed, routing },
6994
- done.length === 0 ? "Nothing to do: this repository is set up." : `Set up: ${done.join("; ")}.`
6995
- );
7085
+ const summary = done.length === 0 ? "Nothing to do: this repository is set up." : `Set up: ${done.join("; ")}.`;
7086
+ if (steps) context.io.step("Done: the MCP server, for a client that needs it, is in the project's Install tab", "end");
7087
+ answer(context, { root: local.root, project: linked, harnesses: installed, routing }, summary);
6996
7088
  }
6997
7089
  };
6998
7090
  const update = {
@@ -7001,16 +7093,17 @@ const update = {
7001
7093
  run: async (context) => {
7002
7094
  const local = await projectHere(context.io.cwd);
7003
7095
  const skills = await shippedSkills();
7004
- const updated = await updateSkills(local.root, skills);
7096
+ const updated = [...await updateSkills(local.root, skills), ...await updateSkills(context.services.home, skills, "user")];
7005
7097
  if (updated.length === 0) throw new CliError("no Skills of this CLI are installed here: `pixel-vibing-cli setup` installs them");
7006
- const entry = mcpEntry(apiUrlOf(context));
7007
7098
  const harnesses = [];
7008
- for (const result of updated) harnesses.push({ ...result, mcp: await configureHarness(local.root, result.harness, entry, true) });
7009
- const written = harnesses.flatMap((result) => [
7010
- ...result.replaced.map((name) => `replaced ${HARNESSES[result.harness]}/skills/${name}`),
7011
- ...result.installed.map((name) => `installed ${HARNESSES[result.harness]}/skills/${name}`),
7012
- ...result.mcp === "written" ? [`brought the MCP server in ${HARNESS_FILES[result.harness]} to this version`] : []
7013
- ]);
7099
+ const written = [];
7100
+ for (const result of updated) {
7101
+ written.push(
7102
+ ...result.replaced.map((name) => `replaced ${skillsDirectory(result.harness, result.scope)}/${name}`),
7103
+ ...result.installed.map((name) => `installed ${skillsDirectory(result.harness, result.scope)}/${name}`)
7104
+ );
7105
+ harnesses.push(result);
7106
+ }
7014
7107
  const routing = await writeRouting(local.root, skills);
7015
7108
  const said = routingWords(routing);
7016
7109
  written.push(...said.done);
@@ -7183,7 +7276,6 @@ function machineServices(env) {
7183
7276
  settingsFile: settingsPath(process.platform, env, homedir()),
7184
7277
  fetch: globalThis.fetch,
7185
7278
  openExternal: (url) => detached(...browserCommand(process.platform, url)),
7186
- hostname,
7187
7279
  sleep: (ms) => new Promise((resolve2) => setTimeout(resolve2, ms)),
7188
7280
  now: Date.now,
7189
7281
  platform: process.platform,
@@ -7227,6 +7319,38 @@ function readLine(input, output, question) {
7227
7319
  });
7228
7320
  });
7229
7321
  }
7322
+ function answered(value) {
7323
+ if (isCancel(value)) throw new Error("cancelled");
7324
+ return value;
7325
+ }
7326
+ const streamsOf = (input, output) => ({ input, output });
7327
+ async function readChecklist(input, output, question, options) {
7328
+ const marked = answered(
7329
+ await multiselect({
7330
+ message: question,
7331
+ options: options.map((option, index) => ({ value: index, label: option.label })),
7332
+ initialValues: options.flatMap((option, index) => option.marked ? [index] : []),
7333
+ required: false,
7334
+ ...streamsOf(input, output)
7335
+ })
7336
+ );
7337
+ return options.map((_, index) => marked.includes(index));
7338
+ }
7339
+ async function readPick(input, output, question, options) {
7340
+ return answered(
7341
+ await select({
7342
+ message: question,
7343
+ options: options.map((option, index) => ({ value: index, label: option.label, ...option.hint === void 0 ? {} : { hint: option.hint } })),
7344
+ ...streamsOf(input, output)
7345
+ })
7346
+ );
7347
+ }
7348
+ function drawStep(output, title, at) {
7349
+ const opts = { output };
7350
+ if (at === "start") intro(title, opts);
7351
+ else if (at === "end") outro(title, opts);
7352
+ else log.step(title, opts);
7353
+ }
7230
7354
  async function readAll(input) {
7231
7355
  let text2 = "";
7232
7356
  for await (const chunk2 of input) text2 += chunk2.toString();
@@ -7244,9 +7368,17 @@ function terminalOf(streams) {
7244
7368
  cwd: streams.cwd,
7245
7369
  interactive: streams.stdin.isTTY === true && streams.stderr.isTTY === true,
7246
7370
  ask: (question, hidden = false) => hidden ? readHidden(streams.stdin, streams.stderr, question) : readLine(streams.stdin, streams.stderr, question),
7247
- readInput: () => readAll(streams.stdin)
7371
+ readInput: () => readAll(streams.stdin),
7372
+ choose: (question, options) => readChecklist(streams.stdin, streams.stderr, question, options),
7373
+ pick: (question, options) => readPick(streams.stdin, streams.stderr, question, options),
7374
+ step: (title, at = "step") => drawStep(streams.stderr, title, at)
7248
7375
  };
7249
7376
  }
7377
+ const version = "0.57.0";
7378
+ const manifest = {
7379
+ version
7380
+ };
7381
+ const VERSION = manifest.version;
7250
7382
  const COMMANDS = [
7251
7383
  setup,
7252
7384
  update,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pixelvibing/cli",
3
- "version": "0.56.2",
3
+ "version": "0.57.0",
4
4
  "description": "PixelVibing: pixel art game assets from the command line, and the Skills that teach coding assistants to make them.",
5
5
  "license": "MIT",
6
6
  "homepage": "https://pixelvibing.com",
@@ -32,6 +32,7 @@
32
32
  "access": "public"
33
33
  },
34
34
  "dependencies": {
35
+ "@clack/prompts": "1.8.1",
35
36
  "@napi-rs/keyring": "2.1.0"
36
37
  },
37
38
  "devDependencies": {