codeply-cli 0.2.0 → 0.3.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 (43) hide show
  1. package/THIRD_PARTY_NOTICES.md +63 -0
  2. package/bin/codeply.js +562 -370
  3. package/lib/agent.mjs +1627 -160
  4. package/lib/ai.js +1107 -387
  5. package/lib/apply-limit.js +33 -51
  6. package/lib/apply-patch.js +217 -0
  7. package/lib/arity-table.json +138 -0
  8. package/lib/arity.js +70 -0
  9. package/lib/auth.js +118 -112
  10. package/lib/commands.js +103 -0
  11. package/lib/config.js +392 -136
  12. package/lib/design-library/artifact-template.html +500 -0
  13. package/lib/design-library/assemble-artifact.mjs +21 -0
  14. package/lib/design-library/build.mjs +226 -0
  15. package/lib/design-library/cleanup.mjs +45 -0
  16. package/lib/design-library/index.json +22380 -0
  17. package/lib/design-library/make-table-data.mjs +39 -0
  18. package/lib/design-library/query.mjs +104 -0
  19. package/lib/diagnostics.js +192 -0
  20. package/lib/edit-engine.js +171 -14
  21. package/lib/github-agent.mjs +290 -0
  22. package/lib/history.js +75 -0
  23. package/lib/mcp.js +258 -0
  24. package/lib/native-tools.mjs +189 -0
  25. package/lib/oauth-connectors.js +478 -0
  26. package/lib/permissions.js +109 -0
  27. package/lib/plugins.js +353 -0
  28. package/lib/render-md.mjs +617 -617
  29. package/lib/server.mjs +457 -0
  30. package/lib/session-db.js +220 -0
  31. package/lib/share.js +168 -0
  32. package/lib/skills.js +80 -22
  33. package/lib/snapshot.js +146 -0
  34. package/lib/subagents.js +164 -0
  35. package/lib/terminal-hints.js +154 -0
  36. package/lib/textarea.mjs +258 -258
  37. package/lib/tools.mjs +1658 -79
  38. package/lib/tui.js +7 -7
  39. package/lib/tui.mjs +1086 -992
  40. package/lib/typecheck-worker.js +147 -0
  41. package/lib/web-tools.js +158 -0
  42. package/package.json +4 -3
  43. package/skills/premium-web-design/SKILL.md +154 -0
