@cruxy/cli 0.1.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/LICENSE +21 -0
  2. package/README.md +105 -0
  3. package/dist/agent/approval.d.ts +41 -0
  4. package/dist/agent/approval.js +179 -0
  5. package/dist/agent/index.d.ts +4 -0
  6. package/dist/agent/index.js +4 -0
  7. package/dist/agent/loop.d.ts +53 -0
  8. package/dist/agent/loop.js +148 -0
  9. package/dist/agent/prompts.d.ts +53 -0
  10. package/dist/agent/prompts.js +99 -0
  11. package/dist/agent/session.d.ts +107 -0
  12. package/dist/agent/session.js +236 -0
  13. package/dist/cli/commands/config.d.ts +2 -0
  14. package/dist/cli/commands/config.js +59 -0
  15. package/dist/cli/commands/run.d.ts +2 -0
  16. package/dist/cli/commands/run.js +85 -0
  17. package/dist/cli/program.d.ts +2 -0
  18. package/dist/cli/program.js +36 -0
  19. package/dist/cli/repl.d.ts +15 -0
  20. package/dist/cli/repl.js +114 -0
  21. package/dist/cli/stream-print.d.ts +14 -0
  22. package/dist/cli/stream-print.js +26 -0
  23. package/dist/config/index.d.ts +4 -0
  24. package/dist/config/index.js +4 -0
  25. package/dist/config/manager.d.ts +34 -0
  26. package/dist/config/manager.js +151 -0
  27. package/dist/config/paths.d.ts +9 -0
  28. package/dist/config/paths.js +31 -0
  29. package/dist/config/project.d.ts +10 -0
  30. package/dist/config/project.js +36 -0
  31. package/dist/config/schema.d.ts +303 -0
  32. package/dist/config/schema.js +100 -0
  33. package/dist/constants.d.ts +11 -0
  34. package/dist/constants.js +31 -0
  35. package/dist/index.d.ts +2 -0
  36. package/dist/index.js +13 -0
  37. package/dist/tools/file/apply-patch.d.ts +94 -0
  38. package/dist/tools/file/apply-patch.js +195 -0
  39. package/dist/tools/file/edit-file.d.ts +14 -0
  40. package/dist/tools/file/edit-file.js +81 -0
  41. package/dist/tools/file/glob.d.ts +10 -0
  42. package/dist/tools/file/glob.js +52 -0
  43. package/dist/tools/file/grep-files.d.ts +32 -0
  44. package/dist/tools/file/grep-files.js +113 -0
  45. package/dist/tools/file/index.d.ts +7 -0
  46. package/dist/tools/file/index.js +7 -0
  47. package/dist/tools/file/paths.d.ts +24 -0
  48. package/dist/tools/file/paths.js +65 -0
  49. package/dist/tools/file/read-file.d.ts +8 -0
  50. package/dist/tools/file/read-file.js +52 -0
  51. package/dist/tools/file/write-file.d.ts +10 -0
  52. package/dist/tools/file/write-file.js +56 -0
  53. package/dist/tools/git-status.d.ts +8 -0
  54. package/dist/tools/git-status.js +26 -0
  55. package/dist/tools/index.d.ts +5 -0
  56. package/dist/tools/index.js +5 -0
  57. package/dist/tools/list-files.d.ts +7 -0
  58. package/dist/tools/list-files.js +27 -0
  59. package/dist/tools/registry.d.ts +23 -0
  60. package/dist/tools/registry.js +63 -0
  61. package/dist/tools/shell/index.d.ts +1 -0
  62. package/dist/tools/shell/index.js +1 -0
  63. package/dist/tools/shell/run-command.d.ts +10 -0
  64. package/dist/tools/shell/run-command.js +100 -0
  65. package/dist/tools/types.d.ts +113 -0
  66. package/dist/tools/types.js +1 -0
  67. package/dist/utils/git.d.ts +17 -0
  68. package/dist/utils/git.js +43 -0
  69. package/dist/utils/logger.d.ts +16 -0
  70. package/dist/utils/logger.js +42 -0
  71. package/package.json +52 -0
