@megacode/cli 0.2.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 (78) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +268 -0
  3. package/dist/adapters/accounts.js +34 -0
  4. package/dist/adapters/auth/anthropic.js +36 -0
  5. package/dist/adapters/auth/chatgpt.js +100 -0
  6. package/dist/adapters/auth/oauth.js +68 -0
  7. package/dist/adapters/auth/openrouter.js +27 -0
  8. package/dist/adapters/auth/store.js +25 -0
  9. package/dist/adapters/git/worktree.js +113 -0
  10. package/dist/adapters/mcp/config.js +59 -0
  11. package/dist/adapters/mcp/manager.js +165 -0
  12. package/dist/adapters/mcp/results.js +21 -0
  13. package/dist/adapters/mcp/transport.js +19 -0
  14. package/dist/adapters/project.js +15 -0
  15. package/dist/adapters/providers/anthropic.js +103 -0
  16. package/dist/adapters/providers/catalog.js +80 -0
  17. package/dist/adapters/providers/chatgpt.js +117 -0
  18. package/dist/adapters/providers/credentials.js +55 -0
  19. package/dist/adapters/providers/gemini.js +123 -0
  20. package/dist/adapters/providers/openai.js +128 -0
  21. package/dist/adapters/providers/registry.js +68 -0
  22. package/dist/adapters/providers/shared.js +12 -0
  23. package/dist/adapters/providers/usage.js +95 -0
  24. package/dist/adapters/settings.js +15 -0
  25. package/dist/adapters/skills.js +163 -0
  26. package/dist/adapters/storage.js +30 -0
  27. package/dist/adapters/tools/files.js +113 -0
  28. package/dist/adapters/tools/image.js +63 -0
  29. package/dist/adapters/tools/index.js +26 -0
  30. package/dist/adapters/tools/output.js +56 -0
  31. package/dist/adapters/tools/paths.js +5 -0
  32. package/dist/adapters/tools/questions.js +52 -0
  33. package/dist/adapters/tools/shell.js +73 -0
  34. package/dist/adapters/tools/validation.js +34 -0
  35. package/dist/args.js +30 -0
  36. package/dist/cli.js +111 -0
  37. package/dist/composition.js +18 -0
  38. package/dist/core/agent.js +128 -0
  39. package/dist/core/conversation.js +37 -0
  40. package/dist/core/prompts.js +12 -0
  41. package/dist/core/provider.js +8 -0
  42. package/dist/core/settings.js +15 -0
  43. package/dist/core/suggestion.js +29 -0
  44. package/dist/core/tools.js +10 -0
  45. package/dist/lib/async.js +6 -0
  46. package/dist/ui/App.js +218 -0
  47. package/dist/ui/ApprovalDialog.js +39 -0
  48. package/dist/ui/ConfigMenu.js +106 -0
  49. package/dist/ui/Dialog.js +10 -0
  50. package/dist/ui/DiffLines.js +12 -0
  51. package/dist/ui/EffortPicker.js +8 -0
  52. package/dist/ui/Help.js +21 -0
  53. package/dist/ui/KeyList.js +10 -0
  54. package/dist/ui/LoginDialog.js +154 -0
  55. package/dist/ui/McpMenu.js +138 -0
  56. package/dist/ui/ModelPicker.js +117 -0
  57. package/dist/ui/PromptInput.js +161 -0
  58. package/dist/ui/Questionnaire.js +35 -0
  59. package/dist/ui/Select.js +28 -0
  60. package/dist/ui/Spinner.js +18 -0
  61. package/dist/ui/StatusLine.js +19 -0
  62. package/dist/ui/TextField.js +22 -0
  63. package/dist/ui/Transcript.js +40 -0
  64. package/dist/ui/WorktreeMenu.js +83 -0
  65. package/dist/ui/autocomplete.js +6 -0
  66. package/dist/ui/commands.js +117 -0
  67. package/dist/ui/diff.js +130 -0
  68. package/dist/ui/format.js +138 -0
  69. package/dist/ui/history.js +9 -0
  70. package/dist/ui/hooks/useAgentSession.js +34 -0
  71. package/dist/ui/hooks/usePromptSuggestion.js +23 -0
  72. package/dist/ui/hooks/useStreamedText.js +38 -0
  73. package/dist/ui/hooks/useWorktree.js +30 -0
  74. package/dist/ui/loginFlow.js +37 -0
  75. package/dist/ui/mcpWizard.js +18 -0
  76. package/dist/ui/plain.js +60 -0
  77. package/dist/ui/session.js +168 -0
  78. package/package.json +57 -0
