diff --git a/README.md b/README.md index a67dc745..678fa85f 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/crates/skilld-command/src/lib.rs b/crates/skilld-command/src/lib.rs index 284694e3..a43d7204 100644 --- a/crates/skilld-command/src/lib.rs +++ b/crates/skilld-command/src/lib.rs @@ -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", @@ -60,16 +62,36 @@ pub struct Cli { enum Command { /// Search for Skills. Search { query: Vec }, - /// 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, - #[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, - #[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, - #[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. @@ -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) -> Self { Self::usage("INVALID_CONFIG", message) } @@ -602,10 +638,14 @@ fn dispatch(command: Command, host: &H) -> Result { 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), }, diff --git a/crates/skilld-command/src/remote.rs b/crates/skilld-command/src/remote.rs index 6ad7b9aa..00813780 100644 --- a/crates/skilld-command/src/remote.rs +++ b/crates/skilld-command/src/remote.rs @@ -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!( diff --git a/crates/skilld-command/tests/remote.rs b/crates/skilld-command/tests/remote.rs index 788e2f99..39f665c3 100644 --- a/crates/skilld-command/tests/remote.rs +++ b/crates/skilld-command/tests/remote.rs @@ -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( diff --git a/crates/skilld-native/tests/cli.rs b/crates/skilld-native/tests/cli.rs index 9b38fc41..0690e64a 100644 --- a/crates/skilld-native/tests/cli.rs +++ b/crates/skilld-native/tests/cli.rs @@ -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();