@cruxy/cli 0.11.0 → 0.12.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 (44) hide show
  1. package/dist/approval/prompt.js +17 -15
  2. package/dist/cli/commands/checkpoint.js +6 -4
  3. package/dist/cli/commands/config.js +10 -7
  4. package/dist/cli/commands/index.js +16 -15
  5. package/dist/cli/commands/init.js +5 -3
  6. package/dist/cli/commands/login.js +5 -3
  7. package/dist/cli/commands/pr.js +8 -7
  8. package/dist/cli/commands/rollback.js +7 -6
  9. package/dist/cli/commands/run.js +7 -6
  10. package/dist/cli/commands/skills.js +12 -10
  11. package/dist/cli/program.js +7 -6
  12. package/dist/cli/repl.js +11 -9
  13. package/dist/components/frame.js +3 -1
  14. package/dist/components/fuzzy.d.ts +4 -4
  15. package/dist/components/fuzzy.js +14 -13
  16. package/dist/components/select.js +8 -7
  17. package/dist/errors/format.js +8 -8
  18. package/dist/onboarding/flow.js +6 -6
  19. package/dist/onboarding/steps.js +11 -11
  20. package/dist/plan/approve.js +6 -6
  21. package/dist/plan/render.js +26 -18
  22. package/dist/render/capabilities.js +4 -0
  23. package/dist/render/diff.d.ts +6 -7
  24. package/dist/render/diff.js +33 -22
  25. package/dist/render/highlight.d.ts +3 -3
  26. package/dist/render/highlight.js +15 -15
  27. package/dist/render/index.d.ts +1 -1
  28. package/dist/render/plain-renderer.d.ts +2 -1
  29. package/dist/render/plain-renderer.js +7 -6
  30. package/dist/render/state.d.ts +7 -2
  31. package/dist/render/state.js +16 -10
  32. package/dist/render/tty-renderer.d.ts +2 -1
  33. package/dist/render/tty-renderer.js +20 -17
  34. package/dist/render/types.d.ts +7 -0
  35. package/dist/subagent/orchestrator.js +21 -6
  36. package/dist/theme/index.d.ts +2 -0
  37. package/dist/theme/index.js +2 -0
  38. package/dist/theme/resolve.d.ts +32 -0
  39. package/dist/theme/resolve.js +73 -0
  40. package/dist/theme/tokens.d.ts +104 -0
  41. package/dist/theme/tokens.js +52 -0
  42. package/dist/utils/logger.d.ts +2 -0
  43. package/dist/utils/logger.js +7 -4
  44. package/package.json +1 -1
@@ -1,7 +1,7 @@
1
1
  import path from "node:path";
2
- import pc from "picocolors";
3
2
  import { readSingleKey } from "../components/input.js";
4
3
  import { renderActionPreview } from "../render/diff.js";
4
+ import { themeForColor } from "../theme/index.js";
5
5
  /**
6
6
  * Render the action, read one key, and map it to a {@link PromptChoice}. `n`/`t`
7
7
  * read a follow-up line (reason / instruction). Anything else — including EOF —
@@ -35,33 +35,35 @@ export async function promptForApproval(request, io) {
35
35
  }
36
36
  /** Render the full prompt block: header, detail (diff or command+cwd), choices. */
37
37
  export function render(request, color) {
38
- const c = pc.createColors(color);
38
+ const t = themeForColor(color);
39
39
  const destructive = request.tier === "destructive";
40
- const mark = destructive ? c.red(c.bold("!")) : c.yellow("?");
41
- const label = destructive ? c.red(c.bold(" (destructive)")) : "";
40
+ // The risk mark carries meaning by shape (`!` vs `?`), not by color alone —
41
+ // so it survives NO_COLOR (U.1 accessibility groundwork).
42
+ const mark = destructive ? t.danger(t.strong("!")) : t.warning("?");
43
+ const label = destructive ? t.danger(t.strong(" (destructive)")) : "";
42
44
  const lines = [];
43
- lines.push(`${mark} cruxy wants to ${c.bold(request.summary)}${label}`);
44
- lines.push(detail(request, c));
45
- lines.push(choices(request.scope, c));
45
+ lines.push(`${mark} cruxy wants to ${t.strong(request.summary)}${label}`);
46
+ lines.push(detail(request, t));
47
+ lines.push(choices(request.scope, t));
46
48
  return lines.filter((l) => l !== "").join("\n") + " ";
47
49
  }
48
50
  /** The action detail: a diff for file actions, the command + cwd for shell/test. */
