@theholocron/cli 5.0.0-alpha.121 → 5.0.0-alpha.123

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/dist/cli.mjs CHANGED
@@ -1,10 +1,12 @@
1
1
  #!/usr/bin/env node
2
2
  import { createRequire } from "node:module";
3
3
  import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
4
+ import { homedir, hostname, tmpdir, userInfo } from "node:os";
4
5
  import path, { basename, dirname, join, relative, resolve, sep } from "node:path";
5
6
  import { checkbox, confirm, input, select } from "@inquirer/prompts";
6
7
  import { KNOWN_WORKFLOWS, createAstromech, deployOnRelease, extractPreviewConfig, readWorkspacePackages, reusableTemplates } from "@theholocron/astromech";
7
8
  import { loadTasksConfig } from "@theholocron/astromech/config";
9
+ import { LOG_LEVELS, NoopAnalyticsSink, NoopErrorSink, redactObject } from "@theholocron/observability/core";
8
10
  import yargs from "yargs";
9
11
  import { hideBin } from "yargs/helpers";
10
12
  import { AuthError, ProviderApiError, ProviderApiError as ProviderApiError$1, createRestClient } from "@theholocron/http-client";
@@ -12,18 +14,16 @@ import { createEnvLookup } from "@theholocron/env-utils";
12
14
  import { Entry, findCredentials } from "@napi-rs/keyring";
13
15
  import { pathToFileURL } from "node:url";
14
16
  import { errorMessage } from "@theholocron/misc-utils";
15
- import { createLogger, parseLogLevel, resolveAxiomFromEnv } from "@theholocron/observability/logger";
16
- import ora from "ora";
17
+ import { createLogger, isCI, parseLogLevel, resolveAxiomFromEnv } from "@theholocron/observability/logger";
17
18
  import chalk from "chalk";
19
+ import ora from "ora";
18
20
  import { execFile, execFileSync, spawn, spawnSync } from "node:child_process";
19
- import { homedir, hostname, userInfo } from "node:os";
20
21
  import { access, copyFile, mkdir, readFile, readdir, rm, stat, symlink, unlink, writeFile } from "node:fs/promises";
21
22
  import { createHash } from "node:crypto";
22
23
  import { generateReadme } from "@theholocron/components-doc/markdown";
23
24
  import { getClients, getConfigs, getDocs, getPlugins, getSkills, getThemes, getUtils } from "@theholocron/registry-doc";
24
25
  import { createGitHubClient } from "@theholocron/github-client";
25
26
  import { PostHogSink } from "@theholocron/observability/analytics";
26
- import { NoopAnalyticsSink, NoopErrorSink, redactObject } from "@theholocron/observability/core";
27
27
  import { SentrySink } from "@theholocron/observability/errors";
28
28
  import load from "@commitlint/load";
29
29
  import lint from "@commitlint/lint";
@@ -303,23 +303,54 @@ function resolveConfig(raw) {
303
303
  };
304
304
  }
305
305
  //#endregion
306
+ //#region src/ui/style.ts
307
+ const style = {
308
+ success: (msg) => `${chalk.green("✓")} ${msg}`,
309
+ warn: (msg) => `${chalk.yellow("⚠")} ${msg}`,
310
+ fail: (msg) => `${chalk.red("✗")} ${msg}`,
311
+ step: (msg) => `${chalk.cyan("→")} ${msg}`,
312
+ /** A dim `·` marker for a row that's neither a pass nor a failure — loaded but inactive, or skipped. */
313
+ skip: (msg) => chalk.dim(`· ${msg}`),
314
+ hint: (msg) => chalk.dim(msg),
315
+ dim: (msg) => chalk.dim(msg),
316
+ header: (msg) => chalk.bold(msg)
317
+ };
318
+ //#endregion
306
319
  //#region src/logger.ts
307
320
  /**
308
321
  * Resolve the explicit level to hand to `createLogger`, in priority order:
309
322
  *
310
- * 1. `--verbose` → `"debug"` 2. `--quiet` → `"error"`
311
- * 3. `HOLOCRON_LOG_LEVEL` env var
312
- * 4. `holocron.config` `log.level` (`configLevel`)
323
+ * 1. `--log <level>` 2. `--verbose` → `"debug"` 3. `--quiet` → `"error"`
324
+ * 4. `HOLOCRON_LOG_LEVEL` env var
325
+ * 5. `holocron.config` `log.level` (`configLevel`)
313
326
  *
314
327
  * Returns `undefined` when nothing applies — `createLogger` then defaults
315
328
  * to `"info"`. Resolving the full chain here (rather than passing
316
329
  * `configLevel` straight through) keeps config below the env var.
317
330
  */
318
331
  function resolveLogLevel(argv, configLevel) {
332
+ if (argv.log) return argv.log;
319
333
  if (argv.verbose) return "debug";
320
334
  if (argv.quiet) return "error";
321
335
  return parseLogLevel(env.get("HOLOCRON_LOG_LEVEL")) ?? configLevel;
322
336
  }
337
+ /**
338
+ * Resolve whether human-visible console output (pretty or NDJSON) should run
339
+ * at all, independent of level — the `@theholocron/observability`
340
+ * `consoleOutput` override.
341
+ *
342
+ * Local/interactive runs are quiet by default: any raw structured logger
343
+ * output (colorized or not) stays off regardless of the resolved level,
344
+ * unless the user explicitly asked to see it via `--log <level>`,
345
+ * `--verbose`, or `--quiet`. CI is untouched either way — returning
346
+ * `undefined` there preserves `createLogger`'s own `ci`/`tty`
347
+ * auto-detection exactly, so CI's existing NDJSON-to-stdout behavior is
348
+ * unaffected by this flag.
349
+ */
350
+ function resolveConsoleOutput(argv) {
351
+ if (argv.log || argv.verbose || argv.quiet) return true;
352
+ return isCI() ? void 0 : false;
353
+ }
323
354
  let root;