@@ -0,0 +1,10 @@
1
+ import { z } from "zod";
2
+ import type { Tool } from "../types.js";
3
+ /**
4
+ * Create or overwrite a file within the project root. Gated on `ctx.approve`
5
+ * before anything is written.
6
+ */
7
+ export declare const writeFileTool: Tool<z.ZodObject<{
8
+ path: z.ZodString;
9
+ content: z.ZodString;
10
+ }>>;
@@ -0,0 +1,56 @@
1
+ import { promises as fs } from "node:fs";
2
+ import path from "node:path";
3
+ import { z } from "zod";
4
+ import { resolveInRoot } from "./paths.js";
5
+ /** How many leading lines of new content the approval preview shows. */
6
+ const PREVIEW_LINES = 20;
7
+ /**
8
+ * Create or overwrite a file within the project root. Gated on `ctx.approve`
9
+ * before anything is written.
10
+ */
11
+ export const writeFileTool = {
12
+ name: "write_file",
13
+ description: "Create or overwrite a file with the given content (creates parent directories). Use for new files or full rewrites; prefer edit_file for small changes.",
14
+ parameters: z.object({
15
+ path: z
16
+ .string()
17
+ .describe("Path to the file, relative to the project root."),
18
+ content: z.string().describe("Full UTF-8 contents to write."),
19
+ }),
20
+ async execute(input, ctx) {
21
+ let abs;
22
+ try {
23
+ abs = await resolveInRoot(ctx, input.path);
24
+ }
25
+ catch (err) {
26
+ return { ok: false, error: err.message };
27
+ }
28
+ // Does the target already exist? Drives create-vs-overwrite in the preview.
29
+ const exists = await fs
30
+ .access(abs)
31
+ .then(() => true)
32
+ .catch(() => false);
33
+ // First N lines of the new content, with a count of what's omitted.
34
+ const allLines = input.content.split("\n");
35
+ const lines = allLines.slice(0, PREVIEW_LINES);
36
+ const omittedLines = Math.max(0, allLines.length - PREVIEW_LINES);
37
+ // Approve BEFORE any mutation; a denial writes nothing.
38
+ const approved = await ctx.approve({
39
+ kind: "write",
40
+ path: abs,
41
+ preview: { type: "write", exists, lines, omittedLines },
42
+ });
43
+ if (!approved) {
44
+ return { ok: false, error: `write to ${input.path} denied` };
45
+ }
46
+ try {
47
+ await fs.mkdir(path.dirname(abs), { recursive: true });
48
+ await fs.writeFile(abs, input.content, "utf8");
49
+ const bytes = Buffer.byteLength(input.content, "utf8");
50
+ return { ok: true, output: `wrote ${input.path} (${bytes} bytes)` };
51
+ }
52
+ catch (err) {
53
+ return { ok: false, error: err.message };
54
+ }
55
+ },
56
+ };
@@ -0,0 +1,8 @@
1
+ import { z } from "zod";
2
+ import type { Tool } from "./types.js";
3
+ /**
4
+ * Report the current git branch and working-tree status (`git status
5
+ * --porcelain`) for the project root. Read-only — no approval, like read_file
6
+ * and glob.
7
+ */
8
+ export declare const gitStatusTool: Tool<z.ZodObject<Record<string, never>>>;
@@ -0,0 +1,26 @@
1
+ import { z } from "zod";
2
+ import { getGitStatus } from "../utils/git.js";
3
+ /**
4
+ * Report the current git branch and working-tree status (`git status
5
+ * --porcelain`) for the project root. Read-only — no approval, like read_file
6
+ * and glob.
7
+ */
8
+ export const gitStatusTool = {
9
+ name: "git_status",
10
+ description: "Show the current git branch and the working-tree status (porcelain format: ' M file' modified, '?? file' untracked, etc.). Read-only and requires no approval.",
11
+ parameters: z.object({}),
12
+ async execute(_input, ctx) {
13
+ const info = getGitStatus(ctx.cwd);
14
+ if (info === null) {
15
+ return {
16
+ ok: false,
17
+ error: "not a git repository (or git is unavailable)",
18
+ };
19
+ }
20
+ const body = info.status.trim();
21
+ return {
22
+ ok: true,
23
+ output: `branch ${info.branch}\n${body === "" ? "working tree clean" : body}`,
24
+ };
25
+ },
26
+ };
@@ -0,0 +1,5 @@
1
+ export * from "./types.js";
2
+ export * from "./registry.js";
3
+ export * from "./list-files.js";
4
+ export * from "./git-status.js";
5
+ export * from "./file/index.js";
@@ -0,0 +1,5 @@
1
+ export * from "./types.js";
2
+ export * from "./registry.js";
3
+ export * from "./list-files.js";
4
+ export * from "./git-status.js";
5
+ export * from "./file/index.js";
@@ -0,0 +1,7 @@
1
+ import { z } from "zod";
2
+ import type { Tool } from "./types.js";
3
+ /**
4
+ * Proof-of-concept tool: list the entries of `ctx.cwd`, marking each as a
5
+ * directory or a file. Takes no arguments.
6
+ */
7
+ export declare const listFilesTool: Tool<z.ZodObject<Record<string, never>>>;
@@ -0,0 +1,27 @@
1
+ import { promises as fs } from "node:fs";
2
+ import { z } from "zod";
3
+ /**
4
+ * Proof-of-concept tool: list the entries of `ctx.cwd`, marking each as a
5
+ * directory or a file. Takes no arguments.
6
+ */
7
+ export const listFilesTool = {
8
+ name: "list_files",
9
+ description: "List the files and directories in the current working directory.",
10
+ parameters: z.object({}),
11
+ async execute(_input, ctx) {
12
+ try {
13
+ const entries = await fs.readdir(ctx.cwd, { withFileTypes: true });
14
+ if (entries.length === 0) {
15
+ return { ok: true, output: "(empty directory)" };
16
+ }
17
+ const lines = entries
18
+ .slice()
19
+ .sort((a, b) => a.name.localeCompare(b.name))
20
+ .map((entry) => `${entry.isDirectory() ? "dir " : "file"} ${entry.name}`);
21
+ return { ok: true, output: lines.join("\n") };
22
+ }
23
+ catch (err) {
24
+ return { ok: false, error: err.message };
25
+ }
26
+ },
27
+ };
@@ -0,0 +1,23 @@
1
+ import type { ToolSpec } from "@cruxy/sdk";
2
+ import type { Tool } from "./types.js";
3
+ /**
4
+ * In-memory catalogue of the tools available to the agent. Names are unique;
5
+ * `toToolSpecs()` projects the catalogue into the `@cruxy/sdk` wire format.
6
+ */
7
+ export declare class ToolRegistry {
8
+ private readonly tools;
9
+ /** Register a tool. Throws if a tool with the same name already exists. */
10
+ register(tool: Tool): void;
11
+ /** Look up a tool by name, or `undefined` if not registered. */
12
+ get(name: string): Tool | undefined;
13
+ /** All registered tools, in registration order. */
14
+ list(): Tool[];
15
+ /**
16
+ * Project every tool into a `ToolSpec` for the provider: name, description,
17
+ * and the zod schema rendered as JSON Schema (with the `$schema` / `definitions`
18
+ * cruft the wire format rejects stripped out).
19
+ */
20
+ toToolSpecs(): ToolSpec[];
21
+ }
22
+ /** Build the default registry with every built-in tool registered. */
23
+ export declare function buildDefaultRegistry(): ToolRegistry;
@@ -0,0 +1,63 @@
1
+ import { zodToJsonSchema } from "zod-to-json-schema";
2
+ import { listFilesTool } from "./list-files.js";
3
+ import { gitStatusTool } from "./git-status.js";
4
+ import { readFileTool, writeFileTool, editFileTool, applyPatchTool, globTool, grepFilesTool, } from "./file/index.js";
5
+ import { runCommandTool } from "./shell/index.js";
6
+ /**
7
+ * In-memory catalogue of the tools available to the agent. Names are unique;
8
+ * `toToolSpecs()` projects the catalogue into the `@cruxy/sdk` wire format.
9
+ */
10
+ export class ToolRegistry {
11
+ tools = new Map();
12
+ /** Register a tool. Throws if a tool with the same name already exists. */
13
+ register(tool) {
14
+ if (this.tools.has(tool.name)) {
15
+ throw new Error(`tool "${tool.name}" is already registered`);
16
+ }
17
+ this.tools.set(tool.name, tool);
18
+ }
19
+ /** Look up a tool by name, or `undefined` if not registered. */
20
+ get(name) {
21
+ return this.tools.get(name);
22
+ }
23
+ /** All registered tools, in registration order. */
24
+ list() {
25
+ return [...this.tools.values()];
26
+ }
27
+ /**
28
+ * Project every tool into a `ToolSpec` for the provider: name, description,
29
+ * and the zod schema rendered as JSON Schema (with the `$schema` / `definitions`
30
+ * cruft the wire format rejects stripped out).
31
+ */
32
+ toToolSpecs() {
33
+ return this.list().map((tool) => ({
34
+ name: tool.name,
35
+ description: tool.description,
36
+ input_schema: toInputSchema(tool.parameters),
37
+ }));
38
+ }
39
+ }
40
+ /** Render a zod schema to a wire-safe JSON Schema object. */
41
+ function toInputSchema(schema) {
42
+ // `$refStrategy: "none"` inlines everything so no `$ref`/`definitions` block
43
+ // is emitted; we still strip both defensively along with `$schema`.
44
+ const json = zodToJsonSchema(schema, { $refStrategy: "none" });
45
+ delete json.$schema;
46
+ delete json.definitions;
47
+ delete json.$ref;
48
+ return json;
49
+ }
50
+ /** Build the default registry with every built-in tool registered. */
51
+ export function buildDefaultRegistry() {
52
+ const registry = new ToolRegistry();
53
+ registry.register(listFilesTool);
54
+ registry.register(readFileTool);
55
+ registry.register(writeFileTool);
56
+ registry.register(editFileTool);
57
+ registry.register(applyPatchTool);
58
+ registry.register(globTool);
59
+ registry.register(grepFilesTool);
60
+ registry.register(gitStatusTool);
61
+ registry.register(runCommandTool);
62
+ return registry;
63
+ }
@@ -0,0 +1 @@
1
+ export * from "./run-command.js";
@@ -0,0 +1 @@
1
+ export * from "./run-command.js";
@@ -0,0 +1,10 @@
1
+ import { z } from "zod";
2
+ import type { Tool } from "../types.js";
3
+ /**
4
+ * Run an arbitrary shell command in the project root. The highest-risk tool we
5
+ * ship: it is gated on `ctx.approve` (a denial runs nothing) and bounded by a
6
+ * timeout that kills the whole process tree plus a cap on captured output.
7
+ */
8
+ export declare const runCommandTool: Tool<z.ZodObject<{
9
+ command: z.ZodString;
10
+ }>>;
@@ -0,0 +1,100 @@
1
+ import { spawn } from "node:child_process";
2
+ import { z } from "zod";
3
+ /**
4
+ * Run an arbitrary shell command in the project root. The highest-risk tool we
5
+ * ship: it is gated on `ctx.approve` (a denial runs nothing) and bounded by a
6
+ * timeout that kills the whole process tree plus a cap on captured output.
7
+ */
8
+ export const runCommandTool = {
9
+ name: "run_command",
10
+ description: "Run a shell command in the project root and return its exit code, stdout, and stderr. Use this for builds, tests, linters, and git. Prefer the dedicated file tools (read_file/write_file/edit_file) over shelling out to cat/sed/echo for inspecting or editing files. A non-zero exit is still returned so you can react to the failure output.",
11
+ parameters: z.object({
12
+ command: z
13
+ .string()
14
+ .describe("The shell command to run (executed via the system shell)."),
15
+ }),
16
+ async execute(input, ctx) {
17
+ // Approve BEFORE anything runs; a denial executes nothing.
18
+ if (!(await ctx.approve({ kind: "shell", command: input.command }))) {
19
+ return { ok: false, error: "denied by user" };
20
+ }
21
+ return runBounded(input.command, ctx);
22
+ },
23
+ };
24
+ /** Spawn the command, capture bounded output, and enforce the timeout. */
25
+ function runBounded(command, ctx) {
26
+ const { timeoutMs, maxOutputBytes } = ctx.config.shell;
27
+ return new Promise((resolve) => {
28
+ // `detached` makes the child its own process-group leader so the whole tree
29
+ // (the shell plus anything it spawns) can be killed on timeout.
30
+ const child = spawn(command, {
31
+ shell: true,
32
+ cwd: ctx.cwd,
33
+ detached: true,
34
+ });
35
+ const chunks = [];
36
+ let captured = 0;
37
+ let truncated = false;
38
+ const capture = (buf) => {
39
+ if (truncated)
40
+ return;
41
+ const room = maxOutputBytes - captured;
42
+ if (buf.length <= room) {
43
+ chunks.push(buf);
44
+ captured += buf.length;
45
+ }
46
+ else {
47
+ if (room > 0) {
48
+ chunks.push(buf.subarray(0, room));
49
+ captured += room;
50
+ }
51
+ truncated = true;
52
+ }
53
+ };
54
+ child.stdout?.on("data", capture);
55
+ child.stderr?.on("data", capture);
56
+ // A single guard so the timeout-kill and the natural close can't both fire.
57
+ let settled = false;
58
+ const timer = setTimeout(() => {
59
+ if (settled)
60
+ return;
61
+ settled = true;
62
+ killTree(child.pid);
63
+ resolve({ ok: false, error: `timed out after ${timeoutMs}ms` });
64
+ }, timeoutMs);
65
+ child.on("error", (err) => {
66
+ if (settled)
67
+ return;
68
+ settled = true;
69
+ clearTimeout(timer);
70
+ resolve({ ok: false, error: err.message });
71
+ });
72
+ child.on("close", (code, signal) => {
73
+ if (settled)
74
+ return;
75
+ settled = true;
76
+ clearTimeout(timer);
77
+ const exit = code ?? signal ?? "unknown";
78
+ let output = `exit code ${exit}\n${Buffer.concat(chunks).toString("utf8")}`;
79
+ if (truncated) {
80
+ output += `\n… [output truncated at ${maxOutputBytes} bytes]`;
81
+ }
82
+ resolve({ ok: true, output });
83
+ });
84
+ });
85
+ }
86
+ /**
87
+ * Kill the command's entire process group. POSIX-specific (negative pid targets
88
+ * the group); fine on our darwin/linux targets. Swallows errors — the process
89
+ * may already be gone.
90
+ */
91
+ function killTree(pid) {
92
+ if (pid === undefined)
93
+ return;
94
+ try {
95
+ process.kill(-pid, "SIGKILL");
96
+ }
97
+ catch {
98
+ // Already exited, or no group — nothing to kill.
99
+ }
100
+ }
@@ -0,0 +1,113 @@
1
+ import type { z, ZodTypeAny } from "zod";
2
+ import type { CruxyConfig } from "../config/index.js";
3
+ import type { logger } from "../utils/logger.js";
4
+ /** The leveled logger instance shared across the CLI. */
5
+ type Logger = typeof logger;
6
+ /**
7
+ * The outcome of a tool run. Tools never throw across this boundary — failures
8
+ * are reported as `{ ok: false }` so the agent loop can feed the error back to
9
+ * the model instead of crashing.
10
+ */
11
+ export type ToolResult = {
12
+ ok: true;
13
+ output: string;
14
+ } | {
15
+ ok: false;
16
+ error: string;
17
+ };
18
+ /**
19
+ * One file's worth of change inside an `apply_patch` preview. Paths are
20
+ * project-relative (the tool relativizes them for display).
21
+ */
22
+ export type PatchFilePreview = {
23
+ op: "update";
24
+ path: string;
25
+ hunks: {
26
+ oldStr: string;
27
+ newStr: string;
28
+ }[];
29
+ } | {
30
+ op: "create";
31
+ path: string;
32
+ lines: string[];
33
+ omittedLines: number;
34
+ } | {
35
+ op: "delete";
36
+ path: string;
37
+ };
38
+ /**
39
+ * An optional preview of exactly what a mutating action will change, threaded
40
+ * from the tool into `ctx.approve` so the prompt can show it before the user
41
+ * decides. The tool fills this in from data it has already computed.
42
+ */
43
+ export type ActionPreview =
44
+ /** The exact strings `edit_file` is about to swap. */
45
+ {
46
+ type: "edit";
47
+ oldStr: string;
48
+ newStr: string;
49
+ }
50
+ /**
51
+ * What `write_file` will write: whether the path already exists (overwrite vs
52
+ * create) and the first lines of the new content, pre-capped by the tool.
53
+ */
54
+ | {
55
+ type: "write";
56
+ exists: boolean;
57
+ lines: string[];
58
+ omittedLines: number;
59
+ }
60
+ /** The full set of file changes `apply_patch` will make, for a combined diff. */
61
+ | {
62
+ type: "patch";
63
+ files: PatchFilePreview[];
64
+ };
65
+ /**
66
+ * A side-effecting action a tool wants to take, passed to `ctx.approve`. The
67
+ * `kind` set grows as more mutating tools land; it drives the permission prompt.
68
+ */
69
+ export interface ApproveAction {
70
+ /** The category of side effect being requested. */
71
+ kind: "write" | "edit" | "shell" | "patch";
72
+ /** Absolute resolved path the action targets (write/edit). */
73
+ path?: string;
74
+ /** The command to run (shell). */
75
+ command?: string;
76
+ /** Exact-change preview rendered above the prompt (write/edit/patch). */
77
+ preview?: ActionPreview;
78
+ }
79
+ /**
80
+ * Ambient capabilities handed to every tool at execution time. Tools run on the
81
+ * user's machine, so this is the only sanctioned door to the filesystem root
82
+ * (`cwd`), configuration, logging, and the permission gate.
83
+ */
84
+ export interface ToolContext {
85
+ /** Absolute path the tool should treat as its working root. */
86
+ cwd: string;
87
+ /** Fully-resolved CLI configuration. */
88
+ config: CruxyConfig;
89
+ /** Shared leveled logger (diagnostics to stderr, `print` to stdout). */
90
+ logger: Logger;
91
+ /**
92
+ * Permission gate for side-effecting actions. Returns `true` when the action
93
+ * is allowed. Backed by the interactive `Approver` (see agent/approval.ts);
94
+ * tests may inject their own.
95
+ */
96
+ approve(action: ApproveAction): Promise<boolean>;
97
+ }
98
+ /**
99
+ * The one interface every tool implements. `parameters` is a zod schema; it both
100
+ * validates the model's arguments and (via the registry) becomes the wire-format
101
+ * JSON Schema advertised to the provider.
102
+ */
103
+ export interface Tool<Schema extends ZodTypeAny = ZodTypeAny> {
104
+ /** Unique, snake_case identifier the model uses to call the tool. */
105
+ name: string;
106
+ /** One-line description shown to the model. */
107
+ description: string;
108
+ /** Zod schema for the tool's input arguments. */
109
+ parameters: Schema;
110
+ /** Run the tool against validated `input` and the ambient `ctx`. */
111
+ execute(input: z.infer<Schema>, ctx: ToolContext): Promise<ToolResult>;
112
+ }
113
+ export {};
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Branch name plus the raw `git status --porcelain` text for `cwd`, or `null`
3
+ * when it isn't a git repository (or git is unavailable). Backs the `git_status`
4
+ * tool, which surfaces the porcelain output to the model.
5
+ */
6
+ export declare function getGitStatus(cwd: string): {
7
+ branch: string;
8
+ status: string;
9
+ } | null;
10
+ /**
11
+ * Compact git context for the system prompt: current branch and whether the
12
+ * working tree has uncommitted changes. `null` when not a repo / git missing.
13
+ */
14
+ export declare function getGitInfo(cwd: string): {
15
+ branch: string;
16
+ dirty: boolean;
17
+ } | null;
@@ -0,0 +1,43 @@
1
+ import { spawnSync } from "node:child_process";
2
+ /** Hard ceiling on a git invocation; a hung git must never stall startup. */
3
+ const GIT_TIMEOUT_MS = 5000;
4
+ /**
5
+ * Run `git <args>` in `cwd` and return trimmed stdout, or `null` on any failure
6
+ * — git missing (ENOENT), not a repo (non-zero exit), or a timeout. Never throws.
7
+ */
8
+ function runGit(args, cwd) {
9
+ const res = spawnSync("git", args, {
10
+ cwd,
11
+ encoding: "utf8",
12
+ timeout: GIT_TIMEOUT_MS,
13
+ windowsHide: true,
14
+ });
15
+ if (res.error || res.status !== 0 || typeof res.stdout !== "string") {
16
+ return null;
17
+ }
18
+ return res.stdout;
19
+ }
20
+ /**
21
+ * Branch name plus the raw `git status --porcelain` text for `cwd`, or `null`
22
+ * when it isn't a git repository (or git is unavailable). Backs the `git_status`
23
+ * tool, which surfaces the porcelain output to the model.
24
+ */
25
+ export function getGitStatus(cwd) {
26
+ const branch = runGit(["rev-parse", "--abbrev-ref", "HEAD"], cwd);
27
+ if (branch === null)
28
+ return null;
29
+ const status = runGit(["status", "--porcelain"], cwd);
30
+ if (status === null)
31
+ return null;
32
+ return { branch: branch.trim(), status };
33
+ }
34
+ /**
35
+ * Compact git context for the system prompt: current branch and whether the
36
+ * working tree has uncommitted changes. `null` when not a repo / git missing.
37
+ */
38
+ export function getGitInfo(cwd) {
39
+ const info = getGitStatus(cwd);
40
+ if (info === null)
41
+ return null;
42
+ return { branch: info.branch, dirty: info.status.trim().length > 0 };
43
+ }
@@ -0,0 +1,16 @@
1
+ export declare const LOG_LEVELS: readonly ["debug", "info", "warn", "error", "silent"];
2
+ export type LogLevel = (typeof LOG_LEVELS)[number];
3
+ declare class Logger {
4
+ private level;
5
+ setLevel(level: LogLevel): void;
6
+ getLevel(): LogLevel;
7
+ private enabled;
8
+ debug(...args: unknown[]): void;
9
+ info(...args: unknown[]): void;
10
+ warn(...args: unknown[]): void;
11
+ error(...args: unknown[]): void;
12
+ /** Primary user-facing output — always written to stdout. */
13
+ print(...args: unknown[]): void;
14
+ }
15
+ export declare const logger: Logger;
16
+ export {};
@@ -0,0 +1,42 @@
1
+ import pc from "picocolors";
2
+ export const LOG_LEVELS = ["debug", "info", "warn", "error", "silent"];
3
+ const WEIGHT = {
4
+ debug: 10,
5
+ info: 20,
6
+ warn: 30,
7
+ error: 40,
8
+ silent: 100,
9
+ };
10
+ class Logger {
11
+ level = "info";
12
+ setLevel(level) {
13
+ this.level = level;
14
+ }
15
+ getLevel() {
16
+ return this.level;
17
+ }
18
+ enabled(level) {
19
+ return WEIGHT[level] >= WEIGHT[this.level];
20
+ }
21
+ debug(...args) {
22
+ if (this.enabled("debug"))
23
+ console.error(pc.dim("debug"), ...args);
24
+ }
25
+ info(...args) {
26
+ if (this.enabled("info"))
27
+ console.error(...args);
28
+ }
29
+ warn(...args) {
30
+ if (this.enabled("warn"))
31
+ console.error(pc.yellow("warn"), ...args);
32
+ }
33
+ error(...args) {
34
+ if (this.enabled("error"))
35
+ console.error(pc.red("error"), ...args);
36
+ }
37
+ /** Primary user-facing output — always written to stdout. */
38
+ print(...args) {
39
+ console.log(...args);
40
+ }
41
+ }
42
+ export const logger = new Logger();
package/package.json ADDED
@@ -0,0 +1,52 @@
1
+ {
2
+ "name": "@cruxy/cli",
3
+ "version": "0.1.0",
4
+ "description": "an agentic coding CLI",
5
+ "type": "module",
6
+ "bin": {
7
+ "cruxy": "./dist/index.js"
8
+ },
9
+ "files": [
10
+ "dist"
11
+ ],
12
+ "engines": {
13
+ "node": ">=20"
14
+ },
15
+ "keywords": [
16
+ "cli",
17
+ "agent",
18
+ "coding",
19
+ "ai"
20
+ ],
21
+ "license": "MIT",
22
+ "publishConfig": {
23
+ "access": "public"
24
+ },
25
+ "repository": {
26
+ "type": "git",
27
+ "url": "git+https://github.com/cruxy-ai/cli.git",
28
+ "directory": "packages/cli"
29
+ },
30
+ "dependencies": {
31
+ "commander": "^12.1.0",
32
+ "picocolors": "^1.1.1",
33
+ "tinyglobby": "^0.2.10",
34
+ "zod": "^3.23.8",
35
+ "zod-to-json-schema": "^3.23.5",
36
+ "@cruxy/sdk": "0.1.0"
37
+ },
38
+ "devDependencies": {
39
+ "@types/node": "^22.10.0",
40
+ "tsx": "^4.19.2",
41
+ "typescript": "^5.7.2",
42
+ "vitest": "^2.1.8"
43
+ },
44
+ "scripts": {
45
+ "build": "tsc -p tsconfig.json",
46
+ "dev": "tsx src/index.ts",
47
+ "start": "node dist/index.js",
48
+ "typecheck": "tsc --noEmit",
49
+ "clean": "rm -rf dist",
50
+ "test": "vitest run"
51
+ }
52
+ }