pinsay-cli 0.7.2 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +3 -3
  2. package/dist/cli.js +221 -95
  3. package/package.json +3 -3
package/README.md CHANGED
@@ -17,8 +17,8 @@ npx pinsay-cli init
17
17
 
18
18
  | Command | What it does |
19
19
  |---|---|
20
- | `npx pinsay-cli init` | Configure a repo (server, project, widget snippet, skills) |
21
- | `npx pinsay-cli login` / `whoami` / `logout` | Sign in once per machine; check / clear credentials |
20
+ | `npx pinsay-cli init` | Configure a repo (project, widget snippet, skills) |
21
+ | `npx pinsay-cli login` / `whoami` / `logout` | Sign in (asks: this machine or this repo only); check / clear credentials |
22
22
  | `npx pinsay-cli list` / `get <id>` | List pending feedback; inspect one comment |
23
23
  | `npx pinsay-cli apply` | Generate the fix-it prompt for your AI tool, then record what changed |
24
24
  | `npx pinsay-cli apply --plan` | Preview proposed edits without touching code |
@@ -38,7 +38,7 @@ npx pinsay-cli init
38
38
 
39
39
  ## API keys
40
40
 
41
- Keys look like `pnsy_` + 40 hex characters. They resolve in this order: `PINSAY_API_KEY` env var → `.pinsay/credentials.env` → the per-machine store (`~/.config/pointer/credentials.json`, mode 0600, never committed).
41
+ Keys look like `pnsy_` + 40 hex characters. They resolve in this order: `PINSAY_API_KEY` env var → `.pinsay/credentials.env` → the per-machine store (`~/.config/pinsay/credentials.json`, mode 0600, never committed).
42
42
 
43
43
  ## MCP (Claude Code / Cursor)
44
44
 
package/dist/cli.js CHANGED
@@ -15,7 +15,7 @@ var init_build_constants = __esm({
15
15
  "src/build-constants.ts"() {
16
16
  "use strict";
17
17
  BUILD_DEFAULT_SERVER = true ? "https://app.pinsay.dev" : "https://app.pinsay.dev";
18
- BUILD_CLI_VERSION = true ? "0.7.2" : "0.0.0-dev";
18
+ BUILD_CLI_VERSION = true ? "0.8.0" : "0.0.0-dev";
19
19
  }
20
20
  });
21
21
 
@@ -124,8 +124,6 @@ async function writeCredentials(cwd2, token, extra = {}) {
124
124
  const file = join(cwd2, CREDENTIALS_FILE);
125
125
  await fs.mkdir(dirname(file), { recursive: true });
126
126
  const lines = [`PINSAY_API_KEY=${token}`];
127
- if (extra.server)
128
- lines.push(`PINSAY_SERVER=${extra.server}`);
129
127
  if (extra.project)
130
128
  lines.push(`PINSAY_PROJECT=${extra.project}`);
131
129
  await fs.writeFile(file, lines.join("\n") + "\n", { encoding: "utf8", mode: 384 });
@@ -278,38 +276,83 @@ function normalizeServerOrigin(server) {
278
276
  return server.replace(/\/+$/, "");
279
277
  }
280
278
  }
279
+ function configBase() {
280
+ if (process.platform === "win32") {
281
+ return process.env.APPDATA || join10(homedir(), "AppData", "Roaming");
282
+ }
283
+ return process.env.XDG_CONFIG_HOME || join10(homedir(), ".config");
284
+ }
281
285
  function globalConfigDir() {
282
286
  if (process.env.PINSAY_CONFIG_DIR)
283
287
  return process.env.PINSAY_CONFIG_DIR;
284
- if (process.platform === "win32") {
285
- return join10(process.env.APPDATA || join10(homedir(), "AppData", "Roaming"), "pointer");
286
- }
287
- return join10(process.env.XDG_CONFIG_HOME || join10(homedir(), ".config"), "pointer");
288
+ return join10(configBase(), DIR_NAME);
288
289
  }
289
290
  function globalCredentialsPath() {
290
291
  return join10(globalConfigDir(), "credentials.json");
291
292
  }
293
+ function legacyGlobalCredentialsPath() {
294
+ if (process.env.PINSAY_CONFIG_DIR)
295
+ return void 0;
296
+ return join10(configBase(), LEGACY_DIR_NAME, "credentials.json");
297
+ }
292
298
  function globalCacheDir() {
293
299
  if (process.env.PINSAY_CONFIG_DIR)
294
300
  return join10(process.env.PINSAY_CONFIG_DIR, "cache");
295
301
  if (process.platform === "win32") {
296
- return join10(process.env.LOCALAPPDATA || join10(homedir(), "AppData", "Local"), "pointer", "cache");
302
+ return join10(process.env.LOCALAPPDATA || join10(homedir(), "AppData", "Local"), DIR_NAME, "cache");
297
303
  }
298
- return join10(process.env.XDG_CACHE_HOME || join10(homedir(), ".cache"), "pointer");
304
+ return join10(process.env.XDG_CACHE_HOME || join10(homedir(), ".cache"), DIR_NAME);
299
305
  }
300
306
  function tokenCacheFile(server, apiKey) {
301
307
  const hash = createHash("sha256").update(`${normalizeServerOrigin(server)}:${apiKey}`).digest("hex").slice(0, 32);
302
308
  return join10(globalCacheDir(), `${hash}.json`);
303
309
  }
