Skip to content

Commit 82c2501

Browse files
UnbreakableMJclaude
andcommitted
feat(construct-cli): Phase 3 — general sources (git URLs) + find/use/init
Adds a sources layer: resolve a source spec (local path, git URL, or owner/repo — shallow-cloned into the XDG cache and reused), and discover skills across the common container dirs (skills/, skills/.curated/). SKILL.md frontmatter parsing powers new commands: `skill find` (browse name+description), `skill use` (print prompts without installing), `skill init` (scaffold a SKILL.md). `skill add`/`update` now accept git URLs (--refresh re-clones). Verified end-to-end cloning Spacecraft-Software/Construct. 27 tests, clippy -D warnings, reuse lint, nix build all pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 4ad5e16 commit 82c2501

10 files changed

Lines changed: 821 additions & 99 deletions

File tree

construct-cli/src/cli.rs

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,25 @@ pub(crate) enum SkillCommand {
185185
)]
186186
Update(AddArgs),
187187

188+
/// Browse a source's catalogue (name + description from SKILL.md).
189+
#[command(
190+
visible_alias = "search",
191+
after_help = "Examples:\n construct skill find\n construct skill find rust\n construct skill find --source vercel-labs/skills --json"
192+
)]
193+
Find(FindArgs),
194+
195+
/// Print selected skills' prompts to stdout without installing.
196+
#[command(
197+
after_help = "Examples:\n construct skill use --skills spacecraft-rust-guidelines\n construct skill use vercel-labs/skills --skills find-skills"
198+
)]
199+
Use(UseArgs),
200+
201+
/// Scaffold a new skill directory with a SKILL.md template.
202+
#[command(
203+
after_help = "Examples:\n construct skill init my-skill\n construct skill init my-skill --dir ./skills"
204+
)]
205+
Init(InitArgs),
206+
188207
/// Update the Construct flake input in a consuming flake (no rebuild).
189208
#[command(
190209
after_help = "Examples:\n construct skill sync\n construct skill sync --json\n construct skill sync --flake-dir /etc/nixos --dry-run"
@@ -233,6 +252,10 @@ pub(crate) struct AddArgs {
233252
/// Target every known agent (not just detected ones).
234253
#[arg(long)]
235254
pub(crate) all: bool,
255+
256+
/// Re-clone a cached remote source before installing.
257+
#[arg(long)]
258+
pub(crate) refresh: bool,
236259
}
237260

238261
/// Arguments for `construct skill list`.
@@ -290,6 +313,44 @@ pub(crate) struct SyncArgs {
290313
pub(crate) flake_dir: Option<PathBuf>,
291314
}
292315

316+
/// Arguments for `construct skill find`.
317+
#[derive(Debug, Args)]
318+
pub(crate) struct FindArgs {
319+
/// Filter skills whose name or description contains this query.
320+
pub(crate) query: Option<String>,
321+
322+
/// Source to browse (local path, git URL, or owner/repo; default Construct).
323+
#[arg(long, value_name = "SRC")]
324+
pub(crate) source: Option<String>,
325+
}
326+
327+
/// Arguments for `construct skill use`.
328+
#[derive(Debug, Args)]
329+
pub(crate) struct UseArgs {
330+
/// Source (local path, git URL, or owner/repo); default the Construct clone.
331+
pub(crate) source: Option<String>,
332+
333+
/// Skills whose prompts to print (comma-separated); default all in source.
334+
#[arg(
335+
short = 's',
336+
long = "skills",
337+
value_delimiter = ',',
338+
value_name = "NAME[,NAME...]"
339+
)]
340+
pub(crate) skills: Vec<String>,
341+
}
342+
343+
/// Arguments for `construct skill init`.
344+
#[derive(Debug, Args)]
345+
pub(crate) struct InitArgs {
346+
/// Name (directory) of the new skill.
347+
pub(crate) name: String,
348+
349+
/// Parent directory to create the skill under (default: current dir).
350+
#[arg(long, value_name = "DIR")]
351+
pub(crate) dir: Option<PathBuf>,
352+
}
353+
293354
/// Arguments for `construct skill ship`.
294355
#[derive(Debug, Args)]
295356
pub(crate) struct ShipArgs {

construct-cli/src/commands/mod.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,9 @@ pub(crate) fn dispatch(cli: &Cli, ctx: &Context) -> Result<Option<CommandOutput>
3333
SkillCommand::List(args) => skill::list(ctx, args).map(Some),
3434
SkillCommand::Remove(args) => skill::remove(ctx, args).map(Some),
3535
SkillCommand::Update(args) => skill::update(ctx, args).map(Some),
36+
SkillCommand::Find(args) => skill::find(ctx, args).map(Some),
37+
SkillCommand::Use(args) => skill::use_prompt(ctx, args).map(Some),
38+
SkillCommand::Init(args) => skill::init(ctx, args).map(Some),
3639
SkillCommand::Sync(args) => sync::run(ctx, args).map(Some),
3740
SkillCommand::Ship(args) => ship::run(ctx, args).map(Some),
3841
},

