@v1nvn/agentic-core 0.45.0 → 0.46.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/package.json +5 -5
- package/src/cli.js +194 -0
- package/src/dev.js +25 -0
- package/src/draw.js +244 -0
- package/src/index.js +8 -0
- package/src/input.js +25 -0
- package/src/last-reply.js +75 -0
- package/src/logger.js +50 -0
- package/src/mcp.js +29 -0
- package/src/shutdown.js +23 -0
- package/src/vite.js +63 -0
package/package.json
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@v1nvn/agentic-core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.46.0",
|
|
4
4
|
"description": "Shared source home for the @v1nvn agent tools — runtime helpers and the drawing kit every process-side surface imports.",
|
|
5
5
|
"type": "module",
|
|
6
|
-
"main": "./src/index.
|
|
6
|
+
"main": "./src/index.js",
|
|
7
7
|
"exports": {
|
|
8
|
-
".": "./src/index.
|
|
9
|
-
"./draw": "./src/draw.
|
|
10
|
-
"./vite": "./src/vite.
|
|
8
|
+
".": "./src/index.js",
|
|
9
|
+
"./draw": "./src/draw.js",
|
|
10
|
+
"./vite": "./src/vite.js"
|
|
11
11
|
},
|
|
12
12
|
"scripts": {
|
|
13
13
|
"test": "vitest run",
|
package/src/cli.js
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import { Command, InvalidArgumentError, Option } from 'commander';
|
|
2
|
+
import { describeError } from "./mcp.js";
|
|
3
|
+
// A --help ask caught mid-parse: the parse doors return it instead of exiting,
|
|
4
|
+
// and runCli answers it (the help payload) on stdout with 0.
|
|
5
|
+
export class HelpRequested extends Error {
|
|
6
|
+
command;
|
|
7
|
+
constructor(command) {
|
|
8
|
+
super(`${command.name()} --help`);
|
|
9
|
+
this.command = command;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
function addOption(cmd, option) {
|
|
13
|
+
const bare = option.env === undefined &&
|
|
14
|
+
option.choices === undefined &&
|
|
15
|
+
option.default === undefined &&
|
|
16
|
+
option.parse === undefined;
|
|
17
|
+
if (bare) {
|
|
18
|
+
cmd.option(option.flags, option.description);
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
const built = new Option(option.flags, option.description);
|
|
22
|
+
if (option.env !== undefined) {
|
|
23
|
+
built.env(option.env);
|
|
24
|
+
}
|
|
25
|
+
if (option.choices !== undefined) {
|
|
26
|
+
built.choices(option.choices);
|
|
27
|
+
}
|
|
28
|
+
if (option.default !== undefined) {
|
|
29
|
+
built.default(option.default);
|
|
30
|
+
}
|
|
31
|
+
const { parse } = option;
|
|
32
|
+
if (parse !== undefined) {
|
|
33
|
+
// A plain Error from a parse fn skips commander's exitOverride, which is
|
|
34
|
+
// what records the subcommand for the usage print — wrap it so it rides.
|
|
35
|
+
built.argParser((value) => {
|
|
36
|
+
try {
|
|
37
|
+
return parse(value);
|
|
38
|
+
}
|
|
39
|
+
catch (err) {
|
|
40
|
+
if (err.code === 'commander.invalidArgument') {
|
|
41
|
+
throw err;
|
|
42
|
+
}
|
|
43
|
+
throw new InvalidArgumentError(describeError(err));
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
cmd.addOption(built);
|
|
48
|
+
}
|
|
49
|
+
function wire(cmd, spec, program, isRoot, sink) {
|
|
50
|
+
if (spec.description !== undefined) {
|
|
51
|
+
cmd.description(spec.description);
|
|
52
|
+
}
|
|
53
|
+
if (spec.argument !== undefined) {
|
|
54
|
+
cmd.argument(spec.argument.flags, spec.argument.description);
|
|
55
|
+
}
|
|
56
|
+
for (const option of spec.options ?? []) {
|
|
57
|
+
addOption(cmd, option);
|
|
58
|
+
}
|
|
59
|
+
if (isRoot && spec.version !== undefined) {
|
|
60
|
+
cmd.option('-V, --version', 'print the version and exit');
|
|
61
|
+
}
|
|
62
|
+
cmd.action((...all) => {
|
|
63
|
+
const options = all.at(-2);
|
|
64
|
+
const command = all.at(-1);
|
|
65
|
+
sink?.({
|
|
66
|
+
command: isRoot ? undefined : spec.name,
|
|
67
|
+
options,
|
|
68
|
+
args: command.args,
|
|
69
|
+
root: program.opts(),
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
for (const sub of spec.commands ?? []) {
|
|
73
|
+
const child = new Command(sub.name);
|
|
74
|
+
wire(child, sub, program, false, sink);
|
|
75
|
+
cmd.addCommand(child);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
function materialize(spec, sink) {
|
|
79
|
+
const program = new Command(spec.name);
|
|
80
|
+
wire(program, spec, program, true, sink);
|
|
81
|
+
return program;
|
|
82
|
+
}
|
|
83
|
+
export function buildCli(spec) {
|
|
84
|
+
return materialize(spec);
|
|
85
|
+
}
|
|
86
|
+
function quiet(command, onUsageError) {
|
|
87
|
+
command.configureOutput({
|
|
88
|
+
writeOut: () => undefined,
|
|
89
|
+
writeErr: () => undefined,
|
|
90
|
+
});
|
|
91
|
+
command.exitOverride(err => {
|
|
92
|
+
const code = err.code;
|
|
93
|
+
if (code === 'commander.help' || code === 'commander.helpDisplayed') {
|
|
94
|
+
throw new HelpRequested(command);
|
|
95
|
+
}
|
|
96
|
+
onUsageError(command);
|
|
97
|
+
throw err;
|
|
98
|
+
});
|
|
99
|
+
command.commands.forEach(c => {
|
|
100
|
+
quiet(c, onUsageError);
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
export function parseCli(spec, argv, onHelp, onUsageError) {
|
|
104
|
+
let invocation;
|
|
105
|
+
const program = materialize(spec, found => {
|
|
106
|
+
invocation = found;
|
|
107
|
+
});
|
|
108
|
+
let errored;
|
|
109
|
+
quiet(program, command => {
|
|
110
|
+
errored = command;
|
|
111
|
+
});
|
|
112
|
+
try {
|
|
113
|
+
program.parse([...argv], { from: 'user' });
|
|
114
|
+
}
|
|
115
|
+
catch (err) {
|
|
116
|
+
if (!(err instanceof HelpRequested)) {
|
|
117
|
+
if (errored !== undefined) {
|
|
118
|
+
const cause = err instanceof Error ? err : new Error(String(err));
|
|
119
|
+
onUsageError?.(errored, cause);
|
|
120
|
+
}
|
|
121
|
+
return undefined;
|
|
122
|
+
}
|
|
123
|
+
return onHelp !== undefined
|
|
124
|
+
? onHelp(err.command.name(), err.command.helpInformation())
|
|
125
|
+
: err;
|
|
126
|
+
}
|
|
127
|
+
return invocation;
|
|
128
|
+
}
|
|
129
|
+
export function helpTextFor(spec, topic) {
|
|
130
|
+
const program = materialize(spec);
|
|
131
|
+
const command = topic === spec.name
|
|
132
|
+
? program
|
|
133
|
+
: program.commands.find(c => c.name() === topic);
|
|
134
|
+
if (command === undefined) {
|
|
135
|
+
throw new Error(`no '${topic}' command to describe`);
|
|
136
|
+
}
|
|
137
|
+
return command.helpInformation();
|
|
138
|
+
}
|
|
139
|
+
function usageError(spec, topic, message) {
|
|
140
|
+
if (message !== undefined) {
|
|
141
|
+
// Commander 15 builds its messages with the 'error: ' prefix already in.
|
|
142
|
+
process.stderr.write(`${message}\n`);
|
|
143
|
+
}
|
|
144
|
+
process.stderr.write(`${helpTextFor(spec, topic === undefined ? spec.name : topic)}\n`);
|
|
145
|
+
return spec.usageExit ?? 1;
|
|
146
|
+
}
|
|
147
|
+
async function call(run, options, args) {
|
|
148
|
+
try {
|
|
149
|
+
return (await run(options, args)) ?? 0;
|
|
150
|
+
}
|
|
151
|
+
catch (err) {
|
|
152
|
+
console.error(err.message);
|
|
153
|
+
return 1;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
export async function runCli(spec, argv, onHelp) {
|
|
157
|
+
let asked;
|
|
158
|
+
let usageTopic;
|
|
159
|
+
let usageMessage;
|
|
160
|
+
const parsed = parseCli(spec, argv, (topic, text) => {
|
|
161
|
+
asked = { topic, text };
|
|
162
|
+
return 0;
|
|
163
|
+
}, (command, err) => {
|
|
164
|
+
usageTopic = command.name();
|
|
165
|
+
usageMessage = err.message;
|
|
166
|
+
});
|
|
167
|
+
if (parsed === undefined) {
|
|
168
|
+
return usageError(spec, usageTopic, usageMessage);
|
|
169
|
+
}
|
|
170
|
+
if (asked !== undefined) {
|
|
171
|
+
if (onHelp !== undefined) {
|
|
172
|
+
return onHelp(asked.topic, asked.text) ?? 0;
|
|
173
|
+
}
|
|
174
|
+
process.stdout.write(`${asked.text}\n`);
|
|
175
|
+
return 0;
|
|
176
|
+
}
|
|
177
|
+
const invocation = parsed;
|
|
178
|
+
if (spec.version !== undefined && invocation.root.version === true) {
|
|
179
|
+
process.stdout.write(`${spec.version}\n`);
|
|
180
|
+
return 0;
|
|
181
|
+
}
|
|
182
|
+
if (invocation.command === undefined) {
|
|
183
|
+
if ((spec.commands?.length ?? 0) > 0) {
|
|
184
|
+
return usageError(spec);
|
|
185
|
+
}
|
|
186
|
+
return spec.run === undefined
|
|
187
|
+
? 0
|
|
188
|
+
: await call(spec.run, invocation.options, invocation.args);
|
|
189
|
+
}
|
|
190
|
+
const sub = spec.commands?.find(c => c.name === invocation.command);
|
|
191
|
+
return sub?.run === undefined
|
|
192
|
+
? 0
|
|
193
|
+
: await call(sub.run, invocation.options, invocation.args);
|
|
194
|
+
}
|
package/src/dev.js
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
const RELOAD_DEBOUNCE_MS = 150;
|
|
2
|
+
const IGNORED_SEGMENTS = ['/node_modules/', '/dist/', '/.git/', '/coverage/'];
|
|
3
|
+
function shouldReload(file) {
|
|
4
|
+
return IGNORED_SEGMENTS.every(seg => !file.includes(seg));
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* Debounced file-change reloads, serialized via a promise chain: concurrent
|
|
8
|
+
* saves never overlap, and a mid-reload save still produces a follow-up.
|
|
9
|
+
*/
|
|
10
|
+
export function watchWithReload(vite, reload) {
|
|
11
|
+
let chain = Promise.resolve();
|
|
12
|
+
let timer;
|
|
13
|
+
vite.watcher.on('change', file => {
|
|
14
|
+
if (!shouldReload(file)) {
|
|
15
|
+
return;
|
|
16
|
+
}
|
|
17
|
+
if (timer) {
|
|
18
|
+
clearTimeout(timer);
|
|
19
|
+
}
|
|
20
|
+
timer = setTimeout(() => {
|
|
21
|
+
timer = undefined;
|
|
22
|
+
chain = chain.then(reload);
|
|
23
|
+
}, RELOAD_DEBOUNCE_MS);
|
|
24
|
+
});
|
|
25
|
+
}
|
package/src/draw.js
ADDED
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The drawing kit — the one home of the repo's terminal drawing model: ink
|
|
3
|
+
* kinds are a closed union, color values are data, surfaces render
|
|
4
|
+
* documents, and `toSgr` is the one skin, resetting each inked segment
|
|
5
|
+
* unless it carries its ink into the next. Loaded only by processes — the
|
|
6
|
+
* engine's island loader imports its own files by relative path alone.
|
|
7
|
+
*/
|
|
8
|
+
export function rgb(r, g, b) {
|
|
9
|
+
return { rgb: [r, g, b] };
|
|
10
|
+
}
|
|
11
|
+
export function indexed(n) {
|
|
12
|
+
return { indexed: n };
|
|
13
|
+
}
|
|
14
|
+
export function bg(ink) {
|
|
15
|
+
return { bg: ink };
|
|
16
|
+
}
|
|
17
|
+
export function line(...segments) {
|
|
18
|
+
return segments;
|
|
19
|
+
}
|
|
20
|
+
export function doc(...rows) {
|
|
21
|
+
return rows;
|
|
22
|
+
}
|
|
23
|
+
export function plain(text) {
|
|
24
|
+
return { text };
|
|
25
|
+
}
|
|
26
|
+
export function dim(text) {
|
|
27
|
+
return { text, ink: 'dim' };
|
|
28
|
+
}
|
|
29
|
+
export function bold(text) {
|
|
30
|
+
return { text, ink: 'bold' };
|
|
31
|
+
}
|
|
32
|
+
export function green(text) {
|
|
33
|
+
return { text, ink: 'green' };
|
|
34
|
+
}
|
|
35
|
+
export function magenta(text) {
|
|
36
|
+
return { text, ink: 'magenta' };
|
|
37
|
+
}
|
|
38
|
+
export function red(text) {
|
|
39
|
+
return { text, ink: 'red' };
|
|
40
|
+
}
|
|
41
|
+
export function yellow(text) {
|
|
42
|
+
return { text, ink: 'yellow' };
|
|
43
|
+
}
|
|
44
|
+
function assertNever(kind) {
|
|
45
|
+
throw new Error(`unhandled Ink kind: ${String(kind)}`);
|
|
46
|
+
}
|
|
47
|
+
function paramsOf(ink) {
|
|
48
|
+
if (typeof ink !== 'string') {
|
|
49
|
+
if ('bg' in ink) {
|
|
50
|
+
return paramsOf(ink.bg)
|
|
51
|
+
.replace(/^3(\d)/, '4$1')
|
|
52
|
+
.replace(/^9(\d)/, '10$1');
|
|
53
|
+
}
|
|
54
|
+
return 'indexed' in ink
|
|
55
|
+
? `38;5;${ink.indexed}`
|
|
56
|
+
: `38;2;${ink.rgb[0]};${ink.rgb[1]};${ink.rgb[2]}`;
|
|
57
|
+
}
|
|
58
|
+
switch (ink) {
|
|
59
|
+
case 'blue':
|
|
60
|
+
return '34';
|
|
61
|
+
case 'bold':
|
|
62
|
+
return '1';
|
|
63
|
+
case 'brightBlack':
|
|
64
|
+
return '90';
|
|
65
|
+
case 'cyan':
|
|
66
|
+
return '36';
|
|
67
|
+
case 'dim':
|
|
68
|
+
return '2';
|
|
69
|
+
case 'green':
|
|
70
|
+
return '32';
|
|
71
|
+
case 'magenta':
|
|
72
|
+
return '35';
|
|
73
|
+
case 'mark':
|
|
74
|
+
return '1;97';
|
|
75
|
+
case 'plain':
|
|
76
|
+
return '39';
|
|
77
|
+
case 'red':
|
|
78
|
+
return '31';
|
|
79
|
+
case 'white':
|
|
80
|
+
return '37';
|
|
81
|
+
case 'yellow':
|
|
82
|
+
return '33';
|
|
83
|
+
default:
|
|
84
|
+
return assertNever(ink);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
function rowsOf(rows) {
|
|
88
|
+
return rows.every(r => Array.isArray(r))
|
|
89
|
+
? rows
|
|
90
|
+
: [rows];
|
|
91
|
+
}
|
|
92
|
+
export function toSgr(rows) {
|
|
93
|
+
return rowsOf(rows)
|
|
94
|
+
.map(row => {
|
|
95
|
+
let out = '';
|
|
96
|
+
let inkOpen = false;
|
|
97
|
+
for (const seg of row) {
|
|
98
|
+
if (seg.ink === undefined) {
|
|
99
|
+
out += (inkOpen ? '\x1b[0m' : '') + seg.text;
|
|
100
|
+
inkOpen = false;
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
out += `\x1b[${paramsOf(seg.ink)}m${seg.text}`;
|
|
104
|
+
if (seg.carry === true) {
|
|
105
|
+
inkOpen = true;
|
|
106
|
+
}
|
|
107
|
+
else {
|
|
108
|
+
out += '\x1b[0m';
|
|
109
|
+
inkOpen = false;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return inkOpen ? `${out}\x1b[0m` : out;
|
|
113
|
+
})
|
|
114
|
+
.join('\n');
|
|
115
|
+
}
|
|
116
|
+
export function stripSgr(text) {
|
|
117
|
+
// eslint-disable-next-line no-control-regex
|
|
118
|
+
return text.replace(/\x1b\[[0-9;]*m/g, '');
|
|
119
|
+
}
|
|
120
|
+
export function textOf(rows) {
|
|
121
|
+
return rowsOf(rows)
|
|
122
|
+
.map(row => row.map(seg => seg.text).join(''))
|
|
123
|
+
.join('\n');
|
|
124
|
+
}
|
|
125
|
+
function stdioOf(result) {
|
|
126
|
+
return typeof result === 'object' && result !== null ? result : {};
|
|
127
|
+
}
|
|
128
|
+
export function stdoutOf(result) {
|
|
129
|
+
const { stdout } = stdioOf(result);
|
|
130
|
+
return typeof stdout === 'string' ? stdout : '';
|
|
131
|
+
}
|
|
132
|
+
/** The one line an exec shows: the refusal's reason, stdout, else stderr or `fallback`. */
|
|
133
|
+
export function lineOf(call, fallback) {
|
|
134
|
+
if (call.deny !== undefined) {
|
|
135
|
+
return call.deny;
|
|
136
|
+
}
|
|
137
|
+
if (call.isError === true) {
|
|
138
|
+
return call.text ?? fallback;
|
|
139
|
+
}
|
|
140
|
+
const out = stdoutOf(call.result).trim();
|
|
141
|
+
if (out !== '') {
|
|
142
|
+
return out;
|
|
143
|
+
}
|
|
144
|
+
const { stderr } = stdioOf(call.result);
|
|
145
|
+
const err = typeof stderr === 'string' ? stderr.trim() : '';
|
|
146
|
+
return err !== '' ? err : fallback;
|
|
147
|
+
}
|
|
148
|
+
export const RULE_WIDTH = 68;
|
|
149
|
+
export const BLOCK = '█';
|
|
150
|
+
export const TRACK = '·';
|
|
151
|
+
export const EIGHTHS = [
|
|
152
|
+
'',
|
|
153
|
+
'▏',
|
|
154
|
+
'▎',
|
|
155
|
+
'▍',
|
|
156
|
+
'▌',
|
|
157
|
+
'▋',
|
|
158
|
+
'▊',
|
|
159
|
+
'▉',
|
|
160
|
+
];
|
|
161
|
+
export function rule(width = RULE_WIDTH) {
|
|
162
|
+
return '─'.repeat(Math.max(0, width));
|
|
163
|
+
}
|
|
164
|
+
export function pad(text, width, side) {
|
|
165
|
+
if (text.length >= width) {
|
|
166
|
+
return text;
|
|
167
|
+
}
|
|
168
|
+
const gap = ' '.repeat(width - text.length);
|
|
169
|
+
return side === 'left' ? gap + text : text + gap;
|
|
170
|
+
}
|
|
171
|
+
export function meter(pct, width, filled = 'green') {
|
|
172
|
+
const safePct = typeof pct === 'number' && Number.isFinite(pct) ? pct : 0;
|
|
173
|
+
const n = Math.max(0, Math.min(width, Math.round((safePct / 100) * width)));
|
|
174
|
+
return line({ text: BLOCK.repeat(n), ink: filled }, { text: TRACK.repeat(width - n), ink: 'dim' });
|
|
175
|
+
}
|
|
176
|
+
/** Fixed-width bar field: full blocks, an eighth-fraction edge, spaces to width. */
|
|
177
|
+
export function bar(value, max, width) {
|
|
178
|
+
if (!(value > 0) || !(max > 0)) {
|
|
179
|
+
return ' '.repeat(width);
|
|
180
|
+
}
|
|
181
|
+
const scaled = (value / max) * width;
|
|
182
|
+
let full = Math.floor(scaled);
|
|
183
|
+
let fi = Math.round((scaled - full) * 8);
|
|
184
|
+
if (fi === 8) {
|
|
185
|
+
full += 1;
|
|
186
|
+
fi = 0;
|
|
187
|
+
}
|
|
188
|
+
if (full === 0 && fi === 0) {
|
|
189
|
+
fi = 1;
|
|
190
|
+
}
|
|
191
|
+
let out = BLOCK.repeat(Math.min(full, width));
|
|
192
|
+
if (full < width && fi > 0) {
|
|
193
|
+
out += EIGHTHS[fi] ?? '';
|
|
194
|
+
}
|
|
195
|
+
return pad(out, width, 'right');
|
|
196
|
+
}
|
|
197
|
+
export function pad2(n) {
|
|
198
|
+
return String(n).padStart(2, '0');
|
|
199
|
+
}
|
|
200
|
+
export const MONTHS = [
|
|
201
|
+
'Jan',
|
|
202
|
+
'Feb',
|
|
203
|
+
'Mar',
|
|
204
|
+
'Apr',
|
|
205
|
+
'May',
|
|
206
|
+
'Jun',
|
|
207
|
+
'Jul',
|
|
208
|
+
'Aug',
|
|
209
|
+
'Sep',
|
|
210
|
+
'Oct',
|
|
211
|
+
'Nov',
|
|
212
|
+
'Dec',
|
|
213
|
+
];
|
|
214
|
+
/** Compact count: 1.2K / 3.4M / 5.6B; '—' for absent or NaN. */
|
|
215
|
+
export function fmtTokens(n) {
|
|
216
|
+
if (n == null || Number.isNaN(n)) {
|
|
217
|
+
return '—';
|
|
218
|
+
}
|
|
219
|
+
if (n >= 1e9) {
|
|
220
|
+
return (n / 1e9).toFixed(1) + 'B';
|
|
221
|
+
}
|
|
222
|
+
if (n >= 1e6) {
|
|
223
|
+
return (n / 1e6).toFixed(1) + 'M';
|
|
224
|
+
}
|
|
225
|
+
if (n >= 1e3) {
|
|
226
|
+
return (n / 1e3).toFixed(1) + 'K';
|
|
227
|
+
}
|
|
228
|
+
return String(n);
|
|
229
|
+
}
|
|
230
|
+
/** Grouped integer, en-US separators; 0 for absent. */
|
|
231
|
+
export function fmtNum(n) {
|
|
232
|
+
return (n || 0).toLocaleString('en-US');
|
|
233
|
+
}
|
|
234
|
+
/** Local date-time stamp: `YYYY-MM-DD`, plus `HH:mm` and optionally seconds. */
|
|
235
|
+
export function localStamp(now, clock) {
|
|
236
|
+
const day = `${now.getFullYear()}-${pad2(now.getMonth() + 1)}-${pad2(now.getDate())}`;
|
|
237
|
+
if (clock === 'none') {
|
|
238
|
+
return day;
|
|
239
|
+
}
|
|
240
|
+
const hhmm = `${pad2(now.getHours())}:${pad2(now.getMinutes())}`;
|
|
241
|
+
return clock === 'second'
|
|
242
|
+
? `${day} ${hhmm}:${pad2(now.getSeconds())}`
|
|
243
|
+
: `${day} ${hhmm}`;
|
|
244
|
+
}
|
package/src/index.js
ADDED
package/src/input.js
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { readFileSync, statSync } from 'node:fs';
|
|
2
|
+
function readAll(stream) {
|
|
3
|
+
return new Promise((resolve, reject) => {
|
|
4
|
+
let data = '';
|
|
5
|
+
stream.setEncoding('utf8');
|
|
6
|
+
stream.on('data', (chunk) => {
|
|
7
|
+
data += chunk;
|
|
8
|
+
});
|
|
9
|
+
stream.on('end', () => {
|
|
10
|
+
resolve(data);
|
|
11
|
+
});
|
|
12
|
+
stream.on('error', reject);
|
|
13
|
+
});
|
|
14
|
+
}
|
|
15
|
+
export function readStdin(stream = process.stdin) {
|
|
16
|
+
return readAll(stream);
|
|
17
|
+
}
|
|
18
|
+
/** A markdown file's contents, with the CLI's no-such-file error. */
|
|
19
|
+
export function readMarkdownFile(path) {
|
|
20
|
+
const stats = statSync(path, { throwIfNoEntry: false });
|
|
21
|
+
if (!stats?.isFile()) {
|
|
22
|
+
throw new Error(`no such file: ${path}`);
|
|
23
|
+
}
|
|
24
|
+
return readFileSync(path, 'utf8');
|
|
25
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
function isFile(path) {
|
|
5
|
+
try {
|
|
6
|
+
return statSync(path).isFile();
|
|
7
|
+
}
|
|
8
|
+
catch {
|
|
9
|
+
return false;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
function textBlocks(entry) {
|
|
13
|
+
const content = entry.message?.content;
|
|
14
|
+
if (entry.type !== 'assistant' || !Array.isArray(content)) {
|
|
15
|
+
return undefined;
|
|
16
|
+
}
|
|
17
|
+
const blocks = content.filter((b) => typeof b === 'object' &&
|
|
18
|
+
b !== null &&
|
|
19
|
+
b.type === 'text');
|
|
20
|
+
return blocks.length > 0 ? blocks : undefined;
|
|
21
|
+
}
|
|
22
|
+
export function claudeProjectsDir() {
|
|
23
|
+
const claudeDir = process.env.CLAUDE_DIR ?? join(homedir(), '.claude');
|
|
24
|
+
return join(claudeDir, 'projects');
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* The last assistant text reply from a Claude Code session, reproducing Claude
|
|
28
|
+
* Code's `/copy` byte-for-byte: the last assistant entry that contains a
|
|
29
|
+
* `text` block, only its `text` block(s) (tool_use / thinking dropped), blocks
|
|
30
|
+
* joined with a blank line, and no trailing newline.
|
|
31
|
+
*
|
|
32
|
+
* @param arg a transcript file, a session UUID under the project dir, or
|
|
33
|
+
* nothing to use the newest session for the current project.
|
|
34
|
+
*/
|
|
35
|
+
export function lastReply(arg) {
|
|
36
|
+
// Claude Code keys transcripts under the project root with "/" → "-".
|
|
37
|
+
// Prefer $CLAUDE_PROJECT_DIR (exported to hook processes) over $PWD.
|
|
38
|
+
const proj = (process.env.CLAUDE_PROJECT_DIR ?? process.cwd()).replaceAll('/', '-');
|
|
39
|
+
const projDir = join(claudeProjectsDir(), proj);
|
|
40
|
+
let file;
|
|
41
|
+
if (arg !== undefined) {
|
|
42
|
+
file = isFile(arg) ? arg : join(projDir, `${arg}.jsonl`);
|
|
43
|
+
}
|
|
44
|
+
else if (existsSync(projDir)) {
|
|
45
|
+
const newest = readdirSync(projDir)
|
|
46
|
+
.filter(f => f.endsWith('.jsonl'))
|
|
47
|
+
.map(f => ({ f, mtimeMs: statSync(join(projDir, f)).mtimeMs }))
|
|
48
|
+
.sort((a, b) => b.mtimeMs - a.mtimeMs)
|
|
49
|
+
.at(0);
|
|
50
|
+
if (newest) {
|
|
51
|
+
file = join(projDir, newest.f);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
if (file === undefined || !isFile(file)) {
|
|
55
|
+
throw new Error(`no session transcript found in ${projDir}`);
|
|
56
|
+
}
|
|
57
|
+
let last;
|
|
58
|
+
for (const line of readFileSync(file, 'utf8').split('\n')) {
|
|
59
|
+
if (!line.includes('"assistant"')) {
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
let entry;
|
|
63
|
+
try {
|
|
64
|
+
entry = JSON.parse(line);
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
const blocks = textBlocks(entry);
|
|
70
|
+
if (blocks) {
|
|
71
|
+
last = blocks;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return (last ?? []).map(b => b.text ?? '').join('\n\n');
|
|
75
|
+
}
|
package/src/logger.js
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
const LEVEL_RANK = {
|
|
2
|
+
debug: 10,
|
|
3
|
+
info: 20,
|
|
4
|
+
warn: 30,
|
|
5
|
+
error: 40,
|
|
6
|
+
silent: Number.MAX_SAFE_INTEGER,
|
|
7
|
+
};
|
|
8
|
+
const LEVEL_LABEL = {
|
|
9
|
+
debug: 'DEBUG',
|
|
10
|
+
error: 'ERROR',
|
|
11
|
+
info: 'INFO',
|
|
12
|
+
warn: 'WARN',
|
|
13
|
+
};
|
|
14
|
+
const VALID_LEVELS = [
|
|
15
|
+
'debug',
|
|
16
|
+
'info',
|
|
17
|
+
'warn',
|
|
18
|
+
'error',
|
|
19
|
+
'silent',
|
|
20
|
+
];
|
|
21
|
+
export function loggerFromEnv(env, variable, fallback = 'info') {
|
|
22
|
+
const raw = env[variable];
|
|
23
|
+
return new Logger(VALID_LEVELS.find(level => level === raw) ?? fallback);
|
|
24
|
+
}
|
|
25
|
+
// stdout carries the MCP transport; everything here goes to stderr.
|
|
26
|
+
export class Logger {
|
|
27
|
+
activeLevel;
|
|
28
|
+
stderr;
|
|
29
|
+
constructor(activeLevel, stderr = line => process.stderr.write(`${line}\n`)) {
|
|
30
|
+
this.activeLevel = activeLevel;
|
|
31
|
+
this.stderr = stderr;
|
|
32
|
+
}
|
|
33
|
+
debug(message) {
|
|
34
|
+
this.log('debug', message);
|
|
35
|
+
}
|
|
36
|
+
error(message) {
|
|
37
|
+
this.log('error', message);
|
|
38
|
+
}
|
|
39
|
+
info(message) {
|
|
40
|
+
this.log('info', message);
|
|
41
|
+
}
|
|
42
|
+
warn(message) {
|
|
43
|
+
this.log('warn', message);
|
|
44
|
+
}
|
|
45
|
+
log(level, message) {
|
|
46
|
+
if (LEVEL_RANK[level] >= LEVEL_RANK[this.activeLevel]) {
|
|
47
|
+
this.stderr(`${LEVEL_LABEL[level]} ${message}`);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
package/src/mcp.js
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
export function describeError(err) {
|
|
2
|
+
return err instanceof Error ? err.message : String(err);
|
|
3
|
+
}
|
|
4
|
+
/**
|
|
5
|
+
* The wrapper owns the error-drift class: a thrown or rejected error (zod
|
|
6
|
+
* parse included) logs and answers an `isError` result instead of escaping
|
|
7
|
+
* the tool call.
|
|
8
|
+
*/
|
|
9
|
+
export function toolHandler(log, toErrorResult) {
|
|
10
|
+
return tool;
|
|
11
|
+
function tool(name, run, schema) {
|
|
12
|
+
function fail(err) {
|
|
13
|
+
log.error(`${name} failed: ${describeError(err)}`);
|
|
14
|
+
return toErrorResult(err);
|
|
15
|
+
}
|
|
16
|
+
return raw => {
|
|
17
|
+
try {
|
|
18
|
+
const args = schema === undefined ? raw : schema.parse(raw);
|
|
19
|
+
const result = run(args);
|
|
20
|
+
return result instanceof Promise
|
|
21
|
+
? result.then(undefined, fail)
|
|
22
|
+
: result;
|
|
23
|
+
}
|
|
24
|
+
catch (err) {
|
|
25
|
+
return fail(err);
|
|
26
|
+
}
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
}
|
package/src/shutdown.js
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/** Close each closable in order (best-effort), then force-exit. */
|
|
2
|
+
export function shutdownOnSignals(closables, signals = ['SIGINT', 'SIGTERM']) {
|
|
3
|
+
function shutdown() {
|
|
4
|
+
void closables
|
|
5
|
+
.reduce((acc, closable) => acc.then(async () => {
|
|
6
|
+
try {
|
|
7
|
+
await closable.close();
|
|
8
|
+
}
|
|
9
|
+
catch {
|
|
10
|
+
// best-effort; exiting regardless
|
|
11
|
+
}
|
|
12
|
+
return;
|
|
13
|
+
}), Promise.resolve())
|
|
14
|
+
.finally(() => {
|
|
15
|
+
// Force-exit on signal; the n/no-process-exit rule targets libraries.
|
|
16
|
+
// eslint-disable-next-line n/no-process-exit
|
|
17
|
+
process.exit(0);
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
for (const signal of signals) {
|
|
21
|
+
process.on(signal, shutdown);
|
|
22
|
+
}
|
|
23
|
+
}
|
package/src/vite.js
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { dirname, resolve } from 'node:path';
|
|
2
|
+
import { createServerModuleRunner, createServer as createViteServer, } from 'vite';
|
|
3
|
+
import { configDefaults } from 'vitest/config';
|
|
4
|
+
import { watchWithReload } from "./dev.js";
|
|
5
|
+
import { describeError } from "./mcp.js";
|
|
6
|
+
import { shutdownOnSignals } from "./shutdown.js";
|
|
7
|
+
export function serverVite({ testTimeout } = {}) {
|
|
8
|
+
return {
|
|
9
|
+
// Vite 8 spins the HMR websocket from `server.ws`, not `server.hmr`
|
|
10
|
+
// (`hmr: false` stops messaging, not the socket): without this a second
|
|
11
|
+
// dev boot dies fighting over the default ws port 24678.
|
|
12
|
+
server: { ws: false },
|
|
13
|
+
test: {
|
|
14
|
+
globals: true,
|
|
15
|
+
environment: 'node',
|
|
16
|
+
...(testTimeout === undefined ? {} : { testTimeout }),
|
|
17
|
+
exclude: [...configDefaults.exclude, 'dist/**'],
|
|
18
|
+
},
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* The hot-reload harness every MCP-server dev entry runs. The transport and
|
|
23
|
+
* McpServer are single-use under the SDK (`StdioServerTransport.start()`
|
|
24
|
+
* throws on a second call), so `start` creates and connects them once and
|
|
25
|
+
* only the registrations swap on each file change. `start` must register
|
|
26
|
+
* before connecting — the SDK's `registerCapabilities` throws post-connect.
|
|
27
|
+
* `entry` is the module that exports the registration families, at
|
|
28
|
+
* `<package root>/src/<name>.ts`.
|
|
29
|
+
*/
|
|
30
|
+
export async function runDevServer(options) {
|
|
31
|
+
const vite = await createViteServer({
|
|
32
|
+
appType: 'custom',
|
|
33
|
+
configFile: false,
|
|
34
|
+
// Vite logs `info` (e.g. "(ssr) page reload") via console.log to stdout,
|
|
35
|
+
// which corrupts the MCP stream; `warn` keeps errors on stderr.
|
|
36
|
+
logLevel: 'warn',
|
|
37
|
+
root: resolve(dirname(options.entry), '..'),
|
|
38
|
+
server: { middlewareMode: true, ws: false },
|
|
39
|
+
});
|
|
40
|
+
// hmr: false in a hot-reload harness looks wrong but is right: the
|
|
41
|
+
// watcher re-imports the whole entry rather than patching modules.
|
|
42
|
+
const runner = createServerModuleRunner(vite.environments.ssr, {
|
|
43
|
+
hmr: false,
|
|
44
|
+
});
|
|
45
|
+
const first = await runner.import(options.entry);
|
|
46
|
+
const session = await options.start(first);
|
|
47
|
+
async function runOneReload() {
|
|
48
|
+
try {
|
|
49
|
+
runner.evaluatedModules.clear();
|
|
50
|
+
// Import the next module before reload removes the live
|
|
51
|
+
// registrations: a failed import leaves what is serving intact.
|
|
52
|
+
const next = await runner.import(options.entry);
|
|
53
|
+
session.reload(next);
|
|
54
|
+
options.log.info('[reload] success');
|
|
55
|
+
}
|
|
56
|
+
catch (err) {
|
|
57
|
+
options.log.error(`[reload] failed: ${describeError(err)}`);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
watchWithReload(vite, runOneReload);
|
|
61
|
+
options.log.info('[reload] ready');
|
|
62
|
+
shutdownOnSignals([...session.closers, vite]);
|
|
63
|
+
}
|