@hncode/hncode 0.3.2 → 0.3.4
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/README.md +43 -0
- package/package.json +1 -1
- package/src/agent-task.js +78 -0
- package/src/agent.js +150 -45
- package/src/cache.js +130 -0
- package/src/ci.js +244 -0
- package/src/colors.js +18 -0
- package/src/config.js +13 -0
- package/src/git.js +501 -0
- package/src/hooks.js +247 -0
- package/src/index.js +131 -17
- package/src/llm.js +14 -2
- package/src/mcp.js +384 -0
- package/src/plan.js +127 -0
- package/src/skills.js +150 -0
- package/src/subagent-types.js +1 -1
- package/src/tools/edit.js +17 -5
- package/src/tools/git.js +155 -0
- package/src/tools/index.js +2 -0
- package/src/tui.js +1256 -121
- package/src/update.js +35 -14
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.
|
|
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).
|
|
30
|
-
//
|
|
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
|
package/src/tools/git.js
ADDED
|
@@ -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
|
+
};
|
package/src/tools/index.js
CHANGED
|
@@ -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,
|