construct-cli/src/commands/skill.rs

Lines changed: 119 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,20 @@
11
// SPDX-FileCopyrightText: 2026 Mohamed Hammad <Mohamed.Hammad@SpacecraftSoftware.org>
22
// SPDX-License-Identifier: GPL-3.0-or-later
33

4-
//! `construct skill add | list | remove | update` — the imperative installer's
5-
//! command surface. These build options from the parsed args and delegate to
6-
//! [`crate::install`], then wrap the structured report into a [`CommandOutput`].
4+
//! `construct skill add | list | remove | update | find | use | init` — the
5+
//! skill command surface. These build options from the parsed args and delegate
6+
//! to [`crate::install`] / [`crate::sources`], then wrap the result.
77
88
use std::path::PathBuf;
99

10-
use serde_json::Value;
10+
use serde_json::{json, Value};
1111

12-
use crate::cli::{AddArgs, RemoveArgs, ScopeQueryArgs};
12+
use crate::cli::{AddArgs, FindArgs, InitArgs, RemoveArgs, ScopeQueryArgs, UseArgs};
1313
use crate::context::Context;
1414
use crate::install::{self, plan::AddOptions, plan::Scope, InstallReport, RemoveOptions};
15-
use crate::output::error::AppError;
15+
use crate::output::error::{AppError, ErrorCode};
1616
use crate::output::{CommandOutput, HumanRender};
17+
use crate::sources::{self, skillmd};
1718

1819
/// Map the `--global` flag to a [`Scope`].
1920
fn scope_of(global: bool) -> Scope {
@@ -24,20 +25,24 @@ fn scope_of(global: bool) -> Scope {
2425
}
2526
}
2627

28+
/// Resolve the source spec, defaulting to the local Construct catalogue.
29+
fn source_spec(explicit: Option<&String>) -> String {
30+
explicit
31+
.cloned()
32+
.unwrap_or_else(|| install::plan::DEFAULT_SOURCE.to_owned())
33+
}
34+
2735
/// Build [`AddOptions`] from parsed args; `force` is set true for `update`.
2836
fn add_options(args: &AddArgs, force: bool) -> AddOptions {
29-
let source = args
30-
.source
31-
.clone()
32-
.unwrap_or_else(|| install::plan::DEFAULT_SOURCE.to_owned());
3337
AddOptions {
34-
source: PathBuf::from(source),
38+
source: source_spec(args.source.as_ref()),
3539
skills: args.skills.clone(),
3640
agents: args.agents.clone(),
3741
all_agents: args.all,
3842
scope: scope_of(args.global),
3943
copy: args.copy,
4044
force,
45+
refresh: args.refresh,
4146
}
4247
}
4348

@@ -71,6 +76,82 @@ pub(crate) fn remove(ctx: &Context, args: &RemoveArgs) -> Result<CommandOutput,
7176
Ok(report_output(&report))
7277
}
7378

