crack-code 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.
package/src/repl.ts ADDED
@@ -0,0 +1,284 @@
1
+ import * as readline from "node:readline";
2
+ import type { ModelMessage, LanguageModel } from "ai";
3
+ import type { Config } from "./config.js";
4
+ import type { ToolRegistry } from "./tools/registry.js";
5
+ import type { PermissionManager } from "./permissions/index.js";
6
+ import { runAgent, type TokenUsage } from "./agent.js";
7
+ import * as ui from "./ui/renderer.js";
8
+
9
+ // Types
10
+
11
+ interface ReplContext {
12
+ model: LanguageModel;
13
+ config: Config;
14
+ tools: ToolRegistry;
15
+ permissions: PermissionManager;
16
+ messages: ModelMessage[];
17
+ totalUsage: TokenUsage;
18
+ }
19
+
20
+ // Slash Commands
21
+
22
+ interface SlashCommand {
23
+ description: string;
24
+ handler: (ctx: ReplContext, args: string) => void | Promise<void>;
25
+ }
26
+
27
+ const commands: Record<string, SlashCommand> = {
28
+ "/help": {
29
+ description: "Show available commands",
30
+ handler: () => {
31
+ console.log();
32
+ for (const [name, cmd] of Object.entries(commands)) {
33
+ console.log(` \x1b[36m${name.padEnd(14)}\x1b[0m ${cmd.description}`);
34
+ }
35
+ console.log();
36
+ },
37
+ },
38
+
39
+ "/exit": {
40
+ description: "Exit Crack Code",
41
+ handler: () => {
42
+ ui.info("Goodbye.");
43
+ process.exit(0);
44
+ },
45
+ },
46
+
47
+ "/clear": {
48
+ description: "Clear conversation history",
49
+ handler: (ctx) => {
50
+ ctx.messages = [];
51
+ ctx.totalUsage = { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
52
+ ctx.permissions.clearSession();
53
+ ui.success("Conversation cleared.");
54
+ },
55
+ },
56
+
57
+ "/usage": {
58
+ description: "Show token usage for this session",
59
+ handler: (ctx) => {
60
+ console.log();
61
+ ui.dim(` Input tokens: ${ctx.totalUsage.inputTokens}`);
62
+ ui.dim(` Output tokens: ${ctx.totalUsage.outputTokens}`);
63
+ ui.dim(` Total tokens: ${ctx.totalUsage.totalTokens}`);
64
+ ui.dim(` Messages in context: ${ctx.messages.length}`);
65
+ console.log();
66
+ },
67
+ },
68
+
69
+ "/mode": {
70
+ description: "Toggle read-only ↔ edit mode",
71
+ handler: (ctx) => {
72
+ ctx.config.allowEdits = !ctx.config.allowEdits;
73
+ if (ctx.config.allowEdits) {
74
+ ui.warn("Edit mode enabled. The AI can now modify files.");
75
+ } else {
76
+ ui.success("Read-only mode. The AI can only read and analyze.");
77
+ }
78
+ },
79
+ },
80
+
81
+ "/model": {
82
+ description: "Show current model and provider",
83
+ handler: (ctx) => {
84
+ console.log();
85
+ ui.dim(` Provider: ${ctx.config.provider}`);
86
+ ui.dim(` Model: ${ctx.config.model}`);
87
+ console.log();
88
+ },
89
+ },
90
+
91
+ "/policy": {
92
+ description: "Show or set permission policy (ask/skip/allow-all/deny-all)",
93
+ handler: (ctx, args) => {
94
+ if (!args) {
95
+ ui.dim(` Current policy: ${ctx.permissions.getPolicy()}`);
96
+ return;
97
+ }
98
+ const valid = ["ask", "skip", "allow-all", "deny-all"];
99
+ if (!valid.includes(args)) {
100
+ ui.error(`Invalid policy. Use: ${valid.join(", ")}`);
101
+ return;
102
+ }
103
+ ctx.permissions.setPolicy(args as any);
104
+ ui.success(`Permission policy set to: ${args}`);
105
+ },
106
+ },
107
+
108
+ "/compact": {
109
+ description: "Summarize conversation to reduce context size",
110
+ handler: async (ctx) => {
111
+ const count = ctx.messages.length;
112
+ if (count <= 2) {
113
+ ui.info("Conversation too short to compact.");
114
+ return;
115
+ }
116
+
117
+ const loading = ui.spinner("Compacting conversation...");
118
+
119
+ // Keep the last exchange, summarize everything before it
120
+ const toSummarize = ctx.messages.slice(0, -2);
121
+ const recent = ctx.messages.slice(-2);
122
+
123
+ const summaryText = toSummarize
124
+ .map((m) => {
125
+ const content =
126
+ typeof m.content === "string"
127
+ ? m.content
128
+ : JSON.stringify(m.content);
129
+ return `[${m.role}]: ${content.slice(0, 200)}`;
130
+ })
131
+ .join("\n");
132
+
133
+ ctx.messages = [
134
+ {
135
+ role: "user",
136
+ content: `[Previous conversation summary — ${count - 2} messages]\n${summaryText.slice(0, 2000)}`,
137
+ },
138
+ ...recent,
139
+ ];
140
+
141
+ loading.stop();
142
+ ui.success(
143
+ `Compacted ${count} messages → ${ctx.messages.length} messages.`,
144
+ );
145
+ },
146
+ },
147
+ };
148
+
149
+ // Input Handling
150
+
151
+ function createInput(): {
152
+ readLine: () => Promise<string>;
153
+ close: () => void;
154
+ } {
155
+ const rl = readline.createInterface({
156
+ input: process.stdin,
157
+ output: process.stdout,
158
+ terminal: true,
159
+ });
160
+
161
+ // Handle Ctrl+C gracefully
162
+ rl.on("SIGINT", () => {
163
+ console.log();
164
+ ui.info("Goodbye.");
165
+ process.exit(0);
166
+ });
167
+
168
+ return {
169
+ readLine: () =>
170
+ new Promise<string>((resolve) => {
171
+ ui.userPrompt();
172
+ rl.once("line", (line) => resolve(line.trim()));
173
+ }),
174
+ close: () => rl.close(),
175
+ };
176
+ }
177
+
178
+ // Main REPL Loop
179
+
180
+ export async function startRepl(
181
+ model: LanguageModel,
182
+ config: Config,
183
+ tools: ToolRegistry,
184
+ permissions: PermissionManager,
185
+ ): Promise<void> {
186
+ const ctx: ReplContext = {
187
+ model,
188
+ config,
189
+ tools,
190
+ permissions,
191
+ messages: [],
192
+ totalUsage: { inputTokens: 0, outputTokens: 0, totalTokens: 0 },
193
+ };
194
+
195
+ const mode = config.allowEdits ? "edits enabled" : "read-only";
196
+ ui.banner(config.model, mode);
197
+
198
+ const input = createInput();
199
+
200
+ while (true) {
201
+ const line = await input.readLine();
202
+
203
+ if (!line) continue;
204
+
205
+ // Slash command
206
+ const spaceIdx = line.indexOf(" ");
207
+ const cmdName = spaceIdx === -1 ? line : line.slice(0, spaceIdx);
208
+ const cmdArgs = spaceIdx === -1 ? "" : line.slice(spaceIdx + 1).trim();
209
+
210
+ if (cmdName.startsWith("/")) {
211
+ const command = commands[cmdName];
212
+ if (command) {
213
+ await command.handler(ctx, cmdArgs);
214
+ } else {
215
+ ui.error(
216
+ `Unknown command: ${cmdName}. Type /help for available commands.`,
217
+ );
218
+ }
219
+ continue;
220
+ }
221
+
222
+ // Send to agent
223
+ ctx.messages.push({ role: "user", content: line });
224
+ ui.newline();
225
+
226
+ try {
227
+ const loading = ui.spinner("Thinking...");
228
+ let firstToken = true;
229
+
230
+ ctx.messages = await runAgent(
231
+ ctx.messages,
232
+ {
233
+ model: ctx.model,
234
+ tools: ctx.tools,
235
+ permissions: ctx.permissions,
236
+ systemPrompt: ctx.config.systemPrompt,
237
+ maxSteps: ctx.config.maxSteps,
238
+ maxTokens: ctx.config.maxTokens,
239
+ },
240
+ {
241
+ onText: (delta) => {
242
+ if (firstToken) {
243
+ loading.stop();
244
+ firstToken = false;
245
+ }
246
+ ui.streamText(delta);
247
+ },
248
+
249
+ onToolStart: (name, args) => {
250
+ if (firstToken) {
251
+ loading.stop();
252
+ firstToken = false;
253
+ }
254
+ ui.toolStart(name, args);
255
+ },
256
+
257
+ onToolEnd: (name, result) => {
258
+ ui.toolEnd(name, result);
259
+ },
260
+
261
+ onUsage: (usage) => {
262
+ ctx.totalUsage.inputTokens += usage.inputTokens;
263
+ ctx.totalUsage.outputTokens += usage.outputTokens;
264
+ ctx.totalUsage.totalTokens += usage.totalTokens;
265
+ },
266
+
267
+ onError: (err) => {
268
+ loading.stop();
269
+ ui.error(err);
270
+ },
271
+ },
272
+ );
273
+
274
+ // If spinner never stopped (empty response), stop it now
275
+ if (firstToken) loading.stop();
276
+
277
+ ui.newline();
278
+ } catch (e: any) {
279
+ // Agent threw — keep previous messages intact
280
+ ctx.messages.pop(); // remove the user message that caused the error
281
+ ui.error(e.message);
282
+ }
283
+ }
284
+ }
@@ -0,0 +1,77 @@
1
+ import { z } from "zod";
2
+ import { resolve, relative } from "node:path";
3
+ import type { ToolDef } from "./registry.js";
4
+
5
+ const MAX_SIZE = 256 * 1024; // 256 KB
6
+
7
+ const schema = z.object({
8
+ path: z.string().describe("Relative or absolute path to the file"),
9
+ start_line: z.number().optional().describe("First line to read (1-based)"),
10
+ end_line: z
11
+ .number()
12
+ .optional()
13
+ .describe("Last line to read (1-based, inclusive)"),
14
+ });
15
+
16
+ export const readFileTool: ToolDef<typeof schema> = {
17
+ name: "read_file",
18
+ description:
19
+ "Read the contents of a file. Returns numbered lines. " +
20
+ "Use start_line/end_line to read a specific range. " +
21
+ "Files larger than 256 KB are truncated.",
22
+ inputSchema: schema,
23
+ requiresApproval: false,
24
+
25
+ async execute({ path: filePath, start_line, end_line }) {
26
+ const cwd = process.cwd();
27
+ const abs = resolve(cwd, filePath);
28
+ const rel = relative(cwd, abs);
29
+
30
+ // Block path traversal outside cwd
31
+ if (rel.startsWith("..")) {
32
+ return `Error: path "${filePath}" is outside the working directory.`;
33
+ }
34
+
35
+ const file = Bun.file(abs);
36
+ if (!(await file.exists())) {
37
+ return `Error: file not found — ${rel}`;
38
+ }
39
+
40
+ const size = file.size;
41
+ if (size > MAX_SIZE) {
42
+ const partial = await file.text();
43
+ const truncated = partial.slice(0, MAX_SIZE);
44
+ const lines = truncated.split("\n");
45
+ return (
46
+ `⚠ File truncated (${(size / 1024).toFixed(0)} KB > 256 KB limit). Showing first ${lines.length} lines.\n\n` +
47
+ numberLines(lines, 1)
48
+ );
49
+ }
50
+
51
+ const content = await file.text();
52
+ let lines = content.split("\n");
53
+
54
+ // Apply line range
55
+ const start = start_line ? Math.max(1, start_line) : 1;
56
+ const end = end_line ? Math.min(lines.length, end_line) : lines.length;
57
+ lines = lines.slice(start - 1, end);
58
+
59
+ if (lines.length === 0) {
60
+ return `File is empty — ${rel}`;
61
+ }
62
+
63
+ const header =
64
+ start_line || end_line
65
+ ? `${rel} (lines ${start}–${end})`
66
+ : `${rel} (${lines.length} lines)`;
67
+
68
+ return `${header}\n\n${numberLines(lines, start)}`;
69
+ },
70
+ };
71
+
72
+ function numberLines(lines: string[], startAt: number): string {
73
+ const width = String(startAt + lines.length - 1).length;
74
+ return lines
75
+ .map((line, i) => `${String(startAt + i).padStart(width)} │ ${line}`)
76
+ .join("\n");
77
+ }
@@ -0,0 +1,42 @@
1
+ import { z } from "zod";
2
+ import { resolve, relative, dirname } from "node:path";
3
+ import { mkdir } from "node:fs/promises";
4
+ import type { ToolDef } from "./registry.js";
5
+
6
+ const schema = z.object({
7
+ path: z.string().describe("Relative or absolute path to the file"),
8
+ content: z.string().describe("The full content to write to the file"),
9
+ });
10
+
11
+ export const writeFileTool: ToolDef<typeof schema> = {
12
+ name: "write_file",
13
+ description:
14
+ "Write content to a file. Creates the file if it doesn't exist, " +
15
+ "overwrites if it does. Parent directories are created automatically. " +
16
+ "Always requires user approval.",
17
+ inputSchema: schema,
18
+ requiresApproval: true,
19
+
20
+ async execute({ path: filePath, content }) {
21
+ const cwd = process.cwd();
22
+ const abs = resolve(cwd, filePath);
23
+ const rel = relative(cwd, abs);
24
+
25
+ // Block path traversal outside cwd
26
+ if (rel.startsWith("..")) {
27
+ return `Error: path "${filePath}" is outside the working directory.`;
28
+ }
29
+
30
+ // Ensure parent directories exist
31
+ await mkdir(dirname(abs), { recursive: true });
32
+
33
+ const existed = await Bun.file(abs).exists();
34
+ await Bun.write(abs, content);
35
+
36
+ const lines = content.split("\n").length;
37
+ const bytes = Buffer.byteLength(content, "utf-8");
38
+ const action = existed ? "Updated" : "Created";
39
+
40
+ return `${action} ${rel} (${lines} lines, ${bytes} bytes)`;
41
+ },
42
+ };
@@ -0,0 +1,84 @@
1
+ import { z } from "zod";
2
+ import type { ToolDef } from "./registry.js";
3
+
4
+ const MAX_RESULTS = 500;
5
+
6
+ const schema = z.object({
7
+ pattern: z
8
+ .string()
9
+ .describe('Glob pattern to match (e.g. "**/*.ts", "src/**/*.js")'),
10
+ ignore: z
11
+ .array(z.string())
12
+ .optional()
13
+ .describe("Additional glob patterns to ignore"),
14
+ });
15
+
16
+ export const listFilesTool: ToolDef<typeof schema> = {
17
+ name: "list_files",
18
+ description:
19
+ "List files matching a glob pattern in the working directory. " +
20
+ "Common ignore patterns (node_modules, .git, dist, etc.) are excluded by default. " +
21
+ "Returns up to 500 matching paths sorted alphabetically.",
22
+ inputSchema: schema,
23
+ requiresApproval: false,
24
+
25
+ async execute({ pattern, ignore }) {
26
+ const cwd = process.cwd();
27
+
28
+ const defaultIgnore = [
29
+ "node_modules/**",
30
+ ".git/**",
31
+ "dist/**",
32
+ "build/**",
33
+ "coverage/**",
34
+ ".next/**",
35
+ "__pycache__/**",
36
+ "vendor/**",
37
+ "target/**",
38
+ ];
39
+
40
+ const ignorePatterns = [...defaultIgnore, ...(ignore ?? [])];
41
+
42
+ const glob = new Bun.Glob(pattern);
43
+ const matches: string[] = [];
44
+
45
+ for await (const path of glob.scan({ cwd, dot: false })) {
46
+ if (shouldIgnore(path, ignorePatterns)) continue;
47
+ matches.push(path);
48
+ if (matches.length >= MAX_RESULTS) break;
49
+ }
50
+
51
+ matches.sort();
52
+
53
+ if (matches.length === 0) {
54
+ return `No files matched pattern "${pattern}"`;
55
+ }
56
+
57
+ const header =
58
+ matches.length >= MAX_RESULTS
59
+ ? `Found ${MAX_RESULTS}+ files (showing first ${MAX_RESULTS}):`
60
+ : `Found ${matches.length} file${matches.length === 1 ? "" : "s"}:`;
61
+
62
+ return `${header}\n\n${matches.join("\n")}`;
63
+ },
64
+ };
65
+
66
+ function shouldIgnore(path: string, patterns: string[]): boolean {
67
+ for (const pattern of patterns) {
68
+ // Simple prefix match for directory globs like "node_modules/**"
69
+ if (pattern.endsWith("/**")) {
70
+ const dir = pattern.slice(0, -3);
71
+ if (path === dir || path.startsWith(dir + "/")) return true;
72
+ }
73
+
74
+ // Simple extension match for patterns like "*.lock"
75
+ if (pattern.startsWith("*.")) {
76
+ const ext = pattern.slice(1);
77
+ if (path.endsWith(ext)) return true;
78
+ }
79
+
80
+ // Exact match
81
+ if (path === pattern) return true;
82
+ }
83
+ return false;
84
+ }
@@ -0,0 +1,63 @@
1
+ import type { ToolSet } from "ai";
2
+ import type { z } from "zod";
3
+ import type { PermissionManager } from "../permissions/index.js";
4
+ import * as ui from "../ui/renderer.js";
5
+
6
+ // Each tool module exports one of these
7
+ export interface ToolDef<
8
+ TSchema extends z.ZodObject<z.ZodRawShape> = z.ZodObject<z.ZodRawShape>,
9
+ > {
10
+ name: string;
11
+ description: string;
12
+ inputSchema: TSchema;
13
+ requiresApproval: boolean;
14
+ execute: (input: z.infer<TSchema>) => Promise<string>;
15
+ }
16
+
17
+ export class ToolRegistry {
18
+ private defs: ToolDef[] = [];
19
+
20
+ register(def: ToolDef): void {
21
+ this.defs.push(def);
22
+ }
23
+
24
+ registerAll(...defs: ToolDef[]): void {
25
+ for (const def of defs) this.defs.push(def);
26
+ }
27
+
28
+ // Convert to AI SDK ToolSet with permission gating and UI hooks
29
+ toAISDKTools(permissions: PermissionManager): ToolSet {
30
+ const tools: ToolSet = {};
31
+
32
+ for (const def of this.defs) {
33
+ tools[def.name] = {
34
+ description: def.description,
35
+ inputSchema: def.inputSchema,
36
+ execute: async (input: Record<string, unknown>) => {
37
+ ui.toolStart(def.name, input);
38
+
39
+ if (def.requiresApproval) {
40
+ const allowed = await permissions.check(def.name, input);
41
+ if (!allowed) return "⛔ Tool call denied by user.";
42
+ }
43
+
44
+ try {
45
+ const result = await def.execute(input);
46
+ ui.toolEnd(def.name, result);
47
+ return result;
48
+ } catch (err: any) {
49
+ const msg = `Error: ${err.message ?? err}`;
50
+ ui.toolEnd(def.name, msg);
51
+ return msg;
52
+ }
53
+ },
54
+ };
55
+ }
56
+
57
+ return tools;
58
+ }
59
+
60
+ getNames(): string[] {
61
+ return this.defs.map((d) => d.name);
62
+ }
63
+ }
@@ -0,0 +1,70 @@
1
+ import { z } from "zod";
2
+ import type { ToolDef } from "./registry.js";
3
+
4
+ const DEFAULT_TIMEOUT = 30_000; // 30 seconds
5
+ const MAX_OUTPUT = 128 * 1024; // 128 KB
6
+
7
+ const schema = z.object({
8
+ command: z.string().describe("The shell command to execute"),
9
+ timeout_ms: z
10
+ .number()
11
+ .optional()
12
+ .describe("Max runtime in milliseconds (default 30000)"),
13
+ });
14
+
15
+ export const runCommandTool: ToolDef<typeof schema> = {
16
+ name: "run_command",
17
+ description:
18
+ "Execute a shell command in the working directory. " +
19
+ "Output is captured from stdout and stderr. " +
20
+ "Commands are killed after the timeout (default 30s). " +
21
+ "Always requires user approval.",
22
+ inputSchema: schema,
23
+ requiresApproval: true,
24
+
25
+ async execute({ command, timeout_ms }) {
26
+ const timeout = timeout_ms ?? DEFAULT_TIMEOUT;
27
+ const cwd = process.cwd();
28
+
29
+ try {
30
+ const proc = Bun.spawn(["sh", "-c", command], {
31
+ cwd,
32
+ stdout: "pipe",
33
+ stderr: "pipe",
34
+ env: process.env,
35
+ });
36
+
37
+ const timer = setTimeout(() => proc.kill(), timeout);
38
+
39
+ const [stdoutBuf, stderrBuf] = await Promise.all([
40
+ new Response(proc.stdout).arrayBuffer(),
41
+ new Response(proc.stderr).arrayBuffer(),
42
+ ]);
43
+
44
+ clearTimeout(timer);
45
+
46
+ const code = proc.exitCode ?? (await proc.exited);
47
+ let stdout = new TextDecoder().decode(stdoutBuf);
48
+ let stderr = new TextDecoder().decode(stderrBuf);
49
+
50
+ // Truncate if too large
51
+ if (stdout.length > MAX_OUTPUT) {
52
+ stdout = stdout.slice(0, MAX_OUTPUT) + "\n… (stdout truncated)";
53
+ }
54
+ if (stderr.length > MAX_OUTPUT) {
55
+ stderr = stderr.slice(0, MAX_OUTPUT) + "\n… (stderr truncated)";
56
+ }
57
+
58
+ const parts: string[] = [`Exit code: ${code}`];
59
+ if (stdout.trim()) parts.push(`stdout:\n${stdout.trim()}`);
60
+ if (stderr.trim()) parts.push(`stderr:\n${stderr.trim()}`);
61
+
62
+ return parts.join("\n\n");
63
+ } catch (err: any) {
64
+ if (err.message?.includes("kill")) {
65
+ return `Error: command timed out after ${timeout}ms — "${command}"`;
66
+ }
67
+ return `Error: ${err.message ?? err}`;
68
+ }
69
+ },
70
+ };