Skip to content
Merged
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
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,11 @@ The API does not expose private storage addresses.
### Direct mode

`--direct` fetches a public GitHub Repository without the skilld.dev API.

```sh
skilld install github:skilld-dev/skilld/skills/skilld --direct --agent codex
```

The installed Skill receives the `unverified` source status.
The user reviews the Skill before use.

Expand Down
58 changes: 49 additions & 9 deletions crates/skilld-command/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ use output::{
resolve_mode,
};

const DIRECT_SOURCE_GUIDANCE: &str = "--direct requires a github:OWNER/REPOSITORY/SKILL_PATH source or a GitHub tree URL. Remove --direct, then run the same command again.";

#[derive(Debug, Parser)]
#[command(
name = "skilld",
Expand All @@ -60,16 +62,36 @@ pub struct Cli {
enum Command {
/// Search for Skills.
Search { query: Vec<String> },
/// Install a Skill or restore lockfile state.
/// Install a Skill, or restore the Skills recorded in your lockfile.
#[command(
long_about = "Install a Skill, or restore the Skills recorded in your lockfile.\n\nGive SOURCE as:\n skilld:OWNER/REPOSITORY/SKILL\n Install a hosted Artifact.\n github:OWNER/REPOSITORY/SKILL_PATH\n github:OWNER/REPOSITORY/SKILL_PATH#branch:BRANCH\n github:OWNER/REPOSITORY/SKILL_PATH#tag:TAG\n github:OWNER/REPOSITORY/SKILL_PATH#commit:SHA\n https://github.com/OWNER/REPOSITORY/tree/REF/SKILL_PATH\n Public GitHub Repository paths. Each one requires --direct.\n ./RELATIVE_PATH or ABSOLUTE_PATH\n Install a local Skill.\n skilld\n Install the skilld-maintained Skill with --global.\n\nRun skilld install without SOURCE to restore .skills/skilld-lock.yaml.\nVerified remote Skills restore the exact locked Git commit.",
after_long_help = "Examples:\n skilld install skilld:skilld-dev/skills/find-skill --agent codex\n skilld install github:skilld-dev/skilld/skills/skilld --direct --agent codex\n skilld install"
)]
Install {
/// The Skill source to install. Omit SOURCE to restore .skills/skilld-lock.yaml.
#[arg(value_name = "SOURCE")]
source: Option<String>,
#[arg(long)]
#[arg(
long,
long_help = "Install to your account-level Agent targets. The default is the current project."
)]
global: bool,
#[arg(long = "agent")]
#[arg(
long = "agent",
value_name = "AGENT",
long_help = "Select an Agent target. Repeat --agent to select several.\nValues: claude-code, cursor, windsurf, cline, codex, github-copilot,\n gemini-cli, goose, amp, opencode, roo, antigravity.\nDefault: every Agent target skilld detects. If skilld detects none, it uses agent.targets."
)]
agents: Vec<String>,
#[arg(long)]
#[arg(
long,
value_name = "MODE",
long_help = "Choose how each Agent target receives the Skill.\nValues: copy, symlink. The default comes from install.mode. A fresh configuration sets install.mode to copy."
)]
mode: Option<String>,
#[arg(long)]
#[arg(
long,
long_help = "Fetch a public GitHub Repository without going through skilld.dev.\nGive a github: source or a GitHub tree URL.\nA direct install records the unverified source status."
)]
direct: bool,
},
/// List installed Skills.
Expand Down Expand Up @@ -275,6 +297,20 @@ impl CommandError {
Self::usage("INVALID_SOURCE", message)
}

fn direct_local_source() -> Self {
Self::usage(
"DIRECT_SOURCE_REQUIRED",
"--direct cannot install a local Skill. Remove --direct, then run the same command again.",
)
}

fn direct_bundled_source() -> Self {
Self::usage(
"DIRECT_SOURCE_REQUIRED",
"--direct cannot install the skilld-maintained Skill. Run skilld install skilld --global instead",
)
}

pub fn config(message: impl Into<String>) -> Self {
Self::usage("INVALID_CONFIG", message)
}
Expand Down Expand Up @@ -602,10 +638,14 @@ fn dispatch<H: Host>(command: Command, host: &H) -> Result<CommandOutput, Comman
(true, InstallSource::Remote(source)) => {
InstallOperation::Install(InstallSource::DirectRemote(source))
}
(true, _) => {
return Err(CommandError::input(
"--direct needs an explicit public GitHub Repository selector",
));
(true, InstallSource::DirectRemote(source)) => {
InstallOperation::Install(InstallSource::DirectRemote(source))
}
(true, InstallSource::Local(_)) => {
return Err(CommandError::direct_local_source());
}
(true, InstallSource::BundledSkilld) => {
return Err(CommandError::direct_bundled_source());
}
(false, source) => InstallOperation::Install(source),
},
Expand Down
4 changes: 2 additions & 2 deletions crates/skilld-command/src/remote.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1055,14 +1055,14 @@ impl SkilldRemote {
if !selector.is_explicit_github() {
return Err(RemoteError::new(
"DIRECT_SOURCE_REQUIRED",
"--direct needs an explicit public GitHub Repository selector",
crate::DIRECT_SOURCE_GUIDANCE,
));
}
let source = selector.source();
let SourceSelector::Path { path: skill_path } = &source.selector else {
return Err(RemoteError::new(
"DIRECT_SOURCE_REQUIRED",
"--direct needs an explicit GitHub Skill path",
crate::DIRECT_SOURCE_GUIDANCE,
));
};
let repository_url = format!(
Expand Down
18 changes: 18 additions & 0 deletions crates/skilld-command/tests/remote.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1310,6 +1310,24 @@ fn direct_github_access_resolves_an_exact_public_commit_without_tokens() {
}));
}