79+
/// `construct skill find` — browse a source's catalogue (name + description).
80+
pub(crate) fn find(ctx: &Context, args: &FindArgs) -> Result<CommandOutput, AppError> {
81+
let root = sources::resolve_source(ctx, &source_spec(args.source.as_ref()), false)?;
82+
let query = args.query.as_deref().map(str::to_lowercase);
83+
84+
let mut records = Vec::new();
85+
let mut rows = Vec::new();
86+
for skill in sources::discover(&root) {
87+
let desc = skill.description.clone().unwrap_or_default();
88+
if let Some(q) = &query {
89+
if !format!("{} {desc}", skill.name).to_lowercase().contains(q) {
90+
continue;
91+
}
92+
}
93+
rows.push(vec![skill.name.clone(), truncate(&desc, 80)]);
94+
records.push(json!({ "name": skill.name, "description": skill.description }));
95+
}
96+
let human = HumanRender::Table {
97+
headers: vec!["SKILL".to_owned(), "DESCRIPTION".to_owned()],
98+
rows,
99+
};
100+
Ok(CommandOutput::new(Value::Array(records), human))
101+
}
102+
103+
/// `construct skill use` — print selected skills' prompts without installing.
104+
pub(crate) fn use_prompt(ctx: &Context, args: &UseArgs) -> Result<CommandOutput, AppError> {
105+
let root = sources::resolve_source(ctx, &source_spec(args.source.as_ref()), false)?;
106+
let selected = sources::select_skills(ctx, &root, &args.skills)?;
107+
108+
let mut records = Vec::new();
109+
let mut sections = Vec::new();
110+
for skill in &selected {
111+
let content = skillmd::body(&skill.dir.join("SKILL.md"));
112+
sections.push(format!("# {}\n\n{content}", skill.name));
113+
records.push(json!({ "skill": skill.name, "content": content }));
114+
}
115+
let human = HumanRender::Message(sections.join("\n\n---\n\n"));
116+
Ok(CommandOutput::new(Value::Array(records), human))
117+
}
118+
119+
/// `construct skill init` — scaffold a new skill directory with a `SKILL.md`.
120+
pub(crate) fn init(ctx: &Context, args: &InitArgs) -> Result<CommandOutput, AppError> {
121+
let dir = args.dir.clone().unwrap_or_else(|| PathBuf::from("."));
122+
let skill_dir = dir.join(&args.name);
123+
let skill_md = skill_dir.join("SKILL.md");
124+
125+
if skill_md.exists() {
126+
return Err(AppError::new(
127+
ctx,
128+
ErrorCode::Conflict,
129+
5,
130+
format!("'{}' already exists", skill_md.display()),
131+
"construct skill init <other-name>",
132+
));
133+
}
134+
135+
let path = skill_md.display().to_string();
136+
if ctx.dry_run {
137+
let human = HumanRender::Message(format!("[dry-run] would create {path}"));
138+
return Ok(CommandOutput::new(
139+
json!({ "created": false, "path": path }),
140+
human,
141+
));
142+
}
143+
144+
std::fs::create_dir_all(&skill_dir).map_err(|e| io_error(ctx, &skill_md, &e))?;
145+
std::fs::write(&skill_md, template(&args.name)).map_err(|e| io_error(ctx, &skill_md, &e))?;
146+
let human = HumanRender::Message(format!("created {path}"));
147+
Ok(CommandOutput::new(
148+
json!({ "created": true, "path": path }),
149+
human,
150+
))
151+
}
152+
153+
// ── helpers ───────────────────────────────────────────────────────────────-
154+
74155
/// Wrap an [`InstallReport`] into machine `data` plus a human table.
75156
fn report_output(report: &InstallReport) -> CommandOutput {
76157
let rows = report
@@ -97,3 +178,30 @@ fn report_output(report: &InstallReport) -> CommandOutput {
97178
let data = serde_json::to_value(report).unwrap_or(Value::Null);
98179
CommandOutput::new(data, human)
99180
}
181+
182+
/// Truncate to `n` characters, appending an ellipsis when shortened.
183+
fn truncate(s: &str, n: usize) -> String {
184+
if s.chars().count() <= n {
185+
s.to_owned()
186+
} else {
187+
let head: String = s.chars().take(n).collect();
188+
format!("{head}…")
189+
}
190+
}
191+
192+
/// The house-style `SKILL.md` scaffold.
193+
fn template(name: &str) -> String {
194+
format!(
195+
"---\nname: {name}\ndescription: >\n TODO: one paragraph (<= 1000 chars) describing what this skill does and\n when an agent should use it.\nlicense: GPL-3.0-or-later\n---\n\n# {name}\n\nTODO: skill body.\n"
196+
)
197+
}
198+
199+
/// Map a filesystem error to a structured `AppError`.
200+
fn io_error(ctx: &Context, path: &std::path::Path, err: &std::io::Error) -> AppError {
201+
AppError::general(
202+
ctx,
203+
ErrorCode::InternalError,
204+
format!("filesystem error at '{}': {err}", path.display()),
205+
"check permissions and retry",
206+
)
207+
}

construct-cli/src/install/mod.rs

Lines changed: 13 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ use serde::Serialize;
2424
use crate::context::Context;
2525
use crate::output::error::{AppError, ErrorCode};
2626
use crate::registry::{self, detect, Agent, SkillFormat};
27+
use crate::sources::{self, DiscoveredSkill};
2728
use plan::{AddOptions, Scope};
2829

2930
/// One (agent, skill) outcome.
@@ -62,27 +63,11 @@ pub(crate) struct RemoveOptions {
6263

6364
/// Install skills from a catalogue source into the selected agents.
6465
pub(crate) fn add(ctx: &Context, opts: &AddOptions) -> Result<InstallReport, AppError> {
65-
let source_str = opts.source.to_string_lossy().into_owned();
66-
if plan::looks_remote(&source_str) {
67-
return Err(AppError::new(
68-
ctx,
69-
ErrorCode::FeatureUnavailable,
70-
1,
71-
"remote git sources are not supported yet",
72-
"construct skill add /spacecraft-software/construct",
73-
));
74-
}
75-
let source = opts.source.canonicalize().map_err(|_| {
76-
AppError::not_found(
77-
ctx,
78-
format!("source '{source_str}' does not exist"),
79-
"construct skill add /spacecraft-software/construct",
80-
)
81-
})?;
82-
66+
// Resolve the source (local path, git URL, or owner/repo — cloned to cache).
67+
let source = sources::resolve_source(ctx, &opts.source, opts.refresh)?;
8368
let registry = registry::all();
8469
let agents = plan::resolve_agents(ctx, opts, &registry)?;
85-
let skills = plan::resolve_skills(ctx, &source, &opts.skills)?;
70+
let skills = sources::select_skills(ctx, &source, &opts.skills)?;
8671
let root = plan::project_root();
8772

8873
// Pre-flight: an explicit `--agents` + `--global` into an HM-managed dir is
@@ -110,7 +95,7 @@ pub(crate) fn add(ctx: &Context, opts: &AddOptions) -> Result<InstallReport, App
11095

11196
let mut items = Vec::new();
11297
for agent in &agents {
113-
place_agent(ctx, agent, &source, &skills, opts, &root, &mut items)?;
98+
place_agent(ctx, agent, &skills, opts, &root, &mut items)?;
11499
}
115100

116101
Ok(InstallReport {
@@ -125,8 +110,7 @@ pub(crate) fn add(ctx: &Context, opts: &AddOptions) -> Result<InstallReport, App
125110
fn place_agent(
126111
ctx: &Context,
127112
agent: &Agent,
128-
source: &Path,
129-
skills: &[String],
113+
skills: &[DiscoveredSkill],
130114
opts: &AddOptions,
131115
root: &Path,
132116
items: &mut Vec<ItemResult>,
@@ -164,9 +148,9 @@ fn place_agent(
164148
for skill in skills {
165149
items.push(ItemResult {
166150
agent: agent.id.clone(),
167-
skill: skill.clone(),
151+
skill: skill.name.clone(),
168152
scope,
169-
target: base.join(skill).display().to_string(),
153+
target: base.join(&skill.name).display().to_string(),
170154
action: "refused_hm_managed",
171155
detail: Some("symlinked to ~/.agents/skills by Home Manager".to_owned()),
172156
});
@@ -179,12 +163,11 @@ fn place_agent(
179163
}
180164

181165
for skill in skills {
182-
let target = base.join(skill);
183-
let skill_source = source.join(skill);
184-
let (action, detail) = place(ctx, &skill_source, &target, opts.copy, opts.force)?;
166+
let target = base.join(&skill.name);
167+
let (action, detail) = place(ctx, &skill.dir, &target, opts.copy, opts.force)?;
185168
items.push(ItemResult {
186169
agent: agent.id.clone(),
187-
skill: skill.clone(),
170+
skill: skill.name.clone(),
188171
scope,
189172
target: target.display().to_string(),
190173
action,
@@ -379,7 +362,7 @@ pub(crate) fn remove(ctx: &Context, opts: &RemoveOptions) -> Result<InstallRepor
379362
fn push_all(
380363
items: &mut Vec<ItemResult>,
381364
agent: &Agent,
382-
skills: &[String],
365+
skills: &[DiscoveredSkill],
383366
scope: &'static str,
384367
target: &str,
385368
action: &'static str,
@@ -388,7 +371,7 @@ fn push_all(
388371
for skill in skills {
389372
items.push(ItemResult {
390373
agent: agent.id.clone(),
391-
skill: skill.clone(),
374+
skill: skill.name.clone(),
392375
scope,
393376
target: target.to_owned(),
394377
action,

0 commit comments

Comments
 (0)