@canonmsg/claude-code-plugin 0.15.2 → 0.19.1
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/dist/approval-diff.d.ts +15 -0
- package/dist/approval-diff.js +287 -0
- package/dist/host.d.ts +9 -0
- package/dist/host.js +400 -191
- package/dist/outbox.d.ts +83 -0
- package/dist/outbox.js +132 -0
- package/dist/startup-recovery.d.ts +46 -0
- package/dist/startup-recovery.js +59 -0
- package/dist/tool-policy.d.ts +7 -1
- package/dist/tool-policy.js +10 -0
- package/package.json +7 -6
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { type CanonUnifiedDiff } from '@canonmsg/core';
|
|
2
|
+
/** Read a file's text, or null when the file does not exist. */
|
|
3
|
+
export type ApprovalDiffReadFile = (filePath: string) => Promise<string | null>;
|
|
4
|
+
export interface SynthesizeApprovalDiffOptions {
|
|
5
|
+
/** Session working directory — resolves relative tool paths and shortens display paths. */
|
|
6
|
+
cwd?: string;
|
|
7
|
+
readFile: ApprovalDiffReadFile;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Synthesize a CanonUnifiedDiff preview for a pending Edit/MultiEdit/Write
|
|
11
|
+
* tool call. Returns undefined for any other tool (including NotebookEdit —
|
|
12
|
+
* cell edits do not map onto file hunks) and whenever the post-image cannot
|
|
13
|
+
* be derived faithfully. Output is size-disciplined via truncateUnifiedDiff.
|
|
14
|
+
*/
|
|
15
|
+
export declare function synthesizeClaudeApprovalDiff(toolName: string, toolInput: Record<string, unknown>, options: SynthesizeApprovalDiffOptions): Promise<CanonUnifiedDiff | undefined>;
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
import { isAbsolute, join, relative } from 'node:path';
|
|
2
|
+
import { sanitizeUnifiedDiffForSharing, truncateUnifiedDiff, } from '@canonmsg/core';
|
|
3
|
+
/** Unified-hunk context lines on either side of a change (git default). */
|
|
4
|
+
const HUNK_CONTEXT_LINES = 3;
|
|
5
|
+
/**
|
|
6
|
+
* Skip synthesis entirely for sources beyond this size — the card budget
|
|
7
|
+
* (UNIFIED_DIFF_MAX_FILE_BYTES) would clip the output to a sliver anyway, and
|
|
8
|
+
* diffing multi-megabyte files inside canUseTool would stall the approval.
|
|
9
|
+
*/
|
|
10
|
+
const MAX_SOURCE_CHARS = 2 * 1024 * 1024;
|
|
11
|
+
/**
|
|
12
|
+
* LCS cost ceiling (old lines × new lines). Beyond it the changed middle is
|
|
13
|
+
* emitted as one delete-all/add-all block instead of a minimal diff.
|
|
14
|
+
*/
|
|
15
|
+
const MAX_LCS_CELLS = 1_000_000;
|
|
16
|
+
const NO_NEWLINE_MARKER = '\';
|
|
17
|
+
/**
|
|
18
|
+
* Synthesize a CanonUnifiedDiff preview for a pending Edit/MultiEdit/Write
|
|
19
|
+
* tool call. Returns undefined for any other tool (including NotebookEdit —
|
|
20
|
+
* cell edits do not map onto file hunks) and whenever the post-image cannot
|
|
21
|
+
* be derived faithfully. Output is size-disciplined via truncateUnifiedDiff.
|
|
22
|
+
*/
|
|
23
|
+
export async function synthesizeClaudeApprovalDiff(toolName, toolInput, options) {
|
|
24
|
+
const normalized = toolName.trim().toLowerCase();
|
|
25
|
+
if (normalized !== 'edit' && normalized !== 'multiedit' && normalized !== 'write') {
|
|
26
|
+
return undefined;
|
|
27
|
+
}
|
|
28
|
+
const filePath = typeof toolInput.file_path === 'string' ? toolInput.file_path.trim() : '';
|
|
29
|
+
if (!filePath)
|
|
30
|
+
return undefined;
|
|
31
|
+
const resolvedPath = isAbsolute(filePath)
|
|
32
|
+
? filePath
|
|
33
|
+
: options.cwd
|
|
34
|
+
? join(options.cwd, filePath)
|
|
35
|
+
: filePath;
|
|
36
|
+
const preImage = await options.readFile(resolvedPath);
|
|
37
|
+
if (preImage !== null && !isDiffableText(preImage))
|
|
38
|
+
return undefined;
|
|
39
|
+
let postImage;
|
|
40
|
+
let status;
|
|
41
|
+
if (normalized === 'write') {
|
|
42
|
+
if (typeof toolInput.content !== 'string')
|
|
43
|
+
return undefined;
|
|
44
|
+
postImage = toolInput.content;
|
|
45
|
+
status = preImage === null ? 'created' : 'modified';
|
|
46
|
+
}
|
|
47
|
+
else {
|
|
48
|
+
// Edit / MultiEdit need a pre-image to splice into; if the file is absent
|
|
49
|
+
// the tool itself will fail, so there is no change to preview.
|
|
50
|
+
if (preImage === null)
|
|
51
|
+
return undefined;
|
|
52
|
+
const edits = normalized === 'edit'
|
|
53
|
+
? parseSingleEdit(toolInput)
|
|
54
|
+
: parseMultiEdits(toolInput);
|
|
55
|
+
if (!edits)
|
|
56
|
+
return undefined;
|
|
57
|
+
const spliced = applyEdits(preImage, edits);
|
|
58
|
+
if (spliced === null)
|
|
59
|
+
return undefined;
|
|
60
|
+
postImage = spliced;
|
|
61
|
+
status = 'modified';
|
|
62
|
+
}
|
|
63
|
+
if (!isDiffableText(postImage))
|
|
64
|
+
return undefined;
|
|
65
|
+
const oldText = preImage ?? '';
|
|
66
|
+
if (oldText === postImage)
|
|
67
|
+
return undefined;
|
|
68
|
+
const hunks = buildUnifiedHunks(oldText, postImage);
|
|
69
|
+
if (!hunks)
|
|
70
|
+
return undefined;
|
|
71
|
+
// Sanitize before truncating so byte budgets are spent on hunks that ship —
|
|
72
|
+
// secret-bearing paths and private-key blocks lose their hunk text here.
|
|
73
|
+
return truncateUnifiedDiff(sanitizeUnifiedDiffForSharing({
|
|
74
|
+
files: [{
|
|
75
|
+
path: toDisplayPath(resolvedPath, options.cwd),
|
|
76
|
+
status,
|
|
77
|
+
diff: hunks.text,
|
|
78
|
+
additions: hunks.additions,
|
|
79
|
+
deletions: hunks.deletions,
|
|
80
|
+
}],
|
|
81
|
+
}));
|
|
82
|
+
}
|
|
83
|
+
// ── Edit splicing ────────────────────────────────────────────────────
|
|
84
|
+
function parseSingleEdit(toolInput) {
|
|
85
|
+
const edit = parseEdit(toolInput);
|
|
86
|
+
return edit ? [edit] : null;
|
|
87
|
+
}
|
|
88
|
+
function parseMultiEdits(toolInput) {
|
|
89
|
+
if (!Array.isArray(toolInput.edits) || toolInput.edits.length === 0)
|
|
90
|
+
return null;
|
|
91
|
+
const edits = [];
|
|
92
|
+
for (const entry of toolInput.edits) {
|
|
93
|
+
if (!entry || typeof entry !== 'object')
|
|
94
|
+
return null;
|
|
95
|
+
const edit = parseEdit(entry);
|
|
96
|
+
if (!edit)
|
|
97
|
+
return null;
|
|
98
|
+
edits.push(edit);
|
|
99
|
+
}
|
|
100
|
+
return edits;
|
|
101
|
+
}
|
|
102
|
+
function parseEdit(value) {
|
|
103
|
+
const oldString = typeof value.old_string === 'string' ? value.old_string : null;
|
|
104
|
+
const newString = typeof value.new_string === 'string' ? value.new_string : null;
|
|
105
|
+
// Empty old_string is a create-or-append special case with tool-version
|
|
106
|
+
// dependent semantics — skip rather than guess at the post-image.
|
|
107
|
+
if (!oldString || newString === null)
|
|
108
|
+
return null;
|
|
109
|
+
return { oldString, newString, replaceAll: value.replace_all === true };
|
|
110
|
+
}
|
|
111
|
+
/** Apply edits sequentially (MultiEdit semantics). Null when any edit cannot apply. */
|
|
112
|
+
function applyEdits(text, edits) {
|
|
113
|
+
let current = text;
|
|
114
|
+
for (const edit of edits) {
|
|
115
|
+
current = applyEdit(current, edit);
|
|
116
|
+
if (current === null)
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
return current;
|
|
120
|
+
}
|
|
121
|
+
function applyEdit(text, edit) {
|
|
122
|
+
if (edit.oldString === edit.newString)
|
|
123
|
+
return null;
|
|
124
|
+
const first = text.indexOf(edit.oldString);
|
|
125
|
+
if (first === -1)
|
|
126
|
+
return null;
|
|
127
|
+
if (edit.replaceAll) {
|
|
128
|
+
return text.split(edit.oldString).join(edit.newString);
|
|
129
|
+
}
|
|
130
|
+
// The Edit tool requires a unique match without replace_all; mirror that so
|
|
131
|
+
// the preview never shows a splice the tool would refuse to make.
|
|
132
|
+
if (text.indexOf(edit.oldString, first + edit.oldString.length) !== -1)
|
|
133
|
+
return null;
|
|
134
|
+
return text.slice(0, first) + edit.newString + text.slice(first + edit.oldString.length);
|
|
135
|
+
}
|
|
136
|
+
function isDiffableText(text) {
|
|
137
|
+
return text.length <= MAX_SOURCE_CHARS && !text.includes('\u0000');
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* Split into lines keeping CRLF intact: we split on '\n' only, so '\r' stays
|
|
141
|
+
* part of the line text and survives into the hunk verbatim. The trailing-
|
|
142
|
+
* newline state rides on the final line so "a\n" vs "a" still diffs.
|
|
143
|
+
*/
|
|
144
|
+
function splitDiffLines(text) {
|
|
145
|
+
if (text.length === 0)
|
|
146
|
+
return [];
|
|
147
|
+
const parts = text.split('\n');
|
|
148
|
+
const trailingNewline = parts[parts.length - 1] === '';
|
|
149
|
+
if (trailingNewline)
|
|
150
|
+
parts.pop();
|
|
151
|
+
return parts.map((line, index) => ({
|
|
152
|
+
text: line,
|
|
153
|
+
noEol: !trailingNewline && index === parts.length - 1,
|
|
154
|
+
}));
|
|
155
|
+
}
|
|
156
|
+
function sameLine(a, b) {
|
|
157
|
+
return a.text === b.text && a.noEol === b.noEol;
|
|
158
|
+
}
|
|
159
|
+
/** Common prefix/suffix trim, then a bounded LCS over the changed middle. */
|
|
160
|
+
function diffLines(oldLines, newLines) {
|
|
161
|
+
let prefix = 0;
|
|
162
|
+
const maxPrefix = Math.min(oldLines.length, newLines.length);
|
|
163
|
+
while (prefix < maxPrefix && sameLine(oldLines[prefix], newLines[prefix]))
|
|
164
|
+
prefix += 1;
|
|
165
|
+
let suffix = 0;
|
|
166
|
+
while (suffix < maxPrefix - prefix
|
|
167
|
+
&& sameLine(oldLines[oldLines.length - 1 - suffix], newLines[newLines.length - 1 - suffix]))
|
|
168
|
+
suffix += 1;
|
|
169
|
+
return [
|
|
170
|
+
...oldLines.slice(0, prefix).map((line) => ({ kind: ' ', line })),
|
|
171
|
+
...diffMiddle(oldLines.slice(prefix, oldLines.length - suffix), newLines.slice(prefix, newLines.length - suffix)),
|
|
172
|
+
...oldLines.slice(oldLines.length - suffix).map((line) => ({ kind: ' ', line })),
|
|
173
|
+
];
|
|
174
|
+
}
|
|
175
|
+
function diffMiddle(oldLines, newLines) {
|
|
176
|
+
const replaceAll = () => [
|
|
177
|
+
...oldLines.map((line) => ({ kind: '-', line })),
|
|
178
|
+
...newLines.map((line) => ({ kind: '+', line })),
|
|
179
|
+
];
|
|
180
|
+
if (oldLines.length === 0 || newLines.length === 0)
|
|
181
|
+
return replaceAll();
|
|
182
|
+
if (oldLines.length * newLines.length > MAX_LCS_CELLS)
|
|
183
|
+
return replaceAll();
|
|
184
|
+
// Classic LCS-length table, walked forward to interleave -/+ runs.
|
|
185
|
+
const cols = newLines.length + 1;
|
|
186
|
+
const table = new Uint32Array((oldLines.length + 1) * cols);
|
|
187
|
+
for (let i = oldLines.length - 1; i >= 0; i -= 1) {
|
|
188
|
+
for (let j = newLines.length - 1; j >= 0; j -= 1) {
|
|
189
|
+
table[i * cols + j] = sameLine(oldLines[i], newLines[j])
|
|
190
|
+
? table[(i + 1) * cols + j + 1] + 1
|
|
191
|
+
: Math.max(table[(i + 1) * cols + j], table[i * cols + j + 1]);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
const ops = [];
|
|
195
|
+
let i = 0;
|
|
196
|
+
let j = 0;
|
|
197
|
+
while (i < oldLines.length && j < newLines.length) {
|
|
198
|
+
if (sameLine(oldLines[i], newLines[j])) {
|
|
199
|
+
ops.push({ kind: ' ', line: oldLines[i] });
|
|
200
|
+
i += 1;
|
|
201
|
+
j += 1;
|
|
202
|
+
}
|
|
203
|
+
else if (table[(i + 1) * cols + j] >= table[i * cols + j + 1]) {
|
|
204
|
+
ops.push({ kind: '-', line: oldLines[i] });
|
|
205
|
+
i += 1;
|
|
206
|
+
}
|
|
207
|
+
else {
|
|
208
|
+
ops.push({ kind: '+', line: newLines[j] });
|
|
209
|
+
j += 1;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
while (i < oldLines.length)
|
|
213
|
+
ops.push({ kind: '-', line: oldLines[i++] });
|
|
214
|
+
while (j < newLines.length)
|
|
215
|
+
ops.push({ kind: '+', line: newLines[j++] });
|
|
216
|
+
return ops;
|
|
217
|
+
}
|
|
218
|
+
/**
|
|
219
|
+
* Render diff ops as unified hunk text with @@ headers and git-style framing
|
|
220
|
+
* (counts of 1 omitted, "" markers). Null when the
|
|
221
|
+
* texts are line-identical.
|
|
222
|
+
*/
|
|
223
|
+
function buildUnifiedHunks(oldText, newText) {
|
|
224
|
+
const ops = diffLines(splitDiffLines(oldText), splitDiffLines(newText));
|
|
225
|
+
// Old/new line number of each op's position (1-based, "next line to emit").
|
|
226
|
+
const oldPos = new Array(ops.length);
|
|
227
|
+
const newPos = new Array(ops.length);
|
|
228
|
+
let oldLine = 1;
|
|
229
|
+
let newLine = 1;
|
|
230
|
+
ops.forEach((op, index) => {
|
|
231
|
+
oldPos[index] = oldLine;
|
|
232
|
+
newPos[index] = newLine;
|
|
233
|
+
if (op.kind !== '+')
|
|
234
|
+
oldLine += 1;
|
|
235
|
+
if (op.kind !== '-')
|
|
236
|
+
newLine += 1;
|
|
237
|
+
});
|
|
238
|
+
const changed = ops.flatMap((op, index) => (op.kind === ' ' ? [] : [index]));
|
|
239
|
+
if (changed.length === 0)
|
|
240
|
+
return null;
|
|
241
|
+
// Group changes into hunks: expand by the context window, merge overlaps.
|
|
242
|
+
const ranges = [];
|
|
243
|
+
for (const index of changed) {
|
|
244
|
+
const start = Math.max(0, index - HUNK_CONTEXT_LINES);
|
|
245
|
+
const end = Math.min(ops.length - 1, index + HUNK_CONTEXT_LINES);
|
|
246
|
+
const last = ranges[ranges.length - 1];
|
|
247
|
+
if (last && start <= last.end + 1) {
|
|
248
|
+
last.end = end;
|
|
249
|
+
}
|
|
250
|
+
else {
|
|
251
|
+
ranges.push({ start, end });
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
let additions = 0;
|
|
255
|
+
let deletions = 0;
|
|
256
|
+
const lines = [];
|
|
257
|
+
for (const range of ranges) {
|
|
258
|
+
const hunkOps = ops.slice(range.start, range.end + 1);
|
|
259
|
+
const oldCount = hunkOps.filter((op) => op.kind !== '+').length;
|
|
260
|
+
const newCount = hunkOps.filter((op) => op.kind !== '-').length;
|
|
261
|
+
// Zero-count sides anchor on the line BEFORE the hunk (git convention).
|
|
262
|
+
const oldStart = oldCount > 0 ? oldPos[range.start] : oldPos[range.start] - 1;
|
|
263
|
+
const newStart = newCount > 0 ? newPos[range.start] : newPos[range.start] - 1;
|
|
264
|
+
lines.push(`@@ -${formatHunkSide(oldStart, oldCount)} +${formatHunkSide(newStart, newCount)} @@`);
|
|
265
|
+
for (const op of hunkOps) {
|
|
266
|
+
if (op.kind === '+')
|
|
267
|
+
additions += 1;
|
|
268
|
+
if (op.kind === '-')
|
|
269
|
+
deletions += 1;
|
|
270
|
+
lines.push(op.kind + op.line.text);
|
|
271
|
+
if (op.line.noEol)
|
|
272
|
+
lines.push(NO_NEWLINE_MARKER);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
return { text: lines.join('\n'), additions, deletions };
|
|
276
|
+
}
|
|
277
|
+
function formatHunkSide(start, count) {
|
|
278
|
+
return count === 1 ? `${start}` : `${start},${count}`;
|
|
279
|
+
}
|
|
280
|
+
function toDisplayPath(filePath, cwd) {
|
|
281
|
+
if (!cwd || !isAbsolute(filePath) || !isAbsolute(cwd))
|
|
282
|
+
return filePath;
|
|
283
|
+
const rel = relative(cwd, filePath);
|
|
284
|
+
if (!rel || rel.startsWith('..') || isAbsolute(rel))
|
|
285
|
+
return filePath;
|
|
286
|
+
return rel;
|
|
287
|
+
}
|
package/dist/host.d.ts
CHANGED
|
@@ -1,2 +1,11 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
import { type SDKControlReloadPluginsResponse } from '@anthropic-ai/claude-agent-sdk';
|
|
3
|
+
import { type CanonRuntimeCommandDescriptor } from '@canonmsg/core';
|
|
4
|
+
/**
|
|
5
|
+
* Claude Code's own slash commands (built-ins, plugins, project .claude/
|
|
6
|
+
* commands) are executable when sent as prompt text, so the runtime's command
|
|
7
|
+
* inventory is exposed as composer slash entries that pass the raw slash text
|
|
8
|
+
* through. Canon-injected aliases keep precedence over native names.
|
|
9
|
+
*/
|
|
10
|
+
export declare function buildNativeSlashCommandDescriptors(native: SDKControlReloadPluginsResponse['commands'], reservedAliases: ReadonlySet<string>): CanonRuntimeCommandDescriptor[];
|
|
2
11
|
export declare function main(): Promise<void>;
|