@@ -0,0 +1,146 @@
1
+ /**
2
+ * Undo for agent changes: a hidden git repository, kept outside the project,
3
+ * that records the project's files before a message runs so exactly what that
4
+ * message changed can be put back.
5
+ *
6
+ * The approach (a separate --git-dir pointed at the project as --work-tree,
7
+ * trees written with write-tree, files restored with checkout) follows
8
+ * opencode's snapshot/index.ts, MIT License, Copyright (c) 2025 opencode.
9
+ *
10
+ * It never touches the project's own .git: no commits, no index, no refs.
11
+ * The project's .gitignore still applies, plus a built-in list of heavy
12
+ * folders so a project without a .gitignore doesn't snapshot node_modules.
13
+ * Every function fails soft (returns null/false) when git is missing.
14
+ */
15
+ const fs = require('fs');
16
+ const os = require('os');
17
+ const path = require('path');
18
+ const crypto = require('crypto');
19
+ const { execFile } = require('child_process');
20
+
21
+ const ROOT = path.join(os.homedir(), '.codeply', 'snapshots');
22
+ const TIMEOUT_MS = 30000;
23
+ const EXCLUDES = [
24
+ 'node_modules/', '.git/', 'dist/', 'build/', 'out/', '.next/', '.nuxt/', '.turbo/', '.cache/', 'coverage/',
25
+ '__pycache__/', '.venv/', 'venv/', 'target/', '*.log', '.DS_Store', 'Thumbs.db',
26
+ ];
27
+ const MAX_FILE_BYTES = 5 * 1024 * 1024;
28
+
29
+ function gitDirFor(cwd) {
30
+ const key = crypto.createHash('sha1').update(path.resolve(cwd).toLowerCase()).digest('hex').slice(0, 16);
31
+ return path.join(ROOT, key);
32
+ }
33
+
34
+ function git(cwd, args, { input } = {}) {
35
+ const gitDir = gitDirFor(cwd);
36
+ const full = [
37
+ '-c', 'core.autocrlf=false', '-c', 'core.longpaths=true', '-c', 'core.fsmonitor=false',
38
+ '-c', 'core.quotepath=false', '-c', `safe.directory=*`,
39
+ `--git-dir=${gitDir}`, `--work-tree=${path.resolve(cwd)}`, ...args,
40
+ ];
41
+ return new Promise((resolve) => {
42
+ const child = execFile('git', full, { cwd, timeout: TIMEOUT_MS, windowsHide: true, maxBuffer: 32 * 1024 * 1024 },
43
+ (err, stdout, stderr) => resolve({ ok: !err, out: String(stdout || ''), err: String(stderr || (err && err.message) || '') }));
44
+ if (input != null) { child.stdin.end(input); }
45
+ });
46
+ }
47
+
48
+ async function ensureRepo(cwd) {
49
+ const gitDir = gitDirFor(cwd);
50
+ if (!fs.existsSync(path.join(gitDir, 'HEAD'))) {
51
+ fs.mkdirSync(gitDir, { recursive: true });
52
+ const r = await git(cwd, ['init', '-q']);
53
+ if (!r.ok) return false;
54
+ fs.writeFileSync(path.join(gitDir, 'codeply-project.txt'), path.resolve(cwd));
55
+ }
56
+ // Refreshed every time so changes to the list reach existing repos.
57
+ fs.mkdirSync(path.join(gitDir, 'info'), { recursive: true });
58
+ fs.writeFileSync(path.join(gitDir, 'info', 'exclude'), EXCLUDES.join('\n') + '\n');
59
+ return true;
60
+ }
61
+
62
+ /** Big files (videos, datasets) that aren't gitignored are left out, like opencode does. */
63
+ async function excludeLargeUntracked(cwd) {
64
+ const r = await git(cwd, ['ls-files', '--others', '--exclude-standard', '-z']);
65
+ if (!r.ok || !r.out) return [];
66
+ const big = [];
67
+ for (const rel of r.out.split('\0').filter(Boolean)) {
68
+ try { if (fs.statSync(path.join(cwd, rel)).size > MAX_FILE_BYTES) big.push(rel); } catch {}
69
+ }
70
+ return big;
71
+ }
72
+
73
+ /**
74
+ * Record the project's current files.
75
+ * @returns {Promise<string|null>} a tree hash, or null if snapshots are unavailable
76
+ */
77
+ async function track(cwd) {
78
+ try {
79
+ if (!cwd || !fs.existsSync(cwd) || !(await ensureRepo(cwd))) return null;
80
+ const big = await excludeLargeUntracked(cwd);
81
+ const add = await git(cwd, ['add', '-A', '--', '.', ...big.map((b) => `:(exclude,literal)${b}`)]);
82
+ if (!add.ok) return null;
83
+ const tree = await git(cwd, ['write-tree']);
84
+ return tree.ok ? tree.out.trim() || null : null;
85
+ } catch { return null; }
86
+ }
87
+
88
+ /** Files that differ between two recorded trees. */
89
+ async function changedFiles(cwd, fromTree, toTree) {
90
+ if (!fromTree || !toTree || fromTree === toTree) return [];
91
+ const r = await git(cwd, ['diff-tree', '-r', '--name-status', '--no-renames', '-z', fromTree, toTree]);
92
+ if (!r.ok) return [];
93
+ const parts = r.out.split('\0').filter(Boolean);
94
+ const out = [];
95
+ for (let i = 0; i + 1 < parts.length; i += 2) {
96
+ out.push({ status: parts[i], file: parts[i + 1] }); // A added, M modified, D deleted
97
+ }
98
+ return out;
99
+ }
100
+
101
+ /** Does `file` exist in `tree`? */
102
+ async function inTree(cwd, tree, file) {
103
+ const r = await git(cwd, ['cat-file', '-e', `${tree}:${file}`]);
104
+ return r.ok;
105
+ }
106
+
107
+ /**
108
+ * Put `files` back the way they were in `tree`: restore the ones it had,
109
+ * delete the ones it didn't (they were created after it).
110
+ * @returns {Promise<{ok:boolean, restored:string[], removed:string[], failed:string[]}>}
111
+ */
112
+ async function restore(cwd, tree, files) {
113
+ const restored = [], removed = [], failed = [];
114
+ if (!tree || !(await ensureRepo(cwd))) return { ok: false, restored, removed, failed: files.slice() };
115
+ const existed = [];
116
+ for (const f of files) {
117
+ if (await inTree(cwd, tree, f)) existed.push(f);
118
+ else {
119
+ try { fs.rmSync(path.join(cwd, f), { force: true }); removed.push(f); } catch { failed.push(f); }
120
+ }
121
+ }
122
+ for (let i = 0; i < existed.length; i += 100) {
123
+ const batch = existed.slice(i, i + 100);
124
+ const r = await git(cwd, ['checkout', tree, '--', ...batch]);
125
+ if (r.ok) restored.push(...batch);
126
+ else {
127
+ // One bad path shouldn't block the rest.
128
+ for (const f of batch) {
129
+ const one = await git(cwd, ['checkout', tree, '--', f]);
130
+ (one.ok ? restored : failed).push(f);
131
+ }
132
+ }
133
+ }
134
+ return { ok: failed.length === 0, restored, removed, failed };
135
+ }
136
+
137
+ /** Is git usable here at all? Cached. */
138
+ let gitAvailable;
139
+ function available() {
140
+ if (gitAvailable !== undefined) return Promise.resolve(gitAvailable);
141
+ return new Promise((resolve) => {
142
+ execFile('git', ['--version'], { timeout: 5000, windowsHide: true }, (err) => { gitAvailable = !err; resolve(gitAvailable); });
143
+ });
144
+ }
145
+
146
+ module.exports = { track, changedFiles, restore, available, gitDirFor };
@@ -0,0 +1,164 @@
1
+ /**
2
+ * Roles for the single Codeply agent.
3
+ *
4
+ * There is exactly one agent. For each task it takes on the role that fits -
5
+ * Frontend, Backend, Database, DevOps, Security, Testing, Docs - and the
6
+ * role's guide (agents/<id>.md: YAML frontmatter + markdown body) is injected
7
+ * into that turn's system prompt as the standard to hold the work to. Roles
8
+ * never run in parallel and never hand work to each other; a multi-part
9
+ * request simply switches role between tasks (see main.js).
10
+ *
11
+ * detectRole() picks a role from a message's text with plain keyword
12
+ * matching - no model call, so it costs nothing and never blocks a send.
13
+ */
14
+ const fs = require('fs');
15
+ const path = require('path');
16
+
17
+ const AGENTS_DIR = path.join(__dirname, '..', 'agents');
18
+
19
+ /** Same minimal frontmatter reader as skills.js - kept local so this module has no cross-dependency. */
20
+ function parseFrontmatter(text) {
21
+ const m = text.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?/);
22
+ if (!m) return { meta: {}, body: text };
23
+ const meta = {};
24
+ for (const line of m[1].split(/\r?\n/)) {
25
+ const kv = line.match(/^([a-zA-Z0-9_]+):\s*(.*)$/);
26
+ if (kv) meta[kv[1]] = kv[2].replace(/^["']|["']$/g, '').trim();
27
+ }
28
+ return { meta, body: text.slice(m[0].length).trim() };
29
+ }
30
+
31
+ let cache = null;
32
+
33
+ /** Every specialist, sorted by their declared `order`. Cached - the set is fixed at build time, not user-editable. */
34
+ function listSubagents() {
35
+ if (cache) return cache;
36
+ let files;
37
+ try { files = fs.readdirSync(AGENTS_DIR).filter((f) => f.endsWith('.md')); }
38
+ catch { cache = []; return cache; }
39
+
40
+ cache = files.map((f) => {
41
+ const text = fs.readFileSync(path.join(AGENTS_DIR, f), 'utf8');
42
+ const { meta, body } = parseFrontmatter(text);
43
+ return {
44
+ id: meta.id || f.replace(/\.md$/, ''),
45
+ name: meta.name || f.replace(/\.md$/, ''),
46
+ tagline: meta.tagline || '',
47
+ color: meta.color || '#888888',
48
+ mascot: meta.mascot || `${meta.id || f.replace(/\.md$/, '')}.png`,
49
+ order: Number(meta.order) || 0,
50
+ // The guides were written for separate agents that handed work to each
51
+ // other; the "When X Hands Off" section doesn't apply to one agent.
52
+ persona: body.replace(/\r?\n## When [^\r\n]*Hands Off[\s\S]*?(?=\r?\n## |$)/, '').trim(),
53
+ };
54
+ }).sort((a, b) => a.order - b.order);
55
+
56
+ return cache;
57
+ }
58
+
59
+ /** Metadata only, no persona body - what the UI's picker needs, nothing it doesn't. */
60
+ function listSubagentsMeta() {
61
+ return listSubagents().map(({ persona, ...meta }) => meta);
62
+ }
63
+
64
+ function getSubagent(id) {
65
+ if (!id) return null;
66
+ return listSubagents().find((a) => a.id === id) || null;
67
+ }
68
+
69
+ /**
70
+ * Loose match against a free-text name (what the model writes into a
71
+ * <name> tag, e.g. "frontend", "the Frontend specialist", "Backend Bot") -
72
+ * exact id match first, then a substring check either direction so a
73
+ * reasonably-named delegation call still resolves.
74
+ */
75
+ function findSubagentByName(name) {
76
+ const q = String(name || '').trim().toLowerCase();
77
+ if (!q) return null;
78
+ const all = listSubagents();
79
+ return all.find((a) => a.id === q) ||
80
+ all.find((a) => a.name.toLowerCase() === q) ||
81
+ all.find((a) => q.includes(a.id) || a.name.toLowerCase().includes(q) || q.includes(a.name.toLowerCase())) ||
82
+ null;
83
+ }
84
+
85
+ // Auto-routing: picks a specialist FOR ONE MESSAGE from its own text, no
86
+ // manual pin required - the same specialist can get picked again on a later,
87
+ // unrelated message, or a different one each time, since this runs fresh per
88
+ // call. Pure keyword classification (no I/O, no model call) so it costs
89
+ // nothing and never blocks a send. Ordered most-specific-and-safety-critical
90
+ // first: a message that mentions both "security" and "css" should route to
91
+ // Warden, not Pixel, so security/data-integrity concerns are checked before
92
+ // the broader, easier-to-accidentally-match categories (frontend/design).
93
+ // Deliberately conservative - every pattern requires a real technical term,
94
+ // not a generic word ("test" alone doesn't match; "unit test"/"write a test"
95
+ // does) - a false match hands the whole turn a persona/tone that doesn't fit
96
+ // the actual request, which is worse than staying General for an ambiguous one.
97
+ // Every bare noun below is written with its plural covered (`issues?`,
98
+ // `bugs?`, `endpoints?`, ...) - an earlier version required the exact
99
+ // singular form, so "security issues" (plural) silently missed the
100
+ // security route entirely and fell through to a much broader, wrong
101
+ // category. Getting plurals right matters more here than almost anywhere
102
+ // else in this file: a missed match doesn't just fail quietly, it falls
103
+ // back to whatever an unrelated EARLIER message in the conversation was
104
+ // about (see effectiveSubagentId in main.js), which can point at a
105
+ // completely wrong specialist instead of just staying General.
106
+ const AUTO_ROUTES = [
107
+ { id: 'security', re: /\b(vulnerabilit(?:y|ies)|xss|csrf|sql\s?injections?|penetration\s?tests?|pentest|exploits?|security\b|auth(?:entication|orization)?\s?(?:bugs?|bypass(?:es)?|checks?|flows?|tokens?)|hash(?:ing)?\s?passwords?|encrypt(?:ion|ed)?|secrets?\s?(?:leak(?:ed)?|expos(?:ed|ure))|credentials?(?:\s?leak(?:ed)?)?|rate\s?limit(?:ing)?|owasp|csp\b|cors\b|jwt\b|sanitiz(?:e|ing|ation)|injection\s?attacks?)\b/i },
108
+ { id: 'database', re: /\b(databases?|db\s?schemas?|schemas?\s?(?:change|design|migration)|migrat(?:ion|ions|e|ing)|sql\s?quer(?:y|ies)|quer(?:y|ies)\s?(?:plan|slow|performance)|add(?:ing)?\s?an?\s?index|indexes?|foreign\s?keys?|primary\s?keys?|postgres(?:ql)?|mysql|sqlite|mongo(?:db)?|n\+1\s?quer|tables?\s?(?:column|row|schema)|orm\b|prisma\b|supabase\s?tables?)\b/i },
109
+ { id: 'devops', re: /\b(ci\/cd|ci\s?pipelines?|github\s?actions|deploy(?:ment|ments|ing|ed)?|rollbacks?|dockerfiles?|docker\s?compose|kubernetes|k8s|zero[- ]downtime|infra(?:structure)?(?:\s?as\s?code)?|env(?:ironment)?\s?variables?|secrets?\s?manager|build\s?pipelines?|hosting|vercel|netlify|render\.com|health\s?checks?|uptime|logging\s?(?:setup|infra)|monitoring|observability)\b/i },
110
+ { id: 'testing', re: /\b(unit\s?tests?|write\s?(?:a\s?|some\s?)?tests?|regression\s?tests?|e2e\s?tests?|end[- ]to[- ]end\s?tests?|test\s?coverage|flaky\s?tests?|reproduce\s?(?:this|the)\s?bugs?|test\s?suites?|qa\s?pass|edge\s?cases?|testing\s?strategy|test\s?(?:the|this|my|it|out)\b|does\s?(?:it|this)\s?works?|check\s?if\s?(?:it|this)\s?works?)\b/i },
111
+ { id: 'backend', re: /\b(api\s?(?:endpoints?|routes?|integrations?)|rest\s?api|backend\s?(?:logic|routes?|bugs?|code)|business\s?logic|server[- ]side|race\s?conditions?|idempotent|webhooks?|graphql|endpoints?\s?(?:for|to)|route\s?handlers?|server\s?errors?)\b/i },
112
+ { id: 'frontend', re: /\b(css\b|styles?\s?(?:sheet)?|responsive(?:\s?layout)?|react\s?components?|vue\s?components?|components?\b|flexbox|grid\s?layout|accessib(?:le|ility)|aria[- ]|keyboard\s?nav|hover\s?states?|animations?|transitions?|dark\s?mode\b|light\s?mode\b|frontend\b|ui\s?(?:bugs?|fix|elements?|components?)?|buttons?\b|layouts?\b|typography|fonts?(?:-family)?|padding|margins?|spacing|glassmorphism|sections?\b|footers?\b|headers?\b|navbar|sidebars?|modals?\b|breakpoints?|visual\s?hierarchy|design\s?systems?|information\s?architecture|user\s?flows?|wireframes?|mockups?|color\s?palettes?|type\s?scale|ux\s?review|luxury|aesthetics?|polish(?:ed)?|brand(?:ing)?|design\s?(?:direction|refinement|feedback|advice)|make\s?it\s?(?:look|feel)|world[- ]class|premium\s?feel|(?:landing|collection|product|pricing|contact|about|home)\s?pages?|new\s?pages?|web\s?pages?|html\s?pages?|(?:the\s+)?website\b|images?\b|photos?\b|pictures?\b)\b/i },
113
+ { id: 'docs', re: /\b(write\s?(?:a\s?)?readme|api\s?docs?|documentation|doc[- ]?comments?|changelog|release\s?notes|write\s?a\s?(?:guide|tutorial)|code\s?comments?)\b/i },
114
+ ];
115
+
116
+ // Alternate ways someone actually types a niche's name in a sentence - not
117
+ // just the single-word form AUTO_ROUTES already matches as a topic keyword,
118
+ // but explicitly asking FOR that specialist ("use front end agent", "have
119
+ // the security specialist look at this"). "front end agent" (two words,
120
+ // space) was silently missing before this existed - AUTO_ROUTES only had
121
+ // the one-word "frontend" as a keyword, so a request naming the specialist
122
+ // by its actual two-word name never matched anything.
123
+ const NICHE_ALIASES = {
124
+ frontend: ['frontend', 'front[- ]end'],
125
+ backend: ['backend', 'back[- ]end'],
126
+ database: ['database', 'db'],
127
+ devops: ['dev\\s?ops'],
128
+ security: ['security'],
129
+ testing: ['testing', 'qa'],
130
+ docs: ['docs', 'documentation'],
131
+ };
132
+
133
+ /**
134
+ * Catches a request that names a specialist directly - by its mascot name
135
+ * ("have Pixel look at this") or by its niche plus an agent/specialist/bot
136
+ * word ("use the front end agent", "security specialist please review").
137
+ * Checked before the topic-keyword routes below so an explicit ask always
138
+ * wins over guessing from subject matter.
139
+ */
140
+ function detectExplicitMention(message) {
141
+ const text = String(message || '');
142
+ for (const a of listSubagents()) {
143
+ if (new RegExp(`\\b${a.name}\\b`, 'i').test(text)) return a.id;
144
+ for (const alias of NICHE_ALIASES[a.id] || []) {
145
+ if (new RegExp(`\\b${alias}\\s+(?:agent|specialist|bot)\\b`, 'i').test(text)) return a.id;
146
+ }
147
+ }
148
+ return null;
149
+ }
150
+
151
+ function detectSpecialist(message) {
152
+ const text = String(message || '');
153
+ const explicit = detectExplicitMention(text);
154
+ if (explicit) return explicit;
155
+ for (const route of AUTO_ROUTES) {
156
+ if (route.re.test(text)) return route.id;
157
+ }
158
+ return null;
159
+ }
160
+
161
+ const detectRole = detectSpecialist;
162
+ const getRole = getSubagent;
163
+
164
+ module.exports = { listSubagents, listSubagentsMeta, getSubagent, getRole, findSubagentByName, detectSpecialist, detectRole };
@@ -0,0 +1,154 @@
1
+ /**
2
+ * One short, actionable recovery hint for a failed shell command, and a
3
+ * warning when an exit code of 0 is hiding a failure.
4
+ *
5
+ * Adapted from Hermes Agent (tools/terminal_hints.py), MIT License,
6
+ * Copyright (c) 2025 Nous Research - https://github.com/NousResearch/hermes-agent
7
+ * Windows (cmd.exe / PowerShell) and Node patterns added for Codeply.
8
+ *
9
+ * Rules: hints only fire on a non-zero exit, first match wins, only the head
10
+ * of the output is scanned (hints key on error headers, not deep logs), and
11
+ * each hint states the next action to take. Pure functions, no I/O.
12
+ */
13
+
14
+ const SCAN_CHARS = 4000;
15
+
16
+ const MISSING_COMMAND_HINTS = {
17
+ python: 'There is no bare `python` here. Try `python3` or `py` (Windows launcher), or the project venv interpreter.',
18
+ pip: 'There is no bare `pip` here. Try `pip3`, `python -m pip` / `py -m pip`, or the project venv pip.',
19
+ };
20
+
21
+ function missingCommandHint(cmd) {
22
+ const name = String(cmd || '').replace(/^['"]|['"]$/g, '');
23
+ const onWindows = process.platform === 'win32';
24
+ return MISSING_COMMAND_HINTS[name.toLowerCase()] ||
25
+ `\`${name}\` is not installed or not on PATH. Check with \`${onWindows ? `where ${name}` : `command -v ${name}`}\`, ` +
26
+ `then install it (${onWindows ? 'winget, npm -g, or the tool\'s installer' : 'brew/apt/npm -g'}) or use its full path. ` +
27
+ 'Do not retry the same command unchanged.';
28
+ }
29
+
30
+ const rx = (pattern, message) => ({ pattern, message });
31
+
32
+ // The shell itself choked on the command's syntax. Retrying a variation of
33
+ // the same one-liner rarely works; a different tool does.
34
+ const WRITE_IT_INSTEAD = 'Do not retry a variation of this one-liner. Write the content with write_file instead ' +
35
+ '(for a script: write_file it, then run it with node/python/etc.).';
36
+
37
+ // Ordered roughly by how often they show up. First match wins.
38
+ const OUTPUT_HINTS = [
39
+ rx(/<< was unexpected at this time|The '<' operator is reserved for future use|Missing file specification after redirection operator|here-document at line \d+ delimited by end-of-file/i,
40
+ `This shell cannot take a heredoc (<<) here (cmd.exe and PowerShell have none). ${WRITE_IT_INSTEAD}`),
41
+ rx(/The token '&&' is not a valid statement separator|The token '\|\|' is not a valid statement separator/i,
42
+ 'This is Windows PowerShell 5.1, which has no && or ||. Chain with `;` (or `if ($?) { next }`), or run the whole line through `cmd /c "..."`.'),
43
+ rx(/The string is missing the terminator|unexpected EOF while looking for matching|Unterminated quoted string|syntax error: unterminated quoted string/i,
44
+ `The shell's quoting broke on this command (nested or multi-line quotes). ${WRITE_IT_INSTEAD}`),
45
+ rx(/The command line is too long|Argument list too long|E2BIG/i,
46
+ `The command is longer than the shell allows. ${WRITE_IT_INSTEAD}`),
47
+ rx(/The filename, directory name, or volume label syntax is incorrect/i,
48
+ 'Windows rejected a path in this command: check for forward/back slash mix-ups, stray quotes, or characters like : * ? " < > | in a file name.'),
49
+ rx(/^CONFLICT |Automatic merge failed|needs merge/m,
50
+ 'Git merge conflict. Do not retry this command. Fix the conflicted files listed above (edit, then `git add`), ' +
51
+ 'then continue (`git rebase --continue` or commit the merge), or abort with `--abort`.'),
52
+ rx(/(?:bash: line \d+: |bash: |sh: \d*:? ?)?([\w.+-]+): command not found/, (m) => missingCommandHint(m[1])),
53
+ // cmd.exe
54
+ rx(/'([^']+)' is not recognized as an internal or external command/i, (m) => missingCommandHint(m[1])),
55
+ // PowerShell
56
+ rx(/The term '([^']+)' is not recognized as (?:the )?name of a cmdlet/i, (m) => missingCommandHint(m[1])),
57
+ rx(/Cannot find module '([^']+)'/,
58
+ (m) => m[1].startsWith('.') || m[1].includes('/') && !m[1].startsWith('@')
59
+ ? `Node cannot find '${m[1]}'. Check the path is right relative to the file that requires it (list_dir), and the file extension.`
60
+ : `Node cannot find the package '${m[1]}'. Run \`npm install\` in the project folder (or \`npm install ${m[1]}\` if it is not in package.json yet).`),
61
+ rx(/(?:ModuleNotFoundError|ImportError): No module named '?([\w.]+)/,
62
+ (m) => `Python cannot import '${m[1]}'. Usually the wrong interpreter is running: use the project venv's python. ` +
63
+ 'Only pip install it if it is genuinely missing from that environment.'),
64
+ rx(/npm ERR! (?:code )?ERESOLVE|could not resolve dependency/i,
65
+ 'npm dependency conflict. Read which packages clash above; retry with `--legacy-peer-deps` only if the versions are genuinely compatible.'),
66
+ rx(/Missing script: "?([\w:.-]+)/,
67
+ (m) => `package.json has no "${m[1]}" script. Check the real script names (read package.json) instead of guessing.`),
68
+ rx(/EADDRINUSE[^\n]*?:(\d+)/,
69
+ (m) => `Port ${m[1]} is already in use, probably by a server you started earlier. Reuse that server, or pick another port.`),
70
+ rx(/(?:fatal|error):.*?'([^']+)' already exists/,
71
+ (m) => `'${m[1]}' already exists, so retrying unchanged will keep failing. Reuse it, pick another name, or delete it first if it is stale.`),
72
+ rx(/API rate limit|was submitted too quickly/,
73
+ 'Rate limit hit, so immediate retries will keep failing. Do other work first and retry this later.'),
74
+ rx(/Permission denied|EACCES|EPERM|Access is denied/,
75
+ 'Permission denied. Check the target path is writable and not open in another program; prefer a folder the user owns. ' +
76
+ 'Do not escalate privileges unless the task truly needs it.'),
77
+ rx(/cannot be loaded because running scripts is disabled/i,
78
+ 'PowerShell blocked the script (execution policy). Run the tool through its .cmd shim (e.g. `npm.cmd`, `npx.cmd`) or through cmd instead of changing system policy.'),
79
+ rx(/ENOENT[^\n]*?'([^']+)'/,
80
+ (m) => `No such file or folder: '${m[1]}'. Check the path with list_dir before retrying.`),
81
+ ];
82
+
83
+ const EXIT_CODE_HINTS = {
84
+ 124: 'Exit 124: the command timed out. Run something smaller, or start long-running servers in the background instead of waiting on them.',
85
+ 126: 'Exit 126: the file exists but is not executable. Run it through its interpreter (e.g. `node script.js`, `bash script.sh`).',
86
+ 127: 'Exit 127: command not found. Check it is installed and on PATH, or use its full path.',
87
+ 137: 'Exit 137: the process was killed, usually out of memory. Reduce what it processes at once before retrying.',
88
+ 9009: 'Exit 9009: Windows could not find that program. Check it is installed and on PATH (`where <name>`).',
89
+ };
90
+
91
+ /** @returns {string|null} one recovery hint for a failed command */
92
+ function annotateFailure(command, exitCode, output) {
93
+ if (!exitCode) return null;
94
+ const window = String(output || '').slice(0, SCAN_CHARS);
95
+ if (window) {
96
+ for (const { pattern, message } of OUTPUT_HINTS) {
97
+ const m = window.match(pattern);
98
+ if (m) return typeof message === 'function' ? message(m) : message;
99
+ }
100
+ }
101
+ return EXIT_CODE_HINTS[exitCode] || null;
102
+ }
103
+
104
+ // ─── Masked success ─────────────────────────────────────────────────────────
105
+ // `npm test | tail -20` exits with tail's 0 and `build || echo done` with
106
+ // echo's 0, so the model can conclude a build passed while its own output
107
+ // says it failed. Needs BOTH a masking shape AND a strong, tool-specific
108
+ // failure line, and skips read-only commands whose output legitimately
109
+ // contains error text (grep for "error", cat a log).
110
+
111
+ const PASSTHROUGH = '(?:tail|head|cat|tee|less|more|wc|sort|uniq|Select-Object|Out-String|findstr)';
112
+ const MASKING_SHAPES = [
113
+ [new RegExp(`(?<!\\|)\\|(?!\\|)\\s*${PASSTHROUGH}\\b[^|]*$`, 'i'),
114
+ 'Exit 0 here is the status of the last command in the pipe (tail/head/...), NOT of the command before it, and the ' +
115
+ 'output shows a failure. Treat this run as FAILED: run the command again without the pipe to get its real exit code.'],
116
+ [/\|\|\s*(?:echo\b|printf\b|true\b|exit 0\b|:\s|:$)/i,
117
+ 'Exit 0 here comes from the `||` fallback, NOT from the command before it, and the output shows a failure. ' +
118
+ 'Treat this run as FAILED: run the command on its own to get its real exit code.'],
119
+ [/;\s*(?:exit\s+0|echo\b[^;|&]*)\s*$/i,
120
+ 'Exit 0 here comes from the command after the `;`, NOT from the one before it, and the output shows a failure. ' +
121
+ 'Treat this run as FAILED: run the command on its own to get its real exit code.'],
122
+ ];
123
+
124
+ const READONLY_HEADS = new Set(['grep', 'rg', 'ag', 'find', 'findstr', 'ls', 'dir', 'cat', 'type', 'head', 'tail', 'jq', 'awk', 'sed', 'echo', 'printf', 'get-content', 'select-string']);
125
+
126
+ const FAILURE_SHAPES = new RegExp([
127
+ 'error\\[E\\d+\\]', 'error: could not compile', 'error: aborting due to', // rust
128
+ 'Traceback \\(most recent call last\\)', // python
129
+ '^(?:=+ )?\\d+ failed', '^FAILED (?:\\S+::|\\S+\\.py)', // pytest
130
+ 'compilation terminated\\.', // gcc/clang
131
+ 'npm ERR!', '^npm error ', // npm
132
+ 'BUILD FAILED', 'Build FAILED', 'FAILED: ', // gradle/msbuild/ninja
133
+ '^make(?:\\[\\d+\\])?: \\*\\*\\*', // make
134
+ '^\\s*(?:Tests?|Test Suites):\\s+\\d+ failed', // jest/vitest
135
+ 'error TS\\d{4}:', // tsc
136
+ '^SyntaxError: ', // node
137
+ ].join('|'), 'm');
138
+
139
+ function firstToken(command) {
140
+ for (const tok of String(command || '').trim().split(/\s+/)) {
141
+ if (!tok.includes('=') || /^(=|\.\/|\/)/.test(tok)) return tok.split(/[\\/]/).pop().toLowerCase();
142
+ }
143
+ return '';
144
+ }
145
+
146
+ /** @returns {string|null} a warning when an exit-0 result likely hides a failure */
147
+ function annotateMaskedSuccess(command, output) {
148
+ const window = String(output || '').slice(0, SCAN_CHARS);
149
+ if (!command || !window || READONLY_HEADS.has(firstToken(command)) || !FAILURE_SHAPES.test(window)) return null;
150
+ const hit = MASKING_SHAPES.find(([shape]) => shape.test(command));
151
+ return hit ? hit[1] : null;
152
+ }
153
+
154
+ module.exports = { annotateFailure, annotateMaskedSuccess };