@dynamicagents/plugins 0.15.0 → 0.16.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.
@@ -17,6 +17,22 @@
17
17
  interface RefreshOutcome {
18
18
  message: string;
19
19
  branch?: string;
20
+ /**
21
+ * Set once this directory is known to hold a checkout of the repository that
22
+ * was asked for, with a tree git could read.
23
+ *
24
+ * Weaker than `branch` on purpose, and the difference is the dirty tree: that
25
+ * checkout is there and is the right repository, so an answer may describe
26
+ * what is in it, while nothing may *act* on it.
27
+ *
28
+ * The returns above the status read leave it unset, and so does the status
29
+ * read's own failure: a tree git could not read is not one to read anything
30
+ * else out of, and the doubt that stops the reset below stops a description of
31
+ * what is on the disk under it. A caller cannot tell any of that from the
32
+ * message alone, which is why this is a flag and not a reading of the
33
+ * sentence.
34
+ */
35
+ checkout?: true;
20
36
  }
21
37
  /**
22
38
  * What this module needs back from a git command.
@@ -119,6 +119,7 @@ export async function refreshCheckout({ dir, url, branch, author, plain, fetchOr
119
119
  // call established — kicking off an install over it would be acting on a
120
120
  // state the model has not looked at yet.
121
121
  return {
122
+ checkout: true,
122
123
  message: `${dir} already has this repository checked out, with uncommitted changes:\n` +
123
124
  `${dirty.stdout.trim()}\n\n` +
124
125
  `Left untouched — these may be unfinished work from an earlier task. ` +
@@ -140,10 +141,11 @@ export async function refreshCheckout({ dir, url, branch, author, plain, fetchOr
140
141
  const current = await plain("symbolic-ref --quiet --short HEAD", dir);
141
142
  const on = current.success ? current.stdout.trim() : "";
142
143
  if (!on)
143
- return { message: failure };
144
+ return { message: failure, checkout: true };
144
145
  return {
145
146
  message: `${failure}\nThe checkout was left on ${on}, clean but not updated from the remote.`,
146
- branch: on
147
+ branch: on,
148
+ checkout: true
147
149
  };
148
150
  }
149
151
  // The branch to land on: the one asked for, else the remote's own default.
@@ -155,12 +157,16 @@ export async function refreshCheckout({ dir, url, branch, author, plain, fetchOr
155
157
  // way.
156
158
  if (head.unreachable)
157
159
  return {
158
- message: `could not read the default branch for ${url}: ${head.unreachable}`
160
+ message: `could not read the default branch for ${url}: ${head.unreachable}`,
161
+ checkout: true
159
162
  };
160
163
  target = head.branch;
161
164
  }
162
165
  if (!target)
163
- return { message: `could not determine a default branch for ${url}` };
166
+ return {
167
+ message: `could not determine a default branch for ${url}`,
168
+ checkout: true
169
+ };
164
170
  // The one place in this plugin where a model-authored value lands in git's
165
171
  // *operand* position — everywhere else it is prefixed (`origin/…`,
166
172
  // `refs/heads/…`) or is a flag's value. Quoting is not enough: it stops
@@ -172,17 +178,20 @@ export async function refreshCheckout({ dir, url, branch, author, plain, fetchOr
172
178
  });
173
179
  if (!checkout.success)
174
180
  return {
175
- message: `could not check out ${target}: ${checkout.stderr || checkout.stdout}`
181
+ message: `could not check out ${target}: ${checkout.stderr || checkout.stdout}`,
182
+ checkout: true
176
183
  };
177
184
  const reset = await plain(`reset --hard "origin/$REPO_BRANCH"`, dir, {
178
185
  REPO_BRANCH: target
179
186
  });
180
187
  if (!reset.success)
181
188
  return {
182
- message: `could not reset to origin/${target}: ${reset.stderr || reset.stdout}`
189
+ message: `could not reset to origin/${target}: ${reset.stderr || reset.stdout}`,
190
+ checkout: true
183
191
  };
184
192
  return {
185
193
  message: `reused the existing checkout at ${dir}, fetched and reset to origin/${target}`,
186
- branch: target
194
+ branch: target,
195
+ checkout: true
187
196
  };
188
197
  }
@@ -47,6 +47,17 @@ export interface RepoContext {
47
47
  stdout?: string;
48
48
  }) => void;
49
49
  plain: (args: string, cwd: string, vars?: Record<string, string>) => Promise<RunResult>;
50
+ /**
51
+ * The same runner without git in front of it, for the one thing these tools
52
+ * read that git cannot answer: what a checkout's own files say.
53
+ * {@link file://./skills.ts skillInventory} is what runs on it.
54
+ *
55
+ * Published alongside `plain` rather than instead of it. Every command this
56
+ * plugin runs is still one of two shapes, and a reader can tell which from the
57
+ * call: `plain` is a git subcommand, `shell` is a script whose text is this
58
+ * package's own constant.
59
+ */
60
+ shell: (script: string, cwd: string, vars?: Record<string, string>) => Promise<RunResult>;
50
61
  runGit: (op: () => Promise<RepoGitResult>) => Promise<RunResult>;
