@hncode/hncode 0.3.1 → 0.3.3

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/plan.js ADDED
@@ -0,0 +1,127 @@
1
+ // Plan review helpers — turning a model's <plan> block into something the user
2
+ // can actually review before any file is touched.
3
+ //
4
+ // Plan mode already asks the model for a <plan>…</plan> block and prompts to
5
+ // approve it. What this module adds is the review material and the persistence:
6
+ //
7
+ // * filesReferenced(plan) — which paths the plan says it will touch, so the
8
+ // user sees the blast radius instead of prose.
9
+ // * planStats(plan) — step/line counts for a compact summary line.
10
+ // * savePlan() — write the approved plan to
11
+ // <workspace>/.hncode/plans/<timestamp>.md so it
12
+ // survives the session and can be handed to a
13
+ // subagent or pasted into an issue.
14
+ //
15
+ // Everything here is pure except savePlan(). The parsing is deliberately
16
+ // tolerant: a plan is free-form Markdown, so a missed file reference degrades to
17
+ // "fewer files listed", never to an error.
18
+
19
+ import fs from 'node:fs';
20
+ import path from 'node:path';
21
+
22
+ // Paths that appear in a plan: backticked spans, and bare tokens that look like
23
+ // a path (contains a slash and a file-ish extension, or a well-known filename).
24
+ // Ordered by first appearance, de-duplicated, and filtered to plausible files.
25
+ const PATH_EXT = /\.(?:[a-z0-9]{1,8})$/i;
26
+ const KNOWN_FILES = /^(?:AGENTS\.md|README(?:\.[a-z]+)?|package\.json|pyproject\.toml|Cargo\.toml|go\.mod|Makefile|Dockerfile|\.gitignore|tsconfig\.json)$/i;
27
+
28
+ export function filesReferenced(plan) {
29
+ const text = String(plan || '');
30
+ const found = [];
31
+ const seen = new Set();
32
+ const push = (raw) => {
33
+ let p = String(raw || '').trim();
34
+ // Strip trailing punctuation the prose adds around a path.
35
+ p = p.replace(/^[`'"(\[]+/, '').replace(/[`'")\],.;:]+$/, '');
36
+ if (!p || p.length > 200) return;
37
+ if (/\s/.test(p)) return;
38
+ if (p.includes('://')) return; // a URL, not a path
39
+ if (/^[-*#>]/.test(p)) return;
40
+ // A token is a plausible path when it has a separator, a file-ish extension,
41
+ // or is a well-known project filename. This keeps a bare word like "config"
42
+ // from being reported as a file.
43
+ const looksPath = p.includes('/') || p.includes('\\') || PATH_EXT.test(p) || KNOWN_FILES.test(p);
44
+ if (!looksPath) return;
45
+ const key = p.replace(/\\/g, '/');
46
+ if (seen.has(key)) return;
47
+ seen.add(key);
48
+ found.push(p);
49
+ };
50
+
51
+ // 1. backticked spans (the prompt asks for files in backticks)
52
+ for (const m of text.matchAll(/`([^`\n]+)`/g)) push(m[1]);
53
+ // 2. bare tokens that look like paths (covers un-backticked mentions)
54
+ for (const m of text.matchAll(/(?:^|[\s(])((?:[\w.@-]+\/)+[\w.@-]+|\b[\w.-]+\.[a-z0-9]{1,8}\b)/gi)) push(m[1]);
55
+ return found;
56
+ }
57
+
58
+ // Compact summary for the review box: how many steps and how many lines.
59
+ export function planStats(plan) {
60
+ const text = String(plan || '');
61
+ const lines = text.split('\n');
62
+ const nonEmpty = lines.filter((l) => l.trim()).length;
63
+ // A "step" is a heading (## / ###) or a top-level numbered item.
64
+ const steps = lines.filter((l) => /^\s*(?:#{1,6}\s+\S|\d+[.)]\s+\S)/.test(l)).length;
65
+ const words = text.split(/\s+/).filter(Boolean).length;
66
+ return { lines: nonEmpty, steps, words, files: filesReferenced(text).length };
67
+ }
68
+
69
+ // Where a saved plan goes: <workspace>/.hncode/plans/<id>.md. Kept inside the
70
+ // project (not ~/.hncode) so it can be committed or reviewed with the change.
71
+ export function plansDir(workspace) {
72
+ return path.join(workspace || process.cwd(), '.hncode', 'plans');
73
+ }
74
+
75
+ // Persist a plan. `id` defaults to a timestamp slug. Returns
76
+ // { ok, path, error }. Never throws.
77
+ export function savePlan(plan, workspace, id) {
78
+ const text = String(plan || '').trim();
79
+ if (!text) return { ok: false, path: '', error: 'empty plan' };
80
+ const dir = plansDir(workspace);
81
+ const slug = id || new Date().toISOString().replace(/[:.]/g, '-').replace('T', '_').slice(0, 19);
82
+ const file = path.join(dir, `${slug}.md`);
83
+ try {
84
+ fs.mkdirSync(dir, { recursive: true });
85
+ fs.writeFileSync(file, `# Plan (${new Date().toISOString()})\n\n${text}\n`, 'utf8');
86
+ } catch (e) {
87
+ return { ok: false, path: file, error: e.message };
88
+ }
89
+ return { ok: true, path: file, error: null };
90
+ }
91
+
92
+ export function listPlans(workspace) {
93
+ const dir = plansDir(workspace);
94
+ let names;
95
+ try { names = fs.readdirSync(dir); } catch { return []; }
96
+ const out = [];
97
+ for (const n of names) {
98
+ if (!n.endsWith('.md')) continue;
99
+ const full = path.join(dir, n);
100
+ try { const st = fs.statSync(full); if (st.isFile()) out.push({ name: n, path: full, mtimeMs: st.mtimeMs }); }
101
+ catch { /* unreadable: skip */ }
102
+ }
103
+ // Newest first. Tie-break by name DESCENDING so that two plans written in the
104
+ // same millisecond still come back in a deterministic (and, for the
105
+ // timestamp-slug naming, newest-looking) order.
106
+ out.sort((a, b) => (b.mtimeMs - a.mtimeMs) || (a.name < b.name ? 1 : a.name > b.name ? -1 : 0));
107
+ return out;
108
+ }
109
+
110
+ // The review panel's lines. Kept here (not the TUI) so it can be unit-tested.
111
+ export function reviewLines(plan, workspace) {
112
+ const stats = planStats(plan);
113
+ const files = filesReferenced(plan);
114
+ const lines = [];
115
+ lines.push(`Steps: ${stats.steps} Lines: ${stats.lines} Files mentioned: ${files.length}`);
116
+ lines.push('');
117
+ if (files.length) {
118
+ lines.push('Files this plan will touch:');
119
+ for (const f of files.slice(0, 40)) lines.push(` ${f}`);
120
+ if (files.length > 40) lines.push(` … and ${files.length - 40} more`);
121
+ } else {
122
+ lines.push('No specific files named in the plan.');
123
+ }
124
+ lines.push('');
125
+ lines.push('Enter = approve & execute e = edit the plan Esc = keep planning');
126
+ return lines;
127
+ }
package/src/skills.js ADDED
@@ -0,0 +1,150 @@
1
+ // Skills — user-supplied reusable prompt packages.
2
+ //
3
+ // A skill is a Markdown file under ~/.hncode/skills/<name>.md. Its body is
4
+ // text that gets injected into the conversation when the user activates it, so
5
+ // a skill is essentially a saved prompt fragment ("review this PR for security
6
+ // issues", "write tests in this project's style", …).
7
+ //
8
+ // Two ways a skill comes into being:
9
+ // * /import-skill <file.md> — copies (or overwrites) an external .md into the
10
+ // skills directory. A duplicate name is REPLACED, not rejected.
11
+ // * hand-authoring a file in the skills directory.
12
+ //
13
+ // Activation is /skill:<name>. The file body is what gets sent; an optional YAML
14
+ // front-matter block (--- … ---) is stripped from the body and only used for the
15
+ // display name / description, so a skill file can stay self-describing without
16
+ // leaking its metadata into the prompt.
17
+ //
18
+ // Everything here is filesystem-only and side-effect free apart from the
19
+ // explicit import/write helpers.
20
+
21
+ import fs from 'node:fs';
22
+ import os from 'node:os';
23
+ import path from 'node:path';
24
+
25
+ export const SKILL_PREFIX = 'skill:';
26
+
27
+ // The skills directory. Kept next to the other hncode state (~/.hncode/…), and
28
+ // overridable through the environment for tests.
29
+ export function skillsDir() {
30
+ return process.env.HNCODE_SKILLS_DIR || path.join(os.homedir(), '.hncode', 'skills');
31
+ }
32
+
33
+ // A skill name is the file's basename without extension. Normalise what the
34
+ // user typed ("Skill:Review", "/skill:review", "review.md") to a bare name.
35
+ export function normalizeSkillName(name) {
36
+ let n = String(name == null ? '' : name).trim();
37
+ n = n.replace(/^\/+/, ''); // leading slash
38
+ if (n.toLowerCase().startsWith(SKILL_PREFIX)) n = n.slice(SKILL_PREFIX.length);
39
+ n = n.replace(/\.md$/i, ''); // trailing .md
40
+ return n.trim();
41
+ }
42
+
43
+ // Parse an optional leading `---\n…\n---` front-matter block. Returns the body
44
+ // with the block removed, plus whatever fields were found. Tolerates a missing
45
+ // or malformed block (then body === text and meta is empty).
46
+ export function parseFrontMatter(text) {
47
+ const src = String(text == null ? '' : text);
48
+ const m = /^\uFEFF?---\r?\n([\s\S]*?)\r?\n---[ \t]*\r?\n?/.exec(src);
49
+ if (!m) return { meta: {}, body: src };
50
+ const meta = {};
51
+ for (const line of m[1].split(/\r?\n/)) {
52
+ const kv = /^\s*([A-Za-z0-9_-]+)\s*:\s*(.*?)\s*$/.exec(line);
53
+ if (!kv) continue;
54
+ let v = kv[2];
55
+ // Strip a single layer of matching quotes.
56
+ if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) v = v.slice(1, -1);
57
+ meta[kv[1].toLowerCase()] = v;
58
+ }
59
+ return { meta, body: src.slice(m[0].length) };
60
+ }
61
+
62
+ // All skills, sorted by name. Each entry: { name, description, path, size }.
63
+ // Unreadable files are skipped rather than failing the whole listing.
64
+ export function listSkills() {
65
+ const dir = skillsDir();
66
+ let files;
67
+ try { files = fs.readdirSync(dir); } catch { return []; }
68
+ const out = [];
69
+ for (const f of files) {
70
+ if (!f.toLowerCase().endsWith('.md')) continue;
71
+ const full = path.join(dir, f);
72
+ let st;
73
+ try { st = fs.statSync(full); } catch { continue; }
74
+ if (!st.isFile()) continue;
75
+ const name = f.replace(/\.md$/i, '');
76
+ let description = '';
77
+ try {
78
+ const { meta } = parseFrontMatter(fs.readFileSync(full, 'utf8'));
79
+ description = meta.description || meta.title || '';
80
+ } catch { /* unreadable: keep it listed with no description */ }
81
+ out.push({ name, description, path: full, size: st.size });
82
+ }
83
+ out.sort((a, b) => a.name.localeCompare(b.name));
84
+ return out;
85
+ }
86
+
87
+ // Read a skill's body (front-matter stripped). Returns null when not found.
88
+ export function readSkill(name) {
89
+ const n = normalizeSkillName(name);
90
+ if (!n) return null;
91
+ const full = path.join(skillsDir(), `${n}.md`);
92
+ let text;
93
+ try { text = fs.readFileSync(full, 'utf8'); } catch { return null; }
94
+ const { meta, body } = parseFrontMatter(text);
95
+ return { name: n, description: meta.description || meta.title || '', body: body.trim(), path: full };
96
+ }
97
+
98
+ // Import (copy or overwrite) an external Markdown file as a skill.
99
+ // `name` overrides the derived name when given. Returns
100
+ // { ok: true, name, path, replaced } or { ok: false, error }.
101
+ export function importSkill(file, name) {
102
+ const srcPath = path.resolve(String(file == null ? '' : file));
103
+ let text;
104
+ try {
105
+ const st = fs.statSync(srcPath);
106
+ if (!st.isFile()) return { ok: false, error: `Not a file: ${srcPath}` };
107
+ text = fs.readFileSync(srcPath, 'utf8');
108
+ } catch (e) {
109
+ return { ok: false, error: `Cannot read ${srcPath}: ${e.message}` };
110
+ }
111
+ // Name precedence: explicit argument, else front-matter `name`, else filename.
112
+ const { meta } = parseFrontMatter(text);
113
+ const derived = name || meta.name || path.basename(srcPath).replace(/\.md$/i, '');
114
+ const skillName = normalizeSkillName(derived);
115
+ if (!skillName) return { ok: false, error: 'Could not derive a skill name (pass one explicitly).' };
116
+ if (/[\\/]/.test(skillName)) return { ok: false, error: `Invalid skill name: ${skillName}` };
117
+
118
+ const dir = skillsDir();
119
+ const dest = path.join(dir, `${skillName}.md`);
120
+ const replaced = fs.existsSync(dest);
121
+ try {
122
+ fs.mkdirSync(dir, { recursive: true });
123
+ fs.writeFileSync(dest, text, 'utf8');
124
+ } catch (e) {
125
+ return { ok: false, error: `Cannot write ${dest}: ${e.message}` };
126
+ }
127
+ return { ok: true, name: skillName, path: dest, replaced };
128
+ }
129
+
130
+ export function deleteSkill(name) {
131
+ const n = normalizeSkillName(name);
132
+ if (!n) return false;
133
+ try { fs.unlinkSync(path.join(skillsDir(), `${n}.md`)); return true; } catch { return false; }
134
+ }
135
+
136
+ // Completion candidates for the composer: names prefixed with the marker the
137
+ // user must type, so accepting a candidate yields `/skill:<name>`.
138
+ export function skillCompletions(prefix = '') {
139
+ const p = normalizeSkillName(prefix).toLowerCase();
140
+ return listSkills()
141
+ .filter((s) => !p || s.name.toLowerCase().startsWith(p))
142
+ .map((s) => ({ name: s.name, description: s.description, insert: SKILL_PREFIX + s.name }));
143
+ }
144
+
145
+ // Build the text a skill contributes to the conversation. A short header keeps
146
+ // the model from treating the body as the user's own words.
147
+ export function skillPrompt(skill) {
148
+ if (!skill || !skill.body) return '';
149
+ return `[skill: ${skill.name}]\n\n${skill.body}`;
150
+ }
@@ -41,7 +41,7 @@ export const SUBAGENT_TYPES = {
41
41
  // An explicit allowlist (not `null` = everything) so Agent/AgentSwarm are
42
42
  // excluded. Mirrors kimi's CODER_TOOLS.
43
43
  tools: [
44
- 'Read', 'Write', 'Edit', 'Glob', 'Grep', 'FileLines', 'Bash',
44
+ 'Read', 'Write', 'Edit', 'Glob', 'Grep', 'FileLines', 'Bash', 'Git',
45
45
  'TodoList', 'FetchURL', 'WebSearch', 'ReadMediaFile',
46
46
  'TaskList', 'TaskOutput', 'TaskStop', 'TaskWait',
47
47
  ],
package/src/tools/edit.js CHANGED
@@ -13,7 +13,7 @@ import { hashRegion as hashLines } from './read.js';
13
13
 
14
14
  export const spec = {
15
15
  name: 'Edit',
16
- description: 'Edit a file. PREFERRED: line-range mode (path + start_line/end_line/new_content) — replace by line numbers when you have Read the file and know the positions. Use line-range mode whenever possible; fall back to exact-substring mode (path + old_string/new_string) only when the exact text is known and a line range is not convenient.',
16
+ description: 'Edit a file. THE TWO MODES ARE MUTUALLY EXCLUSIVE — NEVER combine them:\n - LINE-RANGE mode: path + start_line + end_line + new_content. Replaces lines [start_line, end_line] with new_content. PREFERRED — use it whenever you have Read the file and know the line numbers.\n - EXACT-SUBSTRING mode: path + old_string + new_string. Replaces text exactly matching old_string. Use ONLY when the exact text is known and a line range is not convenient.\nDo NOT pass start_line/end_line together with old_string/new_string in one call, and do NOT pass any of these four keys when you are not using that mode. Omitting new_content or new_string is an error, not an empty replacement.',
17
17
  parameters: {
18
18
  type: 'object',
19
19
  properties: {
@@ -21,13 +21,13 @@ description: 'Edit a file. PREFERRED: line-range mode (path + start_line/end_lin
21
21
  old_string: { type: 'string', description: 'Exact text to replace, including whitespace and newlines. Omit when using start_line/end_line.' },
22
22
  new_string: { type: 'string', description: 'Replacement text for old_string.' },
23
23
  replace_all: { type: 'boolean', default: false, description: 'Replace all occurrences of old_string.' },
24
- start_line: { type: 'integer', minimum: 1, description: 'PREFERRED MODE. 1-based first line to replace (requires end_line and new_content). Use this line-range mode first.' },
24
+ start_line: { type: 'integer', minimum: 1, description: 'PREFERRED MODE. 1-based first line to replace (requires end_line and new_content). Use this line-range mode first.' },
25
25
  end_line: { type: 'integer', minimum: 1, description: 'PREFERRED MODE. 1-based last line to replace (inclusive; requires start_line).' },
26
26
  new_content: { type: 'string', description: 'PREFERRED MODE. Replacement text for the line range [start_line, end_line]. Requires start_line and end_line.' },
27
27
  },
28
- // Either line-range mode (start_line+end_line+new_content) OR exact-substring
29
- // mode (old_string+new_string). Line-range is the preferred route — see the
30
- // tool description and the PREFERRED MODE notes on the params.
28
+ // Either line-range mode (start_line+end_line+new_content) OR exact-substring
29
+ // mode (old_string+new_string).
30
+ // Line-range is the preferred route.
31
31
  anyOf: [
32
32
  { required: ['path', 'start_line', 'end_line', 'new_content'] },
33
33
  { required: ['path', 'old_string', 'new_string'] },
@@ -39,6 +39,14 @@ start_line: { type: 'integer', minimum: 1, description: 'PREFERRED MODE. 1-based
39
39
  let content;
40
40
  try { content = fs.readFileSync(p, 'utf8'); } catch (e) { return `Error reading ${args.path}: ${e.message}`; }
41
41
  const { start_line, end_line, new_content } = args;
42
+ // The two modes are MUTUALLY EXCLUSIVE: sending line-range AND substring
43
+ // parameters together is ambiguous — reject it rather than guessing which
44
+ // one the caller meant. Only one family may be present in `args`.
45
+ const hasRangeKeys = args.start_line != null || args.end_line != null || args.new_content != null;
46
+ const hasSubKeys = args.old_string != null || args.new_string != null;
47
+ if (hasRangeKeys && hasSubKeys) {
48
+ return `Error: do not mix line-range mode and substring mode. Use EITHER (start_line/end_line/new_content) OR (old_string/new_string), never both.`;
49
+ }
42
50
 
43
51
  // ---- LINE-RANGE mode ---------------------------------------------------
44
52
  // `start_line`..`end_line` replace the lines at those 1-based positions,
@@ -47,6 +55,9 @@ start_line: { type: 'integer', minimum: 1, description: 'PREFERRED MODE. 1-based
47
55
  // self-contained: validate -> check the Read cache -> splice -> write ->
48
56
  // refresh the cache -> return.
49
57
  if (start_line != null && end_line != null) {
58
+ // Neither start/end alone is a valid range, and new_content is required —
59
+ // a missing one would put the literal string "undefined" in the file.
60
+ if (new_content == null) return `Error: new_content is required for line-range mode.`;
50
61
  const start = Number(start_line) | 0, end = Number(end_line) | 0;
51
62
  if (start < 1 || end < start) {
52
63
  return `Error: invalid line range ${start_line}-${end_line} (expected 1-based, start <= end).`;
@@ -90,6 +101,7 @@ start_line: { type: 'integer', minimum: 1, description: 'PREFERRED MODE. 1-based
90
101
  // ---- SUBSTRING mode (old_string -> new_string) --------------------------
91
102
  let { old_string, new_string, replace_all = false } = args;
92
103
  if (old_string === '' || old_string == null) return `Error: old_string must not be empty.`;
104
+ if (new_string == null) return `Error: new_string is required for substring mode.`;
93
105
 
94
106
  // --- Newline normalization (Windows CRLF) ---
95
107
  // The Read tool normalizes \r\n -> \n, so an agent builds old_string/new_string
@@ -0,0 +1,155 @@
1
+ // Git tool — structured access to the operations a coding agent performs
2
+ // constantly, so it does not have to hand-write shell pipelines for status,
3
+ // staging, committing, branching and worktrees.
4
+ //
5
+ // Destructive operations are deliberately NOT exposed (no reset --hard, no
6
+ // checkout --, no branch -D): a mistake through this tool would be unrecoverable.
7
+ // The agent can still run those through Bash when the user explicitly asks.
8
+
9
+ import * as git from '../git.js';
10
+
11
+ const ACTIONS = ['status', 'diff', 'stage', 'unstage', 'commit', 'log', 'branch', 'switch', 'worktree'];
12
+
13
+ export const spec = {
14
+ name: 'Git',
15
+ description: `Run a git operation and return its result. Actions:
16
+ - status: branch, staged/unstaged/untracked files.
17
+ - diff: show changes. \`staged: true\` shows the index; \`path\` limits to one file.
18
+ - stage: add files to the index (omit \`paths\` to stage everything).
19
+ - unstage: remove files from the index.
20
+ - commit: commit the staged index with \`message\` (multi-line allowed). Stages everything first if nothing is staged.
21
+ - log: recent commits (use \`count\`, default 10).
22
+ - branch: list branches, or create one with \`create: "<name>"\`.
23
+ - switch: check out an existing branch named \`branch\`.
24
+ - worktree: list worktrees, or add/remove one for parallel work.
25
+
26
+ Use this instead of writing \`git\` shell pipelines. Destructive resets are not exposed; run those with Bash only if the user asked for it.`,
27
+ parameters: {
28
+ type: 'object',
29
+ properties: {
30
+ action: { type: 'string', enum: ACTIONS, description: 'Which git operation to run.' },
31
+ paths: { type: 'array', items: { type: 'string' }, description: 'File paths (stage/unstage/diff).' },
32
+ message: { type: 'string', description: 'Commit message (commit). May be multi-line.' },
33
+ branch: { type: 'string', description: 'Branch name (switch/branch/worktree).' },
34
+ create: { type: 'string', description: 'Create a branch with this name (branch action).' },
35
+ from: { type: 'string', description: 'Base ref for a new branch (branch action).' },
36
+ staged: { type: 'boolean', default: false, description: 'Show the staged diff (diff action).' },
37
+ count: { type: 'integer', minimum: 1, description: 'Number of commits to show (log action; default 10).' },
38
+ dir: { type: 'string', description: 'Worktree directory (worktree action).' },
39
+ remove: { type: 'boolean', default: false, description: 'Remove the worktree instead of adding it (worktree action).' },
40
+ force: { type: 'boolean', default: false, description: 'Force worktree removal (worktree action).' },
41
+ },
42
+ required: ['action'],
43
+ },
44
+
45
+ async execute(args, ctx) {
46
+ const cwd = (ctx && (ctx.cwd || ctx.workspace)) || process.cwd();
47
+ const action = String(args.action || '').trim();
48
+ if (!ACTIONS.includes(action)) return `Error: unknown action "${action}". Use one of: ${ACTIONS.join(', ')}.`;
49
+ if (!git.isRepo(cwd)) return `Error: not a git repository: ${cwd}`;
50
+
51
+ switch (action) {
52
+ case 'status': {
53
+ const branch = git.currentBranch(cwd);
54
+ const s = git.status(cwd);
55
+ if (!s.ok) return `Error: ${s.error}`;
56
+ const lines = [`branch: ${branch}`, `head: ${git.headSha(cwd)}`, ''];
57
+ if (!s.entries.length) lines.push('Working tree clean.');
58
+ else {
59
+ lines.push(`Changes (${s.entries.length}):`);
60
+ for (const e of s.entries) {
61
+ const code = (e.x + e.y).trim() || '?';
62
+ lines.push(` [${code}] ${e.path}${e.origPath ? ` (from ${e.origPath})` : ''}`);
63
+ }
64
+ }
65
+ return lines.join('\n');
66
+ }
67
+
68
+ case 'diff': {
69
+ const a = ['diff'];
70
+ if (args.staged) a.push('--cached');
71
+ a.push('--stat');
72
+ // paths after --
73
+ const paths = Array.isArray(args.paths) ? args.paths.filter(Boolean) : [];
74
+ const statR = git.runGit(a.concat(paths.length ? ['--', ...paths] : []), cwd);
75
+ if (!statR.ok) return `Error: ${statR.stderr.trim() || statR.error}`;
76
+ if (!statR.stdout.trim()) return args.staged ? 'No staged changes.' : 'No changes.';
77
+ // Also include the full diff, bounded so a huge change cannot flood context.
78
+ const full = git.runGit(['diff'].concat(args.staged ? ['--cached'] : []).concat(paths.length ? ['--', ...paths] : []), cwd);
79
+ const MAX = 60 * 1024;
80
+ let body = full.stdout || '';
81
+ let note = '';
82
+ if (body.length > MAX) { body = body.slice(0, MAX); note = `\n... [diff truncated at ${MAX} chars]`; }
83
+ return `${statR.stdout.trim()}\n\n${body}${note}`;
84
+ }
85
+
86
+ case 'stage': {
87
+ const paths = Array.isArray(args.paths) ? args.paths.filter(Boolean) : [];
88
+ const r = git.stage(paths, cwd);
89
+ if (!r.ok) return `Error: ${r.error}`;
90
+ const staged = git.stagedFiles(cwd);
91
+ return `Staged ${paths.length || 'all'} path(s). Index now holds ${staged.length} file(s):\n${staged.join('\n')}`;
92
+ }
93
+
94
+ case 'unstage': {
95
+ const paths = Array.isArray(args.paths) ? args.paths.filter(Boolean) : [];
96
+ const r = git.unstage(paths, cwd);
97
+ return r.ok ? `Unstaged ${paths.length || 'all'} path(s).` : `Error: ${r.error}`;
98
+ }
99
+
100
+ case 'commit': {
101
+ const message = String(args.message || '').trim();
102
+ if (!message) return 'Error: `message` is required for a commit.';
103
+ // Stage everything when the index is empty — "commit my changes" is the
104
+ // common request and an empty-index commit is almost never intended.
105
+ let autoStaged = false;
106
+ if (!git.hasStagedChanges(cwd)) {
107
+ const st = git.stage([], cwd);
108
+ if (!st.ok) return `Error staging: ${st.error}`;
109
+ autoStaged = true;
110
+ }
111
+ const r = git.commit(message, cwd);
112
+ if (!r.ok) return `Error: ${r.error}`;
113
+ return `${autoStaged ? 'Staged all changes.\n' : ''}Committed ${r.sha}.\n${r.stdout}`;
114
+ }
115
+
116
+ case 'log': {
117
+ const n = Math.max(1, Math.min(200, Number(args.count) || 10));
118
+ const r = git.runGit(['log', `-${n}`, '--pretty=format:%h %ad %s', '--date=short'], cwd);
119
+ return r.ok ? (r.stdout.trim() || 'No commits yet.') : `Error: ${r.stderr.trim() || r.error}`;
120
+ }
121
+
122
+ case 'branch': {
123
+ if (args.create) {
124
+ const r = git.createBranch(args.create, cwd, args.from);
125
+ return r.ok ? `Created and switched to branch ${args.create}.` : `Error: ${r.error}`;
126
+ }
127
+ const cur = git.currentBranch(cwd);
128
+ const list = git.listBranches(cwd);
129
+ return `Branches (current: ${cur}):\n` + list.map((b) => ` ${b === cur ? '* ' : ' '}${b}`).join('\n');
130
+ }
131
+
132
+ case 'switch': {
133
+ const r = git.switchBranch(args.branch, cwd);
134
+ return r.ok ? `Switched to ${args.branch}.` : `Error: ${r.error}`;
135
+ }
136
+
137
+ case 'worktree': {
138
+ if (!args.dir) {
139
+ const list = git.listWorktrees(cwd);
140
+ return `Worktrees (${list.length}):\n` + list.map((w) => ` ${w.path} ${w.branch ? '[' + w.branch + ']' : ''}`).join('\n');
141
+ }
142
+ if (args.remove) {
143
+ const r = git.removeWorktree(args.dir, cwd, { force: !!args.force });
144
+ return r.ok ? `Removed worktree ${args.dir}.` : `Error: ${r.error}`;
145
+ }
146
+ const r = git.addWorktree(args.dir, args.branch, cwd);
147
+ if (!r.ok) return `Error: ${r.error}`;
148
+ return r.reused ? `Worktree already present: ${r.path}` : `Worktree created at ${r.path}${r.branch ? ` on ${r.branch}` : ''}.`;
149
+ }
150
+
151
+ default:
152
+ return `Error: unhandled action "${action}".`;
153
+ }
154
+ },
155
+ };
@@ -12,6 +12,7 @@ import * as fetchUrlMod from './fetch-url.js';
12
12
  import * as webSearchMod from './web-search.js';
13
13
  import * as readMediaFileMod from './read-media-file.js';
14
14
  import * as fileLinesMod from './file-lines.js';
15
+ import * as gitMod from './git.js';
15
16
  import * as askUserQuestionMod from './ask-user-question.js';
16
17
  import { spec as agentSpec } from './agent.js';
17
18
  import { spec as agentSwarmSpec } from './agent-swarm.js';
@@ -31,6 +32,7 @@ const builtinTools = [
31
32
  webSearchMod.spec,
32
33
  readMediaFileMod.spec,
33
34
  fileLinesMod.spec,
35
+ gitMod.spec,
34
36
  askUserQuestionMod.spec,
35
37
  agentSpec,
36
38
  agentSwarmSpec,