Skip to content

Commit adbdf42

Browse files
gilgtmcodexclaude
committed
feat(composer): offer / skill chips for the addressed local agent
In the desktop app, `/` at a word start lists the skills of the one local Claude or Codex agent that the viewer's message addresses. The skills come from that agent's own skill folders on this machine: `~/.claude/skills` for Claude, and `~/.codex/skills` and `~/.agents/skills` for Codex. Skills appear under a "Skills for <agent>" heading, with their own icon. Completion rows now carry an optional group, so a later commands group can go first in the same `/` menu. If several agents are addressed, the menu asks for one. A remote agent, someone else's agent, a person or a browser build lists nothing. One open `/` query lists once across keystrokes, and Retry lists again after a failed read. A chosen skill is a chip for that agent. The chip opens its short description and location in place, as a nested popover. Like other chips, it sends as literal text plus the signed `buzz-tokens-v1` tag. No skill content is copied into the message. `local_skills_list` reads only the SKILL.md front matter in the given skill folders, using the bounded listing helper that the folder chips use. It skips front matter larger than 16 KiB and refuses a listing of more than 512 skills. A local Claude or Codex agent gets one prompt line naming each SKILL.md only when the message's author is the agent's owner and the chip names that agent. Another author's chip, or a chip for another agent, adds nothing. The agent reads the file itself. Co-authored-by: Codex <noreply@openai.com> Co-authored-by: Claude <noreply@anthropic.com> Signed-off-by: Gil Birman <gil@squareup.com>
1 parent 1fbd49c commit adbdf42

31 files changed

Lines changed: 1027 additions & 131 deletions

‎src-tauri/build.rs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,7 @@ fn main() {
157157
"workspace_pick_folder",
158158
"repositories_folder_validate",
159159
"repositories_folder_list",
160+
"local_skills_list",
160161
"legacy_communities",
161162
"plugin_import_folder",
162163
"plugin_update_check",

‎src-tauri/capabilities/default.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,7 @@
103103
"allow-workspace-pick-folder",
104104
"allow-repositories-folder-validate",
105105
"allow-repositories-folder-list",
106+
"allow-local-skills-list",
106107
"allow-legacy-communities",
107108
"allow-plugin-import-folder",
108109
"allow-plugin-import-git",

‎src-tauri/src/host_files.rs‎

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -51,13 +51,14 @@ async fn bounded<T: Send + 'static>(
5151
}
5252

5353
/// Lists a folder with directory resolution's limit on blocking work, and
54-
/// answers its caller after the read timeout.
54+
/// answers its caller with `late` after the read timeout.
5555
pub(crate) async fn bounded_listing<T: Send + 'static>(
56+
late: &'static str,
5657
work: impl FnOnce() -> Result<T, String> + Send + 'static,
5758
) -> Result<T, String> {
5859
tokio::time::timeout(READ_TIMEOUT, bounded(&DIRECTORIES, work))
5960
.await
60-
.map_err(|_| "Listing the repositories folder took too long".to_string())?
61+
.map_err(|_| late.to_string())?
6162
}
6263

6364
/// `path`, absolute or `~/…` (`~\…` too on Windows), or else relative to
@@ -181,7 +182,10 @@ pub(crate) async fn plugin_host_repository_folders<R: tauri::Runtime>(
181182
use tauri::Manager as _;
182183
granted(manager, id, revision).await?;
183184
let home = app.path().home_dir().ok();
184-
bounded_listing(move || crate::repositories_folder::folders(&path, home.as_deref())).await
185+
bounded_listing(crate::repositories_folder::LATE, move || {
186+
crate::repositories_folder::folders(&path, home.as_deref())
187+
})
188+
.await
185189
}
186190

187191
// One admitted byte operation; waiting never clones a raw save body. Tokio's

‎src-tauri/src/lib.rs‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ mod keep_awake;
6262
#[cfg(target_os = "macos")]
6363
mod legacy_cli_shortcut;
6464
mod legacy_communities;
65+
mod local_skills;
6566
mod loopback;
6667
#[cfg(target_os = "macos")]
6768
mod media_context_menu;
@@ -80,6 +81,7 @@ mod window_controls;
8081
mod window_state;
8182
mod workspace_folder;
8283
use legacy_communities::legacy_communities;
84+
use local_skills::local_skills_list;
8385
use os_idle::get_os_idle_seconds;
8486
use repositories_folder::{repositories_folder_list, repositories_folder_validate};
8587
mod relaunch;
@@ -771,6 +773,7 @@ fn commands<R: tauri::Runtime>() -> impl Fn(tauri::ipc::Invoke<R>) -> bool + Sen
771773
workspace_pick_folder,
772774
repositories_folder_validate,
773775
repositories_folder_list,
776+
local_skills_list,
774777
legacy_communities,
775778
plugin_import_folder,
776779
plugin_update_check,

