@hncode/hncode 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +219 -0
- package/README.md +330 -0
- package/TODO.md +72 -0
- package/bin/hncode +9 -0
- package/bin/hncode.cmd +2 -0
- package/bin/hncode.ps1 +4 -0
- package/bin/install.js +96 -0
- package/bin/preinstall.js +42 -0
- package/package.json +26 -0
- package/src/agent.js +373 -0
- package/src/clipboard.js +114 -0
- package/src/colors.js +149 -0
- package/src/config.js +533 -0
- package/src/index.js +296 -0
- package/src/llm.js +449 -0
- package/src/plugin.js +191 -0
- package/src/session.js +73 -0
- package/src/term.js +292 -0
- package/src/toml.js +281 -0
- package/src/tools/bash.js +240 -0
- package/src/tools/edit.js +138 -0
- package/src/tools/fetch-url.js +108 -0
- package/src/tools/file-lines.js +36 -0
- package/src/tools/glob.js +47 -0
- package/src/tools/grep.js +152 -0
- package/src/tools/ignore.js +99 -0
- package/src/tools/index.js +61 -0
- package/src/tools/matchers.js +71 -0
- package/src/tools/read-media-file.js +54 -0
- package/src/tools/read.js +99 -0
- package/src/tools/tasks.js +126 -0
- package/src/tools/todo.js +33 -0
- package/src/tools/utils.js +71 -0
- package/src/tools/web-search.js +75 -0
- package/src/tools/write.js +29 -0
- package/src/tui.js +5503 -0
package/src/index.js
ADDED
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
// hncode entry: CLI arg parsing, subcommands, headless -p mode, and TUI dispatch.
|
|
2
|
+
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
import { resolveConfig, effectiveSnapshot, saveConfig, resolveModelArg } from './config.js';
|
|
7
|
+
import * as sess from './session.js';
|
|
8
|
+
import { Agent, SYSTEM_PROMPT } from './agent.js';
|
|
9
|
+
import { llmTools } from './tools/index.js';
|
|
10
|
+
import { setToolsList } from './llm.js';
|
|
11
|
+
|
|
12
|
+
export const VERSION = '0.1.0';
|
|
13
|
+
|
|
14
|
+
const HELP = `hncode ${VERSION} — a kimi-code-cli-style coding agent.
|
|
15
|
+
|
|
16
|
+
Usage: hncode [options] [command]
|
|
17
|
+
|
|
18
|
+
Cyan-blue TUI for long agent sessions. OpenAI + Anthropic streaming,
|
|
19
|
+
MCP-style toolbelt (Edit, Read, Write, Bash, Glob, Grep, TodoList, ...).
|
|
20
|
+
|
|
21
|
+
Options:
|
|
22
|
+
-V, --version output the version number
|
|
23
|
+
-S, --session [id] Resume a session (with id) or pick interactively.
|
|
24
|
+
-c, --continue Continue the previous session for this directory.
|
|
25
|
+
-y, --yolo Routine edits/commands run; risky actions still ask.
|
|
26
|
+
--auto Never ask; everything runs automatically.
|
|
27
|
+
-m, --model <model> Model alias to use for this invocation.
|
|
28
|
+
-p, --prompt <prompt> Run one prompt non-interactively and print the reply.
|
|
29
|
+
--output-format <format> Output format for prompt mode: text (default) or stream-json.
|
|
30
|
+
--plan Start in plan mode (research only, no writes).
|
|
31
|
+
--add-dir <dir> Add an additional workspace directory. Repeatable.
|
|
32
|
+
-h, --help Show help.
|
|
33
|
+
|
|
34
|
+
Commands:
|
|
35
|
+
session list List sessions, most recent first.
|
|
36
|
+
provider list Show configured providers.
|
|
37
|
+
doctor config [path] Validate a config.toml (default: merged config).
|
|
38
|
+
init Create ~/.hncode/config.toml with defaults.
|
|
39
|
+
export [id] Export a session to ~/.hncode/export/<id>.json.
|
|
40
|
+
`;
|
|
41
|
+
|
|
42
|
+
function printHelp() { process.stdout.write(HELP); }
|
|
43
|
+
|
|
44
|
+
// ---- minimal argv parsing ----
|
|
45
|
+
export function parseArgs(argv) {
|
|
46
|
+
const out = { positional: [], flags: {} };
|
|
47
|
+
for (let i = 0; i < argv.length; i++) {
|
|
48
|
+
const a = argv[i];
|
|
49
|
+
if (a.startsWith('--')) {
|
|
50
|
+
const eq = a.indexOf('=');
|
|
51
|
+
let key, val;
|
|
52
|
+
if (eq >= 0) { key = a.slice(2, eq); val = a.slice(eq + 1); }
|
|
53
|
+
else { key = a.slice(2); val = null; }
|
|
54
|
+
if (key === 'resume') key = 'session'; // `--resume <id>` is an alias for `--session <id>`
|
|
55
|
+
if (['session'].includes(key) && val === null) { const nx = argv[i + 1]; out[key] = nx !== undefined && !nx.startsWith('-') ? (i++, nx) : true; }
|
|
56
|
+
else if (key === 'model' || key === 'prompt' || key === 'output-format' || key === 'add-dir') {
|
|
57
|
+
if (val === null) { val = argv[++i]; }
|
|
58
|
+
out[key] = key === 'add-dir' ? (out[key] || []).concat([val]) : val;
|
|
59
|
+
} else if (val !== null) { out[key] = val === 'true' ? true : val === 'false' ? false : val; }
|
|
60
|
+
else out[key] = true;
|
|
61
|
+
} else if (a.startsWith('-') && a.length > 1) {
|
|
62
|
+
const key = a.slice(1);
|
|
63
|
+
const short = { V: 'version', S: 'session', c: 'continue', y: 'yolo', auto: 'auto', m: 'model', p: 'prompt', h: 'help' };
|
|
64
|
+
if (key === 'S' || key === 'm' || key === 'p') {
|
|
65
|
+
let val = argv[++i];
|
|
66
|
+
out[short[key]] = val;
|
|
67
|
+
} else out[short[key]] = true;
|
|
68
|
+
} else {
|
|
69
|
+
out.positional.push(a);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return out;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// ---- subcommands ----
|
|
76
|
+
function cmdSessionList(args) {
|
|
77
|
+
const items = sess.listSessions();
|
|
78
|
+
if (items.length === 0) { console.log('No sessions yet.'); return 0; }
|
|
79
|
+
for (const s of items.slice(0, 30)) {
|
|
80
|
+
const when = new Date(s.updatedAt || 0).toISOString().slice(0, 19).replace('T', ' ');
|
|
81
|
+
const title = (s.title || '(untitled)').replace(/\n/g, ' ');
|
|
82
|
+
console.log(`${s.id.padEnd(20)} ${when} ${title}`);
|
|
83
|
+
}
|
|
84
|
+
return 0;
|
|
85
|
+
}
|
|
86
|
+
function cmdProviderList() {
|
|
87
|
+
const cfg = resolveConfig();
|
|
88
|
+
const provs = (cfg.raw.providers) || {};
|
|
89
|
+
const models = (cfg.raw.models) || {};
|
|
90
|
+
const counts = {};
|
|
91
|
+
for (const m of Object.values(models)) if (m.provider) counts[m.provider] = (counts[m.provider] || 0) + 1;
|
|
92
|
+
const names = Object.keys(provs).length ? Object.keys(provs) : [cfg.provider];
|
|
93
|
+
for (const name of names) {
|
|
94
|
+
const p = provs[name];
|
|
95
|
+
const url = p ? (p.base_url || p.baseUrl || '') : cfg.baseUrl;
|
|
96
|
+
console.log(`${name} (${counts[name] || 0} models) ${url}`);
|
|
97
|
+
}
|
|
98
|
+
console.log(`active: ${cfg.provider} / ${cfg.model}`);
|
|
99
|
+
return 0;
|
|
100
|
+
}
|
|
101
|
+
async function cmdDoctorConfig(p) {
|
|
102
|
+
let file = p;
|
|
103
|
+
if (!file) {
|
|
104
|
+
const home = os.homedir();
|
|
105
|
+
for (const f of [process.env.HNCODE_CONFIG, path.join(home, '.hncode', 'config.toml')]) {
|
|
106
|
+
if (f && fs.existsSync(f)) { file = f; break; }
|
|
107
|
+
}
|
|
108
|
+
file = file || path.join(home, '.hncode', 'config.toml');
|
|
109
|
+
console.log(`No path given; validated: ${file}`);
|
|
110
|
+
}
|
|
111
|
+
try {
|
|
112
|
+
const txt = fs.readFileSync(file, 'utf8');
|
|
113
|
+
const { parse } = await import('./toml.js');
|
|
114
|
+
parse(txt);
|
|
115
|
+
console.log(`OK: ${file} is valid TOML.`);
|
|
116
|
+
return 0;
|
|
117
|
+
} catch (e) {
|
|
118
|
+
console.error(`INVALID: ${file}: ${e.message}`);
|
|
119
|
+
return 1;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function cmdInit() {
|
|
124
|
+
const cfg = resolveConfig();
|
|
125
|
+
const target = path.join(os.homedir(), '.hncode', 'config.toml');
|
|
126
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
127
|
+
const lines = [
|
|
128
|
+
`# hncode configuration`,
|
|
129
|
+
`default_model = "${cfg.raw.default_model || 'gpt-4o-mini'}"`,
|
|
130
|
+
``,
|
|
131
|
+
`# Or set these directly:`,
|
|
132
|
+
`# base_url = "${cfg.baseUrl}"`,
|
|
133
|
+
`# protocol = "openai" # or "anthropic"`,
|
|
134
|
+
`# max_context_size = 128000`,
|
|
135
|
+
`# max_output_size = 4096`,
|
|
136
|
+
];
|
|
137
|
+
if (!fs.existsSync(target)) {
|
|
138
|
+
fs.writeFileSync(target, lines.join('\n') + '\n', 'utf8');
|
|
139
|
+
console.log(`Created ${target}`);
|
|
140
|
+
} else {
|
|
141
|
+
console.log(`Already exists: ${target}`);
|
|
142
|
+
}
|
|
143
|
+
return 0;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function cmdExport(id) {
|
|
147
|
+
const s = id ? sess.loadSession(id) : sess.latestSession(process.cwd());
|
|
148
|
+
if (!s) { console.error('No session found.'); return 1; }
|
|
149
|
+
const exportDir = path.join(os.homedir(), '.hncode', 'export');
|
|
150
|
+
fs.mkdirSync(exportDir, { recursive: true });
|
|
151
|
+
const out = path.join(exportDir, `${s.id}.json`);
|
|
152
|
+
fs.writeFileSync(out, JSON.stringify(s, null, 2), 'utf8');
|
|
153
|
+
console.log(`Exported ${s.id} -> ${out}`);
|
|
154
|
+
return 0;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const SUBCOMMANDS = {
|
|
158
|
+
'session': { 'list': cmdSessionList },
|
|
159
|
+
'provider': { 'list': cmdProviderList },
|
|
160
|
+
'doctor': { 'config': cmdDoctorConfig },
|
|
161
|
+
'init': cmdInit,
|
|
162
|
+
'export': cmdExport,
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
// ---- headless prompt mode ----
|
|
166
|
+
async function runPrompt({ cfg, prompt, format, session, modelArg }) {
|
|
167
|
+
cfg = resolveModelArg(cfg, modelArg);
|
|
168
|
+
if (!cfg.apiKey) {
|
|
169
|
+
console.error('hncode: no api_key configured. Set HNCODE_API_KEY or configure a provider first. Run `hncode doctor config` to check.');
|
|
170
|
+
return 2;
|
|
171
|
+
}
|
|
172
|
+
const messages = [];
|
|
173
|
+
if (session && session.messages) for (const m of session.messages.slice(-30)) messages.push(m);
|
|
174
|
+
messages.push({ role: 'system', content: SYSTEM_PROMPT });
|
|
175
|
+
const saved = session || { id: sess.newId(), title: prompt.slice(0, 60), workspace: path.resolve(process.cwd()), model: cfg.model, createdAt: Date.now(), messages: [] };
|
|
176
|
+
const agent = new Agent({
|
|
177
|
+
cfg,
|
|
178
|
+
messages,
|
|
179
|
+
// Uncapped: run until the model finishes (see agent.js).
|
|
180
|
+
onEvent: (e) => {
|
|
181
|
+
if (format === 'stream-json') {
|
|
182
|
+
if (e.type === 'context') return; // TUI-only gauge; not part of the JSON stream
|
|
183
|
+
const line = { type: e.type };
|
|
184
|
+
if (e.text) line.text = e.text;
|
|
185
|
+
if (e.name) line.name = e.name;
|
|
186
|
+
if (e.id) line.id = e.id;
|
|
187
|
+
if (e.content != null) line.content = e.content;
|
|
188
|
+
process.stdout.write(JSON.stringify(line) + '\n');
|
|
189
|
+
} else if (e.type === 'data') {
|
|
190
|
+
process.stdout.write(e.text);
|
|
191
|
+
}
|
|
192
|
+
},
|
|
193
|
+
});
|
|
194
|
+
saved.messages = messages;
|
|
195
|
+
await agent.run();
|
|
196
|
+
sess.saveSession(saved);
|
|
197
|
+
if (format !== 'stream-json') process.stdout.write('\n');
|
|
198
|
+
return 0;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// ---- TUI dispatch ----
|
|
202
|
+
async function startTui(opts) {
|
|
203
|
+
const { startTUI } = await import('./tui.js');
|
|
204
|
+
await startTUI(opts);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function loadOrCreateSession(opts, cfg) {
|
|
208
|
+
let session = null;
|
|
209
|
+
// NOTE: these keys must match what parseArgs actually produces — `--continue`
|
|
210
|
+
// yields `args.continue` (not `args.cont`), and `--resume <id>` is normalised
|
|
211
|
+
// to `args.session`. Reading the wrong key silently fell through to "create a
|
|
212
|
+
// new session", which is why --continue looked like it started a fresh one.
|
|
213
|
+
const sessionArg = opts.session;
|
|
214
|
+
const wantsSession = typeof sessionArg === 'string' || sessionArg === true || !!opts.sessionId;
|
|
215
|
+
const wantsContinue = !!(opts.continue || opts.cont);
|
|
216
|
+
if (wantsSession) {
|
|
217
|
+
const id = typeof sessionArg === 'string' ? sessionArg : opts.sessionId;
|
|
218
|
+
if (id) session = sess.loadSession(id) || null;
|
|
219
|
+
} else if (wantsContinue) {
|
|
220
|
+
// --continue: get the most recent session for THIS directory that has messages.
|
|
221
|
+
// Empty shells are created on every launch, so we skip them to find real conversations.
|
|
222
|
+
session = sess.latestSession(process.cwd(), undefined, { skipEmpty: true });
|
|
223
|
+
}
|
|
224
|
+
if (!session) {
|
|
225
|
+
session = { id: sess.newId(), title: '', workspace: path.resolve(process.cwd()), model: cfg.model, createdAt: Date.now(), messages: [] };
|
|
226
|
+
} else {
|
|
227
|
+
// CRITICAL: Update session model to match current config!
|
|
228
|
+
// This prevents using stale model ID from old sessions.
|
|
229
|
+
session.model = cfg.model;
|
|
230
|
+
}
|
|
231
|
+
return session;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
export async function main(argv) {
|
|
235
|
+
const args = parseArgs(argv);
|
|
236
|
+
if (args.help) { printHelp(); return 0; }
|
|
237
|
+
if (args.version) { console.log(VERSION); return 0; }
|
|
238
|
+
|
|
239
|
+
// subcommands
|
|
240
|
+
const [cmd, sub] = args.positional;
|
|
241
|
+
if (cmd) {
|
|
242
|
+
if (cmd === 'doctor' && !sub) { printHelp(); return 0; }
|
|
243
|
+
const handler = SUBCOMMANDS[cmd];
|
|
244
|
+
if (handler) {
|
|
245
|
+
if (sub !== undefined) {
|
|
246
|
+
const h2 = handler[sub];
|
|
247
|
+
if (h2) return await h2(args.positional[2]);
|
|
248
|
+
printHelp(); return 1;
|
|
249
|
+
}
|
|
250
|
+
if (typeof handler === 'function') return await handler(args.positional[1]);
|
|
251
|
+
printHelp(); return 0;
|
|
252
|
+
}
|
|
253
|
+
console.error(`unknown command: ${cmd}`);
|
|
254
|
+
return 1;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const baseCfg = resolveConfig();
|
|
258
|
+
const cfg = resolveModelArg(baseCfg, args.model);
|
|
259
|
+
|
|
260
|
+
// Load plugins (if enabled) before anything else so their tools and commands
|
|
261
|
+
// are available in both headless and TUI modes.
|
|
262
|
+
if (cfg.pluginDir) {
|
|
263
|
+
const { loadPlugins } = await import('./plugin.js');
|
|
264
|
+
await loadPlugins(cfg.pluginDir);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// headless prompt
|
|
268
|
+
if (args.prompt) {
|
|
269
|
+
let session = null;
|
|
270
|
+
if (args.session) session = typeof args.session === 'string' ? sess.loadSession(args.session) : sess.latestSession(undefined, undefined, { skipEmpty: true });
|
|
271
|
+
else if (args.continue) session = sess.latestSession(undefined, undefined, { skipEmpty: true });
|
|
272
|
+
return runPrompt({ cfg, prompt: args.prompt, format: args['output-format'], session, modelArg: args.model });
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
if (!process.stdout.isTTY) {
|
|
276
|
+
console.error('hncode: interactive mode requires a TTY. Use `hncode -p "prompt"` for non-interactive use.');
|
|
277
|
+
return 1;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const opts = {
|
|
281
|
+
cfg,
|
|
282
|
+
sessionFlag: args.session,
|
|
283
|
+
sessionId: typeof args.session === 'string' ? args.session : null,
|
|
284
|
+
cont: !!args.continue,
|
|
285
|
+
yolo: !!args.yolo,
|
|
286
|
+
auto: !!args.auto,
|
|
287
|
+
plan: !!args.plan,
|
|
288
|
+
addDirs: args['add-dir'] || [],
|
|
289
|
+
session: loadOrCreateSession(args, cfg),
|
|
290
|
+
};
|
|
291
|
+
await startTui(opts);
|
|
292
|
+
return 0;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// main() is invoked by bin/hncode (the CLI entry point), which imports this module
|
|
296
|
+
// and calls main(process.argv.slice(2)) explicitly.
|