#[test]
fn direct_install_error_gives_an_agent_an_exact_recovery() {
let remote = SkilldRemote::new(
Arc::new(FakeHttp::default()),
Arc::new(NoTokenProvider),
NativeRemoteConfig::Unconfigured,
);
let selector = RemoteSelector::parse("skilld:skilld-dev/skills/example").unwrap();

let error = remote.prepare(&selector, true).unwrap_err();

assert_eq!(error.code, "DIRECT_SOURCE_REQUIRED");
assert_eq!(
error.message,
"--direct requires a github:OWNER/REPOSITORY/SKILL_PATH source or a GitHub tree URL. Remove --direct, then run the same command again."
);
}

#[test]
fn direct_github_access_rejects_private_repositories() {
let http = Arc::new(FakeHttp::with([response(
Expand Down
103 changes: 103 additions & 0 deletions crates/skilld-native/tests/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,109 @@ fn version_reports_the_rust_package_version() {
assert!(output.stderr.is_empty());
}

#[test]
fn install_help_gives_agents_actionable_source_and_target_grammar() {
let output = Command::new(binary())
.args(["install", "--help"])
.output()
.unwrap();

assert!(output.status.success());
assert!(output.stderr.is_empty());
let help = String::from_utf8(output.stdout).unwrap();
for guidance in [
"skilld:OWNER/REPOSITORY/SKILL",
"github:OWNER/REPOSITORY/SKILL_PATH",
"github:OWNER/REPOSITORY/SKILL_PATH#branch:BRANCH",
"github:OWNER/REPOSITORY/SKILL_PATH#tag:TAG",
"github:OWNER/REPOSITORY/SKILL_PATH#commit:SHA",
"https://github.com/OWNER/REPOSITORY/tree/REF/SKILL_PATH",
"Values: claude-code, cursor, windsurf, cline, codex, github-copilot,",
"gemini-cli, goose, amp, opencode, roo, antigravity.",
"Repeat --agent to select several.",
"Default: every Agent target skilld detects.",
"If skilld detects none, it uses agent.targets.",
"Values: copy, symlink. The default comes from install.mode.",
"A fresh configuration sets install.mode to copy.",
"The default is the current project.",
"A direct install records the unverified source status.",
"Run skilld install without SOURCE to restore .skills/skilld-lock.yaml.",
"Verified remote Skills restore the exact locked Git commit.",
"skilld install skilld:skilld-dev/skills/find-skill --agent codex",
"skilld install github:skilld-dev/skilld/skills/skilld --direct --agent codex",
] {
assert!(help.contains(guidance), "missing help guidance: {guidance}");
}
}

#[test]
fn direct_local_source_error_gives_an_agent_an_exact_recovery() {
let temporary = tempfile::tempdir().unwrap();
let project = temporary.path().join("project");
let data = temporary.path().join("data");
let home = temporary.path().join("home");
fs::create_dir_all(&project).unwrap();
fs::create_dir_all(&home).unwrap();

let output = run(&project, &data, &home, &["install", "./skill", "--direct"]);

assert_eq!(output.status.code(), Some(2));
assert!(output.stdout.is_empty());
assert_eq!(
String::from_utf8(output.stderr).unwrap(),
"DIRECT_SOURCE_REQUIRED: --direct cannot install a local Skill. Remove --direct, then run the same command again.\n"
);
}

#[test]
fn direct_bundled_source_error_gives_an_agent_an_exact_recovery() {
let temporary = tempfile::tempdir().unwrap();
let project = temporary.path().join("project");
let data = temporary.path().join("data");
let home = temporary.path().join("home");
fs::create_dir_all(&project).unwrap();
fs::create_dir_all(&home).unwrap();

let output = run(&project, &data, &home, &["install", "skilld", "--direct"]);

assert_eq!(output.status.code(), Some(2));
assert!(output.stdout.is_empty());
assert_eq!(
String::from_utf8(output.stderr).unwrap(),
"DIRECT_SOURCE_REQUIRED: --direct cannot install the skilld-maintained Skill. Run skilld install skilld --global instead\n"
);
}

#[test]
fn direct_hosted_source_error_gives_an_agent_an_exact_recovery() {
let temporary = tempfile::tempdir().unwrap();
let project = temporary.path().join("project");
let data = temporary.path().join("data");
let home = temporary.path().join("home");
fs::create_dir_all(&project).unwrap();
fs::create_dir_all(&home).unwrap();

let output = run(
&project,
&data,
&home,
&[
"install",
"skilld:skilld-dev/skills/find-skill",
"--direct",
"--agent",
"codex",
],
);

assert_eq!(output.status.code(), Some(2));
assert!(output.stdout.is_empty());
assert_eq!(
String::from_utf8(output.stderr).unwrap(),
"DIRECT_SOURCE_REQUIRED: --direct requires a github:OWNER/REPOSITORY/SKILL_PATH source or a GitHub tree URL. Remove --direct, then run the same command again.\n"
);
}

#[test]
fn interactive_update_requires_terminal_input_and_output() {
let temporary = tempfile::tempdir().unwrap();
Expand Down