@@ -0,0 +1,163 @@
1
+ import { execFile } from "node:child_process";
2
+ import { cpSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync } from "node:fs";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import { promisify } from "node:util";
6
+ import { parse } from "yaml";
7
+ import { configDir } from "./storage.js";
8
+ const NAME = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
9
+ const ignored = new Set([".git", "node_modules", ".megacode"]);
10
+ const exec = promisify(execFile);
11
+ function readSkill(directory) {
12
+ const file = path.join(directory, "SKILL.md");
13
+ const stat = lstatSync(file);
14
+ if (!stat.isFile() || stat.size > 128 * 1024)
15
+ throw new Error(`Invalid or oversized SKILL.md: ${file}`);
16
+ const text = readFileSync(file, "utf8").replace(/^\uFEFF/, "");
17
+ const match = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/.exec(text);
18
+ if (!match)
19
+ throw new Error(`Missing YAML frontmatter: ${file}`);
20
+ const metadata = parse(match[1], { maxAliasCount: 0 });
21
+ if (!metadata || typeof metadata.name !== "string" || !NAME.test(metadata.name) || metadata.name.length > 64)
22
+ throw new Error(`Skill name must be lowercase letters/digits separated by hyphens (max 64): ${file}`);
23
+ if (typeof metadata.description !== "string" || !metadata.description.trim() || metadata.description.length > 1024)
24
+ throw new Error(`Skill needs a description of 1–1024 characters: ${file}`);
25
+ return { name: metadata.name, description: metadata.description.trim(), file };
26
+ }
27
+ function roots({ cwd = process.cwd(), home }) {
28
+ const global = home ? path.join(home, ".megacode") : configDir();
29
+ return [path.resolve(global, "skills"), path.resolve(cwd, ".megacode", "skills")];
30
+ }
31
+ /** Project skills override global skills with the same name. Broken skills don't prevent startup. */
32
+ export function discoverSkills(options = {}) {
33
+ const skills = new Map();
34
+ const warnings = [];
35
+ for (const root of roots(options)) {
36
+ if (!existsSync(root))
37
+ continue;
38
+ try {
39
+ for (const entry of readdirSync(root, { withFileTypes: true })) {
40
+ if (!entry.isDirectory() || entry.name.startsWith("."))
41
+ continue;
42
+ try {
43
+ const skill = readSkill(path.join(root, entry.name));
44
+ skills.set(skill.name, skill);
45
+ }
46
+ catch (error) {
47
+ warnings.push(String(error));
48
+ }
49
+ }
50
+ }
51
+ catch (error) {
52
+ warnings.push(String(error));
53
+ }
54
+ }
55
+ return { skills: [...skills.values()].sort((a, b) => a.name.localeCompare(b.name)), warnings };
56
+ }
57
+ /** Only HTTPS GitHub repositories (or owner/repo shorthand), never arbitrary git transports. */
58
+ function githubUrl(source) {
59
+ const match = /^(?:https:\/\/github\.com\/)?([\w.-]+)\/([\w.-]+?)(?:\.git)?\/?$/.exec(source);
60
+ if (!match || match[1] === "." || match[1] === ".." || match[2] === "." || match[2] === "..")
61
+ throw new Error("Expected an existing local directory, owner/repo, or https://github.com/owner/repo (no branch/subdirectory URLs).");
62
+ return `https://github.com/${match[1]}/${match[2]}.git`;
63
+ }
64
+ function findSkills(directory) {
65
+ const found = [];
66
+ let visited = 0;
67
+ function walk(dir, depth) {
68
+ if (++visited > 10000 || depth > 20)
69
+ throw new Error("Skill source is too large or deeply nested.");
70
+ if (!lstatSync(dir).isDirectory())
71
+ throw new Error(`Expected a real directory: ${dir}`);
72
+ if (existsSync(path.join(dir, "SKILL.md"))) {
73
+ found.push(dir);
74
+ return;
75
+ }
76
+ for (const entry of readdirSync(dir, { withFileTypes: true }))
77
+ if (entry.isDirectory() && !ignored.has(entry.name))
78
+ walk(path.join(dir, entry.name), depth + 1);
79
+ }
80
+ walk(directory, 0);
81
+ if (!found.length)
82
+ throw new Error("No SKILL.md files found in the source.");
83
+ return found;
84
+ }
85
+ function validateTree(directory) {
86
+ let bytes = 0;
87
+ let files = 0;
88
+ function walk(file, depth) {
89
+ const stat = lstatSync(file);
90
+ if (++files > 10000 || depth > 30 || (bytes += stat.size) > 50 * 1024 * 1024)
91
+ throw new Error("Skill exceeds installation limits (50 MiB / 10000 entries / 30 levels).");
92
+ if (stat.isDirectory()) {
93
+ for (const name of readdirSync(file))
94
+ if (!ignored.has(name))
95
+ walk(path.join(file, name), depth + 1);
96
+ }
97
+ else if (!stat.isFile())
98
+ throw new Error(`Symlinks and special files are not supported: ${file}`);
99
+ }
100
+ walk(directory, 0);
101
+ }
102
+ export async function installSkills(source, options = {}) {
103
+ let temporary;
104
+ try {
105
+ let directory = path.resolve(source);
106
+ if (!existsSync(directory)) {
107
+ const url = githubUrl(source);
108
+ temporary = mkdtempSync(path.join(os.tmpdir(), "megacode-skills-"));
109
+ directory = path.join(temporary, "repo");
110
+ await exec("git", ["-c", "core.hooksPath=/dev/null", "clone", "--depth", "1", "--", url, directory], {
111
+ timeout: 120000, maxBuffer: 1024 * 1024, env: { ...process.env, GIT_TERMINAL_PROMPT: "0" },
112
+ });
113
+ }
114
+ const sources = findSkills(directory);
115
+ const skills = sources.map(readSkill);
116
+ const root = roots(options)[options.global ? 0 : 1];
117
+ const names = new Set();
118
+ for (const [i, skill] of skills.entries()) {
119
+ if (names.has(skill.name))
120
+ throw new Error(`Duplicate skill name: ${skill.name}`);
121
+ names.add(skill.name);
122
+ if (existsSync(path.join(root, skill.name)))
123
+ throw new Error(`Skill already installed: ${skill.name}. Remove its directory before reinstalling.`);
124
+ validateTree(sources[i]);
125
+ }
126
+ mkdirSync(root, { recursive: true });
127
+ const created = [];
128
+ try {
129
+ for (const [i, skill] of skills.entries()) {
130
+ const target = path.join(root, skill.name);
131
+ mkdirSync(target); // Exclusive reservation: never overwrite another installation.
132
+ created.push(target);
133
+ cpSync(sources[i], target, { recursive: true, filter: (file) => !ignored.has(path.basename(file)) });
134
+ }
135
+ }
136
+ catch (error) {
137
+ for (const target of created)
138
+ rmSync(target, { recursive: true, force: true });
139
+ throw error;
140
+ }
141
+ return skills.map((skill) => ({ ...skill, file: path.join(root, skill.name, "SKILL.md") }));
142
+ }
143
+ finally {
144
+ if (temporary)
145
+ rmSync(temporary, { recursive: true, force: true });
146
+ }
147
+ }
148
+ export const SKILLS_HELP = "Usage: skills [list | install <local-directory|owner/repo|GitHub-URL> [--global]]";
149
+ /** Shared by the standalone CLI and /skills. */
150
+ export async function runSkillsCommand(args, options = {}) {
151
+ if (!args.length || (args.length === 1 && args[0] === "list")) {
152
+ const { skills, warnings } = discoverSkills(options);
153
+ return [skills.length ? skills.map((s) => `${s.name}: ${s.description}\n ${s.file}`).join("\n") : "No skills installed.", ...warnings, SKILLS_HELP].join("\n");
154
+ }
155
+ if (args[0] !== "install")
156
+ throw new Error(SKILLS_HELP);
157
+ const global = args.includes("--global");
158
+ const sources = args.slice(1).filter((arg) => arg !== "--global");
159
+ if (sources.length !== 1 || sources[0].startsWith("--"))
160
+ throw new Error(SKILLS_HELP);
161
+ const installed = await installSkills(sources[0], { ...options, global });
162
+ return installed.map((s) => `Installed ${s.name} at ${s.file}`).join("\n");
163
+ }
@@ -0,0 +1,30 @@
1
+ import { chmodSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ let dir = path.join(os.homedir(), ".megacode");
5
+ /** Where megacode persists everything: ~/.megacode unless the entry point chose another folder. */
6
+ export const configDir = () => dir;
7
+ /** Points all persistence at another folder ($MEGACODE_CONFIG_DIR in the CLI, a temporary one in tests). */
8
+ export function setConfigDir(next) {
9
+ dir = next;
10
+ }
11
+ export function readJson(file, fallback) {
12
+ try {
13
+ return JSON.parse(readFileSync(path.join(dir, file), "utf8"));
14
+ }
15
+ catch {
16
+ return fallback;
17
+ }
18
+ }
19
+ export function writeJson(file, data, opts = {}) {
20
+ try {
21
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
22
+ const p = path.join(dir, file);
23
+ writeFileSync(p, JSON.stringify(data, null, 2), { mode: opts.secret ? 0o600 : 0o644 });
24
+ if (opts.secret)
25
+ chmodSync(p, 0o600); // mode is ignored when the file already exists
26
+ }
27
+ catch {
28
+ // persistence is a convenience; ignore write failures
29
+ }
30
+ }
@@ -0,0 +1,113 @@
1
+ import { glob, mkdir, readFile, writeFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { compactOutput, filePage } from "./output.js";
4
+ import { displayPath, resolvePath } from "./paths.js";
5
+ const IGNORE = ["**/node_modules/**", "**/.git/**"];
6
+ const MAX_LISTED = 1000;
7
+ /** File contents, or null if it doesn't exist. */
8
+ const readIfExists = (file) => readFile(file, "utf8").catch((error) => {
9
+ if (error.code === "ENOENT")
10
+ return null;
11
+ throw error;
12
+ });
13
+ /**
14
+ * Asks the user to approve the change, then writes it only if the file is still exactly what was
15
+ * previewed, so edits made while the dialog was open are never overwritten. Null if denied.
16
+ */
17
+ async function applyChange(ctx, p) {
18
+ const change = { file: p.file, before: p.before ?? "", after: p.after, ...(p.before === null ? { created: true } : {}) };
19
+ const title = `${p.action === "write" ? "Write" : "Edit"} ${displayPath(p.file)}`;
20
+ if (!(await ctx.approve({ tool: p.tool, title, change })))
21
+ return null;
22
+ if ((await readIfExists(p.file)) !== p.before)
23
+ throw new Error(`File changed while awaiting approval. Read it again before retrying the ${p.action}.`);
24
+ await mkdir(path.dirname(p.file), { recursive: true });
25
+ await writeFile(p.file, p.after);
26
+ return change;
27
+ }
28
+ export const readFileTool = {
29
+ name: "read_file",
30
+ description: "Read numbered text lines. Large results include a continuation offset.",
31
+ parameters: {
32
+ type: "object",
33
+ properties: {
34
+ path: { type: "string", description: "Relative or absolute path" },
35
+ offset: { type: "integer", minimum: 1, description: "1-based line to start from" },
36
+ limit: { type: "integer", minimum: 1, description: "Max lines (default 200); also bounded by character budget" },
37
+ },
38
+ required: ["path"],
39
+ },
40
+ async run({ path, offset, limit }) {
41
+ return filePage(await readFile(resolvePath(path), "utf8"), offset, limit);
42
+ },
43
+ };
44
+ export const writeFileTool = {
45
+ name: "write_file",
46
+ description: "Create or overwrite a file with the given content. Prefer edit_file for changes to existing files.",
47
+ parameters: {
48
+ type: "object",
49
+ properties: { path: { type: "string" }, content: { type: "string" } },
50
+ required: ["path", "content"],
51
+ },
52
+ async run({ path, content }, ctx) {
53
+ const file = resolvePath(path);
54
+ const before = await readIfExists(file);
55
+ const change = await applyChange(ctx, { tool: "write_file", action: "write", file, before, after: content });
56
+ if (!change)
57
+ return "User denied the write.";
58
+ return { output: `Wrote ${file}`, isError: false, change };
59
+ },
60
+ };
61
+ export const editFile = {
62
+ name: "edit_file",
63
+ description: "Replace an exact string in a file. old_string must match exactly (including whitespace) and be unique unless replace_all is true.",
64
+ parameters: {
65
+ type: "object",
66
+ properties: {
67
+ path: { type: "string" },
68
+ old_string: { type: "string" },
69
+ new_string: { type: "string" },
70
+ replace_all: { type: "boolean" },
71
+ },
72
+ required: ["path", "old_string", "new_string"],
73
+ },
74
+ async run({ path, old_string, new_string, replace_all = false }, ctx) {
75
+ if (old_string === "")
76
+ throw new Error("old_string must not be empty.");
77
+ const file = resolvePath(path);
78
+ const before = await readFile(file, "utf8");
79
+ const matches = before.split(old_string).length - 1;
80
+ if (matches === 0)
81
+ throw new Error("old_string not found in file");
82
+ if (matches > 1 && !replace_all)
83
+ throw new Error(`old_string matches ${matches} times; add context to make it unique or set replace_all`);
84
+ // A replacer function keeps `$&` and friends in new_string literal.
85
+ const after = replace_all ? before.replaceAll(old_string, () => new_string) : before.replace(old_string, () => new_string);
86
+ const change = await applyChange(ctx, { tool: "edit_file", action: "edit", file, before, after });
87
+ if (!change)
88
+ return "User denied the edit.";
89
+ const replaced = replace_all ? matches : 1;
90
+ return { output: `Edited ${file} (${replaced} replacement${replaced === 1 ? "" : "s"})`, isError: false, change };
91
+ },
92
+ };
93
+ export const listFiles = {
94
+ name: "list_files",
95
+ description: "List files matching a glob pattern (e.g. 'src/**/*.ts'). Ignores node_modules and .git.",
96
+ parameters: {
97
+ type: "object",
98
+ properties: { pattern: { type: "string", description: "Glob pattern, default '**/*'" } },
99
+ },
100
+ async run({ pattern = "**/*" }) {
101
+ const files = [];
102
+ let truncated = false;
103
+ for await (const f of glob(pattern, { cwd: process.cwd(), exclude: IGNORE })) {
104
+ if (files.length >= MAX_LISTED) {
105
+ truncated = true;
106
+ break;
107
+ }
108
+ files.push(f);
109
+ }
110
+ const output = files.length ? await compactOutput(files.sort().join("\n")) : "No files found.";
111
+ return output + (truncated ? `\n[Only ${MAX_LISTED} entries listed; narrow the glob pattern]` : "");
112
+ },
113
+ };
@@ -0,0 +1,63 @@
1
+ import { open } from "node:fs/promises";
2
+ import { resolvePath } from "./paths.js";
3
+ const MAX_BYTES = 5 * 1024 * 1024;
4
+ const TOO_LARGE = "Image exceeds 5 MiB; resize it before viewing.";
5
+ const ascii = (data, start, end) => data.toString("ascii", start, end);
6
+ const PNG = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
7
+ /** Magic-byte signatures. The file extension is never trusted. */
8
+ const SIGNATURES = [
9
+ ["image/png", (d) => d.subarray(0, PNG.length).equals(PNG)],
10
+ ["image/jpeg", (d) => d[0] === 0xff && d[1] === 0xd8 && d[2] === 0xff],
11
+ ["image/gif", (d) => ["GIF87a", "GIF89a"].includes(ascii(d, 0, 6))],
12
+ ["image/webp", (d) => ascii(d, 0, 4) === "RIFF" && ascii(d, 8, 12) === "WEBP"],
13
+ ];
14
+ export const detectImageType = (data) => SIGNATURES.find(([, matches]) => matches(data))?.[0];
15
+ /**
16
+ * Reads a regular file of at most `maxBytes`. The size is checked before reading and again while
17
+ * reading, since the file can grow in between; it never reads more than `maxBytes + 1`.
18
+ */
19
+ async function readBounded(file, maxBytes) {
20
+ const handle = await open(file, "r");
21
+ try {
22
+ const info = await handle.stat();
23
+ if (!info.isFile())
24
+ throw new Error("Image path must be a regular file.");
25
+ if (info.size > maxBytes)
26
+ throw new Error(TOO_LARGE);
27
+ const buffer = Buffer.alloc(maxBytes + 1);
28
+ let size = 0;
29
+ while (size < buffer.length) {
30
+ const { bytesRead } = await handle.read(buffer, size, buffer.length - size, null);
31
+ if (bytesRead === 0)
32
+ break;
33
+ size += bytesRead;
34
+ }
35
+ if (size > maxBytes)
36
+ throw new Error(TOO_LARGE);
37
+ return buffer.subarray(0, size);
38
+ }
39
+ finally {
40
+ await handle.close();
41
+ }
42
+ }
43
+ export const viewImage = {
44
+ name: "view_image",
45
+ description: "View a local PNG, JPEG, GIF, or WebP image (up to 5 MiB). Use for screenshots and other visual files; requires a vision-capable model.",
46
+ parameters: {
47
+ type: "object",
48
+ properties: { path: { type: "string", description: "Relative or absolute image path" } },
49
+ required: ["path"],
50
+ },
51
+ async run({ path }) {
52
+ const file = resolvePath(path);
53
+ const data = await readBounded(file, MAX_BYTES);
54
+ const mediaType = detectImageType(data);
55
+ if (!mediaType)
56
+ throw new Error("Unsupported image format. Use PNG, JPEG, GIF, or WebP.");
57
+ return {
58
+ output: `Image: ${file} (${mediaType}, ${data.length} bytes)`,
59
+ isError: false,
60
+ images: [{ mediaType, data: data.toString("base64") }],
61
+ };
62
+ },
63
+ };
@@ -0,0 +1,26 @@
1
+ import { editFile, listFiles, readFileTool, writeFileTool } from "./files.js";
2
+ import { viewImage } from "./image.js";
3
+ import { askQuestions } from "./questions.js";
4
+ import { bash, grep } from "./shell.js";
5
+ import { validateArguments } from "./validation.js";
6
+ const TOOLS = [askQuestions, viewImage, readFileTool, writeFileTool, editFile, bash, listFiles, grep];
7
+ const byName = new Map(TOOLS.map((t) => [t.name, t]));
8
+ /** The tools megacode ships with. Failures come back as error results, never as exceptions. */
9
+ export const builtinTools = {
10
+ specs: () => TOOLS.map(({ name, description, parameters }) => ({ name, description, parameters })),
11
+ has: (name) => byName.has(name),
12
+ async execute(call, ctx) {
13
+ const tool = byName.get(call.name);
14
+ if (!tool)
15
+ return { output: `Unknown tool: ${call.name}`, isError: true };
16
+ try {
17
+ const input = validateArguments(call.input, tool.parameters);
18
+ ctx.signal?.throwIfAborted();
19
+ const result = await tool.run(input, ctx);
20
+ return typeof result === "string" ? { output: result, isError: false } : result;
21
+ }
22
+ catch (e) {
23
+ return { output: `Error: ${e.message}`, isError: true };
24
+ }
25
+ },
26
+ };
@@ -0,0 +1,56 @@
1
+ import { mkdtemp, writeFile } from "node:fs/promises";
2
+ import { tmpdir } from "node:os";
3
+ import path from "node:path";
4
+ import { loadSettings } from "../settings.js";
5
+ /** Character budgets are provider-neutral bounds, not token estimates. */
6
+ export const OUTPUT_CHARS = 12_000;
7
+ export const READ_LINES = 200;
8
+ /** Keep both setup/errors at the start and summaries/failures at the end. */
9
+ export function elideMiddle(text, budget = OUTPUT_CHARS) {
10
+ if (text.length <= budget)
11
+ return text;
12
+ const head = Math.ceil(budget / 2);
13
+ const tail = Math.floor(budget / 2);
14
+ return `${text.slice(0, head)}\n... [${text.length - budget} chars omitted]\n${text.slice(-tail)}`;
15
+ }
16
+ /**
17
+ * Fits output into the "Max tool output" budget (see /config), keeping both ends. The full text goes to a
18
+ * temporary file the model can read, so nothing is lost without filling every later request.
19
+ */
20
+ export async function compactOutput(text, budget = loadSettings().maxToolOutput) {
21
+ if (text.length <= budget)
22
+ return text;
23
+ try {
24
+ const dir = await mkdtemp(path.join(tmpdir(), "megacode-output-"));
25
+ const file = path.join(dir, "output.txt");
26
+ await writeFile(file, text, { mode: 0o600 });
27
+ return `${elideMiddle(text, budget)}\n[Full output: ${file}; read_file or search this file]`;
28
+ }
29
+ catch {
30
+ // Do not silently discard evidence if the temporary directory is unavailable.
31
+ return text;
32
+ }
33
+ }
34
+ /** Never cut a source line silently. A single oversized line is returned in full. */
35
+ export function filePage(text, offset = 1, limit = READ_LINES) {
36
+ if (!Number.isInteger(offset) || offset < 1 || !Number.isInteger(limit) || limit < 1)
37
+ throw new Error("offset and limit must be positive integers");
38
+ const lines = text.split("\n");
39
+ if (lines.length > 1 && lines.at(-1) === "")
40
+ lines.pop(); // a final newline ends the last line; it isn't another one
41
+ if (offset > lines.length)
42
+ return `[EOF: ${lines.length} lines]`;
43
+ const page = [];
44
+ let size = 0;
45
+ let next = offset;
46
+ for (; next <= lines.length && next < offset + limit; next++) {
47
+ const line = `${next}\t${lines[next - 1]}`;
48
+ if (page.length && size + line.length + 1 > OUTPUT_CHARS)
49
+ break;
50
+ page.push(line);
51
+ size += line.length + 1;
52
+ }
53
+ if (next <= lines.length)
54
+ page.push(`[${lines.length} lines total; continue with offset=${next}]`);
55
+ return page.join("\n");
56
+ }
@@ -0,0 +1,5 @@
1
+ import path from "node:path";
2
+ /** Tool paths are relative to the session's working directory. */
3
+ export const resolvePath = (p) => path.resolve(process.cwd(), p);
4
+ /** Short form for titles and messages. */
5
+ export const displayPath = (abs) => path.relative(process.cwd(), abs);
@@ -0,0 +1,52 @@
1
+ const MAX_QUESTIONS = 8;
2
+ const MAX_OPTIONS = 8;
3
+ const MAX_QUESTION_LENGTH = 1000;
4
+ const MAX_OPTION_LENGTH = 200;
5
+ const isText = (v, max) => typeof v === "string" && !!v.trim() && v.length <= max;
6
+ const inRange = (list, max) => list.length >= 1 && list.length <= max;
7
+ function parseQuestion(item) {
8
+ const { question, options } = (item && typeof item === "object" ? item : {});
9
+ if (!isText(question, MAX_QUESTION_LENGTH))
10
+ throw new Error(`Each question must have non-empty question text (up to ${MAX_QUESTION_LENGTH} characters).`);
11
+ if (options === undefined)
12
+ return { question: question.trim() };
13
+ if (!Array.isArray(options) || !inRange(options, MAX_OPTIONS) || !options.every((o) => isText(o, MAX_OPTION_LENGTH)))
14
+ throw new Error(`options must contain 1 to ${MAX_OPTIONS} non-empty strings (up to ${MAX_OPTION_LENGTH} characters each).`);
15
+ return { question: question.trim(), options: options.map((o) => o.trim()) };
16
+ }
17
+ /** Validate model-supplied input before opening an interactive form. */
18
+ export function parseQuestions(input) {
19
+ if (!Array.isArray(input) || !inRange(input, MAX_QUESTIONS))
20
+ throw new Error(`questions must contain between 1 and ${MAX_QUESTIONS} questions.`);
21
+ return input.map(parseQuestion);
22
+ }
23
+ export const askQuestions = {
24
+ name: "ask_questions",
25
+ description: "Ask the user for clarification or preferences using an interactive questionnaire. Use when you need user input before proceeding. Each question accepts a suggested option or a custom text answer. Never invent answers or repeat a cancelled/unavailable questionnaire.",
26
+ parameters: {
27
+ type: "object",
28
+ properties: {
29
+ questions: {
30
+ type: "array", minItems: 1, maxItems: 8,
31
+ items: {
32
+ type: "object",
33
+ properties: {
34
+ question: { type: "string", description: "The question to ask" },
35
+ options: { type: "array", items: { type: "string" }, minItems: 1, maxItems: 8, description: "Optional suggested answers; custom text is always allowed" },
36
+ },
37
+ required: ["question"],
38
+ },
39
+ },
40
+ },
41
+ required: ["questions"],
42
+ },
43
+ async run({ questions }, { askQuestions, signal }) {
44
+ const parsed = parseQuestions(questions);
45
+ if (!askQuestions)
46
+ return { output: "Interactive questionnaires are unavailable in this mode. Ask your questions in your text response instead.", isError: true };
47
+ const answers = await askQuestions(parsed, signal);
48
+ return answers === null
49
+ ? { output: "Questionnaire cancelled by user. No answers submitted.", isError: true }
50
+ : JSON.stringify({ answers });
51
+ },
52
+ };
@@ -0,0 +1,73 @@
1
+ import { spawn } from "node:child_process";
2
+ import { loadSettings } from "../settings.js";
3
+ import { compactOutput } from "./output.js";
4
+ const MAX_CAPTURE = 10_000_000; // bound memory for runaway commands; the model sees a compacted view anyway
5
+ /** Runs a process with combined stdout/stderr, bounded in memory and compacted for the model. Never rejects. */
6
+ function runProcess(cmd, args, opts = {}) {
7
+ return new Promise((resolve) => {
8
+ const child = spawn(cmd, args, {
9
+ cwd: process.cwd(),
10
+ shell: opts.shell,
11
+ timeout: opts.timeout ?? 120_000,
12
+ signal: opts.signal,
13
+ stdio: ["ignore", "pipe", "pipe"], // never let a command grab the terminal
14
+ });
15
+ let out = "";
16
+ let dropped = 0;
17
+ const collect = (data) => {
18
+ const room = Math.max(0, MAX_CAPTURE - out.length);
19
+ out += data.slice(0, room);
20
+ dropped += data.length - Math.min(data.length, room);
21
+ };
22
+ // Decode each stream incrementally so split UTF-8 characters are preserved.
23
+ child.stdout.setEncoding("utf8").on("data", collect);
24
+ child.stderr.setEncoding("utf8").on("data", collect);
25
+ let finished = false;
26
+ const finish = async (isError, ...notes) => {
27
+ if (finished)
28
+ return; // "error" and "close" can both fire
29
+ finished = true;
30
+ const text = (await compactOutput(out)).trimEnd();
31
+ resolve({ output: [text, dropped ? `[${dropped} more chars not captured]` : "", ...notes].filter(Boolean).join("\n"), isError });
32
+ };
33
+ child.on("error", (e) => finish(true, e.name === "AbortError" ? "[interrupted]" : `Error: ${e.message}`));
34
+ child.on("close", (code, signal) => {
35
+ const success = !signal && code !== null && (opts.successCodes ?? [0]).includes(code);
36
+ finish(!success, !success && code !== null ? `[exit code ${code}]` : "", signal ? `[killed: ${signal}]` : "");
37
+ });
38
+ });
39
+ }
40
+ export const bash = {
41
+ name: "bash",
42
+ description: "Run a shell command in the working directory. Returns combined stdout/stderr.",
43
+ parameters: {
44
+ type: "object",
45
+ properties: {
46
+ command: { type: "string" },
47
+ timeout_ms: { type: "integer", minimum: 0, description: "Timeout in milliseconds (default set by the user, usually 120000)" },
48
+ },
49
+ required: ["command"],
50
+ },
51
+ async run({ command, timeout_ms }, { approve, signal }) {
52
+ if (!(await approve({ tool: "bash", title: "Bash command", body: command })))
53
+ return "User denied the command.";
54
+ const result = await runProcess(command, [], { shell: true, timeout: timeout_ms ?? loadSettings().bashTimeoutMs, signal });
55
+ return { ...result, output: result.output || "(no output)" };
56
+ },
57
+ };
58
+ export const grep = {
59
+ name: "grep",
60
+ description: "Search file contents with a regular expression. Returns matching lines with file:line prefixes.",
61
+ parameters: {
62
+ type: "object",
63
+ properties: {
64
+ pattern: { type: "string" },
65
+ path: { type: "string", description: "Directory or file to search, default '.'" },
66
+ },
67
+ required: ["pattern"],
68
+ },
69
+ async run({ pattern, path: p = "." }, { signal }) {
70
+ const result = await runProcess("grep", ["-rnIE", "--exclude-dir=node_modules", "--exclude-dir=.git", "--", pattern, p], { signal, successCodes: [0, 1] });
71
+ return { ...result, output: result.output || "No matches." };
72
+ },
73
+ };
@@ -0,0 +1,34 @@
1
+ const TYPE_CHECKS = {
2
+ string: (v) => typeof v === "string",
3
+ boolean: (v) => typeof v === "boolean",
4
+ number: (v) => typeof v === "number" && Number.isFinite(v),
5
+ integer: (v) => Number.isSafeInteger(v),
6
+ array: Array.isArray,
7
+ object: (v) => !!v && typeof v === "object" && !Array.isArray(v),
8
+ };
9
+ /**
10
+ * Checks model-supplied arguments against a tool's top-level schema: required keys, each property's
11
+ * `type` and `minimum`. Nested structure is left to the tool. Throws a message the model can act on.
12
+ */
13
+ export function validateArguments(input, schema) {
14
+ if (!TYPE_CHECKS.object(input))
15
+ throw new Error("Tool arguments must be a JSON object.");
16
+ const args = input;
17
+ if ("_invalid_json" in args)
18
+ throw new Error("Tool arguments were not valid JSON.");
19
+ const missing = (schema.required ?? []).filter((key) => args[key] === undefined);
20
+ if (missing.length)
21
+ throw new Error(`Missing required arguments: ${missing.join(", ")}`);
22
+ for (const [key, property] of Object.entries(schema.properties)) {
23
+ const value = args[key];
24
+ if (value === undefined || !property.type)
25
+ continue;
26
+ const check = TYPE_CHECKS[property.type];
27
+ if (check && !check(value))
28
+ throw new Error(`${key} must be ${article(property.type)} ${property.type}.`);
29
+ if (property.minimum !== undefined && value < property.minimum)
30
+ throw new Error(`${key} must be at least ${property.minimum}.`);
31
+ }
32
+ return args;
33
+ }
34
+ const article = (word) => (/^[aeiou]/.test(word) ? "an" : "a");
package/dist/args.js ADDED
@@ -0,0 +1,30 @@
1
+ import { parseArgs } from "node:util";
2
+ import { WORKTREE_NAME } from "./adapters/git/worktree.js";
3
+ /**
4
+ * Parses command-line arguments. -w takes an optional name (`megacode -w`, `megacode -w fix-auth "prompt"`),
5
+ * which parseArgs can't express: the next argument is the name if it looks like one, otherwise it's left for the prompt.
6
+ */
7
+ export function parseCliArgs(argv) {
8
+ const args = [...argv];
9
+ let worktree;
10
+ const wi = args.findIndex((a) => a === "-w" || a === "--worktree" || a.startsWith("--worktree="));
11
+ if (wi !== -1) {
12
+ const [flag] = args.splice(wi, 1);
13
+ if (flag.startsWith("--worktree="))
14
+ worktree = { name: flag.slice("--worktree=".length) || undefined };
15
+ else if (args[wi] !== undefined && WORKTREE_NAME.test(args[wi]))
16
+ worktree = { name: args.splice(wi, 1)[0] };
17
+ else
18
+ worktree = {};
19
+ }
20
+ const { values, positionals } = parseArgs({
21
+ args,
22
+ allowPositionals: true,
23
+ options: {
24
+ model: { type: "string", short: "m" },
25
+ ask: { type: "boolean", short: "a" },
26
+ help: { type: "boolean", short: "h" },
27
+ },
28
+ });
29
+ return { model: values.model, ask: !!values.ask, help: !!values.help, worktree, prompt: positionals.join(" ") };
30
+ }