@cruxy/cli 0.11.0 → 0.13.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 (71) 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 +26 -7
  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/cli/session-factory.d.ts +2 -1
  14. package/dist/cli/session-factory.js +10 -3
  15. package/dist/components/frame.js +3 -1
  16. package/dist/components/fuzzy.d.ts +4 -4
  17. package/dist/components/fuzzy.js +14 -13
  18. package/dist/components/select.js +8 -7
  19. package/dist/config/schema.d.ts +123 -0
  20. package/dist/config/schema.js +40 -0
  21. package/dist/errors/constructors.d.ts +21 -0
  22. package/dist/errors/constructors.js +58 -0
  23. package/dist/errors/format.js +8 -8
  24. package/dist/errors/types.d.ts +5 -0
  25. package/dist/errors/types.js +11 -0
  26. package/dist/onboarding/flow.js +6 -6
  27. package/dist/onboarding/steps.js +11 -11
  28. package/dist/plan/approve.js +6 -6
  29. package/dist/plan/render.js +26 -18
  30. package/dist/render/capabilities.js +4 -0
  31. package/dist/render/diff.d.ts +6 -7
  32. package/dist/render/diff.js +33 -22
  33. package/dist/render/highlight.d.ts +3 -3
  34. package/dist/render/highlight.js +15 -15
  35. package/dist/render/index.d.ts +1 -1
  36. package/dist/render/plain-renderer.d.ts +2 -1
  37. package/dist/render/plain-renderer.js +7 -6
  38. package/dist/render/state.d.ts +7 -2
  39. package/dist/render/state.js +16 -10
  40. package/dist/render/tty-renderer.d.ts +2 -1
  41. package/dist/render/tty-renderer.js +20 -17
  42. package/dist/render/types.d.ts +7 -0
  43. package/dist/sandbox/detect.d.ts +22 -0
  44. package/dist/sandbox/detect.js +67 -0
  45. package/dist/sandbox/docker-runtime.d.ts +32 -0
  46. package/dist/sandbox/docker-runtime.js +263 -0
  47. package/dist/sandbox/index.d.ts +7 -0
  48. package/dist/sandbox/index.js +5 -0
  49. package/dist/sandbox/policy.d.ts +17 -0
  50. package/dist/sandbox/policy.js +90 -0
  51. package/dist/sandbox/service.d.ts +57 -0
  52. package/dist/sandbox/service.js +64 -0
  53. package/dist/sandbox/types.d.ts +114 -0
  54. package/dist/sandbox/types.js +17 -0
  55. package/dist/subagent/orchestrator.d.ts +7 -0
  56. package/dist/subagent/orchestrator.js +22 -6
  57. package/dist/testing/run-tests-tool.d.ts +5 -1
  58. package/dist/testing/run-tests-tool.js +8 -1
  59. package/dist/testing/sandbox-runner.d.ts +16 -0
  60. package/dist/testing/sandbox-runner.js +47 -0
  61. package/dist/theme/index.d.ts +2 -0
  62. package/dist/theme/index.js +2 -0
  63. package/dist/theme/resolve.d.ts +32 -0
  64. package/dist/theme/resolve.js +73 -0
  65. package/dist/theme/tokens.d.ts +104 -0
  66. package/dist/theme/tokens.js +52 -0
  67. package/dist/tools/shell/run-command.js +35 -1
  68. package/dist/tools/types.d.ts +10 -0
  69. package/dist/utils/logger.d.ts +2 -0
  70. package/dist/utils/logger.js +7 -4
  71. 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,10 +1,11 @@
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
+ import { SandboxService } from "../../sandbox/index.js";
8
9
  import { runInteractive } from "../repl.js";
9
10
  import { buildAgentSession } from "../session-factory.js";
10
11
  import { apiKeyEnvVar, maybeRunOnboarding } from "../onboard.js";
