feat(cli): ship the agent skill inside the binary
A `cargo install` leaves a binary and nothing else, so there is no file on disk for a coding agent to read. `skills install` writes an embedded SKILL.md into every agent found on the machine — Claude Code and opencode today, any other via --dir — and installing over an older copy is what updating means. The skill carries what --help cannot: the JSON keys of every record, what each exit code means, and the rule that an agent always passes --json. A unit test walks the clap command tree and holds SKILL.md to a documented entry for every command, so a new subcommand fails the build until it is written up.
This commit is contained in:
@@ -0,0 +1,137 @@
|
|||||||
|
---
|
||||||
|
name: soulseek-rs
|
||||||
|
description: >-
|
||||||
|
Search for, download, and share files on the Soulseek peer-to-peer network
|
||||||
|
using the soulseek-rs command-line client. Use when the user wants to find or
|
||||||
|
fetch music or other files from Soulseek, list what a peer shares, check
|
||||||
|
whether a peer is online, read or post in Soulseek chat rooms, send private
|
||||||
|
messages, or stay online serving uploads.
|
||||||
|
---
|
||||||
|
|
||||||
|
# soulseek-rs
|
||||||
|
|
||||||
|
A Soulseek client whose subcommands are built to be driven by a program. Run
|
||||||
|
`soulseek-rs` with no subcommand and it launches an interactive terminal UI —
|
||||||
|
never do that; it needs a real terminal and never returns.
|
||||||
|
|
||||||
|
## The contract
|
||||||
|
|
||||||
|
Always pass `--json`. It is a global flag, so it works on every subcommand and
|
||||||
|
in any position.
|
||||||
|
|
||||||
|
- **stdout is data.** One JSON object per line, and nothing else ever.
|
||||||
|
- **stderr is progress.** Narration and warnings. Never parse it. `--quiet`
|
||||||
|
silences it.
|
||||||
|
- **The exit code is the verdict.** Branch on it, not on the text.
|
||||||
|
|
||||||
|
Without `--json` the same records print as tab-separated fields. Do not parse
|
||||||
|
that form; the columns are a subset of the JSON keys.
|
||||||
|
|
||||||
|
## Exit codes
|
||||||
|
|
||||||
|
| Code | Meaning | What to do |
|
||||||
|
| ---- | ------- | ---------- |
|
||||||
|
| 0 | Success | — |
|
||||||
|
| 1 | Unexpected failure | Report it; do not retry blindly |
|
||||||
|
| 2 | Bad arguments, missing credentials, unusable config | Fix the invocation; run `whoami` to check credentials |
|
||||||
|
| 3 | Could not reach the server, or the login was rejected | Network or password problem, not a query problem |
|
||||||
|
| 4 | It worked and found nothing | Widen or correct the query — retrying it verbatim will not help |
|
||||||
|
| 5 | Gave up waiting | Raise `--timeout`, or `--search-timeout` for searches |
|
||||||
|
| 6 | A transfer started and did not finish | The peer stalled; try another result |
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
Each entry lists the keys of the objects it prints.
|
||||||
|
|
||||||
|
**`search <query>`** — one object per matching file:
|
||||||
|
`user`, `path`, `size`, `bitrate`, `duration`, `free_slot`, `slots`, `speed`.
|
||||||
|
Flags: `--min-bitrate <kbps>`, `--free-slots`, `-n/--limit` (0 means all),
|
||||||
|
`--sort best|size|bitrate|speed`. Collects results for the whole
|
||||||
|
`--search-timeout` window before printing anything.
|
||||||
|
|
||||||
|
**`download <user> <path> [--size N]`** or **`download --stdin`** — one object
|
||||||
|
per finished transfer: `user`, `path`, `size`, `file` (the local path written).
|
||||||
|
`--stdin` consumes `search --json` output as-is.
|
||||||
|
|
||||||
|
**`get <query>`** — search, pick, and download in one step. `--pick
|
||||||
|
best|first|all`, `-n/--limit`, `--min-bitrate`, `--free-slots`. Prefer this
|
||||||
|
over `search` + `download` when the user just wants the file.
|
||||||
|
|
||||||
|
**`browse <user>`** — one object per file the peer shares: `user`, `directory`,
|
||||||
|
`path`, `size`.
|
||||||
|
|
||||||
|
**`user <name>`** — one object: `user`, `status` (`online`/`away`/`offline`),
|
||||||
|
`privileged`, `average_speed`, `shared_files`, `shared_folders`. Any field may
|
||||||
|
be `null` when the server has not answered that half — `null` means unknown,
|
||||||
|
not offline.
|
||||||
|
|
||||||
|
**`whoami`** — one object: `user`, `server`, `listening`, `listen_port`,
|
||||||
|
`shared_folders`, `shared_files`, `download_dir`. The cheapest way to prove the
|
||||||
|
credentials and connection work.
|
||||||
|
|
||||||
|
**`room list`** — `room`, `users`.
|
||||||
|
**`room users <room>`** — `room`, `user`.
|
||||||
|
**`room listen <room>`** — streams `type`, `room`, `user`, `message`.
|
||||||
|
**`room say <room> <message>`** — prints nothing; exit 0 is the confirmation.
|
||||||
|
|
||||||
|
**`message read`** — streams `timestamp`, `user`, `message`.
|
||||||
|
**`message send <user> <message>`** — prints nothing; exit 0 is the confirmation.
|
||||||
|
|
||||||
|
**`serve`** — stay online sharing files, one object per upload change: `user`,
|
||||||
|
`path`, `size`, `bytes_sent`, `speed`, `status`
|
||||||
|
(`queued`/`uploading`/`completed`/`cancelled`/`failed`), `reason`.
|
||||||
|
|
||||||
|
**`shares list`** — one object per configured folder: `directory`, `usable`.
|
||||||
|
**`shares add <directory>`** and **`shares remove <directory>`** — change the
|
||||||
|
config file; they print nothing.
|
||||||
|
**`shares status`** and **`shares reindex`** — `folders`, `files`,
|
||||||
|
`directories`, counted by logging in and scanning.
|
||||||
|
|
||||||
|
**`config path`**, **`config list`**, **`config get <key>`**, and
|
||||||
|
**`config set <key> <value>`** — `key`, `value`.
|
||||||
|
|
||||||
|
**`portmap`** — `ok`, `backend`, `external`, `port`. Exit 4 when no router
|
||||||
|
answered, which is a negative answer rather than an error.
|
||||||
|
|
||||||
|
**`skills install`**, **`skills uninstall`**, and **`skills list`** — write
|
||||||
|
this skill file into the local agent directories, or remove it: `agent`,
|
||||||
|
`path`, `action`.
|
||||||
|
|
||||||
|
## Idioms
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# One shot: find it, pick the best, fetch it.
|
||||||
|
soulseek-rs get 'aphex twin xtal' --json
|
||||||
|
|
||||||
|
# Filter precisely, then fetch what survived.
|
||||||
|
soulseek-rs search 'rjd2' --json \
|
||||||
|
| jq -c 'select(.bitrate >= 320 and .free_slot)' \
|
||||||
|
| soulseek-rs download --stdin --json
|
||||||
|
|
||||||
|
# Take one folder from a peer.
|
||||||
|
soulseek-rs browse someuser --json \
|
||||||
|
| jq -c 'select(.path | endswith(".flac"))' \
|
||||||
|
| soulseek-rs download --stdin --json
|
||||||
|
|
||||||
|
# Is this peer worth queueing behind?
|
||||||
|
soulseek-rs user someuser --json
|
||||||
|
```
|
||||||
|
|
||||||
|
`download --stdin` reads whole records, so filtering with `jq` and piping back
|
||||||
|
is the normal way to be selective.
|
||||||
|
|
||||||
|
## Rules
|
||||||
|
|
||||||
|
- **Remote paths belong to the peer.** They are backslash-separated and may
|
||||||
|
contain anything. Pass them back byte-identical — never normalise, unescape,
|
||||||
|
or let a shell expand them.
|
||||||
|
- **`--follow` never returns.** For `room listen`, `message read`, and `serve`,
|
||||||
|
always pass `--duration <seconds>` instead.
|
||||||
|
- **Every wait is bounded.** `--timeout` per command, `--search-timeout` for
|
||||||
|
searches. There is no unbounded form.
|
||||||
|
- **Exit 4 is not a failure.** A search that finds nothing means the query was
|
||||||
|
too narrow or misspelled. Widen it; use `whoami` to rule out the connection.
|
||||||
|
- **Never put a password in argv** — `ps` shows it to every user on the box.
|
||||||
|
Use `--password-stdin`, the OS keychain, or `SOULSEEK_PASSWORD_CMD`.
|
||||||
|
- **Downloading pulls a file off a stranger's machine.** Confirm with the user
|
||||||
|
before `get --pick all` or any fetch whose size is not known up front.
|
||||||
@@ -26,6 +26,7 @@ Examples:
|
|||||||
soulseek-rs room listen lobby --follow stream room chat
|
soulseek-rs room listen lobby --follow stream room chat
|
||||||
soulseek-rs serve --follow --json share files, stream upload events
|
soulseek-rs serve --follow --json share files, stream upload events
|
||||||
soulseek-rs user someuser is this peer online and worth it
|
soulseek-rs user someuser is this peer online and worth it
|
||||||
|
soulseek-rs skills install teach your coding agent this CLI
|
||||||
|
|
||||||
Exit codes:
|
Exit codes:
|
||||||
0 success 2 usage/config 3 connect or login 4 no results
|
0 success 2 usage/config 3 connect or login 4 no results
|
||||||
@@ -193,6 +194,32 @@ pub enum Commands {
|
|||||||
|
|
||||||
/// Check whether the router opens the listen port (UPnP/NAT-PMP)
|
/// Check whether the router opens the listen port (UPnP/NAT-PMP)
|
||||||
Portmap,
|
Portmap,
|
||||||
|
|
||||||
|
/// Teach a local coding agent this command surface
|
||||||
|
#[command(subcommand)]
|
||||||
|
Skills(SkillsCommand),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Subcommand, Debug)]
|
||||||
|
pub enum SkillsCommand {
|
||||||
|
/// Write the skill into every agent found on this machine, replacing an
|
||||||
|
/// older copy
|
||||||
|
Install(SkillsArgs),
|
||||||
|
|
||||||
|
/// Remove the skill again
|
||||||
|
Uninstall(SkillsArgs),
|
||||||
|
|
||||||
|
/// Show where the skill goes and what is there now
|
||||||
|
List(SkillsArgs),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Args, Debug)]
|
||||||
|
pub struct SkillsArgs {
|
||||||
|
/// Skills directory to use instead of the agents found on this machine;
|
||||||
|
/// repeat for several. Use `--dir .claude/skills` to commit the skill
|
||||||
|
/// alongside a project
|
||||||
|
#[arg(long, value_name = "PATH", action = ArgAction::Append)]
|
||||||
|
pub dir: Vec<PathBuf>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Args, Debug)]
|
#[derive(Args, Debug)]
|
||||||
@@ -588,6 +615,24 @@ mod tests {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn skills_dir_repeats_into_a_list() {
|
||||||
|
let cli = parse(&[
|
||||||
|
"soulseek-rs",
|
||||||
|
"skills",
|
||||||
|
"install",
|
||||||
|
"--dir",
|
||||||
|
"/a",
|
||||||
|
"--dir",
|
||||||
|
"/b",
|
||||||
|
]);
|
||||||
|
let Some(Commands::Skills(SkillsCommand::Install(args))) = cli.command
|
||||||
|
else {
|
||||||
|
panic!("expected skills install");
|
||||||
|
};
|
||||||
|
assert_eq!(args.dir, [PathBuf::from("/a"), PathBuf::from("/b")]);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn get_defaults_to_picking_the_best_result() {
|
fn get_defaults_to_picking_the_best_result() {
|
||||||
let cli = parse(&["soulseek-rs", "get", "query"]);
|
let cli = parse(&["soulseek-rs", "get", "query"]);
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
//! One-shot commands: everything the binary can do without a terminal.
|
//! One-shot commands: everything the binary can do without a terminal.
|
||||||
//!
|
//!
|
||||||
//! Each command opens a [`Session`], does one job, writes records to stdout
|
//! Each command does one job, writes records to stdout and progress to stderr,
|
||||||
//! and progress to stderr, and returns an exit code through [`CliError`].
|
//! and returns an exit code through [`CliError`]. Most open a [`Session`]
|
||||||
|
//! first; the ones that only read the config file or the local machine are
|
||||||
|
//! dispatched by `main::run` before any credentials are demanded.
|
||||||
|
|
||||||
pub mod peer;
|
pub mod peer;
|
||||||
pub mod settings;
|
pub mod settings;
|
||||||
|
pub mod skills;
|
||||||
pub mod social;
|
pub mod social;
|
||||||
pub mod transfer;
|
pub mod transfer;
|
||||||
|
|
||||||
@@ -193,7 +196,7 @@ pub fn run(ctx: &Ctx, command: Commands) -> CliResult {
|
|||||||
// These need no account, so main.rs answers them before asking for
|
// These need no account, so main.rs answers them before asking for
|
||||||
// credentials; the arms exist only to keep the match exhaustive.
|
// credentials; the arms exist only to keep the match exhaustive.
|
||||||
Commands::Portmap => portmap(&ctx.out, ctx.settings.listen_port),
|
Commands::Portmap => portmap(&ctx.out, ctx.settings.listen_port),
|
||||||
Commands::Config(_) | Commands::Shares(_) => {
|
Commands::Config(_) | Commands::Shares(_) | Commands::Skills(_) => {
|
||||||
Err(CliError::usage("handled without a session; see main::run"))
|
Err(CliError::usage("handled without a session; see main::run"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,253 @@
|
|||||||
|
//! Teaching a local coding agent this command surface: `skills`.
|
||||||
|
//!
|
||||||
|
//! A `cargo install` leaves a binary and nothing else, so the skill travels
|
||||||
|
//! inside it and is written out on demand. Installing over an older copy is
|
||||||
|
//! what updating means, which is why there is no separate verb for it.
|
||||||
|
|
||||||
|
use crate::cli::{SkillsArgs, SkillsCommand};
|
||||||
|
use crate::output::{CliError, CliResult, Out, SkillRecord};
|
||||||
|
use directories::UserDirs;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
/// The skill as shipped, and what the drift test holds the CLI to.
|
||||||
|
const SKILL: &str = include_str!("../SKILL.md");
|
||||||
|
|
||||||
|
/// The line `uninstall` requires before deleting anything.
|
||||||
|
const FRONTMATTER_NAME: &str = "name: soulseek-rs";
|
||||||
|
|
||||||
|
/// A skills directory to write into, labelled by whose it is.
|
||||||
|
struct Target {
|
||||||
|
agent: &'static str,
|
||||||
|
skills: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Target {
|
||||||
|
fn dir(&self) -> PathBuf {
|
||||||
|
self.skills.join("soulseek-rs")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn file(&self) -> PathBuf {
|
||||||
|
self.dir().join("SKILL.md")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn record(&self, action: &str) -> SkillRecord {
|
||||||
|
SkillRecord {
|
||||||
|
agent: self.agent.to_string(),
|
||||||
|
path: self.file().display().to_string(),
|
||||||
|
action: action.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn run(out: &Out, command: &SkillsCommand) -> CliResult {
|
||||||
|
match command {
|
||||||
|
SkillsCommand::Install(args) => install(out, &targets(args)?),
|
||||||
|
SkillsCommand::Uninstall(args) => uninstall(out, &targets(args)?),
|
||||||
|
SkillsCommand::List(args) => {
|
||||||
|
list(out, &targets(args)?);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn install(out: &Out, targets: &[Target]) -> CliResult {
|
||||||
|
for target in targets {
|
||||||
|
let file = target.file();
|
||||||
|
let action = if current(&file) {
|
||||||
|
"unchanged"
|
||||||
|
} else {
|
||||||
|
let dir = target.dir();
|
||||||
|
std::fs::create_dir_all(&dir).map_err(|e| unwritable(&dir, &e))?;
|
||||||
|
std::fs::write(&file, SKILL).map_err(|e| unwritable(&file, &e))?;
|
||||||
|
"installed"
|
||||||
|
};
|
||||||
|
out.emit(&target.record(action));
|
||||||
|
}
|
||||||
|
out.status("restart your agent, or start a new session, to pick it up");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete only what this command wrote. A mistyped `--dir` names somebody
|
||||||
|
/// else's directory far more often than it names ours, so the skill file has
|
||||||
|
/// to identify itself first, and only that file goes: a folder holding
|
||||||
|
/// anything else survives the empty-directory sweep that follows.
|
||||||
|
fn uninstall(out: &Out, targets: &[Target]) -> CliResult {
|
||||||
|
for target in targets {
|
||||||
|
let file = target.file();
|
||||||
|
let action = if ours(&file) {
|
||||||
|
std::fs::remove_file(&file).map_err(|e| unwritable(&file, &e))?;
|
||||||
|
let _ = std::fs::remove_dir(target.dir());
|
||||||
|
"removed"
|
||||||
|
} else {
|
||||||
|
"absent"
|
||||||
|
};
|
||||||
|
out.emit(&target.record(action));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn list(out: &Out, targets: &[Target]) {
|
||||||
|
for target in targets {
|
||||||
|
let action = match std::fs::read_to_string(target.file()) {
|
||||||
|
Ok(found) if found == SKILL => "unchanged",
|
||||||
|
Ok(_) => "outdated",
|
||||||
|
Err(_) => "absent",
|
||||||
|
};
|
||||||
|
out.emit(&target.record(action));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Where to write. Naming a directory aims the command at exactly that
|
||||||
|
/// directory: a run that says where to go should not also touch every agent it
|
||||||
|
/// happens to find. Detection is what answers the question when nothing does.
|
||||||
|
fn targets(args: &SkillsArgs) -> CliResult<Vec<Target>> {
|
||||||
|
let chosen: Vec<Target> = args
|
||||||
|
.dir
|
||||||
|
.iter()
|
||||||
|
.map(|dir| Target {
|
||||||
|
agent: "custom",
|
||||||
|
skills: dir.clone(),
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
if !chosen.is_empty() {
|
||||||
|
return Ok(chosen);
|
||||||
|
}
|
||||||
|
|
||||||
|
let home = UserDirs::new()
|
||||||
|
.map(|dirs| dirs.home_dir().to_path_buf())
|
||||||
|
.ok_or_else(|| {
|
||||||
|
CliError::usage(
|
||||||
|
"cannot find your home directory; name a skills directory \
|
||||||
|
with --dir",
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let found = detected(&home, &config_home(&home));
|
||||||
|
if found.is_empty() {
|
||||||
|
return Err(CliError::no_results(
|
||||||
|
"no coding agent found in your home directory; name a skills \
|
||||||
|
directory with --dir",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(found)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The agents that read a plain `SKILL.md`, kept to the ones actually on this
|
||||||
|
/// machine: an agent counts as present when its own directory is, not when it
|
||||||
|
/// already has a skills folder.
|
||||||
|
fn detected(home: &Path, config_home: &Path) -> Vec<Target> {
|
||||||
|
[
|
||||||
|
("claude", home.join(".claude"), "skills"),
|
||||||
|
("opencode", config_home.join("opencode"), "skill"),
|
||||||
|
]
|
||||||
|
.into_iter()
|
||||||
|
.filter(|(_, agent_dir, _)| agent_dir.is_dir())
|
||||||
|
.map(|(agent, agent_dir, skills)| Target {
|
||||||
|
agent,
|
||||||
|
skills: agent_dir.join(skills),
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Where an agent keeps its own configuration, which is the XDG location on
|
||||||
|
/// every platform — unlike this client's config, which follows each platform's
|
||||||
|
/// own convention (see `persist::paths`).
|
||||||
|
fn config_home(home: &Path) -> PathBuf {
|
||||||
|
std::env::var_os("XDG_CONFIG_HOME")
|
||||||
|
.filter(|dir| !dir.is_empty())
|
||||||
|
.map_or_else(|| home.join(".config"), PathBuf::from)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn current(file: &Path) -> bool {
|
||||||
|
std::fs::read_to_string(file).is_ok_and(|found| found == SKILL)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ours(file: &Path) -> bool {
|
||||||
|
std::fs::read_to_string(file)
|
||||||
|
.is_ok_and(|found| found.contains(FRONTMATTER_NAME))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn unwritable(path: &Path, error: &std::io::Error) -> CliError {
|
||||||
|
CliError::usage(format!("cannot write {}: {error}", path.display()))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::cli::Cli;
|
||||||
|
use clap::CommandFactory;
|
||||||
|
|
||||||
|
/// Every command this binary answers, spelled the way a user types it.
|
||||||
|
/// Groups contribute their verbs rather than themselves — nobody runs a
|
||||||
|
/// bare `room`.
|
||||||
|
fn command_paths(command: &clap::Command, prefix: &str) -> Vec<String> {
|
||||||
|
let mut paths = Vec::new();
|
||||||
|
for sub in command.get_subcommands() {
|
||||||
|
if sub.get_name() == "help" {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let path = format!("{prefix}{}", sub.get_name());
|
||||||
|
if sub.get_subcommands().next().is_some() {
|
||||||
|
paths.extend(command_paths(sub, &format!("{path} ")));
|
||||||
|
} else {
|
||||||
|
paths.push(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
paths
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The skill is only worth shipping while it still describes the binary,
|
||||||
|
/// so a new command fails the build until it is documented.
|
||||||
|
///
|
||||||
|
/// Looking for the name alone would pass on coincidence: half of these are
|
||||||
|
/// also JSON keys the document lists, and a command named in passing in the
|
||||||
|
/// prose would satisfy it too. What counts is an entry in the command
|
||||||
|
/// section, which is the only place written as a bold quoted invocation.
|
||||||
|
#[test]
|
||||||
|
fn the_skill_documents_every_command() {
|
||||||
|
let paths = command_paths(&Cli::command(), "");
|
||||||
|
assert!(paths.len() > 10, "expected the full command surface");
|
||||||
|
for path in paths {
|
||||||
|
assert!(
|
||||||
|
SKILL.contains(&format!("**`{path}`"))
|
||||||
|
|| SKILL.contains(&format!("**`{path} ")),
|
||||||
|
"SKILL.md has no `{path}` entry (expected **`{path} …`**)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_skill_tells_agents_to_ask_for_json() {
|
||||||
|
assert!(SKILL.contains("--json"));
|
||||||
|
assert!(SKILL.contains(FRONTMATTER_NAME));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn only_agents_that_are_installed_become_targets() {
|
||||||
|
let home = tempfile::tempdir().expect("tempdir");
|
||||||
|
let config = home.path().join(".config");
|
||||||
|
assert!(detected(home.path(), &config).is_empty());
|
||||||
|
|
||||||
|
std::fs::create_dir_all(home.path().join(".claude")).expect("claude");
|
||||||
|
std::fs::create_dir_all(config.join("opencode")).expect("opencode");
|
||||||
|
let found = detected(home.path(), &config);
|
||||||
|
|
||||||
|
assert_eq!(found.len(), 2);
|
||||||
|
assert_eq!(found[0].skills, home.path().join(".claude/skills"));
|
||||||
|
assert_eq!(found[1].skills, config.join("opencode/skill"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_file_we_did_not_write_is_never_ours_to_delete() {
|
||||||
|
let dir = tempfile::tempdir().expect("tempdir");
|
||||||
|
let file = dir.path().join("SKILL.md");
|
||||||
|
std::fs::write(&file, "---\nname: something-else\n---\n").expect("w");
|
||||||
|
|
||||||
|
assert!(!ours(&file));
|
||||||
|
assert!(!ours(&dir.path().join("missing.md")));
|
||||||
|
|
||||||
|
std::fs::write(&file, SKILL).expect("write");
|
||||||
|
assert!(ours(&file));
|
||||||
|
assert!(current(&file));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -61,6 +61,9 @@ fn run(mut cli: Cli, out: &Out) -> CliResult {
|
|||||||
Commands::Portmap => {
|
Commands::Portmap => {
|
||||||
return commands::portmap(out, resolved.listener_port);
|
return commands::portmap(out, resolved.listener_port);
|
||||||
}
|
}
|
||||||
|
Commands::Skills(ref command) => {
|
||||||
|
return commands::skills::run(out, command);
|
||||||
|
}
|
||||||
Commands::Config(ConfigCommand::Path) => {
|
Commands::Config(ConfigCommand::Path) => {
|
||||||
return commands::settings::config_path(out, &store);
|
return commands::settings::config_path(out, &store);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -423,6 +423,28 @@ impl Record for ConfigRecord {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// What happened to one agent's copy of the skill.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||||
|
pub struct SkillRecord {
|
||||||
|
/// The agent the directory belongs to, or `custom` for a `--dir`.
|
||||||
|
pub agent: String,
|
||||||
|
pub path: String,
|
||||||
|
/// `installed` (written), `unchanged` (already identical), `outdated`
|
||||||
|
/// (present but different, reported by `list`), `removed`, or `absent`.
|
||||||
|
pub action: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Record for SkillRecord {
|
||||||
|
fn text(&self) -> String {
|
||||||
|
format!(
|
||||||
|
"{}\t{}\t{}",
|
||||||
|
sanitize(&self.action),
|
||||||
|
sanitize(&self.agent),
|
||||||
|
sanitize(&self.path)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// The result of probing automatic port mapping.
|
/// The result of probing automatic port mapping.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||||
pub struct PortmapRecord {
|
pub struct PortmapRecord {
|
||||||
|
|||||||
@@ -94,6 +94,7 @@ fn help_lists_every_scriptable_command() {
|
|||||||
let help = stdout(&output);
|
let help = stdout(&output);
|
||||||
for command in [
|
for command in [
|
||||||
"search", "download", "get", "browse", "room", "message", "portmap",
|
"search", "download", "get", "browse", "room", "message", "portmap",
|
||||||
|
"skills",
|
||||||
] {
|
] {
|
||||||
assert!(help.contains(command), "--help should mention {command}");
|
assert!(help.contains(command), "--help should mention {command}");
|
||||||
}
|
}
|
||||||
@@ -256,6 +257,82 @@ fn quiet_keeps_progress_off_stderr() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// What the first record says happened, parsed rather than matched as text.
|
||||||
|
fn action(output: &Output) -> String {
|
||||||
|
let record: serde_json::Value =
|
||||||
|
serde_json::from_str(records(output).first().expect("one record"))
|
||||||
|
.expect("valid JSON");
|
||||||
|
record["action"]
|
||||||
|
.as_str()
|
||||||
|
.expect("a record carries an action")
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn installing_the_skill_is_repeatable_and_reversible() {
|
||||||
|
let target = Scratch::new("skills");
|
||||||
|
let dir = target.display();
|
||||||
|
let installed = target.path().join("soulseek-rs").join("SKILL.md");
|
||||||
|
|
||||||
|
let first = run(&["--json", "skills", "install", "--dir", &dir]);
|
||||||
|
assert_eq!(code(&first), EXIT_OK, "stderr: {}", stderr(&first));
|
||||||
|
assert_eq!(action(&first), "installed");
|
||||||
|
let written = std::fs::read_to_string(&installed).expect("a skill file");
|
||||||
|
assert!(written.contains("name: soulseek-rs"));
|
||||||
|
assert!(written.contains("--json"));
|
||||||
|
|
||||||
|
let second = run(&["--json", "skills", "install", "--dir", &dir]);
|
||||||
|
assert_eq!(
|
||||||
|
action(&second),
|
||||||
|
"unchanged",
|
||||||
|
"reinstalling is how you update"
|
||||||
|
);
|
||||||
|
|
||||||
|
// A neighbour in the same folder proves the sweep removes our file rather
|
||||||
|
// than the directory it happened to be sitting in.
|
||||||
|
let neighbour = installed.with_file_name("notes.md");
|
||||||
|
std::fs::write(&neighbour, "mine").expect("a neighbouring file");
|
||||||
|
|
||||||
|
let removed = run(&["--json", "skills", "uninstall", "--dir", &dir]);
|
||||||
|
assert_eq!(action(&removed), "removed");
|
||||||
|
assert!(!installed.exists());
|
||||||
|
assert!(neighbour.exists(), "a file we did not write must survive");
|
||||||
|
|
||||||
|
let again = run(&["--json", "skills", "uninstall", "--dir", &dir]);
|
||||||
|
assert_eq!(code(&again), EXIT_OK, "removing nothing is not a failure");
|
||||||
|
assert_eq!(action(&again), "absent");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A mistyped `--dir` names somebody else's directory far more often than it
|
||||||
|
/// names one of ours, so uninstall has to leave it standing.
|
||||||
|
#[test]
|
||||||
|
fn uninstall_leaves_a_skill_it_did_not_write() {
|
||||||
|
let target = Scratch::new("skills-foreign");
|
||||||
|
let foreign = target.path().join("soulseek-rs").join("SKILL.md");
|
||||||
|
std::fs::create_dir_all(foreign.parent().expect("a parent"))
|
||||||
|
.expect("mkdir");
|
||||||
|
std::fs::write(&foreign, "---\nname: someone-else\n---\n").expect("write");
|
||||||
|
|
||||||
|
let output =
|
||||||
|
run(&["--json", "skills", "uninstall", "--dir", &target.display()]);
|
||||||
|
assert_eq!(code(&output), EXIT_OK);
|
||||||
|
assert_eq!(action(&output), "absent");
|
||||||
|
assert!(foreign.exists(), "a foreign skill must survive");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn listing_skills_reports_without_writing_anything() {
|
||||||
|
let target = Scratch::new("skills-list");
|
||||||
|
|
||||||
|
let output = run(&["--json", "skills", "list", "--dir", &target.display()]);
|
||||||
|
assert_eq!(code(&output), EXIT_OK);
|
||||||
|
assert_eq!(action(&output), "absent");
|
||||||
|
assert!(
|
||||||
|
!target.path().join("soulseek-rs").exists(),
|
||||||
|
"list must not create anything"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Server required (soulfind)
|
// Server required (soulfind)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
Reference in New Issue
Block a user