‎src-tauri/src/local_skills.rs‎

Lines changed: 235 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,235 @@
1+
//! The `SKILL.md` skills in a local agent harness's skill folders, for the
2+
//! composer's `/` menu. Reads each skill's front matter, never its body.
3+
use std::io::Read as _;
4+
use std::path::Path;
5+
6+
const MAX_ROOTS: usize = 8;
7+
const MAX_ENTRIES: usize = 4096;
8+
const MAX_SKILLS: usize = 512;
9+
/// Front matter must end within this; a skill whose block doesn't is skipped.
10+
const HEAD_BYTES: u64 = 16 * 1024;
11+
12+
#[derive(Debug, PartialEq, Eq, serde::Serialize)]
13+
#[serde(rename_all = "camelCase")]
14+
pub(crate) struct LocalSkill {
15+
name: String,
16+
description: String,
17+
path: String,
18+
display_path: String,
19+
}
20+
21+
/// `name` and `description` from leading `---` front matter: one-line
22+
/// scalars, or a folded or literal block for the description.
23+
fn front_matter(text: &str) -> Option<(String, String)> {
24+
let mut lines = text.lines();
25+
if lines.next()?.trim_end() != "---" {
26+
return None;
27+
}
28+
let (mut name, mut description, mut block) = (None, String::new(), false);
29+
for line in lines {
30+
if line.trim_end() == "---" {
31+
return name.map(|name| (name, description));
32+
}
33+
if block && line.starts_with([' ', '\t']) {
34+
if !description.is_empty() {
35+
description.push(' ');
36+
}
37+
description.push_str(line.trim());
38+
continue;
39+
}
40+
block = false;
41+
let Some((key, value)) = line.split_once(':') else {
42+
continue;
43+
};
44+
let value = value.trim();
45+
let unquoted = [('"', '"'), ('\'', '\'')]
46+
.iter()
47+
.find_map(|(open, close)| value.strip_prefix(*open)?.strip_suffix(*close))
48+
.unwrap_or(value);
49+
match key {
50+
"name" if !unquoted.is_empty() => name = Some(unquoted.to_owned()),
51+
"description" => {
52+
block = value.starts_with(['>', '|']);
53+
description = if block {
54+
String::new()
55+
} else {
56+
unquoted.to_owned()
57+
};
58+
}
59+
_ => {}
60+
}
61+
}
62+
None
63+
}
64+
65+
/// Each `<root>/<folder>/SKILL.md` with a named front matter, by name. The
66+
/// first root to name a skill wins, and a missing root lists nothing. Follows
67+
/// linked skill folders, as the harnesses do. Reads only; the caller names the
68+
/// harness's folders.
69+
pub(crate) fn skills(roots: &[String], home: Option<&Path>) -> Result<Vec<LocalSkill>, String> {
70+
if roots.len() > MAX_ROOTS {
71+
return Err("Too many skill folders.".into());
72+
}
73+
let mut found: Vec<LocalSkill> = Vec::new();
74+
for root in roots {
75+
let root = crate::workspace_folder::expand(root.trim(), home);
76+
if !root.is_absolute() {
77+
return Err("A skill folder must be absolute or under ~/.".into());
78+
}
79+
let entries = match std::fs::read_dir(&root) {
80+
Ok(entries) => entries,
81+
Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
82+
Err(error) => return Err(format!("Could not list {}: {error}", root.display())),
83+
};
84+
for entry in entries.flatten().take(MAX_ENTRIES) {
85+
let path = entry.path().join("SKILL.md");
86+
let mut head = Vec::new();
87+
if std::fs::File::open(&path)
88+
.and_then(|file| file.take(HEAD_BYTES).read_to_end(&mut head))
89+
.is_err()
90+
{
91+
continue;
92+
}
93+
let Some((name, description)) = front_matter(&String::from_utf8_lossy(&head)) else {
94+
continue;
95+
};
96+
let (Some(text), Ok(display_path)) =
97+
(path.to_str(), crate::workspace_folder::display(&path, home))
98+
else {
99+
continue;
100+
};
101+
if found.iter().any(|skill| skill.name == name) {
102+
continue;
103+
}
104+
if found.len() == MAX_SKILLS {
105+
return Err("There are more than 512 skills.".into());
106+
}
107+
found.push(LocalSkill {
108+
name,
109+
description,
110+
path: text.to_owned(),
111+
display_path,
112+
});
113+
}
114+
}
115+
found.sort_by(|a, b| a.name.cmp(&b.name));
116+
Ok(found)
117+
}
118+
119+
/// Lists skills for the trusted app renderer, which names the folders of the
120+
/// agent's harness.
121+
#[tauri::command]
122+
pub(crate) async fn local_skills_list<R: tauri::Runtime>(
123+
app: tauri::AppHandle<R>,
124+
roots: Vec<String>,
125+
) -> Result<Vec<LocalSkill>, String> {
126+
use tauri::Manager as _;
127+
let home = app.path().home_dir().ok();
128+
crate::host_files::bounded_listing("Listing skills took too long", move || {
129+
skills(&roots, home.as_deref())
130+
})
131+
.await
132+
}
133+
134+
#[cfg(test)]
135+
mod tests {
136+
use super::*;
137+
138+
#[test]
139+
fn reads_names_and_descriptions_from_front_matter_only() {
140+
assert_eq!(
141+
front_matter("---\nname: review\ndescription: \"Reviews a diff\"\n---\nname: body"),
142+
Some(("review".into(), "Reviews a diff".into()))
143+
);
144+
assert_eq!(
145+
front_matter("---\nname: 'notes'\ndescription: >-\n Folded\n lines\nother: x\n---\n"),
146+
Some(("notes".into(), "Folded lines".into()))
147+
);
148+
assert_eq!(front_matter("---\ndescription: unnamed\n---\n"), None);
149+
assert_eq!(front_matter("name: review\n"), None);
150+
assert_eq!(front_matter("---\nname: unterminated\n"), None);
151+
}
152+
153+
#[test]
154+
fn lists_each_named_skill_once_by_name() {
155+
let temp = tempfile::tempdir().unwrap();
156+
let home = dunce::canonicalize(temp.path()).unwrap();
157+
let skill = |root: &str, folder: &str, text: &str| {
158+
let dir = home.join(root).join(folder);
159+
std::fs::create_dir_all(&dir).unwrap();
160+
std::fs::write(dir.join("SKILL.md"), text).unwrap();
161+
};
162+
skill(
163+
".claude/skills",
164+
"write",
165+
"---\nname: write\ndescription: Prose\n---\n",
166+
);
167+
skill(".claude/skills", "plain", "# No front matter\n");
168+
skill(
169+
".agents/skills",
170+
"dup",
171+
"---\nname: write\ndescription: Other\n---\n",
172+
);
173+
skill(".agents/skills", "audit", "---\nname: audit\n---\n");
174+
std::fs::write(home.join(".claude/skills/README.md"), "not a skill").unwrap();
175+
let roots = ["~/.claude/skills", "~/.agents/skills", "~/.missing/skills"].map(String::from);
176+
let listed = skills(&roots, Some(&home)).unwrap();
177+
let names: Vec<_> = listed.iter().map(|skill| skill.name.as_str()).collect();
178+
assert_eq!(names, ["audit", "write"]);
179+
assert_eq!(listed[1].description, "Prose");
180+
assert_eq!(listed[1].display_path, "~/.claude/skills/write/SKILL.md");
181+
assert_eq!(
182+
listed[1].path,
183+
home.join(".claude/skills/write/SKILL.md").to_str().unwrap()
184+
);
185+
assert!(skills(&["relative".into()], Some(&home)).is_err());
186+
assert!(skills(&vec![String::from("~/.claude/skills"); 9], Some(&home)).is_err());
187+
}
188+
189+
#[test]
190+
fn caps_what_one_listing_reads() {
191+
let temp = tempfile::tempdir().unwrap();
192+
let home = dunce::canonicalize(temp.path()).unwrap();
193+
let root = home.join("skills");
194+
let skill = |folder: &str, text: &str| {
195+
std::fs::create_dir_all(root.join(folder)).unwrap();
196+
std::fs::write(root.join(folder).join("SKILL.md"), text).unwrap();
197+
};
198+
// Front matter that does not close within the read head is no skill.
199+
let padding = "x".repeat(HEAD_BYTES as usize);
200+
skill(
201+
"long",
202+
&format!("---\nname: long\ndescription: {padding}\n---\n"),
203+
);
204+
skill("short", "---\nname: short\n---\n");
205+
let roots = ["~/skills".to_string()];
206+
let names: Vec<_> = skills(&roots, Some(&home))
207+
.unwrap()
208+
.into_iter()
209+
.map(|skill| skill.name)
210+
.collect();
211+
assert_eq!(names, ["short"]);
212+
for index in 0..MAX_SKILLS {
213+
skill(&format!("s{index}"), &format!("---\nname: s{index}\n---\n"));
214+
}
215+
assert_eq!(
216+
skills(&roots, Some(&home)).unwrap_err(),
217+
"There are more than 512 skills."
218+
);
219+
}
220+
221+
#[cfg(unix)]
222+
#[test]
223+
fn follows_a_linked_skill_folder() {
224+
let temp = tempfile::tempdir().unwrap();
225+
let home = dunce::canonicalize(temp.path()).unwrap();
226+
let shared = home.join("shared/review");
227+
std::fs::create_dir_all(&shared).unwrap();
228+
std::fs::write(shared.join("SKILL.md"), "---\nname: review\n---\n").unwrap();
229+
std::fs::create_dir_all(home.join(".codex/skills")).unwrap();
230+
std::os::unix::fs::symlink(&shared, home.join(".codex/skills/review")).unwrap();
231+
let listed = skills(&["~/.codex/skills".into()], Some(&home)).unwrap();
232+
assert_eq!(listed.len(), 1);
233+
assert_eq!(listed[0].display_path, "~/.codex/skills/review/SKILL.md");
234+
}
235+
}