310
+ function isCredentialStore(value) {
311
+ if (!value || typeof value !== "object" || Array.isArray(value))
312
+ return false;
313
+ return Object.values(value).every(
314
+ (entry) => !!entry && typeof entry === "object" && typeof entry.apiKey === "string"
315
+ );
316
+ }
304
317
  async function readGlobalStore() {
318
+ let raw;
319
+ try {
320
+ raw = await fs10.readFile(globalCredentialsPath(), "utf8");
321
+ } catch (err) {
322
+ if (err?.code === "ENOENT")
323
+ return migrateLegacyStore();
324
+ return {};
325
+ }
305
326
  try {
306
- const raw = await fs10.readFile(globalCredentialsPath(), "utf8");
307
327
  const parsed = JSON.parse(raw);
308
328
  return parsed && typeof parsed === "object" ? parsed : {};
309
329
  } catch {
310
330
  return {};
311
331
  }
312
332
  }
333
+ async function migrateLegacyStore() {
334
+ const legacyFile = legacyGlobalCredentialsPath();
335
+ if (!legacyFile)
336
+ return {};
337
+ let legacy;
338
+ try {
339
+ legacy = JSON.parse(await fs10.readFile(legacyFile, "utf8"));
340
+ } catch {
341
+ return {};
342
+ }
343
+ if (!isCredentialStore(legacy))
344
+ return {};
345
+ try {
346
+ await writeGlobalStore(legacy);
347
+ } catch {
348
+ return legacy;
349
+ }
350
+ await fs10.rm(legacyFile, { force: true }).catch(() => {
351
+ });
352
+ await fs10.rmdir(dirname4(legacyFile)).catch(() => {
353
+ });
354
+ return legacy;
355
+ }
313
356
  async function writeGlobalStore(store) {
314
357
  const file = globalCredentialsPath();
315
358
  await fs10.mkdir(dirname4(file), { recursive: true });
@@ -332,9 +375,12 @@ async function saveGlobalCredential(server, entry) {
332
375
  async function removeGlobalCredential(server) {
333
376
  const store = await readGlobalStore();
334
377
  const origin = normalizeServerOrigin(server);
335
- if (!(origin in store))
378
+ const origins = origin === "https://app.pinsay.dev" ? [origin, "https://api.pinsay.dev"] : [origin];
379
+ const present = origins.filter((o) => o in store);
380
+ if (present.length === 0)
336
381
  return false;
337
- delete store[origin];
382
+ for (const o of present)
383
+ delete store[o];
338
384
  await writeGlobalStore(store);
339
385
  return true;
340
386
  }
@@ -364,9 +410,39 @@ async function removeStaleRepoTokenCache(root) {
364
410
  await fs10.rm(join10(root, ".pinsay", ".token_cache"), { force: true }).catch(() => {
365
411
  });
366
412
  }
413
+ var DIR_NAME, LEGACY_DIR_NAME;
367
414
  var init_credentials = __esm({
368
415
  "src/credentials.ts"() {
369
416
  "use strict";
417
+ DIR_NAME = "pinsay";
418
+ LEGACY_DIR_NAME = "pointer";
419
+ }
420
+ });
421
+
422
+ // src/server.ts
423
+ function resolveServer() {
424
+ const override = process.env.PINSAY_SERVER?.trim();
425
+ return canonicalServer(override || BUILD_DEFAULT_SERVER).replace(/\/+$/, "");
426
+ }
427
+ async function serverSettingError(cwd2, parsed) {
428
+ const server = resolveServer();
429
+ if (parsed["server"] !== void 0) {
430
+ return `--server was removed: this CLI talks only to ${server}.`;
431
+ }
432
+ const root = await findRepoRoot(cwd2);
433
+ const config = await readConfig(root).catch(() => ({}));
434
+ if (config.server && normalizeServerOrigin(config.server) !== normalizeServerOrigin(server)) {
435
+ return `.pinsay/config.json sets "server": "${config.server}", but this CLI talks only to ${server}.
436
+ Remove the "server" line from .pinsay/config.json, then run the command again.`;
437
+ }
438
+ return null;
439
+ }
440
+ var init_server = __esm({
441
+ "src/server.ts"() {
442
+ "use strict";
443
+ init_build_constants();
444
+ init_config();
445
+ init_credentials();
370
446
  }
371
447
  });
372
448
 
@@ -931,11 +1007,7 @@ function mapEnvironmentToString(env) {
931
1007
  async function getClient(cwd2, parsed, opts = {}) {
932
1008
  const root = await findRepoRoot(cwd2);
933
1009
  const config = await readConfig(root);
934
- const server = ((typeof parsed["server"] === "string" ? parsed["server"] : config.server) || BUILD_DEFAULT_SERVER).replace(/\/$/, "");
935
- if (!server) {
936
- console.error("No server configured.");
937
- process.exit(2);
938
- }
1010
+ const server = resolveServer();
939
1011
  const explicitKey = typeof parsed["key"] === "string" ? parsed["key"] : void 0;
940
1012
  const token = await resolveToken(server, root, explicitKey);
941
1013
  const apiKey = explicitKey || await readApiKey(root, server);
@@ -1145,8 +1217,8 @@ var init_comments = __esm({
1145
1217
  init_resolve();
1146
1218
  init_api();
1147
1219
  init_auth();
1148
- init_build_constants();
1149
1220
  init_projection();
1221
+ init_server();
1150
1222
  }
1151
1223
  });
1152
1224
 
@@ -1261,7 +1333,7 @@ function assertInteractive() {
1261
1333
  if (process.stdin.isTTY)
1262
1334
  return;
1263
1335
  console.error(
1264
- "\x1B[31mThis command is interactive, but stdin is not a terminal.\x1B[0m\nPiped input, CI, and some editor-embedded shells have no TTY, so there is no way to ask you anything.\n\nEither run it in a real terminal, or pass every answer as a flag:\n npx -y pinsay-cli init --server <url> --key ptr_... --project <key> --environment local --yes\n\nRun 'npx -y pinsay-cli init --help' for the full list of flags."
1336
+ "\x1B[31mThis command is interactive, but stdin is not a terminal.\x1B[0m\nPiped input, CI, and some editor-embedded shells have no TTY, so there is no way to ask you anything.\n\nEither run it in a real terminal, or pass every answer as a flag:\n npx -y pinsay-cli init --key ptr_... --project <key> --yes\n\nRun 'npx -y pinsay-cli init --help' for the full list of flags."
1265
1337
  );
1266
1338
  process.exit(2);
1267
1339
  }
@@ -2271,6 +2343,7 @@ async function writeStackFile(cwd2, stack, projectKey) {
2271
2343
 
2272
2344
  // src/checks.ts
2273
2345
  init_credentials();
2346
+ init_server();
2274
2347
  var execFileAsync = promisify(execFile);
2275
2348
  function tooOldMessage(cliVersion, min) {
2276
2349
  return `CLI ${cliVersion} is older than the server requires (${min}). Upgrade with: npx -y pinsay@latest`;
@@ -2308,7 +2381,7 @@ async function fetchWithTimeout(url, ms, init) {
2308
2381
  async function runInitChecks(cwd2, overrides = {}, cliVersion = "0.0.0") {
2309
2382
  const checks = [];
2310
2383
  const config = await readConfig(cwd2);
2311
- const server = (overrides.server || config.server || "").replace(/\/$/, "");
2384
+ const server = (overrides.server || resolveServer()).replace(/\/$/, "");
2312
2385
  const multiProject = isMultiProject(config);
2313
2386
  const allProjects = listProjects(config);
2314
2387
  const projectTargets = multiProject && overrides.project ? allProjects.filter((p) => p.key === overrides.project) : allProjects;
@@ -3355,7 +3428,35 @@ async function runDeviceLogin(server, options = {}) {
3355
3428
  return { ok: false, reason: "expired" };
3356
3429
  }
3357
3430
 
3431
+ // src/key-scope.ts
3432
+ var KEY_SCOPE_QUESTION = "Where should this API key be stored?";
3433
+ var KEY_SCOPE_GLOBAL = "Global \u2014 this machine, every repo (~/.config/pinsay/credentials.json)";
3434
+ var KEY_SCOPE_REPO = "Repo \u2014 .pinsay/credentials.env in this repo only (gitignored)";
3435
+ function scopeFromFlags(options) {
3436
+ const raw = options["scope"];
3437
+ if (typeof raw === "string") {
3438
+ const value = raw.toLowerCase();
3439
+ if (value !== "global" && value !== "repo") {
3440
+ return { error: `Invalid --scope "${raw}". Valid values: global, repo.` };
3441
+ }
3442
+ return { scope: value };
3443
+ }
3444
+ if (options["local-credentials"] === true)
3445
+ return { scope: "repo" };
3446
+ return {};
3447
+ }
3448
+ function decideKeyScope(flagScope, interactive) {
3449
+ if (flagScope)
3450
+ return flagScope;
3451
+ return interactive ? "ask" : "global";
3452
+ }
3453
+ async function askKeyScope() {
3454
+ const choice = await select(KEY_SCOPE_QUESTION, [KEY_SCOPE_GLOBAL, KEY_SCOPE_REPO]);
3455
+ return choice === KEY_SCOPE_REPO ? "repo" : "global";
3456
+ }
3457
+
3358
3458
  // src/commands/init.ts
3459
+ init_server();
3359
3460
  async function initCommand(cwd2, options = {}) {
3360
3461
  const isYes = options["yes"] || options["json"];
3361
3462
  const isJson = options["json"];
@@ -3373,12 +3474,9 @@ async function initCommand(cwd2, options = {}) {
3373
3474
  const pathFlag = typeof options["path"] === "string" ? String(options["path"]).replace(/^\.\/+/, "").replace(/\/+$/, "") : void 0;
3374
3475
  const isAddProject = Boolean(pathFlag);
3375
3476
  const configIsMulti = isMultiProject(config);
3376
- const isJoin = !isAddProject && Boolean(config.server) && (Boolean(config.project) || configIsMulti);
3477
+ const isJoin = !isAddProject && (Boolean(config.project) || configIsMulti);
3377
3478
  const mode = isJoin ? "join" : "install";
3378
- let server = options["server"] || config.server || canonicalServer(process.env.PINSAY_SERVER) || BUILD_DEFAULT_SERVER;
3379
- if (!isYes && !options["server"] && !config.server) {
3380
- server = await ask("Server URL", { default: server });
3381
- }
3479
+ const server = resolveServer();
3382
3480
  const scopeFlag = typeof options["scope"] === "string" ? String(options["scope"]).toLowerCase() : void 0;
3383
3481
  if (scopeFlag !== void 0 && scopeFlag !== "global" && scopeFlag !== "repo") {
3384
3482
  console.error(`Invalid --scope "${options["scope"]}". Valid values: global, repo.`);
@@ -3521,11 +3619,7 @@ async function initCommand(cwd2, options = {}) {
3521
3619
  if (freshlyAuthenticated) {
3522
3620
  let saveGlobally = !localCredentialsFlag;
3523
3621
  if (saveGlobally && !isYes && scopeFlag === void 0) {
3524
- const choice = await select("Where should this API key be stored?", [
3525
- "Global \u2014 this machine, every repo (~/.config/pointer/credentials.json)",
3526
- "Repo \u2014 .pinsay/credentials.env in this repo only (gitignored)"
3527
- ]);
3528
- saveGlobally = choice.startsWith("Global");
3622
+ saveGlobally = await askKeyScope() === "global";
3529
3623
  }
3530
3624
  if (saveGlobally) {
3531
3625
  await saveGlobalCredential(server, { apiKey: key, email: me?.email, displayName: me?.displayName });
@@ -3534,7 +3628,7 @@ async function initCommand(cwd2, options = {}) {
3534
3628
  }
3535
3629
  }
3536
3630
  if (writeLocalCreds) {
3537
- await writeCredentials(cwd2, key, { server });
3631
+ await writeCredentials(cwd2, key);
3538
3632
  }
3539
3633
  await upsertGitignore(cwd2, product, options["skills-dir"] || config.skillsDir);
3540
3634
  if (isJoin && configIsMulti) {
@@ -3856,7 +3950,6 @@ and the pinsay-init skill uses them to mount the widget for you afterwards.
3856
3950
  }
3857
3951
  const injectedHtml = injected ? filesMod.find((f) => f.toLowerCase().endsWith(".html"))?.replace(`${cwd2}/`, "") : void 0;
3858
3952
  const configPatch = {
3859
- server,
3860
3953
  project: finalProjectKey,
3861
3954
  aiTool: tool,
3862
3955
  skillsDir: options["skills-dir"],
@@ -3868,7 +3961,7 @@ and the pinsay-init skill uses them to mount the widget for you afterwards.
3868
3961
  await writeConfig(cwd2, configPatch);
3869
3962
  filesMod.push(".pinsay/config.json");
3870
3963
  if (writeLocalCreds) {
3871
- await writeCredentials(cwd2, key, { server, project: finalProjectKey });
3964
+ await writeCredentials(cwd2, key, { project: finalProjectKey });
3872
3965
  filesMod.push(".pinsay/credentials.env");
3873
3966
  }
3874
3967
  filesMod.push(".gitignore");
@@ -3912,7 +4005,7 @@ and the pinsay-init skill uses them to mount the widget for you afterwards.
3912
4005
  const cyan = (s) => `\x1B[36m${s}\x1B[0m`;
3913
4006
  const rule = dim("\u2500".repeat(60));
3914
4007
  const envLabel = environmentPinned ? envs.length > 1 ? envs.join(", ") : env : null;
3915
- const keyLine = keyLivesGlobally ? `this machine's global store ${dim("(~/.config/pointer/credentials.json)")}` : `.pinsay/credentials.env ${dim("(gitignored)")}`;
4008
+ const keyLine = keyLivesGlobally ? `this machine's global store ${dim("(~/.config/pinsay/credentials.json)")}` : `.pinsay/credentials.env ${dim("(gitignored)")}`;
3916
4009
  if (isJoin) {
3917
4010
  console.log(`
3918
4011
  ${rule}
@@ -3944,7 +4037,7 @@ ${rule}`);
3944
4037
  ${bold("Next")} Reviewers open the widget through the ${product} Chrome extension \u2014 nothing was injected.
3945
4038
 
3946
4039
  ${installLine}
3947
- 2. Extension \u2192 Options \u2192 set server ${server}; sign in with your ${product} account.
4040
+ 2. Open the extension and sign in with your ${product} account.
3948
4041
  3. Open your app, click the extension icon, choose project ${dim(finalProjectKey)}, Activate.
3949
4042
  4. Then ${bold("npx pinsay-cli list")} / ${bold("apply")} as usual.
3950
4043
  ${dim(`Dashboard: ${branding.urls?.app || server}`)}`);
@@ -4426,7 +4519,6 @@ async function handleMultiProjectSetup(args) {
4426
4519
  for (const r of results)
4427
4520
  projectsMap[r.key] = r.entry;
4428
4521
  await writeConfigFull(cwd2, {
4429
- server,
4430
4522
  aiTool: tool,
4431
4523
  skillsDir: (options["skills-dir"] || config.skillsDir) ?? void 0,
4432
4524
  cliVersion: BUILD_CLI_VERSION,
@@ -4434,7 +4526,7 @@ async function handleMultiProjectSetup(args) {
4434
4526
  projects: projectsMap
4435
4527
  });
4436
4528
  if (writeLocalCreds) {
4437
- await writeCredentials(cwd2, key, { server });
4529
+ await writeCredentials(cwd2, key);
4438
4530
  }
4439
4531
  await postEvent(server, token, {
4440
4532
  type: "installed",
@@ -4471,6 +4563,7 @@ import { promises as fs15 } from "node:fs";
4471
4563
  import { join as join15 } from "node:path";
4472
4564
  init_api();
4473
4565
  init_credentials();
4566
+ init_server();
4474
4567
  var ICON = { ok: "\u2714", warn: "\u26A0", error: "\u2718" };
4475
4568
  function exitCodeFor(checks) {
4476
4569
  const failed = checks.filter((c) => c.status === "error");
@@ -4500,11 +4593,12 @@ async function doctorCommand(cwd2, options, cliVersion) {
4500
4593
  }
4501
4594
  return 0;
4502
4595
  }
4503
- let checks = await runInitChecks(cwd2, { server: options.server, project: options.project }, cliVersion);
4596
+ const server = (options.server || resolveServer()).replace(/\/$/, "");
4597
+ let checks = await runInitChecks(cwd2, { server, project: options.project }, cliVersion);
4504
4598
  if (options.fix) {
4505
- const repaired = await applyFixes(cwd2, checks);
4599
+ const repaired = await applyFixes(cwd2, checks, server);
4506
4600
  if (repaired.length > 0) {
4507
- checks = await runInitChecks(cwd2, { server: options.server, project: options.project }, cliVersion);
4601
+ checks = await runInitChecks(cwd2, { server, project: options.project }, cliVersion);
4508
4602
  }
4509
4603
  }
4510
4604
  const code = exitCodeFor(checks);
@@ -4534,7 +4628,7 @@ async function reportRun(cwd2, options, checks, ok) {
4534
4628
  return;
4535
4629
  try {
4536
4630
  const config = await readConfig(cwd2);
4537
- const server = (options.server || config.server || "").replace(/\/$/, "");
4631
+ const server = (options.server || resolveServer()).replace(/\/$/, "");
4538
4632
  if (!server)
4539
4633
  return;
4540
4634
  const { key: apiKey } = await resolveApiKey(cwd2, server);
@@ -4551,10 +4645,9 @@ async function reportRun(cwd2, options, checks, ok) {
4551
4645
  } catch {
4552
4646
  }
4553
4647
  }
4554
- async function applyFixes(cwd2, checks) {
4648
+ async function applyFixes(cwd2, checks, server) {
4555
4649
  const repaired = [];
4556
4650
  const config = await readConfig(cwd2);
4557
- const server = (config.server || "").replace(/\/$/, "");
4558
4651
  let token;
4559
4652
  const tokenFor = async () => {
4560
4653
  if (token)
@@ -4626,6 +4719,7 @@ init_config();
4626
4719
  init_api();
4627
4720
  import { promises as fs16 } from "node:fs";
4628
4721
  import { dirname as dirname7, join as join16 } from "node:path";
4722
+ init_server();
4629
4723
  function sourceFor(path) {
4630
4724
  if (path.endsWith("pinsay.sh"))
4631
4725
  return "/pinsay.sh";
@@ -4649,9 +4743,9 @@ async function updateCommand(cwd2, options) {
4649
4743
  for (const f of removedLegacyFiles)
4650
4744
  console.log(`\x1B[2mremoved legacy ${f}\x1B[0m`);
4651
4745
  const config = await readConfig(cwd2);
4652
- const server = (options.server || config.server || "").replace(/\/$/, "");
4653
- if (!server) {
4654
- console.error("No server configured \u2014 run `npx -y pinsay-cli init` first.");
4746
+ const server = (options.server || resolveServer()).replace(/\/$/, "");
4747
+ if (!config.project && !isMultiProject(config)) {
4748
+ console.error("No .pinsay/config.json here \u2014 run `npx -y pinsay-cli init` first.");
4655
4749
  return 1;
4656
4750
  }
4657
4751
  let served = null;
@@ -4659,7 +4753,7 @@ async function updateCommand(cwd2, options) {
4659
4753
  const meta = await api(server, "/api/meta");
4660
4754
  served = meta?.skillVersion ?? null;
4661
4755
  } catch {
4662
- console.error(`Could not reach ${server} \u2014 check the URL.`);
4756
+ console.error(`Could not reach ${server}.`);
4663
4757
  return 1;
4664
4758
  }
4665
4759
  const files = skillFilesFor(config);
@@ -5506,6 +5600,16 @@ function headSha(cwd2) {
5506
5600
  }
5507
5601
  return result.stdout.trim();
5508
5602
  }
5603
+ function resolveCommit(ref, cwd2) {
5604
+ const result = spawnSync2("git", ["rev-parse", "--verify", "--quiet", `${ref}^{commit}`], {
5605
+ cwd: cwd2,
5606
+ encoding: "utf8"
5607
+ });
5608
+ if (result.status !== 0) {
5609
+ throw new Error(`Not a commit in this repository: ${ref}`);
5610
+ }
5611
+ return result.stdout.trim();
5612
+ }
5509
5613
  function getRemoteUrl(cwd2, remote = "origin") {
5510
5614
  const result = spawnSync2("git", ["remote", "get-url", remote], {
5511
5615
  cwd: cwd2,
@@ -5666,11 +5770,21 @@ async function markApplied(options, ctx) {
5666
5770
  const warning = delegationWarning(projectCtx.delegation ?? "auto", models);
5667
5771
  if (warning)
5668
5772
  console.error(warning);
5773
+ let existingSha;
5774
+ if (options.noCommit) {
5775
+ try {
5776
+ existingSha = resolveCommit(options.commit ?? "HEAD", ctx.cwd);
5777
+ } catch (err) {
5778
+ console.error(err.message);
5779
+ process.exit(1);
5780
+ }
5781
+ }
5669
5782
  if (options.dryRun) {
5670
5783
  console.log(`[dry-run] Would mark comment ${options.id} as applied`);
5671
5784
  console.log(`[dry-run] Reply: ${options.reply}`);
5672
5785
  console.log(`[dry-run] AppliedBy: ${appliedByLabel}`);
5673
5786
  console.log(`[dry-run] Models: ${describeModels(models)}`);
5787
+ console.log(`[dry-run] Commit: ${existingSha ?? "a new commit of the staged changes"}`);
5674
5788
  return {
5675
5789
  committed: false,
5676
5790
  patchedIds: typeof options.id === "number" ? [options.id] : []
@@ -5698,6 +5812,9 @@ async function markApplied(options, ctx) {
5698
5812
  sha = headSha(ctx.cwd);
5699
5813
  const remote = getRemoteUrl(ctx.cwd);
5700
5814
  commitUrl = commitUrlFor(sha, remote);
5815
+ } else {
5816
+ sha = existingSha;
5817
+ commitUrl = sha ? commitUrlFor(sha, getRemoteUrl(ctx.cwd)) : null;
5701
5818
  }
5702
5819
  for (const item of pending) {
5703
5820
  await api(ctx.server, `/api/comments/${item.id}`, {
@@ -5746,6 +5863,9 @@ async function markApplied(options, ctx) {
5746
5863
  sha = headSha(ctx.cwd);
5747
5864
  const remote = getRemoteUrl(ctx.cwd);
5748
5865
  commitUrl = commitUrlFor(sha, remote);
5866
+ } else {
5867
+ sha = existingSha;
5868
+ commitUrl = sha ? commitUrlFor(sha, getRemoteUrl(ctx.cwd)) : null;
5749
5869
  }
5750
5870
  await api(ctx.server, `/api/comments/${id}`, {
5751
5871
  method: "PATCH",
@@ -5797,6 +5917,7 @@ function describeFilter(filter) {
5797
5917
 
5798
5918
  // src/commands/apply.ts
5799
5919
  init_projection();
5920
+ init_server();
5800
5921
  async function exitIfWorkspaceFrozen(server, token) {
5801
5922
  if (!token)
5802
5923
  return;
@@ -5845,11 +5966,7 @@ function parseModelFlags(parsed) {
5845
5966
  async function applyCommand(cwd2, parsed, positionals = []) {
5846
5967
  const root = await findRepoRoot(cwd2);
5847
5968
  const config = await readConfig(root);
5848
- const server = ((typeof parsed["server"] === "string" ? parsed["server"] : config.server) || BUILD_DEFAULT_SERVER).replace(/\/$/, "");
5849
- if (!server) {
5850
- console.error("No server configured. Run `npx pinsay-cli init` or pass --server.");
5851
- process.exit(2);
5852
- }
5969
+ const server = resolveServer();
5853
5970
  const projectFlag = typeof parsed["project"] === "string" ? parsed["project"] : void 0;
5854
5971
  const resolved = resolveProject(config, cwd2, root, projectFlag);
5855
5972
  const markVal = parsed["mark"];
@@ -5914,6 +6031,11 @@ async function applyCommand(cwd2, parsed, positionals = []) {
5914
6031
  }
5915
6032
  const noCommit = parsed["no-commit"] === true;
5916
6033
  const dryRun = parsed["dry-run"] === true;
6034
+ const commitRef = typeof parsed["commit"] === "string" ? parsed["commit"] : void 0;
6035
+ if (commitRef && !noCommit) {
6036
+ console.error("--commit <sha> needs --no-commit (plain --mark records the commit it makes).");
6037
+ process.exit(2);
6038
+ }
5917
6039
  const toolExplicit = typeof parsed["tool"] === "string" ? parsed["tool"] : void 0;
5918
6040
  const tool2 = toolExplicit || config.aiTool;
5919
6041
  if (!toolExplicit && config.aiTool) {
@@ -5942,6 +6064,7 @@ async function applyCommand(cwd2, parsed, positionals = []) {
5942
6064
  id: markId,
5943
6065
  reply,
5944
6066
  noCommit,
6067
+ commit: commitRef,
5945
6068
  dryRun,
5946
6069
  tool: tool2,
5947
6070
  models,
@@ -12436,10 +12559,11 @@ async function runMcpServer(ctx, logFile) {
12436
12559
  }
12437
12560
 
12438
12561
  // src/commands/mcp.ts
12562
+ init_server();
12439
12563
  async function mcpCommand(cwd2, parsed = {}) {
12440
12564
  const root = await findRepoRoot(cwd2);
12441
12565
  const config = await readConfig(root).catch(() => ({}));
12442
- const server = ((typeof parsed["server"] === "string" ? parsed["server"] : config.server) || canonicalServer(process.env.PINSAY_SERVER) || BUILD_DEFAULT_SERVER).replace(/\/$/, "");
12566
+ const server = resolveServer();
12443
12567
  const projectFlag = typeof parsed["project"] === "string" ? parsed["project"] : void 0;
12444
12568
  const project = projectFlag || config.project || process.env.PINSAY_PROJECT || "";
12445
12569
  const explicitKey = typeof parsed["key"] === "string" ? parsed["key"] : void 0;
@@ -12476,11 +12600,15 @@ async function mcpCommand(cwd2, parsed = {}) {
12476
12600
  init_config();
12477
12601
  init_api();
12478
12602
  init_credentials();
12479
- init_build_constants();
12603
+ init_server();
12480
12604
  async function loginCommand(cwd2, options = {}) {
12605
+ const flags = scopeFromFlags(options);
12606
+ if (flags.error) {
12607
+ console.error(flags.error);
12608
+ process.exit(2);
12609
+ }
12481
12610
  const root = await findRepoRoot(cwd2);
12482
- const config = await readConfig(root).catch(() => ({}));
12483
- const server = ((typeof options["server"] === "string" ? options["server"] : void 0) || config.server || canonicalServer(process.env.PINSAY_SERVER) || BUILD_DEFAULT_SERVER).replace(/\/$/, "");
12611
+ const server = resolveServer();
12484
12612
  const branding = await getBranding(server);
12485
12613
  const product = branding.productName;
12486
12614
  const flagKey = typeof options["key"] === "string" ? options["key"] : void 0;
@@ -12515,14 +12643,14 @@ async function loginCommand(cwd2, options = {}) {
12515
12643
  console.error("No key provided and no terminal to sign in from \u2014 run `npx pinsay-cli login --key <key>` or set PINSAY_API_KEY.");
12516
12644
  process.exit(2);
12517
12645
  }
12518
- const scope = typeof options["scope"] === "string" ? String(options["scope"]).toLowerCase() : "global";
12519
- if (scope !== "global" && scope !== "repo") {
12520
- console.error(`Invalid --scope "${options["scope"]}". Valid values: global, repo.`);
12521
- process.exit(2);
12522
- }
12646
+ const interactive = Boolean(process.stdin.isTTY) && options["yes"] !== true;
12647
+ const decided = decideKeyScope(flags.scope, interactive);
12648
+ const scope = decided === "ask" ? await askKeyScope() : decided;
12649
+ closePrompts();
12523
12650
  const who = me?.displayName ? `${me.displayName}${me?.email ? ` (${me.email})` : ""}` : me?.email ?? "you";
12524
12651
  if (scope === "repo") {
12525
- await writeCredentials(root, key, { server });
12652
+ await writeCredentials(root, key);
12653
+ await upsertGitignore(root, product);
12526
12654
  console.log(`\u2714 Signed in to ${server} as ${who} \u2014 saved to .pinsay/credentials.env (this repo only; overrides the global store here)`);
12527
12655
  process.exit(0);
12528
12656
  }
@@ -12532,13 +12660,10 @@ async function loginCommand(cwd2, options = {}) {
12532
12660
  }
12533
12661
 
12534
12662
  // src/commands/logout.ts
12535
- init_config();
12536
12663
  init_credentials();
12537
- init_build_constants();
12538
- async function logoutCommand(cwd2, options = {}) {
12539
- const root = await findRepoRoot(cwd2);
12540
- const config = await readConfig(root).catch(() => ({}));
12541
- const server = ((typeof options["server"] === "string" ? options["server"] : void 0) || config.server || canonicalServer(process.env.PINSAY_SERVER) || BUILD_DEFAULT_SERVER).replace(/\/$/, "");
12664
+ init_server();
12665
+ async function logoutCommand(_cwd, options = {}) {
12666
+ const server = resolveServer();
12542
12667
  const origin = normalizeServerOrigin(server);
12543
12668
  const removed = await removeGlobalCredential(server);
12544
12669
  if (options["json"] === true) {
@@ -12555,11 +12680,10 @@ async function logoutCommand(cwd2, options = {}) {
12555
12680
  init_config();
12556
12681
  init_api();
12557
12682
  init_credentials();
12558
- init_build_constants();
12683
+ init_server();
12559
12684
  async function whoamiCommand(cwd2, options = {}) {
12560
12685
  const root = await findRepoRoot(cwd2);
12561
- const config = await readConfig(root).catch(() => ({}));
12562
- const server = ((typeof options["server"] === "string" ? options["server"] : void 0) || config.server || canonicalServer(process.env.PINSAY_SERVER) || BUILD_DEFAULT_SERVER).replace(/\/$/, "");
12686
+ const server = resolveServer();
12563
12687
  const isJson = options["json"] === true;
12564
12688
  const { key, source } = await resolveApiKey(root, server);
12565
12689
  if (!key) {
@@ -12599,6 +12723,7 @@ async function whoamiCommand(cwd2, options = {}) {
12599
12723
  // src/cli.ts
12600
12724
  init_build_constants();
12601
12725
  init_api();
12726
+ init_server();
12602
12727
  import { argv, cwd } from "node:process";
12603
12728
  function parseArgs(args) {
12604
12729
  const parsed = {};
@@ -12655,8 +12780,8 @@ Usage: npx pinsay-cli <command> [options]
12655
12780
 
12656
12781
  Commands:
12657
12782
  init Set up the feedback widget in your project
12658
- login Authenticate once per machine (saves a key for every repo)
12659
- logout Remove this machine's saved key for a server
12783
+ login Sign in and save your API key (this machine, or this repo only)
12784
+ logout Remove this machine's saved key
12660
12785
  whoami Show the signed-in account and where the API key came from
12661
12786
  doctor Diagnose an install and report what is wrong
12662
12787
  update Refresh the served skills to the server's current version
@@ -12687,13 +12812,19 @@ async function main() {
12687
12812
  process.exit(0);
12688
12813
  }
12689
12814
  }
12815
+ if (command !== "map" && !parsed["help"]) {
12816
+ const serverError = await serverSettingError(cwd(), parsed);
12817
+ if (serverError) {
12818
+ console.error(serverError);
12819
+ process.exit(2);
12820
+ }
12821
+ }
12690
12822
  if (command === "init") {
12691
12823
  if (parsed["help"]) {
12692
12824
  console.log(`
12693
12825
  Usage: npx pinsay-cli init [options]
12694
12826
 
12695
12827
  Options:
12696
- --server <url> Feedback server URL
12697
12828
  --key <key> API key
12698
12829
  --project <key> Project key
12699
12830
  --create <name> Create project with name
@@ -12712,9 +12843,10 @@ Options:
12712
12843
  --no-skills Skip skills installation
12713
12844
  --no-design Skip design token detection
12714
12845
  --source-map Wire in the Vite plugin that stamps component source hashes
12715
- --scope <global|repo> Where the API key is stored: global (default \u2014 this machine, all repos,
12716
- ~/.config/pointer/credentials.json) or repo (.pinsay/credentials.env,
12717
- gitignored, this repo only). --local-credentials is an alias for --scope repo
12846
+ --scope <global|repo> Where the API key is stored, without asking: global (this machine, all
12847
+ repos, ~/.config/pinsay/credentials.json; the default with --yes) or
12848
+ repo (.pinsay/credentials.env, gitignored, this repo only).
12849
+ --local-credentials is an alias for --scope repo
12718
12850
  --no-browser When signing in in the browser (first run, no key resolved yet), print
12719
12851
  the link/code but don't try to open a browser
12720
12852
  -y, --yes Non-interactive
@@ -12729,21 +12861,21 @@ Options:
12729
12861
  console.log(`
12730
12862
  Usage: npx pinsay-cli login [options]
12731
12863
 
12732
- Authenticate once per machine and save the result to
12733
- ~/.config/pointer/credentials.json (honours $XDG_CONFIG_HOME / $PINSAY_CONFIG_DIR), keyed by
12734
- server. Every repo on this machine then resolves a key for that server without being asked again.
12864
+ Sign in and save your API key. On a terminal it asks where to save it:
12865
+ Global this machine, every repo (~/.config/pinsay/credentials.json, honours $XDG_CONFIG_HOME)
12866
+ Repo .pinsay/credentials.env in this repo only (gitignored)
12867
+ With no terminal (CI, a pipe) or with --yes it saves globally, unless --scope repo is given.
12735
12868
 
12736
12869
  With no --key on a real terminal, opens your browser to sign in (mirrors \`gh auth login\`): prints
12737
12870
  a link and a short code, waits for you to approve it in the dashboard, then saves the personal API
12738
- key it hands back. Pass --key to skip the browser and validate a pasted key instead, unchanged from
12739
- before.
12871
+ key it hands back. Pass --key to skip the browser and validate a pasted key instead.
12740
12872
 
12741
12873
  Options:
12742
- --server <url> Server URL (default: this repo's .pinsay/config.json, then $PINSAY_SERVER)
12743
12874
  --key <key> API key \u2014 skips the browser flow entirely
12744
12875
  --no-browser Print the sign-in link/code but don't try to open a browser
12745
- --scope <global|repo> global (default): save for every repo on this machine;
12746
- repo: write .pinsay/credentials.env in the current repo only
12876
+ --scope <global|repo> Where to save the key, without asking
12877
+ --local-credentials Alias for --scope repo
12878
+ -y, --yes Don't ask; save globally unless --scope repo is given
12747
12879
  -h, --help Show this help
12748
12880
  `);
12749
12881
  process.exit(0);
@@ -12754,10 +12886,9 @@ Options:
12754
12886
  console.log(`
12755
12887
  Usage: npx pinsay-cli logout [options]
12756
12888
 
12757
- Removes this machine's saved global key for a server.
12889
+ Removes this machine's saved global key.
12758
12890
 
12759
12891
  Options:
12760
- --server <url> Server URL (default: this repo's .pinsay/config.json, then $PINSAY_SERVER)
12761
12892
  --json Emit { ok, server, removed } as JSON
12762
12893
  -h, --help Show this help
12763
12894
  `);
@@ -12773,7 +12904,6 @@ Prints the server, the signed-in account, and which of env/repo/global answered
12773
12904
  never the key itself.
12774
12905
 
12775
12906
  Options:
12776
- --server <url> Server URL (default: this repo's .pinsay/config.json, then $PINSAY_SERVER)
12777
12907
  --json Emit { ok, server, displayName, email, source } as JSON
12778
12908
  -h, --help Show this help
12779
12909
  `);
@@ -12788,7 +12918,6 @@ Usage: npx pinsay-cli doctor [options]
12788
12918
  Checks an existing install and prints one line per check.
12789
12919
 
12790
12920
  Options:
12791
- --server <url> Override the server from .pinsay/config.json
12792
12921
  --project <key> Override the project key
12793
12922
  --json Emit { ok, checks } as JSON
12794
12923
  --fix Apply the idempotent repairs (gitignore, skills, stack)
@@ -12804,7 +12933,6 @@ Exit codes:
12804
12933
  process.exit(0);
12805
12934
  }
12806
12935
  const code = await doctorCommand(cwd(), {
12807
- server: typeof parsed["server"] === "string" ? parsed["server"] : void 0,
12808
12936
  project: typeof parsed["project"] === "string" ? parsed["project"] : void 0,
12809
12937
  json: parsed["json"] === true,
12810
12938
  fix: parsed["fix"] === true,
@@ -12816,17 +12944,15 @@ Exit codes:
12816
12944
  console.log(`
12817
12945
  Usage: npx pinsay-cli update [options]
12818
12946
 
12819
- Refreshes the AI skills and pinsay.sh from the configured server.
12947
+ Refreshes the AI skills and pinsay.sh from the server.
12820
12948
 
12821
12949
  Options:
12822
- --server <url> Override the server from .pinsay/config.json
12823
12950
  --check Report what is out of date without writing anything
12824
12951
  -h, --help Show this help
12825
12952
  `);
12826
12953
  process.exit(0);
12827
12954
  }
12828
12955
  const code = await updateCommand(cwd(), {
12829
- server: typeof parsed["server"] === "string" ? parsed["server"] : void 0,
12830
12956
  check: parsed["check"] === true
12831
12957
  });
12832
12958
  process.exit(code);
@@ -12842,7 +12968,8 @@ Options:
12842
12968
  --tool <name> Hand off prompt to claude, opencode, cursor, or clipboard
12843
12969
  --mark <id>|all Commit staged changes and mark comment(s) applied
12844
12970
  --reply <text> Reply text for applied comment (required with --mark)
12845
- --no-commit Skip git commit during --mark (PATCH only)
12971
+ --no-commit Mark without committing: the fix is already committed (records --commit, default HEAD)
12972
+ --commit <sha> With --no-commit: the commit that holds the fix (default HEAD)
12846
12973
  --dry-run Print what --mark would do without making git/API changes
12847
12974
  --models <list> Every model that worked on the item, with its role, for --mark/--fail:
12848
12975
  "<id>=<role>,<id>=<role>" (role: planner|implementer|reviewer, optional;
@@ -12946,7 +13073,6 @@ Start the PinSay stdio MCP server for AI tools.
12946
13073
 
12947
13074
  Options:
12948
13075
  --log <file> Log MCP server traffic to file
12949
- --server <url> Feedback server URL
12950
13076
  --project <key> Project key
12951
13077
  --key <key> API key
12952
13078
  -h, --help Show this help
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pinsay-cli",
3
- "version": "0.7.2",
3
+ "version": "0.8.0",
4
4
  "description": "Click-to-comment feedback for your web app: install the widget, then turn pending comments into AI apply prompts.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -12,7 +12,7 @@
12
12
  "directory": "."
13
13
  },
14
14
  "bugs": {
15
- "url": "https://github.com/moamen-ui/pinsay-api/issues"
15
+ "url": "https://github.com/moamen-ui/pinsay-cli/issues"
16
16
  },
17
17
  "keywords": [
18
18
  "feedback",
@@ -38,7 +38,7 @@
38
38
  "scripts": {
39
39
  "build": "node build.mjs",
40
40
  "typecheck": "tsc --noEmit",
41
- "test": "node --import tsx --test test/*.test.ts test/**/*.test.ts",
41
+ "test": "node --import tsx --import ./test/setup.ts --test test/*.test.ts test/**/*.test.ts",
42
42
  "prepublishOnly": "npm run typecheck && npm test && npm run build"
43
43
  },
44
44
  "devDependencies": {