@@ -13,8 +14,10 @@ export function runCommand() {
13
14
  .description("run cruxy on a prompt (one-shot), or with no prompt for an interactive session")
14
15
  .argument("[prompt...]", "the task for cruxy to perform (omit for interactive)")
15
16
  .option("--plan", "plan mode: propose a step-by-step plan for approval before executing")
17
+ .option("--sandbox", "run shell + test commands inside an isolated container (fails loud if no runtime)")
16
18
  .action(async (promptParts, opts) => {
17
19
  const prompt = promptParts.join(" ").trim();
20
+ const t = themeForColor(shouldUseColor(process.stdout));
18
21
  const interactive = prompt === "";
19
22
  // No prompt and stdin isn't a terminal: there's no way to read input and
20
23
  // nothing to do — fail fast instead of hanging on a line that never comes.
@@ -23,8 +26,8 @@ export function runCommand() {
23
26
  }
24
27
  const { config, sources } = loadConfig();
25
28
  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"}`));
29
+ logger.info(t.muted(`model: ${config.model.provider}/${config.model.model}`));
30
+ logger.info(t.muted(`config: ${sources.project ?? sources.global ?? "defaults"}`));
28
31
  // First-run with no key (and a TTY) → guided onboarding instead of the
29
32
  // dead-end auth error. The first-win demo is offered only in the no-prompt
30
33
  // (REPL) path; with a real prompt, that prompt IS the first win.
@@ -41,7 +44,7 @@ export function runCommand() {
41
44
  const result = await onboarding;
42
45
  if (!result.completed) {
43
46
  // Aborted/failed mid-setup — guidance already shown; exit cleanly.
44
- logger.print(pc.dim("run `cruxy login` to finish setup."));
47
+ logger.print(t.muted("run `cruxy login` to finish setup."));
45
48
  return;
46
49
  }
47
50
  apiKey = result.apiKey ?? resolveApiKey(config.model.provider);
@@ -60,7 +63,23 @@ export function runCommand() {
60
63
  const checkpoints = config.checkpoint.enabled
61
64
  ? new CheckpointService({ root: process.cwd(), config })
62
65
  : undefined;
63
- const session = buildAgentSession(config, apiKey, process.cwd(), Boolean(process.stdin.isTTY), planMode, renderer, checkpoints);
66
+ // Sandbox (C.16): opt-in via --sandbox or sandbox.enabled. Resolving the
67
+ // service probes the runtime and THROWS CRUXY_E_SANDBOX_UNAVAILABLE if it
68
+ // is missing — fail loud here, before the agent starts, rather than
69
+ // silently running un-sandboxed. When off, ctx.sandbox stays undefined and
70
+ // execution runs on the host, unchanged.
71
+ const sandboxEnabled = opts.sandbox ?? config.sandbox.enabled;
72
+ const sandbox = sandboxEnabled
73
+ ? await SandboxService.create({
74
+ config,
75
+ cwd: process.cwd(),
76
+ reporter: renderer,
77
+ })
78
+ : undefined;
79
+ if (sandbox) {
80
+ logger.info(t.muted(`sandbox: ${sandbox.runtimeName} (network ${config.sandbox.network})`));
81
+ }
82
+ const session = buildAgentSession(config, apiKey, process.cwd(), Boolean(process.stdin.isTTY), planMode, renderer, checkpoints, sandbox);
64
83
  if (interactive) {
65
84
  await runInteractive(session, undefined, renderer, checkpoints);
66
85
  return;
@@ -69,7 +88,7 @@ export function runCommand() {
69
88
  // One-shot: a single turn, then exit. Preserves scripting/pipe use.
70
89
  // Assistant text streams to stdout delta by delta (same as the REPL);
71
90
  // piped output degrades to the plain renderer (no ANSI, chrome on stderr).
72
- logger.print(`${pc.cyan("cruxy")} ${pc.dim("›")} ${prompt}\n`);
91
+ logger.print(`${t.accent("cruxy")} ${t.muted(t.glyph.caret)} ${prompt}\n`);
73
92
  // Provider/network/auth failures propagate to the top-level boundary,
74
93
  // which classifies them (e.g. CRUXY_E_GATEWAY_UNREACHABLE) and exits with
75
94
  // 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,6 +1,7 @@
1
1
  import type { CruxyConfig } from "../config/index.js";
2
2
  import type { ApprovalDecision } from "../approval/index.js";
3
3
  import type { CheckpointService } from "../checkpoint/index.js";
4
+ import type { SandboxService } from "../sandbox/index.js";
4
5
  import type { StreamRenderer } from "../render/index.js";
5
6
  import { type ApproveAction } from "../tools/index.js";
6
7
  import { Session } from "../agent/index.js";
@@ -23,4 +24,4 @@ export declare function withCheckpointGate(requestApproval: (action: ApproveActi
23
24
  * over the shared U.3 allowlist and a `planRunner` so `session.send` proposes →
24
25
  * approves → executes. Plan mode is fully opt-in; the default path is unchanged.
25
26
  */
26
- export declare function buildAgentSession(config: CruxyConfig, apiKey: string, cwd: string, ttyInteractive: boolean, planMode?: boolean, renderer?: StreamRenderer, checkpoints?: CheckpointService): Session;
27
+ export declare function buildAgentSession(config: CruxyConfig, apiKey: string, cwd: string, ttyInteractive: boolean, planMode?: boolean, renderer?: StreamRenderer, checkpoints?: CheckpointService, sandbox?: SandboxService): Session;