49
- function detail(request, c) {
51
+ function detail(request, t) {
50
52
  if (request.action.kind === "shell" || request.action.kind === "test") {
51
53
  return [
52
- ` ${c.dim("$")} ${request.action.command ?? ""}`,
53
- ` ${c.dim(`in ${request.cwd}`)}`,
54
+ ` ${t.muted("$")} ${request.action.command ?? ""}`,
55
+ ` ${t.muted(`in ${request.cwd}`)}`,
54
56
  ].join("\n");
55
57
  }
56
- return renderActionPreview(request.action.preview, c);
58
+ return renderActionPreview(request.action.preview, t);
57
59
  }
58
60
  /** The choices line, including a short label of what an `a` grant would cover. */
59
- function choices(scope, c) {
61
+ function choices(scope, t) {
60
62
  const grant = scopeLabel(scope);
61
63
  const a = grant === null
62
- ? `${c.dim("[a] allow this kind (n/a here)")}`
63
- : `[a] allow ${c.bold(grant)} this session`;
64
- return ` ${c.dim("[y] approve once ·")} ${a} ${c.dim( [n] reject · [t] reject & instruct:")}`;
64
+ ? `${t.muted("[a] allow this kind (n/a here)")}`
65
+ : `[a] allow ${t.strong(grant)} this session`;
66
+ return ` ${t.muted(`[y] approve once ${t.glyph.sep}`)} ${a} ${t.muted(`${t.glyph.sep} [n] reject ${t.glyph.sep} [t] reject & instruct:`)}`;
65
67
  }
66
68
  /** Short human label for what a session grant would allow, or null if none. */
67
69
  function scopeLabel(scope) {
@@ -1,7 +1,8 @@
1
1
  import { Command } from "commander";
2
- import pc from "picocolors";
3
2
  import { loadConfig } from "../../config/index.js";
4
3
  import { CheckpointService } from "../../checkpoint/index.js";
4
+ import { shouldUseColor } from "../../errors/index.js";
5
+ import { themeForColor } from "../../theme/index.js";
5
6
  import { logger } from "../../utils/logger.js";
6
7
  /**
7
8
  * `cruxy checkpoint` (C.32) — inspect the working-tree snapshots that back
@@ -14,18 +15,19 @@ export function checkpointCommand() {
14
15
  .command("list")
15
16
  .description("list saved checkpoints, newest first")
16
17
  .action(async () => {
18
+ const t = themeForColor(shouldUseColor(process.stdout));
17
19
  const { config } = loadConfig();
18
20
  const service = new CheckpointService({ root: process.cwd(), config });
19
21
  const checkpoints = await service.list();
20
22
  if (checkpoints.length === 0) {
21
- logger.print(pc.dim("no checkpoints yet — one is created automatically before an agent run's first file change"));
23
+ logger.print(t.muted("no checkpoints yet — one is created automatically before an agent run's first file change"));
22
24
  return;
23
25
  }
24
26
  for (const c of checkpoints) {
25
27
  const files = `${c.files.length} file${c.files.length === 1 ? "" : "s"}`;
26
- logger.print(`${pc.cyan(c.id)} ${pc.dim(c.createdAt)} ${pc.dim(`[${c.store}]`)} ${files} ${c.runSummary}`);
28
+ logger.print(`${t.accent(c.id)} ${t.muted(c.createdAt)} ${t.muted(`[${c.store}]`)} ${files} ${c.runSummary}`);
27
29
  }
28
- logger.print(pc.dim(`\nrestore one with \`cruxy rollback <id>\` (or \`cruxy rollback\` for the newest)`));
30
+ logger.print(t.muted(`\nrestore one with \`cruxy rollback <id>\` (or \`cruxy rollback\` for the newest)`));
29
31
  });
30
32
  return cmd;
31
33
  }
@@ -1,6 +1,6 @@
1
1
  import { Command } from "commander";
2
- import pc from "picocolors";
3
- import { configKeyUnknown } from "../../errors/index.js";
2
+ import { configKeyUnknown, shouldUseColor } from "../../errors/index.js";
3
+ import { themeForColor } from "../../theme/index.js";
4
4
  import { logger } from "../../utils/logger.js";
5
5
  import { loadConfig, getPath, setValue, initConfig, globalConfigPath, findProjectConfig, } from "../../config/index.js";
6
6
  export function configCommand() {
@@ -35,24 +35,27 @@ export function configCommand() {
35
35
  ? (findProjectConfig() ?? "cruxy.config.json")
36
36
  : globalConfigPath();
37
37
  const written = setValue(key, value, file);
38
- logger.print(`${pc.green("set")} ${pc.bold(key)} = ${value} ${pc.dim(`(${written})`)}`);
38
+ const t = themeForColor(shouldUseColor(process.stdout));
39
+ logger.print(`${t.success("set")} ${t.strong(key)} = ${value} ${t.muted(`(${written})`)}`);
39
40
  });
40
41
  cmd
41
42
  .command("path")
42
43
  .description("show config file locations")
43
44
  .action(() => {
45
+ const t = themeForColor(shouldUseColor(process.stdout));
44
46
  const project = findProjectConfig();
45
- logger.print(`${pc.bold("global:")} ${globalConfigPath()}`);
46
- logger.print(`${pc.bold("project:")} ${project ?? pc.dim("(none found)")}`);
47
+ logger.print(`${t.strong("global:")} ${globalConfigPath()}`);
48
+ logger.print(`${t.strong("project:")} ${project ?? t.muted("(none found)")}`);
47
49
  });
48
50
  cmd
49
51
  .command("init")
50
52
  .description("write a default global config file")
51
53
  .action(() => {
54
+ const t = themeForColor(shouldUseColor(process.stdout));
52
55
  const { path, created } = initConfig(globalConfigPath());
53
56
  logger.print(created
54
- ? `${pc.green("created")} ${path}`
55
- : `${pc.yellow("exists")} ${path} ${pc.dim("(left unchanged)")}`);
57
+ ? `${t.success("created")} ${path}`
58
+ : `${t.warning("exists")} ${path} ${t.muted("(left unchanged)")}`);
56
59
  });
57
60
  return cmd;
58
61
  }
@@ -1,8 +1,8 @@
1
1
  import { existsSync } from "node:fs";
2
2
  import { Command } from "commander";
3
- import pc from "picocolors";
4
3
  import { loadConfig } from "../../config/index.js";
5
- import { CruxyError, indexFailed } from "../../errors/index.js";
4
+ import { CruxyError, indexFailed, shouldUseColor } from "../../errors/index.js";
5
+ import { themeForColor } from "../../theme/index.js";
6
6
  import { getIndexService, indexDbPath, resetIndexServices, } from "../../indexing/index.js";
7
7
  import { logger } from "../../utils/logger.js";
8
8
  /**
@@ -16,6 +16,7 @@ export function indexCommand() {
16
16
  .option("--status", "show index status without building")
17
17
  .option("--force", "re-embed every file, ignoring content hashes")
18
18
  .action(async (opts) => {
19
+ const t = themeForColor(shouldUseColor(process.stdout));
19
20
  const { config } = loadConfig();
20
21
  if (!config.index.enabled) {
21
22
  logger.warn("codebase indexing is disabled (index.enabled = false)");
@@ -25,21 +26,21 @@ export function indexCommand() {
25
26
  if (opts.status) {
26
27
  // Don't create an empty DB just to report "no index built yet".
27
28
  if (config.index.store !== "memory" && !existsSync(indexDbPath(cwd))) {
28
- logger.print(`${pc.bold("index:")} ${pc.dim("not built yet")} — run ${pc.bold("cruxy index")}`);
29
+ logger.print(`${t.strong("index:")} ${t.muted("not built yet")} — run ${t.strong("cruxy index")}`);
29
30
  return;
30
31
  }
31
32
  const service = await getIndexService(cwd, config, logger);
32
- printStatus(service.status());
33
+ printStatus(service.status(), t);
33
34
  await resetIndexServices();
34
35
  return;
35
36
  }
36
- logger.info(pc.dim(`indexing ${cwd} …`));
37
- logger.info(pc.dim("(first run may download the local embedding model)"));
37
+ logger.info(t.muted(`indexing ${cwd} ${t.glyph.ellipsis}`));
38
+ logger.info(t.muted("(first run may download the local embedding model)"));
38
39
  const service = await getIndexService(cwd, config, logger);
39
40
  try {
40
41
  const stats = await service.index({ force: opts.force });
41
- logger.print(`${pc.green("indexed")} ${stats.filesIndexed} file(s), ${stats.chunksIndexed} chunk(s) ` +
42
- pc.dim(`(${stats.filesSkipped} unchanged, ${stats.filesPurged} purged, ${stats.durationMs}ms)`));
42
+ logger.print(`${t.success("indexed")} ${stats.filesIndexed} file(s), ${stats.chunksIndexed} chunk(s) ` +
43
+ t.muted(`(${stats.filesSkipped} unchanged, ${stats.filesPurged} purged, ${stats.durationMs}ms)`));
43
44
  }
44
45
  catch (err) {
45
46
  // Typed index failures (embedder/store unavailable) already carry a
@@ -52,11 +53,11 @@ export function indexCommand() {
52
53
  }
53
54
  });
54
55
  }
55
- function printStatus(status) {
56
- logger.print(`${pc.bold("index:")} ${status.exists ? pc.green("built") : pc.yellow("empty")}`);
57
- logger.print(`${pc.bold("store:")} ${status.storePath ?? "(in-memory)"}`);
58
- logger.print(`${pc.bold("embedder:")} ${status.embedderId ?? pc.dim("(none)")}` +
59
- (status.dim ? pc.dim(` · ${status.dim}d`) : ""));
60
- logger.print(`${pc.bold("files:")} ${status.files}`);
61
- logger.print(`${pc.bold("chunks:")} ${status.chunks}`);
56
+ function printStatus(status, t) {
57
+ logger.print(`${t.strong("index:")} ${status.exists ? t.success("built") : t.warning("empty")}`);
58
+ logger.print(`${t.strong("store:")} ${status.storePath ?? "(in-memory)"}`);
59
+ logger.print(`${t.strong("embedder:")} ${status.embedderId ?? t.muted("(none)")}` +
60
+ (status.dim ? t.muted(`${t.sep}${status.dim}d`) : ""));
61
+ logger.print(`${t.strong("files:")} ${status.files}`);
62
+ logger.print(`${t.strong("chunks:")} ${status.chunks}`);
62
63
  }
@@ -1,5 +1,6 @@
1
1
  import { Command } from "commander";
2
- import pc from "picocolors";
2
+ import { shouldUseColor } from "../../errors/index.js";
3
+ import { themeForColor } from "../../theme/index.js";
3
4
  import { logger } from "../../utils/logger.js";
4
5
  import { loadConfig } from "../../config/index.js";
5
6
  import { createDefaultDeps, defaultOnboardingIO, runOnboarding, } from "../../onboarding/index.js";
@@ -13,8 +14,9 @@ export function initCommand() {
13
14
  return new Command("init")
14
15
  .description("set up cruxy in this project (key + CRUXY.md + a first run)")
15
16
  .action(async () => {
17
+ const t = themeForColor(shouldUseColor(process.stdout));
16
18
  if (!process.stdin.isTTY) {
17
- logger.print(pc.dim("cruxy init is interactive — run it in a terminal, or export your key as an environment variable."));
19
+ logger.print(t.muted("cruxy init is interactive — run it in a terminal, or export your key as an environment variable."));
18
20
  process.exitCode = 1;
19
21
  return;
20
22
  }
@@ -33,7 +35,7 @@ export function initCommand() {
33
35
  }),
34
36
  });
35
37
  if (!result.completed) {
36
- logger.print(pc.dim("setup not completed — run `cruxy init` again to resume."));
38
+ logger.print(t.muted("setup not completed — run `cruxy init` again to resume."));
37
39
  process.exitCode = 1;
38
40
  }
39
41
  });
@@ -1,5 +1,6 @@
1
1
  import { Command } from "commander";
2
- import pc from "picocolors";
2
+ import { shouldUseColor } from "../../errors/index.js";
3
+ import { themeForColor } from "../../theme/index.js";
3
4
  import { logger } from "../../utils/logger.js";
4
5
  import { loadConfig } from "../../config/index.js";
5
6
  import { createDefaultDeps, defaultOnboardingIO, runOnboarding, } from "../../onboarding/index.js";
@@ -13,8 +14,9 @@ export function loginCommand() {
13
14
  return new Command("login")
14
15
  .description("set or replace your API key (validated, saved to ~/.cruxy)")
15
16
  .action(async () => {
17
+ const t = themeForColor(shouldUseColor(process.stdout));
16
18
  if (!process.stdin.isTTY) {
17
- logger.print(pc.dim("cruxy login is interactive — run it in a terminal, or export your key as an environment variable."));
19
+ logger.print(t.muted("cruxy login is interactive — run it in a terminal, or export your key as an environment variable."));
18
20
  process.exitCode = 1;
19
21
  return;
20
22
  }
@@ -29,7 +31,7 @@ export function loginCommand() {
29
31
  deps: createDefaultDeps({ config, cwd: process.cwd() }),
30
32
  });
31
33
  if (!result.completed) {
32
- logger.print(pc.dim("login not completed."));
34
+ logger.print(t.muted("login not completed."));
33
35
  process.exitCode = 1;
34
36
  }
35
37
  });
@@ -1,9 +1,9 @@
1
1
  import { Command } from "commander";
2
- import pc from "picocolors";
3
2
  import { createProvider } from "@cruxy/sdk";
4
3
  import { logger } from "../../utils/logger.js";
5
4
  import { loadConfig, resolveApiKey } from "../../config/index.js";
6
- import { authMissingKey } from "../../errors/index.js";
5
+ import { authMissingKey, shouldUseColor } from "../../errors/index.js";
6
+ import { themeForColor } from "../../theme/index.js";
7
7
  import { ApprovalService } from "../../approval/index.js";
8
8
  import { createForgeProvider, createPrService, generateWithLlm, loadCommitGuidance, resolveForgeToken, } from "../../vcs/index.js";
9
9
  /**
@@ -20,6 +20,7 @@ export function prCommand() {
20
20
  .option("--body <body>", "PR body")
21
21
  .option("--draft", "open the pull request as a draft")
22
22
  .action(async (opts) => {
23
+ const t = themeForColor(shouldUseColor(process.stdout));
23
24
  const cwd = process.cwd();
24
25
  const { config } = loadConfig();
25
26
  // The model writes the PR content, so we need a provider key just like
@@ -54,7 +55,7 @@ export function prCommand() {
54
55
  skillBody: guidance.skillBody,
55
56
  }),
56
57
  });
57
- logger.info(pc.dim("generating pull request content…"));
58
+ logger.info(t.muted("generating pull request content…"));
58
59
  const outcome = await service.openPullRequest({
59
60
  base: opts.base,
60
61
  title: opts.title,
@@ -62,16 +63,16 @@ export function prCommand() {
62
63
  draft: opts.draft,
63
64
  });
64
65
  if (!outcome.approved) {
65
- logger.print(pc.yellow("pull request not opened — approval declined."));
66
+ logger.print(t.warning("pull request not opened — approval declined."));
66
67
  if (outcome.feedback)
67
- logger.print(pc.dim(outcome.feedback));
68
+ logger.print(t.muted(outcome.feedback));
68
69
  return;
69
70
  }
70
71
  const label = outcome.alreadyExists
71
72
  ? "a pull request already exists"
72
73
  : "opened pull request";
73
- logger.print(`${pc.green("✓")} ${label} (${outcome.branch} ${outcome.base})`);
74
- logger.print(pc.cyan(outcome.url));
74
+ logger.print(`${t.success(t.glyph.success)} ${label} (${outcome.branch} ${t.glyph.arrow} ${outcome.base})`);
75
+ logger.print(t.accent(outcome.url));
75
76
  });
76
77
  }
77
78
  /** Environment variable that holds the API key for a provider. */
@@ -1,5 +1,5 @@
1
1
  import { Command } from "commander";
2
- import pc from "picocolors";
2
+ import { themeForColor } from "../../theme/index.js";
3
3
  import { loadConfig } from "../../config/index.js";
4
4
  import { CheckpointService } from "../../checkpoint/index.js";
5
5
  import { ApprovalService, defaultPromptIO } from "../../approval/index.js";
@@ -58,6 +58,7 @@ export function rollbackCommand() {
58
58
  // act. There is no flag to bypass this, by design.
59
59
  if (!interactive)
60
60
  throw rollbackApprovalRequired();
61
+ const t = themeForColor(shouldUseColor(process.stdout));
61
62
  const { config } = loadConfig();
62
63
  const root = process.cwd();
63
64
  const service = new CheckpointService({ root, config });
@@ -71,7 +72,7 @@ export function rollbackCommand() {
71
72
  if (id === undefined) {
72
73
  const picked = await pickCheckpoint(service);
73
74
  if (picked === null) {
74
- logger.print(pc.dim("rollback cancelled — nothing was changed"));
75
+ logger.print(t.muted("rollback cancelled — nothing was changed"));
75
76
  return;
76
77
  }
77
78
  id = picked?.id;
@@ -81,16 +82,16 @@ export function rollbackCommand() {
81
82
  interactive,
82
83
  });
83
84
  if (result.kind === "noop") {
84
- logger.print(pc.dim(`working tree already matches checkpoint ${result.checkpoint.id} — nothing to roll back`));
85
+ logger.print(t.muted(`working tree already matches checkpoint ${result.checkpoint.id} — nothing to roll back`));
85
86
  return;
86
87
  }
87
88
  if (result.kind === "rejected") {
88
- logger.print(pc.dim("rollback declined — nothing was changed"));
89
+ logger.print(t.muted("rollback declined — nothing was changed"));
89
90
  return;
90
91
  }
91
92
  const { recreated, reverted, deleted } = result.applied;
92
- logger.print(`${pc.green("✓")} restored checkpoint ${pc.cyan(result.checkpoint.id)} — ` +
93
+ logger.print(`${t.success(t.glyph.success)} restored checkpoint ${t.accent(result.checkpoint.id)} — ` +
93
94
  `${reverted} reverted, ${recreated} recreated, ${deleted} deleted`);
94
- logger.print(pc.dim("note: commits, pushes, and PRs made during the run are not undone"));
95
+ logger.print(t.muted("note: commits, pushes, and PRs made during the run are not undone"));
95
96
  });
96
97
  }
@@ -1,9 +1,9 @@
1
1
  import { Command } from "commander";
2
- import pc from "picocolors";
3
2
  import { logger } from "../../utils/logger.js";
4
3
  import { loadConfig, resolveApiKey } from "../../config/index.js";
5
- import { authMissingKey, usageError } from "../../errors/index.js";
4
+ import { authMissingKey, shouldUseColor, usageError, } from "../../errors/index.js";
6
5
  import { createRenderer } from "../../render/index.js";
6
+ import { themeForColor } from "../../theme/index.js";
7
7
  import { CheckpointService } from "../../checkpoint/index.js";
8
8
  import { runInteractive } from "../repl.js";
9
9
  import { buildAgentSession } from "../session-factory.js";
@@ -15,6 +15,7 @@ export function runCommand() {
15
15
  .option("--plan", "plan mode: propose a step-by-step plan for approval before executing")
16
16
  .action(async (promptParts, opts) => {
17
17
  const prompt = promptParts.join(" ").trim();
18
+ const t = themeForColor(shouldUseColor(process.stdout));
18
19
  const interactive = prompt === "";
19
20
  // No prompt and stdin isn't a terminal: there's no way to read input and
20
21
  // nothing to do — fail fast instead of hanging on a line that never comes.
@@ -23,8 +24,8 @@ export function runCommand() {
23
24
  }
24
25
  const { config, sources } = loadConfig();
25
26
  let apiKey = resolveApiKey(config.model.provider);
26
- logger.info(pc.dim(`model: ${config.model.provider}/${config.model.model}`));
27
- logger.info(pc.dim(`config: ${sources.project ?? sources.global ?? "defaults"}`));
27
+ logger.info(t.muted(`model: ${config.model.provider}/${config.model.model}`));
28
+ logger.info(t.muted(`config: ${sources.project ?? sources.global ?? "defaults"}`));
28
29
  // First-run with no key (and a TTY) → guided onboarding instead of the
29
30
  // dead-end auth error. The first-win demo is offered only in the no-prompt
30
31
  // (REPL) path; with a real prompt, that prompt IS the first win.
@@ -41,7 +42,7 @@ export function runCommand() {
41
42
  const result = await onboarding;
42
43
  if (!result.completed) {
43
44
  // Aborted/failed mid-setup — guidance already shown; exit cleanly.
44
- logger.print(pc.dim("run `cruxy login` to finish setup."));
45
+ logger.print(t.muted("run `cruxy login` to finish setup."));
45
46
  return;
46
47
  }
47
48
  apiKey = result.apiKey ?? resolveApiKey(config.model.provider);
@@ -69,7 +70,7 @@ export function runCommand() {
69
70
  // One-shot: a single turn, then exit. Preserves scripting/pipe use.
70
71
  // Assistant text streams to stdout delta by delta (same as the REPL);
71
72
  // piped output degrades to the plain renderer (no ANSI, chrome on stderr).
72
- logger.print(`${pc.cyan("cruxy")} ${pc.dim("›")} ${prompt}\n`);
73
+ logger.print(`${t.accent("cruxy")} ${t.muted(t.glyph.caret)} ${prompt}\n`);
73
74
  // Provider/network/auth failures propagate to the top-level boundary,
74
75
  // which classifies them (e.g. CRUXY_E_GATEWAY_UNREACHABLE) and exits with
75
76
  // the matching code — a one-shot run must fail non-zero on error.
@@ -1,6 +1,7 @@
1
1
  import { Command } from "commander";
2
- import pc from "picocolors";
3
2
  import { getSkillService, resetSkillServices } from "../../skills/index.js";
3
+ import { shouldUseColor } from "../../errors/index.js";
4
+ import { themeForColor } from "../../theme/index.js";
4
5
  import { logger } from "../../utils/logger.js";
5
6
  /**
6
7
  * `cruxy skills` — list the skills available to the agent (the same catalog the
@@ -13,37 +14,38 @@ export function skillsCommand() {
13
14
  .description("list the skills available to the agent")
14
15
  .option("--status", "show source directories and validation errors")
15
16
  .action(async (opts) => {
17
+ const t = themeForColor(shouldUseColor(process.stdout));
16
18
  const service = getSkillService(process.cwd(), logger);
17
19
  const status = await service.status();
18
20
  if (status.entries.length === 0) {
19
- logger.print(pc.dim("no skills found"));
21
+ logger.print(t.muted("no skills found"));
20
22
  }
21
23
  else {
22
24
  for (const entry of status.entries) {
23
- logger.print(`${pc.bold(entry.name)} ${pc.dim(`[${entry.source}]`)}`);
25
+ logger.print(`${t.strong(entry.name)} ${t.muted(`[${entry.source}]`)}`);
24
26
  logger.print(` ${entry.description}`);
25
27
  }
26
28
  }
27
29
  if (!opts.status) {
28
30
  if (status.errors.length > 0) {
29
- logger.print(pc.yellow(`\n${status.errors.length} skill(s) failed validation — run ${pc.bold("cruxy skills --status")} for details`));
31
+ logger.print(t.warning(`\n${status.errors.length} skill(s) failed validation — run ${t.strong("cruxy skills --status")} for details`));
30
32
  }
31
33
  resetSkillServices();
32
34
  return;
33
35
  }
34
- logger.print(`\n${pc.bold("sources")} ${pc.dim("(precedence high → low):")}`);
36
+ logger.print(`\n${t.strong("sources")} ${t.muted("(precedence high → low):")}`);
35
37
  for (const { source, dir } of status.sources) {
36
- logger.print(` ${source.padEnd(8)} ${pc.dim(dir)}`);
38
+ logger.print(` ${source.padEnd(8)} ${t.muted(dir)}`);
37
39
  }
38
40
  logger.print("");
39
41
  if (status.errors.length === 0) {
40
- logger.print(pc.green("no validation errors"));
42
+ logger.print(t.success("no validation errors"));
41
43
  }
42
44
  else {
43
- logger.print(pc.yellow(`validation errors (${status.errors.length}):`));
45
+ logger.print(t.warning(`validation errors (${status.errors.length}):`));
44
46
  for (const err of status.errors) {
45
- logger.print(` ${pc.red("✗")} ${pc.bold(err.name)} ${pc.dim(`[${err.source}]`)}: ${err.message}`);
46
- logger.print(` ${pc.dim(err.dir)}`);
47
+ logger.print(` ${t.danger(t.glyph.failure)} ${t.strong(err.name)} ${t.muted(`[${err.source}]`)}: ${err.message}`);
48
+ logger.print(` ${t.muted(err.dir)}`);
47
49
  }
48
50
  }
49
51
  resetSkillServices();
@@ -1,8 +1,8 @@
1
1
  import { Command } from "commander";
2
- import pc from "picocolors";
3
2
  import { APP_NAME, APP_VERSION, APP_DESCRIPTION } from "../constants.js";
4
3
  import { logger } from "../utils/logger.js";
5
- import { usageError } from "../errors/index.js";
4
+ import { shouldUseColor, usageError } from "../errors/index.js";
5
+ import { themeForColor } from "../theme/index.js";
6
6
  import { runCommand } from "./commands/run.js";
7
7
  import { configCommand } from "./commands/config.js";
8
8
  import { indexCommand } from "./commands/index.js";
@@ -63,11 +63,12 @@ export function buildProgram() {
63
63
  await onboarding;
64
64
  return;
65
65
  }
66
- logger.print(pc.cyan(`${APP_NAME} v${APP_VERSION}`));
67
- logger.print(pc.dim("an agentic coding CLI\n"));
66
+ const t = themeForColor(shouldUseColor(process.stdout));
67
+ logger.print(t.accent(`${APP_NAME} v${APP_VERSION}`));
68
+ logger.print(t.muted("an agentic coding CLI\n"));
68
69
  logger.print("The interactive REPL lands in C.3 (terminal UI).");
69
- logger.print(`For now try: ${pc.bold('cruxy run "<task>"')} or ${pc.bold("cruxy config path")}`);
70
- logger.print(`See all commands: ${pc.bold("cruxy --help")}`);
70
+ logger.print(`For now try: ${t.strong('cruxy run "<task>"')} or ${t.strong("cruxy config path")}`);
71
+ logger.print(`See all commands: ${t.strong("cruxy --help")}`);
71
72
  });
72
73
  // Throw CommanderError instead of calling process.exit, and suppress
73
74
  // Commander's own "error:" line — so parse errors (unknown command/option,
package/dist/cli/repl.js CHANGED
@@ -1,10 +1,12 @@
1
1
  import readline from "node:readline";
2
- import pc from "picocolors";
3
2
  import { makeReplCompleter } from "../components/index.js";
3
+ import { themeForColor } from "../theme/index.js";
4
4
  import { formatError, fromUnknown, isVerbose, shouldUseColor, } from "../errors/index.js";
5
5
  import { createRenderer } from "../render/index.js";
6
6
  import { logger } from "../utils/logger.js";
7
- const PROMPT = `${pc.cyan("cruxy")} ${pc.dim("›")} `;
7
+ /** The REPL prompts on stdout; its chrome resolves against stdout's color. */
8
+ const theme = themeForColor(shouldUseColor(process.stdout));
9
+ const PROMPT = `${theme.accent("cruxy")} ${theme.muted(theme.glyph.caret)} `;
8
10
  /**
9
11
  * The REPL's slash commands — the autocomplete candidate set (U.7). Keep in
10
12
  * sync with the dispatch below and the HELP text.
@@ -92,7 +94,7 @@ function printReplError(err) {
92
94
  * passes its own so the approval prompt's status-suspend hook shares it.
93
95
  */
94
96
  export async function runInteractive(session, io = defaultIO(), renderer = createRenderer(io.output, process.stderr), checkpoints) {
95
- logger.print(pc.dim("interactive session — /help for commands, /exit or Ctrl+D to quit"));
97
+ logger.print(theme.muted("interactive session — /help for commands, /exit or Ctrl+D to quit"));
96
98
  try {
97
99
  await replLoop(session, io, renderer, checkpoints);
98
100
  }
@@ -105,25 +107,25 @@ async function replLoop(session, io, renderer, checkpoints) {
105
107
  const line = await readLine(io, PROMPT);
106
108
  // EOF / Ctrl+D.
107
109
  if (line === null) {
108
- logger.print(pc.dim("\nbye"));
110
+ logger.print(theme.muted("\nbye"));
109
111
  return;
110
112
  }
111
113
  const trimmed = line.trim();
112
114
  if (trimmed === "")
113
115
  continue; // empty line → reprompt, no model call
114
116
  if (trimmed === "/exit" || trimmed === "/quit") {
115
- logger.print(pc.dim("bye"));
117
+ logger.print(theme.muted("bye"));
116
118
  return;
117
119
  }
118
120
  if (trimmed === "/clear") {
119
121
  session.clear();
120
- logger.print(pc.dim("history cleared"));
122
+ logger.print(theme.muted("history cleared"));
121
123
  continue;
122
124
  }
123
125
  if (trimmed === "/compact") {
124
126
  try {
125
127
  const n = await session.compact();
126
- logger.print(pc.dim(n ? `compacted ${n} older messages` : "nothing to compact yet"));
128
+ logger.print(theme.muted(n ? `compacted ${n} older messages` : "nothing to compact yet"));
127
129
  }
128
130
  catch (err) {
129
131
  printReplError(err);
@@ -132,7 +134,7 @@ async function replLoop(session, io, renderer, checkpoints) {
132
134
  }
133
135
  if (trimmed === "/reload") {
134
136
  const loaded = session.reloadProjectInstructions();
135
- logger.print(pc.dim(loaded
137
+ logger.print(theme.muted(loaded
136
138
  ? "reloaded project instructions (CRUXY.md)"
137
139
  : "no project instructions found"));
138
140
  continue;
@@ -140,7 +142,7 @@ async function replLoop(session, io, renderer, checkpoints) {
140
142
  if (trimmed === "/plan") {
141
143
  session.setPlanMode(!session.getPlanMode());
142
144
  const on = session.getPlanMode();
143
- logger.print(pc.dim(on
145
+ logger.print(theme.muted(on
144
146
  ? "plan mode on — the next prompt proposes a plan for approval"
145
147
  : "plan mode off"));
146
148
  continue;
@@ -1,3 +1,4 @@
1
+ import { resolveTheme } from "../theme/index.js";
1
2
  /** Erase the current line and return the cursor to column 0 (same as U.2). */
2
3
  const CLEAR_LINE = "\r\x1b[2K";
3
4
  /** Move the cursor up one row. */
@@ -11,6 +12,7 @@ export function stripAnsi(text) {
11
12
  }
12
13
  export function createFrame(write, caps) {
13
14
  let drawn = 0;
15
+ const ellipsis = resolveTheme(caps).glyph.ellipsis;
14
16
  /**
15
17
  * Truncate to width-1 (cursor rests after the last cell; a full-width row
16
18
  * would auto-wrap on some terminals). Width is measured on VISIBLE
@@ -23,7 +25,7 @@ export function createFrame(write, caps) {
23
25
  const plain = stripAnsi(line);
24
26
  if (plain.length <= room)
25
27
  return line;
26
- return plain.slice(0, room - 1) + "…";
28
+ return plain.slice(0, room - 1) + ellipsis;
27
29
  };
28
30
  const erase = () => {
29
31
  if (drawn === 0)
@@ -1,4 +1,4 @@
1
- import pc from "picocolors";
1
+ import { type Theme } from "../theme/index.js";
2
2
  import { type ComponentIO, type InteractiveResult } from "./input.js";
3
3
  /**
4
4
  * Fuzzy finding (U.7): a deterministic, honest subsequence scorer (pure,
@@ -36,10 +36,10 @@ export interface RankedItem<T> {
36
36
  */
37
37
  export declare function rankItems<T>(items: readonly T[], toLabel: (item: T) => string, query: string): RankedItem<T>[];
38
38
  /**
39
- * Bold the matched characters of a label. With color off (NO_COLOR, pipe)
40
- * picocolors' disabled palette is the identity — plain text, zero ANSI.
39
+ * Bold the matched characters of a label via the theme's accent role. With
40
+ * color off (NO_COLOR, pipe) the roles are identity — plain text, zero ANSI.
41
41
  */
42
- export declare function highlightMatch(label: string, positions: readonly number[], colors: ReturnType<typeof pc.createColors>): string;
42
+ export declare function highlightMatch(label: string, positions: readonly number[], theme: Theme): string;
43
43
  export interface FuzzyFindOptions<T> {
44
44
  /** Label an item filters/renders under. Required — items are opaque. */
45
45
  toLabel: (item: T) => string;