‎src-tauri/src/repositories_folder.rs‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ fn validate(value: &str, home: Option<&Path>) -> Result<String, String> {
1919
}
2020

2121
const MAX_ENTRIES: usize = 4096;
22+
pub(crate) const LATE: &str = "Listing the repositories folder took too long";
2223
const MAX_BYTES: usize = 1024 * 1024;
2324

2425
#[derive(Debug, PartialEq, Eq, serde::Serialize)]
@@ -92,7 +93,7 @@ pub(crate) async fn repositories_folder_list<R: tauri::Runtime>(
9293
) -> Result<Vec<RepositoryFolder>, String> {
9394
use tauri::Manager as _;
9495
let home = app.path().home_dir().ok();
95-
crate::host_files::bounded_listing(move || folders(&path, home.as_deref())).await
96+
crate::host_files::bounded_listing(LATE, move || folders(&path, home.as_deref())).await
9697
}
9798

9899
#[tauri::command]

‎src/app/services.test.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,7 @@ it.each([
194194
"buzz.emoji/typeahead",
195195
"buzz.mentions/typeahead",
196196
"buzz.mentions/folders",
197+
"buzz.mentions/skills",
197198
]);
198199
expect(
199200
services.conversation.inline.snapshot().map(({ key }) => key),

‎src/bundled/claude-code/runtime.test.ts‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3059,6 +3059,35 @@ it("grants a listed folder its owner referenced by chip for the rest of the conv
30593059
}
30603060
});
30613061