324
355
  let rootLevel;
325
356
  let rootCommand;
@@ -358,13 +389,15 @@ function buildCliLogger(argv, opts = {}) {
358
389
  const { command, configLevel } = opts;
359
390
  if (command) rootCommand = command;
360
391
  const level = resolveLogLevel(argv, configLevel);
392
+ const consoleOutput = resolveConsoleOutput(argv);
361
393
  const axiom = resolveCliAxiom(opts);
362
394
  const axiomKey = axiom?.dataset;
363
- const rebuildForConfig = configLevel !== void 0 && level !== rootLevel && !argv.verbose && !argv.quiet;
395
+ const rebuildForConfig = configLevel !== void 0 && level !== rootLevel && !argv.verbose && !argv.quiet && !argv.log;
364
396
  if (!root || rebuildForConfig || axiomKey !== void 0 && axiomKey !== rootAxiomKey) {
365
397
  const built = createLogger({
366
398
  ...level ? { level } : {},
367
- ...axiom ? { axiom } : {}
399
+ ...axiom ? { axiom } : {},
400
+ ...consoleOutput !== void 0 ? { consoleOutput } : {}
368
401
  });
369
402
  root = {
370
403
  logger: rootCommand ? built.logger.child({ command: rootCommand }) : built.logger,
@@ -375,9 +408,35 @@ function buildCliLogger(argv, opts = {}) {
375
408
  }
376
409
  return root;
377
410
  }
378
- /** Lazily-memoized `Logger` for module-level call sites with no `argv` in scope. */
411
+ /**
412
+ * Lazily-memoized `Logger` for module-level call sites with no `argv` in
413
+ * scope — notably yargs' own `.fail()` handler, which fires on a
414
+ * pre-middleware validation failure (a missing required argument, an
415
+ * unknown command) and therefore runs before `buildCliLogger` has ever
416
+ * built the real root. Without an explicit `consoleOutput` here, that
417
+ * fallback would use `createLogger`'s bare auto-detection (pretty on any
418
+ * local TTY) and leak a structured block even though no flag asked for one
419
+ * — so it resolves the same "quiet unless CI" default `buildCliLogger`
420
+ * would, as if no flags were passed.
421
+ */
379
422
  function getLogger() {
380
- return (root ??= createLogger()).logger;
423
+ return (root ??= createLogger({ consoleOutput: resolveConsoleOutput({}) })).logger;
424
+ }
425
+ /**
426
+ * Report a fatal, command-ending CLI error: always printed to the console
427
+ * (regardless of `consoleOutput` suppression) via `style.fail`, and also
428
+ * sent through the structured logger for Axiom/telemetry visibility.
429
+ *
430
+ * Use this when a handler is returning or exiting right here with nothing
431
+ * else for the user to see — a resolved `--token` failure, a yargs
432
+ * validation failure, a thrown domain error. Don't use it for a per-step
433
+ * warning inside a multi-step orchestrator that already prints its own
434
+ * `ok / fail / skip` summary — that summary is the user-facing signal;
435
+ * the per-step reason belongs in the structured log alone.
436
+ */
437
+ function reportError(message, log = getLogger()) {
438
+ console.error(style.fail(message));
439
+ log.error(message);
381
440
  }
382
441
  /** The current root logger's correlation id, if a root has been built. */
383
442
  function getRunId() {
@@ -637,17 +696,6 @@ async function withSpinner(label, fn) {
637
696
  }
638
697
  }
639
698
  //#endregion
640
- //#region src/ui/style.ts
641
- const style = {
642
- success: (msg) => `${chalk.green("✓")} ${msg}`,
643
- warn: (msg) => `${chalk.yellow("⚠")} ${msg}`,
644
- fail: (msg) => `${chalk.red("✗")} ${msg}`,
645
- step: (msg) => `${chalk.cyan("→")} ${msg}`,
646
- hint: (msg) => chalk.dim(msg),
647
- dim: (msg) => chalk.dim(msg),
648
- header: (msg) => chalk.bold(msg)
649
- };
650
- //#endregion
651
699
  //#region src/commands/auth.ts
652
700
  /**
653
701
  * `holocron auth <subcommand>` — manage bootstrap credentials in the
@@ -1071,6 +1119,27 @@ async function runCleanupPreview(input) {
1071
1119
  }
1072
1120
  }
1073
1121
  //#endregion
1122
+ //#region src/ui/status-row.ts
1123
+ /** Right-pads `s` to `width` with spaces — never truncates. */
1124
+ function pad(s, width) {
1125
+ return s.length >= width ? s : s + " ".repeat(width - s.length);
1126
+ }
1127
+ /**
1128
+ * One row of a status report: a 2-space indent, the status icon (✓ / ✗ / ·),
1129
+ * then `columns` joined with a two-space gap (blank columns dropped, so a
1130
+ * row that has nothing for its last column doesn't trail whitespace).
1131
+ * Column padding (`pad`) is the caller's job — `doctor` pads capability and
1132
+ * provider to a fixed width; `clone`'s repo names and paths don't, since
1133
+ * they vary too widely for fixed-width alignment to read cleanly.
1134
+ *
1135
+ * Shared by `doctor` (capability / provider / message) and `clone` (repo /
1136
+ * destination / reason) so every status table in the CLI reads the same way.
1137
+ */
1138
+ function statusRow(status, columns) {
1139
+ const joined = columns.filter((c) => c.length > 0).join(" ");
1140
+ return ` ${status === "ok" ? style.success(joined) : status === "fail" ? style.fail(joined) : style.skip(joined)}`;
1141
+ }
1142
+ //#endregion
1074
1143
  //#region src/commands/clone.ts
1075
1144
  /**
1076
1145
  * Accepts a bare org (`"theholocron"`) — clones every repo in it — or an
@@ -1132,7 +1201,6 @@ async function runClone(input) {
1132
1201
  const fetchFn = input.fetch ?? globalThis.fetch;
1133
1202
  const dryRun = input.dryRun ?? false;
1134
1203
  const targetDir = resolve(input.dir ?? join(homedir(), "Code", input.org));
1135
- const label = input.repo ? `${input.org}/${input.repo}` : `org=${input.org}`;
1136
1204
  logger.info({
1137
1205
  org: input.org,
1138
1206
  repo: input.repo,
@@ -1145,7 +1213,7 @@ async function runClone(input) {
1145
1213
  stdio: "inherit"
1146
1214
  }).status };
1147
1215
  });
1148
- print(style.header(`Holocron clone — ${label} → ${targetDir}${dryRun ? " (dry-run)" : ""}`));
1216
+ print("");
1149
1217
  if (!existsSync(targetDir)) {
1150
1218
  if (dryRun) print(style.dim(` would create ${targetDir}`));
1151
1219
  else mkdirSync(targetDir, { recursive: true });
@@ -1196,7 +1264,11 @@ async function runClone(input) {
1196
1264
  for (const repo of repos) {
1197
1265
  const dest = join(targetDir, repoDirName(repo));
1198
1266
  if (existsSync(dest)) {
1199
- print(style.dim(` skip ${repo.full_name}`));
1267
+ print(statusRow("skip", [
1268
+ repo.full_name,
1269
+ dest,
1270
+ "already exists"
1271
+ ]));
1200
1272
  skipped++;
1201
1273
  continue;
1202
1274
  }
@@ -1205,10 +1277,14 @@ async function runClone(input) {
1205
1277
  cloned++;
1206
1278
  continue;
1207
1279
  }
1208
- print(style.step(` clone ${repo.full_name}`));
1280
+ print(style.step(` clone ${repo.full_name} → ${dest}`));
1209
1281
  const { clone_url } = repo;
1210
1282
  if (!clone_url.startsWith("https://github.com/")) {
1211
- print(style.fail(` failed ${repo.full_name} — unexpected clone URL: ${clone_url}`));
1283
+ print(statusRow("fail", [
1284
+ repo.full_name,
1285
+ dest,
1286
+ `unexpected clone URL: ${clone_url}`
1287
+ ]));
1212
1288
  failed++;
1213
1289
  continue;
1214
1290
  }
@@ -1216,7 +1292,11 @@ async function runClone(input) {
1216
1292
  try {
1217
1293
  encodedToken = encodeTokenForGitHttpAuth(input.token);
1218
1294
  } catch (err) {
1219
- print(style.fail(` failed ${repo.full_name} — invalid token format: ${errorMessage(err)}`));
1295
+ print(statusRow("fail", [
1296
+ repo.full_name,
1297
+ dest,
1298
+ `invalid token format: ${errorMessage(err)}`
1299
+ ]));
1220
1300
  failed++;
1221
1301
  continue;
1222
1302
  }
@@ -1226,15 +1306,16 @@ async function runClone(input) {
1226
1306
  `https://x-access-token:${encodedToken}@github.com/${clone_url.slice(19)}`,
1227
1307
  dest
1228
1308
  ], { cwd: targetDir }).status !== 0) {
1229
- print(style.fail(` failed ${repo.full_name}`));
1309
+ print(statusRow("fail", [repo.full_name, dest]));
1230
1310
  failed++;
1231
1311
  } else {
1232
- print(style.success(` cloned ${repo.name}`));
1312
+ print(statusRow("ok", [repo.full_name, dest]));
1233
1313
  cloned++;
1234
1314
  }
1235
1315
  }
1236
- const summary = `${cloned} cloned, ${skipped} skipped, ${failed} failed`;
1237
- print(dryRun ? style.dim(`\n dry-run: ${summary}`) : failed > 0 ? style.fail(`\n ${summary}`) : style.success(`\n ${summary}`));
1316
+ const summary = `${cloned} cloned, ${failed} failed, ${skipped} skipped`;
1317
+ print("");
1318
+ print(dryRun ? style.dim(` dry-run: ${summary}`) : failed > 0 ? style.fail(` ${summary}`) : style.success(` ${summary}`));
1238
1319
  const status = dryRun ? "dry-run" : failed > 0 ? "fail" : "ok";
1239
1320
  logger[failed > 0 ? "warn" : "info"]({
1240
1321
  org: input.org,
@@ -1244,6 +1325,10 @@ async function runClone(input) {
1244
1325
  skipped,
1245
1326
  failed
1246
1327
  }, "clone: done");
1328
+ if (input.confirmOpen) {
1329
+ const openTarget = input.repo ? join(targetDir, repoDirName(repos[0])) : targetDir;
1330
+ if (existsSync(openTarget)) await input.confirmOpen(openTarget, dryRun && cloned > 0);
1331
+ }
1247
1332
  return {
1248
1333
  status,
1249
1334
  cloned,
@@ -1574,7 +1659,7 @@ async function runDoctor(input) {
1574
1659
  assertPluginsResolvable(loader, "doctor");
1575
1660
  const rows = [];
1576
1661
  const config = input.loaded.resolved;
1577
- print(style.header(`Holocron doctor — ${config.name}`));
1662
+ print("");
1578
1663
  print(style.dim(` config: ${input.loaded.filepath}`));
1579
1664
  print("");
1580
1665
  for (const failure of loader.loadFailures()) rows.push({
@@ -1609,10 +1694,7 @@ async function runDoctor(input) {
1609
1694
  status: row.status,
1610
1695
  detail: row.message
1611
1696
  }, `doctor: ${row.capability}`);
1612
- const label = `${pad(row.capability, 14)} via ${pad(row.provider, 14)} ${row.message}`;
1613
- if (row.status === "ok") print(` ${style.success(label)}`);
1614
- else if (row.status === "fail") print(` ${style.fail(label)}`);
1615
- else print(` ${style.dim(`· ${label}`)}`);
1697
+ print(statusRow(row.status, [`${pad(row.capability, 14)} via ${pad(row.provider, 14)}`, row.message]));
1616
1698
  }
1617
1699
  const summary = rows.reduce((acc, r) => {
1618
1700
  acc[r.status] += 1;
@@ -1624,7 +1706,7 @@ async function runDoctor(input) {
1624
1706
  });
1625
1707
  const summaryLine = `${summary.ok} ok, ${summary.fail} fail, ${summary.skip} skipped`;
1626
1708
  print("");
1627
- print(summary.fail > 0 ? style.fail(summaryLine) : style.success(summaryLine));
1709
+ print(style.header(summaryLine));
1628
1710
  return {
1629
1711
  rows,
1630
1712
  summary
@@ -1678,9 +1760,6 @@ function mk(capability, provider, status, message) {
1678
1760
  message
1679
1761
  };
1680
1762
  }
1681
- function pad(s, width) {
1682
- return s.length >= width ? s : s + " ".repeat(width - s.length);
1683
- }
1684
1763
  //#endregion
1685
1764
  //#region src/commands/new.ts
1686
1765
  /**
@@ -8043,6 +8122,15 @@ function parseGitRemoteUrl(url) {
8043
8122
  * {@link promptForPositionals} for just that one command — no spawn needed,
8044
8123
  * the handler already has everything else it needs.
8045
8124
  *
8125
+ * For a command whose handler honours `--dry-run` ({@link
8126
+ * CommandEntry.supportsDryRun}), `launchMenu` always previews it first — a
8127
+ * dry run doesn't mutate anything, so there's nothing to ask permission
8128
+ * for — then, once that preview comes back clean and found something to
8129
+ * do, asks the one real decision: "proceed with the real run?". A preview
8130
+ * that finds nothing to do skips that question too. `--skip-dry-run` opts
8131
+ * a top-level invocation out of the preview entirely, straight to the
8132
+ * real run, for when you already know what you want.
8133
+ *
8046
8134
  * Every prompt is preceded by a `process.stdin.isTTY` check. A non-TTY run
8047
8135
  * (CI, a script, a pipe) throws {@link NonInteractiveError} instead of
8048
8136
  * hanging on `@inquirer/prompts` (which otherwise throws its own
@@ -8072,17 +8160,20 @@ const COMMAND_REGISTRY = [
8072
8160
  message: "org/repo to clone (or a bare org to clone every repo in it):",
8073
8161
  type: "input",
8074
8162
  validate: (v) => v.trim().length > 0 || "org/repo or org is required"
8075
- }]
8163
+ }],
8164
+ supportsDryRun: true
8076
8165
  },
8077
8166
  {
8078
8167
  name: "doctor",
8079
8168
  description: "Load the config and run a smoke check against every provider",
8080
- positionals: []
8169
+ positionals: [],
8170
+ supportsDryRun: true
8081
8171
  },
8082
8172
  {
8083
8173
  name: "setup",
8084
8174
  description: "Apply infra setup actions across every configured capability",
8085
- positionals: []
8175
+ positionals: [],
8176
+ supportsDryRun: true
8086
8177
  },
8087
8178
  {
8088
8179
  name: "secret set",
@@ -8091,7 +8182,8 @@ const COMMAND_REGISTRY = [
8091
8182
  key: "name",
8092
8183
  message: "Secret name:",
8093
8184
  type: "input"
8094
- }]
8185
+ }],
8186
+ supportsDryRun: true
8095
8187
  },
8096
8188
  {
8097
8189
  name: "secrets sync",
@@ -8100,7 +8192,8 @@ const COMMAND_REGISTRY = [
8100
8192
  key: "environmentId",
8101
8193
  message: "Vault environment id:",
8102
8194
  type: "input"
8103
- }]
8195
+ }],
8196
+ supportsDryRun: true
8104
8197
  },
8105
8198
  {
8106
8199
  name: "deploy",
@@ -8109,7 +8202,8 @@ const COMMAND_REGISTRY = [
8109
8202
  key: "branch",
8110
8203
  message: "Branch to deploy:",
8111
8204
  type: "input"
8112
- }]
8205
+ }],
8206
+ supportsDryRun: true
8113
8207
  },
8114
8208
  {
8115
8209
  name: "cleanup-preview",
@@ -8119,7 +8213,8 @@ const COMMAND_REGISTRY = [
8119
8213
  message: "PR number:",
8120
8214
  type: "input",
8121
8215
  validate: numeric
8122
- }]
8216
+ }],
8217
+ supportsDryRun: true
8123
8218
  },
8124
8219
  {
8125
8220
  name: "bump-versions",
@@ -8129,32 +8224,38 @@ const COMMAND_REGISTRY = [
8129
8224
  cliArg: "new-version",
8130
8225
  message: "New version:",
8131
8226
  type: "input"
8132
- }]
8227
+ }],
8228
+ supportsDryRun: true
8133
8229
  },
8134
8230
  {
8135
8231
  name: "publish",
8136
8232
  description: "Publish @theholocron/* packages to npm",
8137
- positionals: []
8233
+ positionals: [],
8234
+ supportsDryRun: true
8138
8235
  },
8139
8236
  {
8140
8237
  name: "sync",
8141
8238
  description: "Sync state from config to the provider and local files (labels, properties, teams, topics, keywords, description, homepage, readme, workflows, scripts, wiki)",
8142
- positionals: []
8239
+ positionals: [],
8240
+ supportsDryRun: true
8143
8241
  },
8144
8242
  {
8145
8243
  name: "ci",
8146
8244
  description: "Run the merge-gating checks locally, in CI order — 'will CI pass?'",
8147
- positionals: []
8245
+ positionals: [],
8246
+ supportsDryRun: true
8148
8247
  },
8149
8248
  {
8150
8249
  name: "sync-github",
8151
8250
  description: "Sync workflow templates and composite actions to theholocron/.github",
8152
- positionals: []
8251
+ positionals: [],
8252
+ supportsDryRun: true
8153
8253
  },
8154
8254
  {
8155
8255
  name: "sync-readme",
8156
8256
  description: "Sync the Installation + Usage block in README.md from package.json",
8157
- positionals: []
8257
+ positionals: [],
8258
+ supportsDryRun: true
8158
8259
  },
8159
8260
  {
8160
8261
  name: "config show",
@@ -8164,7 +8265,8 @@ const COMMAND_REGISTRY = [
8164
8265
  {
8165
8266
  name: "new",
8166
8267
  description: "Scaffold a new repo from a GitHub template (e.g. cli, react, nextjs, node, monorepo, base)",
8167
- positionals: []
8268
+ positionals: [],
8269
+ supportsDryRun: true
8168
8270
  },
8169
8271
  {
8170
8272
  name: "plugin create",
@@ -8177,25 +8279,29 @@ const COMMAND_REGISTRY = [
8177
8279
  key: "vendor",
8178
8280
  message: "Vendor display name (PascalCase):",
8179
8281
  type: "input"
8180
- }]
8282
+ }],
8283
+ supportsDryRun: true
8181
8284
  },
8182
8285
  {
8183
8286
  name: "skills install",
8184
8287
  description: "Copy skills from @theholocron/skills into .agents/ with agent symlinks",
8185
8288
  positionals: [],
8186
- group: "skills"
8289
+ group: "skills",
8290
+ supportsDryRun: true
8187
8291
  },
8188
8292
  {
8189
8293
  name: "skills remove",
8190
8294
  description: "Remove installed skills via npx skills remove",
8191
8295
  positionals: [],
8192
- group: "skills"
8296
+ group: "skills",
8297
+ supportsDryRun: true
8193
8298
  },
8194
8299
  {
8195
8300
  name: "skills update",
8196
8301
  description: "Update installed skills to their latest upstream versions via npx skills update",
8197
8302
  positionals: [],
8198
- group: "skills"
8303
+ group: "skills",
8304
+ supportsDryRun: true
8199
8305
  },
8200
8306
  {
8201
8307
  name: "upgrade node",
@@ -8206,13 +8312,15 @@ const COMMAND_REGISTRY = [
8206
8312
  type: "input",
8207
8313
  validate: numeric
8208
8314
  }],
8209
- group: "upgrade"
8315
+ group: "upgrade",
8316
+ supportsDryRun: true
8210
8317
  },
8211
8318
  {
8212
8319
  name: "upgrade deps",
8213
8320
  description: "Bump every @theholocron/* pin to latest and migrate holocron.config.ts to the current preset API",
8214
8321
  positionals: [],
8215
- group: "upgrade"
8322
+ group: "upgrade",
8323
+ supportsDryRun: true
8216
8324
  },
8217
8325
  {
8218
8326
  name: "auth set",
@@ -8327,25 +8435,41 @@ async function promptForPositionals(entry, argv) {
8327
8435
  * menu-launched child should inherit from the parent invocation. Everything
8328
8436
  * else (command-specific options) was never captured by the top-level `$0`
8329
8437
  * handler in the first place, so there's nothing else to forward.
8438
+ *
8439
+ * `dryRunOverride`, when passed, wins over `argv.dryRun` — `launchMenu`'s
8440
+ * dry-run offer needs to force `--dry-run` on for the preview spawn and
8441
+ * force it back off for the real-run spawn that follows, regardless of
8442
+ * what was on the original invocation.
8330
8443
  */
8331
- function forwardedFlags(argv) {
8444
+ function forwardedFlags(argv, dryRunOverride) {
8332
8445
  const out = [];
8333
8446
  const tokens = argv.token;
8334
8447
  for (const t of tokens ?? []) out.push("--token", t);
8335
8448
  if (typeof argv.org === "string") out.push("--org", argv.org);
8336
8449
  if (typeof argv.cwd === "string") out.push("--cwd", argv.cwd);
8337
- if (argv.dryRun === true) out.push("--dry-run");
8450
+ if (dryRunOverride ?? argv.dryRun === true) out.push("--dry-run");
8338
8451
  return out;
8339
8452
  }
8340
8453
  /** `entry.name` split into tokens, resolved positionals appended, then forwarded flags. */
8341
- function buildChildArgv(entry, positionals, parentArgv) {
8454
+ function buildChildArgv(entry, positionals, parentArgv, dryRunOverride) {
8342
8455
  return [
8343
8456
  ...entry.name.split(" "),
8344
8457
  ...positionals,
8345
- ...forwardedFlags(parentArgv)
8458
+ ...forwardedFlags(parentArgv, dryRunOverride)
8346
8459
  ];
8347
8460
  }
8348
8461
  /**
8462
+ * Set on a dry-run preview child's env ({@link spawnChild}'s `extraEnv`) so
8463
+ * its handler knows this specific `--dry-run` came from `launchMenu`'s own
8464
+ * preview-then-ask flow, not a direct `holocron <cmd> --dry-run` invocation.
8465
+ * A handler that supports it uses this — never bare `argv.dryRun` — to
8466
+ * decide whether to report {@link DRY_RUN_NOOP_EXIT_CODE}: that exit code
8467
+ * only has special meaning to `launchMenu` itself, so a direct/scripted
8468
+ * `--dry-run` call must never produce it — doing so unconditionally would
8469
+ * silently change the public exit-code contract for a successful dry run.
8470
+ */
8471
+ const DRY_RUN_PREVIEW_ENV_VAR = "HOLOCRON_DRY_RUN_PREVIEW";
8472
+ /**
8349
8473
  * Spawn `holocron <...args>` inheriting stdio; resolve with its exit code.
8350
8474
  *
8351
8475
  * `NO_UPDATE_NOTIFIER` is forced on in the child's env — the parent's own
@@ -8353,15 +8477,17 @@ function buildChildArgv(entry, positionals, parentArgv) {
8353
8477
  * docstring), so without this the freshly-spawned child independently runs
8354
8478
  * its own `checkForUpdates()` too and the notice prints twice: once from
8355
8479
  * the child right after its command output, once more from the parent
8356
- * once the child exits.
8480
+ * once the child exits. `extraEnv` layers on top — used only for {@link
8481
+ * DRY_RUN_PREVIEW_ENV_VAR} on the dry-run preview spawn.
8357
8482
  */
8358
- function spawnChild(args) {
8483
+ function spawnChild(args, extraEnv) {
8359
8484
  return new Promise((resolve) => {
8360
8485
  const child = spawn(process.execPath, [process.argv[1], ...args], {
8361
8486
  stdio: "inherit",
8362
8487
  env: {
8363
8488
  ...process.env,
8364
- NO_UPDATE_NOTIFIER: "1"
8489
+ NO_UPDATE_NOTIFIER: "1",
8490
+ ...extraEnv
8365
8491
  }
8366
8492
  });
8367
8493
  child.on("exit", (code) => resolve(code ?? 0));
@@ -8369,6 +8495,17 @@ function spawnChild(args) {
8369
8495
  });
8370
8496
  }
8371
8497
  /**
8498
+ * Whether `launchMenu` should auto-preview a picked command before running
8499
+ * it for real — only when the command's handler actually honours
8500
+ * `--dry-run` ({@link CommandEntry.supportsDryRun}) and the top-level
8501
+ * invocation didn't pass `--skip-dry-run` to opt out of the preview
8502
+ * entirely (an escape hatch for "I already know what I want, skip
8503
+ * straight to the real run").
8504
+ */
8505
+ function shouldPreviewDryRun(entry, parentArgv) {
8506
+ return Boolean(entry.supportsDryRun) && parentArgv.skipDryRun !== true;
8507
+ }
8508
+ /**
8372
8509
  * Layers 1 and 2: pick a command from `entries`, prompt for its required
8373
8510
  * positionals, spawn it as a fresh `holocron` invocation, and set
8374
8511
  * `process.exitCode` from the child — the caller (a `$0` handler in
@@ -8377,12 +8514,79 @@ function spawnChild(args) {
8377
8514
  * `command_completed` event fires with command name "unknown" (the
8378
8515
  * middleware ran before any command was picked) — left as-is rather than
8379
8516
  * suppressed; it's a real, useful signal ("the menu got used").
8517
+ *
8518
+ * For a command that honours `--dry-run` ({@link CommandEntry.supportsDryRun})
8519
+ * and wasn't opted out via `--skip-dry-run` ({@link shouldPreviewDryRun}),
8520
+ * this always previews first — a dry run doesn't mutate anything, so
8521
+ * there's nothing to ask permission for. A preview that exits non-zero
8522
+ * stops here; one that reports {@link DRY_RUN_NOOP_EXIT_CODE} (nothing
8523
+ * would change) stops here too, skipping the follow-up question since the
8524
+ * real run would have nothing to do either. Otherwise it asks "proceed
8525
+ * with the real run?" before the real spawn — the one actual decision
8526
+ * left to make, since this one does mutate something.
8380
8527
  */
8381
8528
  async function launchMenu(entries, parentArgv, pickMessage, nonInteractiveMessage = "Run `holocron --help` to see available commands.") {
8382
8529
  if (!process.stdin.isTTY) throw new NonInteractiveError(nonInteractiveMessage);
8383
8530
  const picked = await pickCommand(entries, pickMessage);
8384
- const childArgv = buildChildArgv(picked, await promptForPositionals(picked, {}), parentArgv);
8385
- process.exitCode = await spawnChild(childArgv);
8531
+ const positionals = await promptForPositionals(picked, {});
8532
+ if (shouldPreviewDryRun(picked, parentArgv)) {
8533
+ const dryExit = await spawnChild(buildChildArgv(picked, positionals, parentArgv, true), { [DRY_RUN_PREVIEW_ENV_VAR]: "1" });
8534
+ if (dryExit === 2) {
8535
+ process.exitCode = 0;
8536
+ return;
8537
+ }
8538
+ if (dryExit !== 0) {
8539
+ process.exitCode = dryExit;
8540
+ return;
8541
+ }
8542
+ if (!await confirm({
8543
+ message: "Dry run complete — proceed with the real run?",
8544
+ default: false
8545
+ })) {
8546
+ process.exitCode = 0;
8547
+ return;
8548
+ }
8549
+ process.exitCode = await spawnChild(buildChildArgv(picked, positionals, parentArgv, false));
8550
+ return;
8551
+ }
8552
+ process.exitCode = await spawnChild(buildChildArgv(picked, positionals, parentArgv));
8553
+ }
8554
+ //#endregion
8555
+ //#region src/ui/open-path.ts
8556
+ /** Platform-appropriate command to open a path in the OS's file browser. */
8557
+ function openCommand(path) {
8558
+ if (process.platform === "darwin") return {
8559
+ cmd: "open",
8560
+ args: [path]
8561
+ };
8562
+ if (process.platform === "win32") return {
8563
+ cmd: "cmd",
8564
+ args: [
8565
+ "/c",
8566
+ "start",
8567
+ "",
8568
+ path
8569
+ ]
8570
+ };
8571
+ return {
8572
+ cmd: "xdg-open",
8573
+ args: [path]
8574
+ };
8575
+ }
8576
+ /**
8577
+ * Open `path` in the OS's default file browser (Finder / Explorer /
8578
+ * whatever `xdg-open` resolves to on Linux). Fire-and-forget — detached so
8579
+ * it outlives this process, and never throws when the opener binary isn't
8580
+ * on PATH (e.g. no `xdg-open` in a minimal container).
8581
+ */
8582
+ function openPath(path) {
8583
+ const { cmd, args } = openCommand(path);
8584
+ const child = spawn(cmd, args, {
8585
+ stdio: "ignore",
8586
+ detached: true
8587
+ });
8588
+ child.on("error", () => {});
8589
+ child.unref();
8386
8590
  }
8387
8591
  //#endregion
8388
8592
  //#region src/update-notifier.ts
@@ -8528,6 +8732,19 @@ function resolveOrg(argv, config) {
8528
8732
  return argv.org ?? env.get("HOLOCRON_ORG") ?? config.org;
8529
8733
  }
8530
8734
  /**
8735
+ * Print the dim version line once per terminal session — keyed by the
8736
+ * parent shell's PID, since that's stable across repeated bare `holocron`
8737
+ * invocations in the same shell but changes in a new tab/window. A marker
8738
+ * file in the OS tmpdir tracks whether this shell has already seen it, so
8739
+ * re-running `holocron` doesn't repeat the line every time.
8740
+ */
8741
+ function printVersionHeaderOnce() {
8742
+ const marker = join(tmpdir(), `.holocron-version-shown-${process.ppid}`);
8743
+ if (existsSync(marker)) return;
8744
+ console.log(style.dim(`v${CLI_VERSION}`));
8745
+ writeFileSync(marker, "");
8746
+ }
8747
+ /**
8531
8748
  * `buildCliLogger` options derived from a loaded `holocron.config` — the
8532
8749
  * level, the Axiom dataset, and the resolved org for a keyring-backed
8533
8750
  * Axiom token. Used by every handler that has already called `loadConfig`.
@@ -8557,7 +8774,7 @@ function tokenContext(rawTokens) {
8557
8774
  return parseTokenArgs(rawTokens);
8558
8775
  } catch (err) {
8559
8776
  if (err instanceof TokenParseError) {
8560
- getLogger().error(`--token: ${err.message}`);
8777
+ reportError(`--token: ${err.message}`);
8561
8778
  process.exitCode = 1;
8562
8779
  return null;
8563
8780
  }
@@ -8574,6 +8791,10 @@ try {
8574
8791
  type: "boolean",
8575
8792
  default: false,
8576
8793
  describe: "Print what would be mutated without calling capability mutators. Commands branch on this; read-only commands ignore it."
8794
+ }).option("skip-dry-run", {
8795
+ type: "boolean",
8796
+ default: false,
8797
+ describe: "Interactive mode only: skip the automatic dry-run preview for a dry-run-capable command and run for real immediately."
8577
8798
  }).option("token", {
8578
8799
  type: "string",
8579
8800
  array: true,
@@ -8597,6 +8818,10 @@ try {
8597
8818
  type: "boolean",
8598
8819
  default: false,
8599
8820
  describe: "Set the log level to error — suppress info and warn."
8821
+ }).option("log", {
8822
+ type: "string",
8823
+ choices: LOG_LEVELS,
8824
+ describe: "Show structured logger output at this level. Local/interactive runs are quiet by default (no console output regardless of level) — this opts back in."
8600
8825
  }).middleware((argv) => {
8601
8826
  const name = argv._.slice(0, 2).join(" ") || "unknown";
8602
8827
  printRunId = Boolean(argv.debug || argv.verbose);
@@ -8618,13 +8843,13 @@ try {
8618
8843
  try {
8619
8844
  token = resolveCloneToken({ cliToken: tokens.cliTokens?.["github"] ?? tokens.cliToken });
8620
8845
  } catch (err) {
8621
- getLogger().error(`clone: ${err instanceof AuthError ? err.message : String(err)}`);
8846
+ reportError(`clone: ${err instanceof AuthError ? err.message : String(err)}`);
8622
8847
  process.exitCode = 1;
8623
8848
  return;
8624
8849
  }
8625
8850
  const [targetInput] = await promptForPositionals(getEntry("clone"), argv);
8626
8851
  const { org, repo } = parseCloneTarget(targetInput);
8627
- if ((await runClone({
8852
+ const report = await runClone({
8628
8853
  org,
8629
8854
  ...repo ? { repo } : {},
8630
8855
  token,
@@ -8637,8 +8862,20 @@ try {
8637
8862
  message: `${org} has ${total} repos — ${alreadyCloned} already cloned locally, ${toClone} new. Clone them all?`,
8638
8863
  default: false
8639
8864
  });
8640
- } } : {}
8641
- })).status === "fail") process.exitCode = 1;
8865
+ } } : {},
8866
+ confirmOpen: async (openTarget, moreToCome) => {
8867
+ if (!process.stdin.isTTY) return;
8868
+ if (moreToCome && env.get("HOLOCRON_DRY_RUN_PREVIEW") === "1") return;
8869
+ if (await confirm({
8870
+ message: `Open ${openTarget}?`,
8871
+ default: false
8872
+ })) openPath(openTarget);
8873
+ }
8874
+ });
8875
+ if (report.status === "fail") {
8876
+ if (report.message) reportError(`clone: ${report.message}`);
8877
+ process.exitCode = 1;
8878
+ } else if (report.status === "dry-run" && report.cloned === 0 && env.get("HOLOCRON_DRY_RUN_PREVIEW") === "1") process.exitCode = 2;
8642
8879
  }).command("doctor", "Load the config and run a smoke check against every provider", (y) => y.option("repo", {
8643
8880
  type: "string",
8644
8881
  describe: "Repo coords (\"owner/name\"). Defaults to plugin-specific resolution."
@@ -8898,7 +9135,7 @@ try {
8898
9135
  if (argv.syncTrust) {
8899
9136
  const [oldFile, newFile] = argv.syncTrust;
8900
9137
  if (!oldFile || !newFile) {
8901
- getLogger().error("publish --sync-trust requires exactly two values: <old-file> <new-file>");
9138
+ reportError("publish --sync-trust requires exactly two values: <old-file> <new-file>");
8902
9139
  process.exitCode = 1;
8903
9140
  return;
8904
9141
  }
@@ -9058,7 +9295,7 @@ try {
9058
9295
  else try {
9059
9296
  token = resolveSyncToken({ cliToken: parsed.cliTokens?.["github"] ?? parsed.cliToken });
9060
9297
  } catch (err) {
9061
- getLogger().error(`sync-github: ${err instanceof AuthError ? err.message : String(err)}`);
9298
+ reportError(`sync-github: ${err instanceof AuthError ? err.message : String(err)}`);
9062
9299
  process.exitCode = 1;
9063
9300
  return;
9064
9301
  }
@@ -9078,13 +9315,17 @@ try {
9078
9315
  }), async (argv) => {
9079
9316
  const loaded = await loadConfig(argv.cwd);
9080
9317
  applyResolvedConfig(argv, loaded.resolved);
9081
- if ((await runSyncReadme({
9318
+ const report = await runSyncReadme({
9082
9319
  loaded,
9083
9320
  context: {
9084
9321
  repoRoot: argv.cwd,
9085
9322
  dryRun: argv.dryRun
9086
9323
  }
9087
- })).status === "fail") process.exitCode = 1;
9324
+ });
9325
+ if (report.status === "fail") {
9326
+ if (report.message) reportError(`sync-readme: ${report.message}`);
9327
+ process.exitCode = 1;
9328
+ }
9088
9329
  }).command("config show", "Print the resolved holocron config", () => {}, async (argv) => {
9089
9330
  const loaded = await loadConfig(argv.cwd);
9090
9331
  console.log(JSON.stringify(loaded.resolved, null, 2));
@@ -9313,12 +9554,12 @@ try {
9313
9554
  }) === "yes";
9314
9555
  if (skills.length === 0) skills = parseTopics(await input({ message: "Agent skills (comma-separated, optional):" }));
9315
9556
  if (!type) {
9316
- getLogger().error("new: template type is required");
9557
+ reportError("new: template type is required");
9317
9558
  process.exitCode = 1;
9318
9559
  return;
9319
9560
  }
9320
9561
  if (!name) {
9321
- getLogger().error("new: repo name is required");
9562
+ reportError("new: repo name is required");
9322
9563
  process.exitCode = 1;
9323
9564
  return;
9324
9565
  }
@@ -9347,7 +9588,7 @@ try {
9347
9588
  })).status === "fail") process.exitCode = 1;
9348
9589
  } catch (err) {
9349
9590
  if (err instanceof NewError) {
9350
- getLogger().error(`new: ${err.message}`);
9591
+ reportError(`new: ${err.message}`);
9351
9592
  process.exitCode = 1;
9352
9593
  return;
9353
9594
  }
@@ -9406,7 +9647,7 @@ try {
9406
9647
  }).status === "fail") process.exitCode = 1;
9407
9648
  } catch (err) {
9408
9649
  if (err instanceof PluginCreateError) {
9409
- getLogger().error(`plugin create: ${err.message}`);
9650
+ reportError(`plugin create: ${err.message}`);
9410
9651
  process.exitCode = 1;
9411
9652
  return;
9412
9653
  }
@@ -9434,7 +9675,7 @@ try {
9434
9675
  extra
9435
9676
  });
9436
9677
  if (report.status === "fail") {
9437
- if (report.message) getLogger().error(`upgrade node: ${report.message}`);
9678
+ if (report.message) reportError(`upgrade node: ${report.message}`);
9438
9679
  process.exitCode = 1;
9439
9680
  }
9440
9681
  }).command("deps", "Bump every @theholocron/* pin to latest and migrate holocron.config.ts to the current preset API", (yy) => yy.option("pins-only", {
@@ -9448,7 +9689,7 @@ try {
9448
9689
  pinsOnly: argv.pinsOnly
9449
9690
  });
9450
9691
  if (report.status === "fail") {
9451
- if (report.message) getLogger().error(`upgrade deps: ${report.message}`);
9692
+ if (report.message) reportError(`upgrade deps: ${report.message}`);
9452
9693
  process.exitCode = 1;
9453
9694
  }
9454
9695
  }).command("$0", false, () => {}, async (argv) => {
@@ -9474,25 +9715,25 @@ try {
9474
9715
  }).command("$0", false, () => {}, async (argv) => {
9475
9716
  await launchMenu(COMMAND_REGISTRY.filter((e) => e.group === "auth"), argv, "auth — choose a subcommand:", "Run `holocron auth --help` to see available auth subcommands.");
9476
9717
  }), () => {}).command("$0", false, () => {}, async (argv) => {
9477
- console.log(style.dim(`holocron v${CLI_VERSION}`));
9718
+ printVersionHeaderOnce();
9478
9719
  await launchMenu(COMMAND_REGISTRY.filter((e) => !e.group), argv);
9479
9720
  }).strict().help().epilogue(`Execution contexts:
9480
9721
  global works from a bare 'npm i -g': ${commandsInContext("global").join(", ")}\n repo-aware needs ./holocron.config in cwd: ${commandsInContext("repo-aware").join(", ")}\n workspace also needs the plugin packages: ${commandsInContext("workspace").join(", ")}\n https://theholocron.github.io/holocron/execution-contexts`).fail((msg, err) => {
9481
9722
  if (err instanceof Error && USER_FACING_ERRORS.has(err.name)) {
9482
9723
  captureException(err);
9483
- getLogger().error(err.message);
9724
+ reportError(err.message);
9484
9725
  errorReported = true;
9485
9726
  process.exitCode = 1;
9486
9727
  return;
9487
9728
  }
9488
9729
  if (err) throw err;
9489
- getLogger().error(msg);
9730
+ reportError(msg);
9490
9731
  errorReported = true;
9491
9732
  process.exitCode = 1;
9492
9733
  }).parse();
9493
9734
  } catch (err) {
9494
9735
  captureException(err);
9495
- if (!errorReported && err instanceof Error && USER_FACING_ERRORS.has(err.name)) getLogger().error(err.message);
9736
+ if (!errorReported && err instanceof Error && USER_FACING_ERRORS.has(err.name)) reportError(err.message);
9496
9737
  if (!process.exitCode) process.exitCode = 1;
9497
9738
  }
9498
9739
  finishCommand(!process.exitCode);