Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions crates/orchestrator/src/manager.rs
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,53 @@ impl AgentManager {
}
}

// Materialize skills into the agent's .claude/skills/ directory before
// the claude process is launched so that Claude Code discovers them on
// startup. Missing skills produce a warning rather than a hard failure
// so that a typo in the skills list does not prevent the agent from
// starting entirely.
if !agent.config.skills.is_empty() {
let discovered = crate::skills::discover_all_skills();
match crate::skills::materialize_skills(
std::path::Path::new(&agent.config.working_dir),
&agent.config.skills,
&discovered,
)
.await
{
Ok(result) => {
if !result.written.is_empty() {
info!(
agent_id = %agent.id,
written = result.written.len(),
"materialized skills into .claude/skills/"
);
}
if !result.skipped.is_empty() {
info!(
agent_id = %agent.id,
skipped = result.skipped.len(),
"skipped skill materialization (agent-local files take precedence)"
);
}
if !result.not_found.is_empty() {
warn!(
agent_id = %agent.id,
missing = ?result.not_found,
"some requested skills were not found in discovered skill set"
);
}
}
Err(e) => {
warn!(
agent_id = %agent.id,
error = %e,
"skill materialization failed; agent will start without skills"
);
}
}
}

// Subprocess stdio mode takes precedence: agents communicate via
// stdin/stdout NDJSON rather than WebSocket.
let use_stdio = self.backend.supports_subprocess_stdio();
Expand Down
92 changes: 92 additions & 0 deletions crates/orchestrator/src/skills.rs
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,98 @@ use serde::{Deserialize, Serialize};
use std::collections::HashSet;
use std::path::{Path, PathBuf};

// ---------------------------------------------------------------------------
// Materialization
// ---------------------------------------------------------------------------

/// Result of a [`materialize_skills`] call.
#[derive(Debug, Default, PartialEq)]
pub struct MaterializeResult {
/// Skill names whose `SKILL.md` file was successfully written.
pub written: Vec<String>,
/// Skill names that were skipped because the target file already existed.
///
/// The agent's own `.claude/skills/<name>/SKILL.md` takes precedence over
/// the agentd-managed copy.
pub skipped: Vec<String>,
/// Skill names that were requested but are not in `discovered_skills`.
pub not_found: Vec<String>,
}

/// Write skill files into the agent's `.claude/skills/` directory.
///
/// For each skill name in `skill_names`, copies the skill content from
/// `discovered_skills` into `<working_dir>/.claude/skills/<name>/SKILL.md`.
///
/// - Creates the directory structure if it does not exist.
/// - Does **not** overwrite existing skill files; agent-local skills take
/// precedence (reported as [`MaterializeResult::skipped`]).
/// - Skill names not present in `discovered_skills` are reported in
/// [`MaterializeResult::not_found`] rather than returning an error.
///
/// # Worktree agents
///
/// When an agent uses `--worktree`, Claude Code creates a temporary git
/// worktree. Skills are written to the source `working_dir` *before* launch.
/// Because `.claude/` is typically in `.gitignore`, the worktree does not
/// inherit those files — the agent's `additional_dirs` (already wired up in
/// `build_claude_command`) point back at the project root where the skills live.
pub async fn materialize_skills(
working_dir: &Path,
skill_names: &[String],
discovered_skills: &[Skill],
) -> Result<MaterializeResult> {
use std::collections::HashMap;

let index: HashMap<&str, &Skill> =
discovered_skills.iter().map(|s| (s.name.as_str(), s)).collect();

let mut result = MaterializeResult::default();

for name in skill_names {
match index.get(name.as_str()) {
None => {
result.not_found.push(name.clone());
}
Some(skill) => {
let target_dir = working_dir.join(".claude").join("skills").join(name);
let target_file = target_dir.join("SKILL.md");

if target_file.exists() {
result.skipped.push(name.clone());
continue;
}

if let Err(e) = tokio::fs::create_dir_all(&target_dir).await {
tracing::warn!(
skill = %name,
dir = %target_dir.display(),
error = %e,
"Failed to create skill directory; skipping"
);
result.not_found.push(name.clone());
continue;
}

if let Err(e) = tokio::fs::write(&target_file, &skill.content).await {
tracing::warn!(
skill = %name,
file = %target_file.display(),
error = %e,
"Failed to write skill file; skipping"
);
result.not_found.push(name.clone());
continue;
}

result.written.push(name.clone());
}
}
}

Ok(result)
}

// ---------------------------------------------------------------------------
// Skill model
// ---------------------------------------------------------------------------
Expand Down