3062+
it("names a skill its owner chose for this agent by chip, never one chosen for another agent", async () => {
3063+
const skill = "/example/.claude/skills/review/SKILL.md";
3064+
const mention = (id: string, target: string) =>
3065+
message(id, "@Claude review", {
3066+
tags: [
3067+
["h", channel],
3068+
["p", self],
3069+
...messageTokenTags([
3070+
{ kind: "skill", id: skill, label: "review", target },
3071+
]),
3072+
],
3073+
});
3074+
const { deliver, claudes, runtime } = setup([], {
3075+
solo: new Set([channel]),
3076+
});
3077+
try {
3078+
await deliver({ type: "mention", event: mention("2", "f".repeat(64)) });
3079+
await flush(10);
3080+
const process = claudes().find((item) => item.prompts.length);
3081+
expect(process?.prompts[0]).not.toContain("<referenced-skills>");
3082+
await deliver({ type: "mention", event: mention("3", self) });
3083+
await flush(10);
3084+
expect(process?.prompts[1]).toContain("<referenced-skills>");
3085+
expect(process?.prompts[1]).toContain(JSON.stringify(skill));
3086+
} finally {
3087+
runtime.dispose();
3088+
}
3089+
});
3090+
30623091
it("hands no Canvas to a shared channel's turns", async () => {
30633092
const event = message("1", "@Claude go", {
30643093
tags: [

‎src/bundled/claude-code/runtime.ts‎

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,10 @@ import {
5454
} from "../../features/agent-harness/core-memory";
5555
import { readAuthorNames } from "../../features/agent-harness/author-names";
5656
import { readProject } from "../../features/agent-harness/project";
57-
import { referencedFolders } from "../../features/agent-harness/referenced-folders";
57+
import {
58+
referencedFolders,
59+
referencedSkills,
60+
} from "../../features/agent-harness/referenced-folders";
5861
import { SessionControls } from "../../features/agent-harness/session-control";
5962
import type {
6063
SessionPlace,
@@ -1344,7 +1347,11 @@ export class ClaudeRuntime {
13441347
];
13451348
if (directories.length) entry.directories.set(key, directories);
13461349
else if (project) entry.directories.delete(key);
1347-
const section = [project?.section, folders.section]
1350+
const section = [
1351+
project?.section,
1352+
folders.section,
1353+
referencedSkills(event, owner, agent.pubkey),
1354+
]
13481355
.filter(Boolean)
13491356
.join("\n\n");
13501357
return {

0 commit comments

Comments
 (0)