@dynamicagents/plugins 0.16.0 → 0.17.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/README.md +1 -1
- package/dist/claude-code/cards.d.ts +6 -1
- package/dist/claude-code/cards.js +21 -2
- package/dist/claude-code/model.d.ts +4 -0
- package/dist/claude-code/model.js +74 -7
- package/dist/claude-code/verified.d.ts +1 -1
- package/dist/claude-code/verified.js +1 -1
- package/dist/computer/client.d.ts +9 -1
- package/dist/computer/context.js +1 -1
- package/dist/computer/index.d.ts +10 -6
- package/dist/computer/index.js +34 -17
- package/dist/computer/paths.d.ts +26 -2
- package/dist/computer/paths.js +51 -5
- package/dist/computer/proxy.d.ts +10 -3
- package/dist/computer/proxy.js +77 -87
- package/dist/computer/render.d.ts +6 -35
- package/dist/computer/render.js +5 -43
- package/dist/computer/sandbox.d.ts +4 -3
- package/dist/computer/sandbox.js +9 -2
- package/dist/computer/shell.d.ts +8 -7
- package/dist/computer/shell.js +8 -7
- package/dist/computer/tools-exec.d.ts +39 -0
- package/dist/computer/tools-exec.js +357 -228
- package/dist/computer/tools-grep.js +10 -4
- package/dist/repo-github/checkout.d.ts +16 -0
- package/dist/repo-github/checkout.js +16 -7
- package/dist/repo-github/context.d.ts +11 -0
- package/dist/repo-github/context.js +1 -0
- package/dist/repo-github/skills.d.ts +167 -0
- package/dist/repo-github/skills.js +414 -0
- package/dist/repo-github/tools-clone.js +25 -4
- package/dist/sandbox/header.d.ts +13 -0
- package/dist/sandbox/header.js +19 -4
- package/dist/sandbox/install-plan.d.ts +1 -1
- package/dist/sandbox/install-plan.js +2 -4
- package/dist/sandbox/object.d.ts +1 -0
- package/dist/sandbox/object.js +21 -13
- package/package.json +9 -7
|
@@ -0,0 +1,414 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What skills a checkout carries, and how a prompt may reach each one.
|
|
3
|
+
*
|
|
4
|
+
* A Claude Code session reads the skills of the repository it is running in, and
|
|
5
|
+
* the agent that briefs that session has no other way to learn they are there:
|
|
6
|
+
* the session is a separate process with a context of its own, so a skill it
|
|
7
|
+
* should follow has to be named in the prompt it is given. This is what lets
|
|
8
|
+
* `repo_clone` answer with the list.
|
|
9
|
+
*
|
|
10
|
+
* **Why it is here rather than in the plugin that owns a session.** This package
|
|
11
|
+
* fails any subpath whose module graph reaches a sibling's, so the plugin that
|
|
12
|
+
* holds the checkout is the only one that can read it. What belongs to the
|
|
13
|
+
* session's own domain is the *policy*, which is to say when a skill should be
|
|
14
|
+
* named, and that stays with the host writing the prompt. This module reports
|
|
15
|
+
* what is in the checkout and what each skill's own frontmatter says about
|
|
16
|
+
* reaching it.
|
|
17
|
+
*
|
|
18
|
+
* The split inside the module is the one {@link file://./checkout.ts checkout.ts}
|
|
19
|
+
* uses: a runner arrives as a parameter and everything that reads is pure, so
|
|
20
|
+
* the parsing and the rendering are tested without a container.
|
|
21
|
+
*/
|
|
22
|
+
/**
|
|
23
|
+
* Where Claude Code reads a project's skills from, relative to the checkout.
|
|
24
|
+
*
|
|
25
|
+
* One level of it: `<dir>/<name>/SKILL.md` and no deeper, which is the layout a
|
|
26
|
+
* session loads. A skill a repository keeps somewhere else of its own is not
|
|
27
|
+
* collected and is not reported as missing either, because nothing here can tell
|
|
28
|
+
* such a directory from any other.
|
|
29
|
+
*/
|
|
30
|
+
export const SKILLS_DIR = ".claude/skills";
|
|
31
|
+
/**
|
|
32
|
+
* The backstop on each `SKILL.md` read: a byte ceiling, and a line count on what
|
|
33
|
+
* is left after it.
|
|
34
|
+
*
|
|
35
|
+
* Neither is where frontmatter *ends*. {@link READ_SKILLS} stops at the closing
|
|
36
|
+
* delimiter, which is the one place that decides it, and these stop a file
|
|
37
|
+
* carrying no such delimiter from sending a whole skill body through the read:
|
|
38
|
+
* frontmatter is at the top of a file and a body runs to tens of kilobytes, so
|
|
39
|
+
* without them a directory of dozens of skills is not a read worth making.
|
|
40
|
+
*
|
|
41
|
+
* They are a backstop rather than a limit because a bound that cuts real
|
|
42
|
+
* frontmatter costs more than a long read does: a field nobody saw is a field
|
|
43
|
+
* {@link invocationOf} cannot report, and a restriction sits wherever in the
|
|
44
|
+
* block its author put it, which in the prompt-only skill this was written
|
|
45
|
+
* against is below a description of several lines. The longest frontmatter in
|
|
46
|
+
* this workspace is 15 lines and 938 bytes, its description 831 characters of
|
|
47
|
+
* that, and these bounds are several times both.
|
|
48
|
+
*
|
|
49
|
+
* Stopping at the delimiter is most of what the read costs, too: this
|
|
50
|
+
* workspace's own set of skills arrives in 9,936 bytes, where a bounded head of
|
|
51
|
+
* every file carries a page of each body along with it and costs five times
|
|
52
|
+
* that.
|
|
53
|
+
*
|
|
54
|
+
* **Bytes are cut by `head` and lines by `awk`**, each by the tool that can do it
|
|
55
|
+
* without holding the file: `head -c` is what keeps a file whose top is one
|
|
56
|
+
* enormous line out of `awk`'s `$0`, and `awk` prints whole lines, so what `head`
|
|
57
|
+
* cut mid-line is still a line of its own in the stream.
|
|
58
|
+
*/
|
|
59
|
+
const SKILL_HEAD_BYTES = 4_000;
|
|
60
|
+
const SKILL_HEAD_LINES = 120;
|
|
61
|
+
/**
|
|
62
|
+
* How many skills the answer lists, and how long a description it carries.
|
|
63
|
+
*
|
|
64
|
+
* Both bound an answer the model pays for on every clone, and neither is a
|
|
65
|
+
* judgement about which skills matter. They are set against a real checkout
|
|
66
|
+
* rather than guessed: this workspace's own set of skills renders whole at these
|
|
67
|
+
* values, in 5,731 characters, inside the sixteen thousand `maxOutputChars`
|
|
68
|
+
* defaults to. A cap tight enough to bite would drop skills in ordinal order,
|
|
69
|
+
* and the one dropped could be the one somebody has to name, which is the
|
|
70
|
+
* failure these numbers are chosen to stay clear of.
|
|
71
|
+
*
|
|
72
|
+
* A description here is a line that helps decide whether to open the skill, not
|
|
73
|
+
* a substitute for reading it.
|
|
74
|
+
*
|
|
75
|
+
* Exported for the specs, which assert the bound rather than restate the
|
|
76
|
+
* number. Nothing outside this package reads them: `./index.ts` is what the
|
|
77
|
+
* subpath publishes, and it exports neither.
|
|
78
|
+
*/
|
|
79
|
+
export const MAX_SKILLS_LISTED = 40;
|
|
80
|
+
export const MAX_DESCRIPTION_CHARS = 200;
|
|
81
|
+
/**
|
|
82
|
+
* Collect every skill's frontmatter block, each file's lines indented by one tab
|
|
83
|
+
* under the line naming its directory.
|
|
84
|
+
*
|
|
85
|
+
* **The tab is what makes the stream unambiguous.** A line at column zero names
|
|
86
|
+
* a skill's directory and every line of that skill's file is indented by one, so
|
|
87
|
+
* nothing in a file can be read as the start of another record. The obvious
|
|
88
|
+
* alternative, a separator line of its own, is a string a folded description
|
|
89
|
+
* could legally contain, and the failure would be a skill silently merged into
|
|
90
|
+
* its neighbour. It is written by `awk`'s own string escape, which is portable
|
|
91
|
+
* where `sed`'s `\t` is GNU's, and a published package does not get to assume a
|
|
92
|
+
* host's image.
|
|
93
|
+
*
|
|
94
|
+
* **`awk` stops at the closing delimiter**, so what arrives is the whole block
|
|
95
|
+
* and not a guess at how long one is. {@link readFrontmatter} reports a block
|
|
96
|
+
* that arrived without it, and {@link invocationOf} is why that matters: a
|
|
97
|
+
* restriction sits in whichever line of the block its author put it on, so a
|
|
98
|
+
* read that stops early is a read that cannot speak for the skill. The `NR == 1`
|
|
99
|
+
* test is the other half of the saving: a file with no block at all costs one
|
|
100
|
+
* line rather than the bound.
|
|
101
|
+
*
|
|
102
|
+
* **A file this cannot read costs its own record and no other.** The loop's exit
|
|
103
|
+
* status is the last `printf`'s, so a failed read leaves the directory line with
|
|
104
|
+
* nothing indented under it, which {@link readFrontmatter} reads as a block
|
|
105
|
+
* nobody has seen. Failing the whole read instead would spend every other
|
|
106
|
+
* skill's inventory on one unreadable file.
|
|
107
|
+
*
|
|
108
|
+
* It reads through whatever `<dir>/<name>` is. A checkout may commit each entry
|
|
109
|
+
* as a symlink to a directory it keeps elsewhere, which is how this workspace
|
|
110
|
+
* holds its own, and the glob and `head` both follow one.
|
|
111
|
+
*
|
|
112
|
+
* A glob that matches nothing stays literal in a POSIX shell, so the `-f` test
|
|
113
|
+
* is what makes an absent `.claude/skills` an empty answer rather than an error.
|
|
114
|
+
*
|
|
115
|
+
* The blank line after each record is the stream's own shape rather than a
|
|
116
|
+
* property of the tool above it: a name starts a line whatever the file below it
|
|
117
|
+
* ended with. No model input reaches any of this; every path is this module's
|
|
118
|
+
* own constant.
|
|
119
|
+
*/
|
|
120
|
+
export const READ_SKILLS = `for d in ${SKILLS_DIR}/*/; do
|
|
121
|
+
[ -f "$d/SKILL.md" ] || continue
|
|
122
|
+
printf '%s\\n' "\${d%/}"
|
|
123
|
+
head -c ${SKILL_HEAD_BYTES} "$d/SKILL.md" | awk -v lines=${SKILL_HEAD_LINES} '
|
|
124
|
+
{ print "\\t" $0 }
|
|
125
|
+
NR == 1 { if ($0 !~ /^---[[:space:]]*$/) exit; next }
|
|
126
|
+
/^---[[:space:]]*$/ { exit }
|
|
127
|
+
NR >= lines { exit }
|
|
128
|
+
'
|
|
129
|
+
printf '\\n'
|
|
130
|
+
done`;
|
|
131
|
+
/**
|
|
132
|
+
* The skills in a checkout, as a sentence the model reads, or why they could not
|
|
133
|
+
* be read.
|
|
134
|
+
*
|
|
135
|
+
* A failed read is reported and never raised: the clone it is appended to
|
|
136
|
+
* succeeded, and a missing inventory is worth a line rather than an error that
|
|
137
|
+
* sends the model to investigate git.
|
|
138
|
+
*/
|
|
139
|
+
export async function skillInventory(shell, dir) {
|
|
140
|
+
const read = await shell(READ_SKILLS, dir);
|
|
141
|
+
if (!read.success) {
|
|
142
|
+
// Clipped the way a description is, and for the same reason: this is one
|
|
143
|
+
// line of an answer about a clone that worked. What `context.ts` hands a
|
|
144
|
+
// tool for a command that never ran is a paragraph on whether retrying a
|
|
145
|
+
// lost `git push` is safe, which is the right thing to tell a model whose
|
|
146
|
+
// push vanished and the wrong thing to attach to a checkout that is on disk.
|
|
147
|
+
const why = read.stderr.trim() || read.stdout.trim();
|
|
148
|
+
return (`The skills under \`${SKILLS_DIR}\` in this checkout could not be read: ` +
|
|
149
|
+
`${why ? clip(why) : "the read failed"}`);
|
|
150
|
+
}
|
|
151
|
+
return skillInventoryText(readSkillInventory(read.stdout));
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* {@link READ_SKILLS}'s output, read back.
|
|
155
|
+
*
|
|
156
|
+
* Unindented lines name directories and indented ones belong to the file above
|
|
157
|
+
* them, so the shape of the stream is what separates the records. A blank
|
|
158
|
+
* unindented line is the trailing newline of the stream, never the end of a
|
|
159
|
+
* record.
|
|
160
|
+
*
|
|
161
|
+
* **A record starts only on a path under {@link SKILLS_DIR}**, which is the one
|
|
162
|
+
* thing the indentation alone does not settle. Directory names come from a
|
|
163
|
+
* cloned repository, and one carrying a newline writes a second line at column
|
|
164
|
+
* zero: without the path, that line opens a record of its own and the skill
|
|
165
|
+
* above it loses the frontmatter that follows to a name nobody wrote. A
|
|
166
|
+
* directory name holds no `/`, so what the second line cannot be is a path,
|
|
167
|
+
* which is what closes it.
|
|
168
|
+
*/
|
|
169
|
+
export function readSkillInventory(stdout) {
|
|
170
|
+
const skills = [];
|
|
171
|
+
let dir;
|
|
172
|
+
let head = [];
|
|
173
|
+
const flush = () => {
|
|
174
|
+
if (dir !== undefined)
|
|
175
|
+
skills.push(skillOf(dir, head));
|
|
176
|
+
head = [];
|
|
177
|
+
};
|
|
178
|
+
for (const raw of stdout.split("\n")) {
|
|
179
|
+
// A checkout written on Windows is still a checkout, and a stray `\r` would
|
|
180
|
+
// otherwise end up inside a name.
|
|
181
|
+
const line = raw.endsWith("\r") ? raw.slice(0, -1) : raw;
|
|
182
|
+
if (line.startsWith("\t")) {
|
|
183
|
+
head.push(line.slice(1));
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
if (!line.startsWith(`${SKILLS_DIR}/`))
|
|
187
|
+
continue;
|
|
188
|
+
flush();
|
|
189
|
+
dir = line.trim();
|
|
190
|
+
}
|
|
191
|
+
flush();
|
|
192
|
+
return skills;
|
|
193
|
+
}
|
|
194
|
+
/** One skill, from its directory and the head of its `SKILL.md`. */
|
|
195
|
+
function skillOf(dir, head) {
|
|
196
|
+
const front = readFrontmatter(head);
|
|
197
|
+
const described = front.fields.description?.trim();
|
|
198
|
+
return {
|
|
199
|
+
// The directory is the fallback because it is the only name left: a block
|
|
200
|
+
// that did not parse, or did not arrive at all, still leaves a directory
|
|
201
|
+
// with a `SKILL.md` in it, and a reader given its name can go and look.
|
|
202
|
+
name: front.fields.name?.trim() || directoryName(dir),
|
|
203
|
+
...(described ? { description: described } : {}),
|
|
204
|
+
invocation: invocationOf(front)
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
function directoryName(dir) {
|
|
208
|
+
const at = dir.lastIndexOf("/");
|
|
209
|
+
return at < 0 ? dir : dir.slice(at + 1);
|
|
210
|
+
}
|
|
211
|
+
/** What the two invocation fields leave open. See {@link SkillInvocation}. */
|
|
212
|
+
function invocationOf(front) {
|
|
213
|
+
// A block that did not arrive whole is a block whose remaining fields nobody
|
|
214
|
+
// has seen, and an absent field is a default only among fields that were
|
|
215
|
+
// read. A restriction sits wherever in the block its author put it, below the
|
|
216
|
+
// description as often as above it, so reading the defaults here would let
|
|
217
|
+
// the length of a skill's prose decide whether its restriction is reported.
|
|
218
|
+
if (!front.whole)
|
|
219
|
+
return "unknown";
|
|
220
|
+
const model = !isTrue(front.fields["disable-model-invocation"]);
|
|
221
|
+
const person = !isFalse(front.fields["user-invocable"]);
|
|
222
|
+
if (model && person)
|
|
223
|
+
return "either";
|
|
224
|
+
if (person)
|
|
225
|
+
return "command";
|
|
226
|
+
if (model)
|
|
227
|
+
return "prose";
|
|
228
|
+
return "neither";
|
|
229
|
+
}
|
|
230
|
+
/**
|
|
231
|
+
* The YAML 1.1 spellings of a boolean, because that is what a frontmatter author
|
|
232
|
+
* writes. Anything else reads as absent, which leaves the field's default: a
|
|
233
|
+
* skill is model-invocable and user-invocable unless it says otherwise, so an
|
|
234
|
+
* unreadable value never takes a way in away.
|
|
235
|
+
*/
|
|
236
|
+
const TRUE = /^(?:true|yes|on)$/i;
|
|
237
|
+
const FALSE = /^(?:false|no|off)$/i;
|
|
238
|
+
const isTrue = (value) => value !== undefined && TRUE.test(value.trim());
|
|
239
|
+
const isFalse = (value) => value !== undefined && FALSE.test(value.trim());
|
|
240
|
+
/** The frontmatter delimiter, opening and closing. */
|
|
241
|
+
const DELIMITER = /^---\s*$/;
|
|
242
|
+
/**
|
|
243
|
+
* A value that says the scalar is the lines below it: a block indicator, or
|
|
244
|
+
* nothing at all. An empty value is the plain multi-line form,
|
|
245
|
+
* `description:` with its text indented under it, and it folds the same way.
|
|
246
|
+
*/
|
|
247
|
+
const BLOCK = /^(?:[>|][-+]?\s*)?$/;
|
|
248
|
+
/**
|
|
249
|
+
* The frontmatter block's **top-level** keys.
|
|
250
|
+
*
|
|
251
|
+
* Top-level is the load-bearing word. A skill's frontmatter carries nested
|
|
252
|
+
* mappings (`metadata:` with children under it), and a child spelled `name:`
|
|
253
|
+
* would otherwise overwrite the skill's own. Reading column zero only is what
|
|
254
|
+
* keeps a nested key out, and it costs nothing: every field this module wants is
|
|
255
|
+
* at the top level.
|
|
256
|
+
*
|
|
257
|
+
* Not a YAML implementation, and deliberately narrow: nothing inside quotes is
|
|
258
|
+
* unescaped, and every block form folds to one line. What it does carry is the
|
|
259
|
+
* YAML that changes an answer here, which is {@link commentOff}'s.
|
|
260
|
+
*/
|
|
261
|
+
function readFrontmatter(head) {
|
|
262
|
+
const fields = {};
|
|
263
|
+
// Nothing arrived at all, which is what a `SKILL.md` the read could not open
|
|
264
|
+
// looks like from here: the directory was named and no line followed it.
|
|
265
|
+
if (head.length === 0)
|
|
266
|
+
return { fields, whole: false };
|
|
267
|
+
let at = head.findIndex((line) => line.trim() !== "");
|
|
268
|
+
// No block to read. Nothing was cut short either, so what the fields below
|
|
269
|
+
// default to is a reading of this file rather than a guess about it.
|
|
270
|
+
if (at < 0 || !DELIMITER.test(head[at]))
|
|
271
|
+
return { fields, whole: true };
|
|
272
|
+
for (at += 1; at < head.length; at += 1) {
|
|
273
|
+
const line = head[at];
|
|
274
|
+
if (DELIMITER.test(line))
|
|
275
|
+
return { fields, whole: true };
|
|
276
|
+
// Indented or blank: a nested key, a block scalar's own lines when one was
|
|
277
|
+
// not consumed below, or spacing. A whole-line comment is not a field
|
|
278
|
+
// either, and reading one as a key is how a commented-out field comes back.
|
|
279
|
+
if (line.trim() === "" || /^[\s#]/.test(line))
|
|
280
|
+
continue;
|
|
281
|
+
const colon = line.indexOf(":");
|
|
282
|
+
if (colon < 0)
|
|
283
|
+
continue;
|
|
284
|
+
const key = line.slice(0, colon).trim();
|
|
285
|
+
const value = commentOff(line.slice(colon + 1).trim());
|
|
286
|
+
if (BLOCK.test(value)) {
|
|
287
|
+
const block = readBlock(head, at + 1);
|
|
288
|
+
fields[key] = block.text;
|
|
289
|
+
at = block.next - 1;
|
|
290
|
+
continue;
|
|
291
|
+
}
|
|
292
|
+
fields[key] = unquote(value);
|
|
293
|
+
}
|
|
294
|
+
return { fields, whole: false };
|
|
295
|
+
}
|
|
296
|
+
/**
|
|
297
|
+
* A block scalar's lines, folded to one.
|
|
298
|
+
*
|
|
299
|
+
* Every block form is folded the same way, `|` included, and that is a
|
|
300
|
+
* simplification rather than an oversight: the answer carries one clipped line
|
|
301
|
+
* per skill, so a literal block's newlines are removed by the clip a moment
|
|
302
|
+
* later. Reading this as YAML's folding rules would be reading it as more than
|
|
303
|
+
* it is.
|
|
304
|
+
*/
|
|
305
|
+
function readBlock(head, from) {
|
|
306
|
+
const lines = [];
|
|
307
|
+
let at = from;
|
|
308
|
+
for (; at < head.length; at += 1) {
|
|
309
|
+
const line = head[at];
|
|
310
|
+
if (DELIMITER.test(line))
|
|
311
|
+
break;
|
|
312
|
+
if (line.trim() !== "" && !/^\s/.test(line))
|
|
313
|
+
break;
|
|
314
|
+
if (line.trim() !== "")
|
|
315
|
+
lines.push(line.trim());
|
|
316
|
+
}
|
|
317
|
+
return { text: lines.join(" "), next: at };
|
|
318
|
+
}
|
|
319
|
+
/**
|
|
320
|
+
* Where a YAML comment starts in a plain scalar: a `#` at the value's start, or
|
|
321
|
+
* one with whitespace in front of it. Nowhere else, which is what keeps `C#` and
|
|
322
|
+
* `#tag` inside a value.
|
|
323
|
+
*/
|
|
324
|
+
const COMMENT = /(?:^|\s)#/;
|
|
325
|
+
/** What may follow a quoted scalar's closing quote, and all that may. */
|
|
326
|
+
const AFTER_QUOTE = /^\s+#/;
|
|
327
|
+
/**
|
|
328
|
+
* One scalar's text with a YAML comment taken off, quotes left as they are.
|
|
329
|
+
*
|
|
330
|
+
* Both halves of YAML's rule change an answer here.
|
|
331
|
+
* `disable-model-invocation: true # named from the prompt` is a restriction
|
|
332
|
+
* whose value is not the string `true`, so left whole it reads as a spelling
|
|
333
|
+
* nothing recognises and hands the skill back the way in it just gave up; and
|
|
334
|
+
* `name: audit # the command` would otherwise be offered as `/audit # the
|
|
335
|
+
* command`. Inside quotes nothing starts a comment, and a description carries a
|
|
336
|
+
* hash legitimately, so a quoted value is read to its closing quote and only a
|
|
337
|
+
* comment may follow it.
|
|
338
|
+
*
|
|
339
|
+
* A plain value that loses text to a ` #` has lost it in Claude Code's reading
|
|
340
|
+
* of the same file, which is the test for whether cutting it is right: this
|
|
341
|
+
* module reads a field the way the parser that acts on it does, or it reports
|
|
342
|
+
* something nobody wrote.
|
|
343
|
+
*/
|
|
344
|
+
function commentOff(value) {
|
|
345
|
+
const quote = value[0];
|
|
346
|
+
if (quote === '"' || quote === "'") {
|
|
347
|
+
const close = value.lastIndexOf(quote);
|
|
348
|
+
// A scalar whose quote closes, with nothing after it or a comment. Anything
|
|
349
|
+
// else never was a quoted scalar, and is left to `unquote` to read as it
|
|
350
|
+
// stands.
|
|
351
|
+
if (close > 0 && AFTER_QUOTE.test(value.slice(close + 1)))
|
|
352
|
+
return value.slice(0, close + 1);
|
|
353
|
+
return value;
|
|
354
|
+
}
|
|
355
|
+
const at = value.search(COMMENT);
|
|
356
|
+
return at < 0 ? value : value.slice(0, at).trimEnd();
|
|
357
|
+
}
|
|
358
|
+
/** A scalar with one matching pair of quotes taken off. */
|
|
359
|
+
function unquote(value) {
|
|
360
|
+
const quoted = value.length >= 2 &&
|
|
361
|
+
(value.startsWith('"') || value.startsWith("'")) &&
|
|
362
|
+
value.endsWith(value[0]);
|
|
363
|
+
return quoted ? value.slice(1, -1) : value;
|
|
364
|
+
}
|
|
365
|
+
/**
|
|
366
|
+
* The inventory as the model reads it: where this looked, a line per skill, and
|
|
367
|
+
* what the list left out.
|
|
368
|
+
*
|
|
369
|
+
* **The counts are unconditional**, the empty answer included. "No skills here"
|
|
370
|
+
* and "this tool does not report skills" are different facts, and a repository
|
|
371
|
+
* that keeps its skills somewhere Claude Code does not read them is the case
|
|
372
|
+
* that makes the difference worth a sentence.
|
|
373
|
+
*/
|
|
374
|
+
export function skillInventoryText(skills) {
|
|
375
|
+
// Ordinal, and not a ranking: nothing here knows which skill matters to the
|
|
376
|
+
// task, so the order is the one a reader can predict.
|
|
377
|
+
const sorted = [...skills].sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0);
|
|
378
|
+
const listed = sorted.slice(0, MAX_SKILLS_LISTED);
|
|
379
|
+
const counts = `${skills.length} ${skills.length === 1 ? "skill" : "skills"}, ` +
|
|
380
|
+
`${listed.length} listed, ${skills.length - listed.length} omitted.`;
|
|
381
|
+
if (listed.length === 0) {
|
|
382
|
+
return `No skills under \`${SKILLS_DIR}\` in this checkout: ${counts}`;
|
|
383
|
+
}
|
|
384
|
+
return [
|
|
385
|
+
`Skills under \`${SKILLS_DIR}\` in this checkout:`,
|
|
386
|
+
...listed.map(skillLine),
|
|
387
|
+
counts
|
|
388
|
+
].join("\n");
|
|
389
|
+
}
|
|
390
|
+
function skillLine(skill) {
|
|
391
|
+
const note = {
|
|
392
|
+
either: "",
|
|
393
|
+
command: ` (name it as \`/${skill.name}\`)`,
|
|
394
|
+
prose: " (name it in prose; it has no `/` form)",
|
|
395
|
+
neither: " (no prompt can invoke it)",
|
|
396
|
+
unknown: " (how to reach it is unknown: its frontmatter did not read whole)"
|
|
397
|
+
}[skill.invocation];
|
|
398
|
+
const described = skill.description ? `: ${clip(skill.description)}` : "";
|
|
399
|
+
return `- \`${skill.name}\`${note}${described}`;
|
|
400
|
+
}
|
|
401
|
+
/**
|
|
402
|
+
* One line, cut at a word rather than mid-word.
|
|
403
|
+
*
|
|
404
|
+
* The bound is a description's, and a failed read's reason takes the same one:
|
|
405
|
+
* both are one line of an answer whose subject is the clone.
|
|
406
|
+
*/
|
|
407
|
+
function clip(text) {
|
|
408
|
+
const flat = text.replace(/\s+/g, " ").trim();
|
|
409
|
+
if (flat.length <= MAX_DESCRIPTION_CHARS)
|
|
410
|
+
return flat;
|
|
411
|
+
const cut = flat.slice(0, MAX_DESCRIPTION_CHARS);
|
|
412
|
+
const space = cut.lastIndexOf(" ");
|
|
413
|
+
return `${space > 0 ? cut.slice(0, space) : cut}…`;
|
|
414
|
+
}
|
|
@@ -2,12 +2,31 @@ import { tool } from "ai";
|
|
|
2
2
|
import { z } from "zod";
|
|
3
3
|
import { parseRepo, repoLocation, UNSAFE_BRANCH } from "./url.js";
|
|
4
4
|
import { refreshCheckout, writeGitIdentity } from "./checkout.js";
|
|
5
|
+
import { skillInventory } from "./skills.js";
|
|
5
6
|
/** Putting a checkout on disk, fresh or refreshed. */
|
|
6
7
|
export function cloneTools(ctx) {
|
|
7
|
-
const { config, workdir, author, allowedHosts, bounded, notifyCheckout, logFailure, plain, runGit, fetchOrigin } = ctx;
|
|
8
|
+
const { config, workdir, author, allowedHosts, bounded, notifyCheckout, logFailure, plain, shell, runGit, fetchOrigin } = ctx;
|
|
9
|
+
/**
|
|
10
|
+
* One answer about a checkout that is on disk: what happened to it, and the
|
|
11
|
+
* skills the repository carries.
|
|
12
|
+
*
|
|
13
|
+
* **Only for an answer that establishes such a checkout.** A URL that names no
|
|
14
|
+
* repository, a directory holding a different one, and a tree git could not
|
|
15
|
+
* read are all answers about where the checkout is *not*, and an inventory
|
|
16
|
+
* read on that path would be describing something nobody asked about. What
|
|
17
|
+
* tells the two apart on the refresh path is `checkout` in
|
|
18
|
+
* {@link file://./checkout.ts refreshCheckout}'s outcome.
|
|
19
|
+
*
|
|
20
|
+
* Bounded here rather than in the inventory: the inventory caps itself at a
|
|
21
|
+
* list a reader can use, and this is the host's own ceiling on an answer,
|
|
22
|
+
* which the rest of the plugin applies in the same place. The refusals that
|
|
23
|
+
* carry no inventory are bounded in the same expression, since each of them
|
|
24
|
+
* quotes something it was given: another remote's URL, or git's own output.
|
|
25
|
+
*/
|
|
26
|
+
const withSkills = async (message, dir) => bounded(`${message}\n\n${await skillInventory(shell, dir)}`);
|
|
8
27
|
return {
|
|
9
28
|
repo_clone: tool({
|
|
10
|
-
description: "Clone a git repository into the workspace. Returns the checkout path
|
|
29
|
+
description: "Clone a git repository into the workspace. Returns the checkout path, the branch you landed on, and the skills the repository carries with how each one may be named in a prompt. If the repository is already checked out from an earlier task, it is fetched and reset to the remote instead. A checkout with uncommitted changes is left alone for you to inspect.",
|
|
11
30
|
inputSchema: z.object({
|
|
12
31
|
url: z.string().describe("HTTPS repository URL"),
|
|
13
32
|
branch: z
|
|
@@ -125,7 +144,9 @@ export function cloneTools(ctx) {
|
|
|
125
144
|
fresh: false
|
|
126
145
|
});
|
|
127
146
|
}
|
|
128
|
-
return refreshed.
|
|
147
|
+
return refreshed.checkout
|
|
148
|
+
? await withSkills(refreshed.message, dir)
|
|
149
|
+
: bounded(refreshed.message);
|
|
129
150
|
}
|
|
130
151
|
// `depth` is bounded here rather than trusted from the schema. It no
|
|
131
152
|
// longer reaches a shell, so this is arithmetic rather than quoting: a
|
|
@@ -166,7 +187,7 @@ export function cloneTools(ctx) {
|
|
|
166
187
|
branch: landed,
|
|
167
188
|
fresh: true
|
|
168
189
|
});
|
|
169
|
-
return `cloned to ${dir} on branch ${landed}
|
|
190
|
+
return await withSkills(`cloned to ${dir} on branch ${landed}`, dir);
|
|
170
191
|
}
|
|
171
192
|
})
|
|
172
193
|
};
|
package/dist/sandbox/header.d.ts
CHANGED
|
@@ -48,3 +48,16 @@ export declare function readRunHeader(text: string): {
|
|
|
48
48
|
};
|
|
49
49
|
/** Whether `path` is `root` or inside it, with no `..` escaping it. */
|
|
50
50
|
export declare function isUnder(path: string, root: string): boolean;
|
|
51
|
+
/**
|
|
52
|
+
* `path` with its dot segments and repeated separators resolved, or `undefined`
|
|
53
|
+
* for one that is not absolute or that climbs above `/`.
|
|
54
|
+
*
|
|
55
|
+
* Shared with the tools rather than kept here, because anything comparing a
|
|
56
|
+
* path against this root has to compare what the path *means*: `/workspace/.`
|
|
57
|
+
* and `/workspace/repo/..` are both this root, and a comparison that read them
|
|
58
|
+
* literally would answer no to a path the sandbox itself answers yes to.
|
|
59
|
+
*
|
|
60
|
+
* A `..` with nothing left to pop is refused rather than clamped at `/`: a
|
|
61
|
+
* clamp would turn an attempt to climb out into a valid root path.
|
|
62
|
+
*/
|
|
63
|
+
export declare function normalizeAbsolute(path: string): string | undefined;
|
package/dist/sandbox/header.js
CHANGED
|
@@ -75,20 +75,35 @@ function assertRunHeader(value) {
|
|
|
75
75
|
}
|
|
76
76
|
/** Whether `path` is `root` or inside it, with no `..` escaping it. */
|
|
77
77
|
export function isUnder(path, root) {
|
|
78
|
+
const normal = normalizeAbsolute(path);
|
|
79
|
+
return (normal !== undefined && (normal === root || normal.startsWith(`${root}/`)));
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* `path` with its dot segments and repeated separators resolved, or `undefined`
|
|
83
|
+
* for one that is not absolute or that climbs above `/`.
|
|
84
|
+
*
|
|
85
|
+
* Shared with the tools rather than kept here, because anything comparing a
|
|
86
|
+
* path against this root has to compare what the path *means*: `/workspace/.`
|
|
87
|
+
* and `/workspace/repo/..` are both this root, and a comparison that read them
|
|
88
|
+
* literally would answer no to a path the sandbox itself answers yes to.
|
|
89
|
+
*
|
|
90
|
+
* A `..` with nothing left to pop is refused rather than clamped at `/`: a
|
|
91
|
+
* clamp would turn an attempt to climb out into a valid root path.
|
|
92
|
+
*/
|
|
93
|
+
export function normalizeAbsolute(path) {
|
|
78
94
|
if (!path.startsWith("/"))
|
|
79
|
-
return
|
|
95
|
+
return undefined;
|
|
80
96
|
const parts = [];
|
|
81
97
|
for (const part of path.split("/")) {
|
|
82
98
|
if (part === "" || part === ".")
|
|
83
99
|
continue;
|
|
84
100
|
if (part === "..") {
|
|
85
101
|
if (parts.length === 0)
|
|
86
|
-
return
|
|
102
|
+
return undefined;
|
|
87
103
|
parts.pop();
|
|
88
104
|
continue;
|
|
89
105
|
}
|
|
90
106
|
parts.push(part);
|
|
91
107
|
}
|
|
92
|
-
|
|
93
|
-
return normal === root || normal.startsWith(`${root}/`);
|
|
108
|
+
return `/${parts.join("/")}`;
|
|
94
109
|
}
|
|
@@ -36,7 +36,7 @@ export interface InstallProbe {
|
|
|
36
36
|
* Where the install has got to, as the host reports it and the tools read it.
|
|
37
37
|
*
|
|
38
38
|
* Declared here rather than in the host because both sides depend on it and
|
|
39
|
-
* neither owns the other: the Durable Object runs the job, and
|
|
39
|
+
* neither owns the other: the Durable Object runs the job, and the shell tool refuses
|
|
40
40
|
* to run against a tree that is still being built. A shape they agreed on
|
|
41
41
|
* informally would drift, and the drift would show up as a shell command running
|
|
42
42
|
* halfway through an `npm ci`.
|
|
@@ -26,6 +26,7 @@
|
|
|
26
26
|
* absent. So: an explicit override wins, then the repository's own
|
|
27
27
|
* `packageManager` pin, then lockfiles in the order the plan lists them.
|
|
28
28
|
*/
|
|
29
|
+
import { sha256Hex } from "@dynamicagents/core";
|
|
29
30
|
/**
|
|
30
31
|
* A sensible starting table, meant to be copied into a host and edited.
|
|
31
32
|
*
|
|
@@ -304,8 +305,5 @@ async function digestFiles(fs, dir, names, prefix) {
|
|
|
304
305
|
}
|
|
305
306
|
if (!found)
|
|
306
307
|
return null;
|
|
307
|
-
|
|
308
|
-
return [...new Uint8Array(digest)]
|
|
309
|
-
.map((b) => b.toString(16).padStart(2, "0"))
|
|
310
|
-
.join("");
|
|
308
|
+
return await sha256Hex(input);
|
|
311
309
|
}
|
package/dist/sandbox/object.d.ts
CHANGED
package/dist/sandbox/object.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { DurableObject } from "cloudflare:workers";
|
|
2
2
|
import { ContainerHarness, containerAgent } from "agents/harness/container";
|
|
3
|
+
import { sha256Hex } from "@dynamicagents/core";
|
|
3
4
|
import { installScheduler, namedDeadline } from "@dynamicagents/core/alarm";
|
|
4
5
|
import { DirectoryBackup, Files } from "@cloudflare/sandbox";
|
|
5
6
|
import { asAgent, AGENT_GID, AGENT_UID, shellQuote } from "./agent-exec.js";
|
|
@@ -380,7 +381,11 @@ export class SandboxObjectBase extends DurableObject {
|
|
|
380
381
|
return {
|
|
381
382
|
type: stat.type,
|
|
382
383
|
size: Number(stat.size),
|
|
383
|
-
modifiedAt: stat.modifiedAt.getTime()
|
|
384
|
+
modifiedAt: stat.modifiedAt.getTime(),
|
|
385
|
+
// Reported because the file tools' own contract asks for one, and
|
|
386
|
+
// the container knows it. Nothing can set it back: the sandbox's
|
|
387
|
+
// write takes no mode.
|
|
388
|
+
mode: stat.mode
|
|
384
389
|
};
|
|
385
390
|
}
|
|
386
391
|
catch (err) {
|
|
@@ -1105,14 +1110,16 @@ function markOf(dir) {
|
|
|
1105
1110
|
function installMarkOf(dir) {
|
|
1106
1111
|
return `${INSTALL_MARKS}/${encodeURIComponent(dir)}`;
|
|
1107
1112
|
}
|
|
1108
|
-
/**
|
|
1113
|
+
/**
|
|
1114
|
+
* `env`, in key order, as a digest a fingerprint can carry.
|
|
1115
|
+
*
|
|
1116
|
+
* The width is load-bearing: this goes into an install mark stored on the
|
|
1117
|
+
* container's disk, so changing it makes every tree already marked look
|
|
1118
|
+
* uninstalled and costs each one an install it does not need.
|
|
1119
|
+
*/
|
|
1109
1120
|
async function digestEnv(env) {
|
|
1110
1121
|
const sorted = Object.entries(env ?? {}).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0);
|
|
1111
|
-
|
|
1112
|
-
return [...new Uint8Array(bytes)]
|
|
1113
|
-
.map((b) => b.toString(16).padStart(2, "0"))
|
|
1114
|
-
.join("")
|
|
1115
|
-
.slice(0, 16);
|
|
1122
|
+
return (await sha256Hex(JSON.stringify(sorted))).slice(0, 16);
|
|
1116
1123
|
}
|
|
1117
1124
|
function isNotFound(err) {
|
|
1118
1125
|
return (typeof err === "object" &&
|
|
@@ -1129,11 +1136,12 @@ function isAbort(err) {
|
|
|
1129
1136
|
function sleep(ms) {
|
|
1130
1137
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
1131
1138
|
}
|
|
1139
|
+
/**
|
|
1140
|
+
* `value`, as the digest a poll returns beside a message.
|
|
1141
|
+
*
|
|
1142
|
+
* Nothing stores it: a poll composes it again for every message it answers
|
|
1143
|
+
* with, so the width has only to tell two messages apart.
|
|
1144
|
+
*/
|
|
1132
1145
|
async function digest(value) {
|
|
1133
|
-
|
|
1134
|
-
const hash = await crypto.subtle.digest("SHA-256", bytes);
|
|
1135
|
-
return [...new Uint8Array(hash)]
|
|
1136
|
-
.slice(0, 12)
|
|
1137
|
-
.map((byte) => byte.toString(16).padStart(2, "0"))
|
|
1138
|
-
.join("");
|
|
1146
|
+
return (await sha256Hex(JSON.stringify(value))).slice(0, 24);
|
|
1139
1147
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dynamicagents/plugins",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.17.0",
|
|
4
4
|
"description": "Optional, composable capabilities for a Dynamic Agent: browser rendering, a Linux container, git and pull requests on GitHub, a scratchpad, and Claude Code sessions. One subpath per plugin, so a bundle grows only with what it imports.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ai-agents",
|
|
@@ -84,10 +84,11 @@
|
|
|
84
84
|
"prepare": "husky || true && npm run build"
|
|
85
85
|
},
|
|
86
86
|
"peerDependencies": {
|
|
87
|
+
"@cloudflare/computer": "^0.5.1",
|
|
87
88
|
"@cloudflare/sandbox": "^1.0.1",
|
|
88
|
-
"@cloudflare/think": ">=0.
|
|
89
|
-
"@dynamicagents/core": ">=0.
|
|
90
|
-
"agents": ">=0.
|
|
89
|
+
"@cloudflare/think": ">=0.21.0 <0.22.0",
|
|
90
|
+
"@dynamicagents/core": ">=0.17.0 <0.18.0",
|
|
91
|
+
"agents": ">=0.30.0 <0.31.0",
|
|
91
92
|
"ai": "^7.0.88",
|
|
92
93
|
"zod": "^4.4.3"
|
|
93
94
|
},
|
|
@@ -97,12 +98,13 @@
|
|
|
97
98
|
}
|
|
98
99
|
},
|
|
99
100
|
"devDependencies": {
|
|
101
|
+
"@cloudflare/computer": "^0.5.1",
|
|
100
102
|
"@cloudflare/sandbox": "^1.0.1",
|
|
101
|
-
"@cloudflare/think": "^0.
|
|
103
|
+
"@cloudflare/think": "^0.21.0",
|
|
102
104
|
"@cloudflare/vitest-plugin": "^1.4.4",
|
|
103
|
-
"@dynamicagents/core": "^0.
|
|
105
|
+
"@dynamicagents/core": "^0.17.1",
|
|
104
106
|
"@types/node": "^26.5.1",
|
|
105
|
-
"agents": "^0.
|
|
107
|
+
"agents": "^0.30.1",
|
|
106
108
|
"ai": "^7.0.101",
|
|
107
109
|
"eslint": "^10.10.0",
|
|
108
110
|
"husky": "^9.1.7",
|