51
62
  fetchOrigin: (dir: string, url: string) => Promise<RunResult>;
52
63
  pushBranch: (dir: string, url: string, branch: string) => Promise<RunResult>;
@@ -532,6 +532,7 @@ runtime = () => undefined) {
532
532
  notifyCheckout,
533
533
  logFailure,
534
534
  plain,
535
+ shell,
535
536
  runGit,
536
537
  fetchOrigin,
537
538
  pushBranch,
@@ -0,0 +1,167 @@
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 declare const SKILLS_DIR = ".claude/skills";
31
+ /**
32
+ * How many skills the answer lists, and how long a description it carries.
33
+ *
34
+ * Both bound an answer the model pays for on every clone, and neither is a
35
+ * judgement about which skills matter. They are set against a real checkout
36
+ * rather than guessed: this workspace's own set of skills renders whole at these
37
+ * values, in 5,731 characters, inside the sixteen thousand `maxOutputChars`
38
+ * defaults to. A cap tight enough to bite would drop skills in ordinal order,
39
+ * and the one dropped could be the one somebody has to name, which is the
40
+ * failure these numbers are chosen to stay clear of.
41
+ *
42
+ * A description here is a line that helps decide whether to open the skill, not
43
+ * a substitute for reading it.
44
+ *
45
+ * Exported for the specs, which assert the bound rather than restate the
46
+ * number. Nothing outside this package reads them: `./index.ts` is what the
47
+ * subpath publishes, and it exports neither.
48
+ */
49
+ export declare const MAX_SKILLS_LISTED = 40;
50
+ export declare const MAX_DESCRIPTION_CHARS = 200;
51
+ /**
52
+ * What one command this module runs answers with.
53
+ *
54
+ * A structural subset of `context.ts`'s `RunResult`, for the reason
55
+ * {@link file://./checkout.ts GitAnswer} is the home of: the dependency runs one
56
+ * way, so the tools know about this file and this file knows only the shape of
57
+ * an answer. Narrower than that one by `unreachable`, which nothing here reads:
58
+ * a read that never ran and a read that ran and failed cost the same line.
59
+ */
60
+ export interface ShellAnswer {
61
+ success: boolean;
62
+ stdout: string;
63
+ stderr: string;
64
+ }
65
+ /** The runner {@link skillInventory} borrows from `repoContext`. */
66
+ export type ShellRunner = (script: string, cwd: string) => Promise<ShellAnswer>;
67
+ /**
68
+ * Collect every skill's frontmatter block, each file's lines indented by one tab
69
+ * under the line naming its directory.
70
+ *
71
+ * **The tab is what makes the stream unambiguous.** A line at column zero names
72
+ * a skill's directory and every line of that skill's file is indented by one, so
73
+ * nothing in a file can be read as the start of another record. The obvious
74
+ * alternative, a separator line of its own, is a string a folded description
75
+ * could legally contain, and the failure would be a skill silently merged into
76
+ * its neighbour. It is written by `awk`'s own string escape, which is portable
77
+ * where `sed`'s `\t` is GNU's, and a published package does not get to assume a
78
+ * host's image.
79
+ *
80
+ * **`awk` stops at the closing delimiter**, so what arrives is the whole block
81
+ * and not a guess at how long one is. {@link readFrontmatter} reports a block
82
+ * that arrived without it, and {@link invocationOf} is why that matters: a
83
+ * restriction sits in whichever line of the block its author put it on, so a
84
+ * read that stops early is a read that cannot speak for the skill. The `NR == 1`
85
+ * test is the other half of the saving: a file with no block at all costs one
86
+ * line rather than the bound.
87
+ *
88
+ * **A file this cannot read costs its own record and no other.** The loop's exit
89
+ * status is the last `printf`'s, so a failed read leaves the directory line with
90
+ * nothing indented under it, which {@link readFrontmatter} reads as a block
91
+ * nobody has seen. Failing the whole read instead would spend every other
92
+ * skill's inventory on one unreadable file.
93
+ *
94
+ * It reads through whatever `<dir>/<name>` is. A checkout may commit each entry
95
+ * as a symlink to a directory it keeps elsewhere, which is how this workspace
96
+ * holds its own, and the glob and `head` both follow one.
97
+ *
98
+ * A glob that matches nothing stays literal in a POSIX shell, so the `-f` test
99
+ * is what makes an absent `.claude/skills` an empty answer rather than an error.
100
+ *
101
+ * The blank line after each record is the stream's own shape rather than a
102
+ * property of the tool above it: a name starts a line whatever the file below it
103
+ * ended with. No model input reaches any of this; every path is this module's
104
+ * own constant.
105
+ */
106
+ export declare const READ_SKILLS = "for d in .claude/skills/*/; do\n [ -f \"$d/SKILL.md\" ] || continue\n printf '%s\\n' \"${d%/}\"\n head -c 4000 \"$d/SKILL.md\" | awk -v lines=120 '\n { print \"\\t\" $0 }\n NR == 1 { if ($0 !~ /^---[[:space:]]*$/) exit; next }\n /^---[[:space:]]*$/ { exit }\n NR >= lines { exit }\n '\n printf '\\n'\ndone";
107
+ /**
108
+ * How a prompt may reach a skill, as its frontmatter decides.
109
+ *
110
+ * The distinction is not cosmetic, and it is the reason this module reports more
111
+ * than a name and a description. `disable-model-invocation` takes the skill away
112
+ * from the model's own skill tool, which leaves naming it as `/<name>` the only
113
+ * way in; `user-invocable: false` removes that command, which leaves prose. A
114
+ * host that cannot tell the two apart writes a prompt that silently does
115
+ * nothing.
116
+ *
117
+ * `unknown` is the one that is not a reading of the frontmatter but a statement
118
+ * about the read: the block did not arrive whole, so the fields that decide this
119
+ * are fields nobody has seen. It is reported rather than guessed because both
120
+ * guesses are wrong in a case that happens. The permissive one advertises a
121
+ * prompt-only skill as one a model picks up by itself, which is the silent
122
+ * failure above; the strict one takes a way in away from a skill that has it.
123
+ */
124
+ export type SkillInvocation = "either" | "command" | "prose" | "neither" | "unknown";
125
+ /** One skill a checkout carries. */
126
+ export interface RepoSkill {
127
+ /** What a prompt names it by: its frontmatter `name`, else its directory. */
128
+ name: string;
129
+ description?: string;
130
+ invocation: SkillInvocation;
131
+ }
132
+ /**
133
+ * The skills in a checkout, as a sentence the model reads, or why they could not
134
+ * be read.
135
+ *
136
+ * A failed read is reported and never raised: the clone it is appended to
137
+ * succeeded, and a missing inventory is worth a line rather than an error that
138
+ * sends the model to investigate git.
139
+ */
140
+ export declare function skillInventory(shell: ShellRunner, dir: string): Promise<string>;
141
+ /**
142
+ * {@link READ_SKILLS}'s output, read back.
143
+ *
144
+ * Unindented lines name directories and indented ones belong to the file above
145
+ * them, so the shape of the stream is what separates the records. A blank
146
+ * unindented line is the trailing newline of the stream, never the end of a
147
+ * record.
148
+ *
149
+ * **A record starts only on a path under {@link SKILLS_DIR}**, which is the one
150
+ * thing the indentation alone does not settle. Directory names come from a
151
+ * cloned repository, and one carrying a newline writes a second line at column
152
+ * zero: without the path, that line opens a record of its own and the skill
153
+ * above it loses the frontmatter that follows to a name nobody wrote. A
154
+ * directory name holds no `/`, so what the second line cannot be is a path,
155
+ * which is what closes it.
156
+ */
157
+ export declare function readSkillInventory(stdout: string): RepoSkill[];
158
+ /**
159
+ * The inventory as the model reads it: where this looked, a line per skill, and
160
+ * what the list left out.
161
+ *
162
+ * **The counts are unconditional**, the empty answer included. "No skills here"
163
+ * and "this tool does not report skills" are different facts, and a repository
164
+ * that keeps its skills somewhere Claude Code does not read them is the case
165
+ * that makes the difference worth a sentence.
166
+ */
167
+ export declare function skillInventoryText(skills: readonly RepoSkill[]): string;
@@ -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 and the branch you landed on. If the repository is already checked out from an earlier task, it is fetched and reset to the remote instead — unless it has uncommitted changes, which are left alone for you to inspect.",
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.message;
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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dynamicagents/plugins",
3
- "version": "0.15.0",
3
+ "version": "0.16.1",
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",
@@ -86,7 +86,7 @@
86
86
  "peerDependencies": {
87
87
  "@cloudflare/sandbox": "^1.0.1",
88
88
  "@cloudflare/think": ">=0.20.2 <0.21.0",
89
- "@dynamicagents/core": ">=0.15.0 <0.16.0",
89
+ "@dynamicagents/core": ">=0.16.0 <0.17.0",
90
90
  "agents": ">=0.28.0 <0.29.0",
91
91
  "ai": "^7.0.88",
92
92
  "zod": "^4.4.3"
@@ -99,8 +99,8 @@
99
99
  "devDependencies": {
100
100
  "@cloudflare/sandbox": "^1.0.1",
101
101
  "@cloudflare/think": "^0.20.2",
102
- "@cloudflare/vitest-plugin": "^1.1.9",
103
- "@dynamicagents/core": "^0.15.0",
102
+ "@cloudflare/vitest-plugin": "^1.4.4",
103
+ "@dynamicagents/core": "^0.16.0",
104
104
  "@types/node": "^26.5.1",
105
105
  "agents": "^0.28.0",
106
106
  "ai": "^7.0.101",
@@ -112,7 +112,7 @@
112
112
  "typescript-eslint": "^8.70.0",
113
113
  "vitest": "^4.1.11",
114
114
  "workers-ai-provider": "^4.0.0",
115
- "wrangler": "^4.131.2",
115
+ "wrangler": "^4.151.1",
116
116
  "zod": "^4.6.5"
117
117
  },
118
118
  "engines": {