saferskills 0.2.0

Every AI capability, independently scanned — install Skills & MCP servers with a verified SaferSkills trust score.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
//! Command-line surface: the clap tree, global flags, and output-config
//! resolution.

pub mod color;
pub mod header;
pub mod output;

use std::path::PathBuf;

use clap::{Parser, Subcommand};

use crate::cli::color::ColorChoice;
use crate::cli::output::{OutputConfig, OutputFormat};

/// `saferskills` — every AI capability, independently scanned.
#[derive(Debug, Parser)]
#[command(
    name = "saferskills",
    version,
    about = "Every AI capability, independently scanned.",
    after_help = "An OpenLatch project · https://saferskills.ai",
    disable_help_subcommand = true
)]
pub struct Cli {
    #[command(subcommand)]
    pub command: Option<Commands>,

    /// Output format.
    #[arg(long, global = true, value_enum, default_value_t = OutputFormat::Human)]
    pub format: OutputFormat,

    /// Emit machine-readable JSON on stdout (implies --format json, no color,
    /// non-interactive).
    #[arg(long, global = true)]
    pub json: bool,

    /// Disable ANSI color.
    #[arg(long, global = true)]
    pub no_color: bool,

    /// Color choice (overrides NO_COLOR / TTY detection).
    #[arg(long, global = true, value_enum)]
    pub color: Option<ColorChoice>,

    /// Verbose output (full finding lists, cause chains).
    #[arg(long, short, global = true)]
    pub verbose: bool,

    /// Suppress non-essential human output.
    #[arg(long, short, global = true)]
    pub quiet: bool,

    /// Assume "yes" for confirmations up to `high` severity.
    #[arg(long, global = true)]
    pub yes: bool,

    /// Override safety gates, including the critical type-name confirm.
    #[arg(long, global = true)]
    pub force: bool,

    /// Never prompt; fail fast naming the flag needed.
    #[arg(long = "non-interactive", visible_alias = "no-input", global = true)]
    pub non_interactive: bool,
}

/// The top-level command grammar.
#[derive(Debug, Subcommand)]
pub enum Commands {
    /// Show an item's SaferSkills score + findings without installing.
    #[command(visible_alias = "check")]
    Info(InfoArgs),

    /// Install a Skill or MCP server to your detected agents.
    Install(InstallArgs),

    /// Remove a previously installed capability.
    Uninstall(UninstallArgs),

    /// Update installed capabilities.
    Update(UpdateArgs),

    /// List installed capabilities with current scores.
    List(ListArgs),

    /// Search the catalog interactively (or headless with --json), then install.
    #[command(visible_alias = "find")]
    Search(SearchArgs),

    /// Scan a capability — an artifact (Skill/Hook/MCP/Plugin/Rules) by path or
    /// GitHub URL; with no target, audit everything installed across your agents.
    Capability(CapabilityArgs),

    /// Behaviorally scan a running agent against the SaferSkills assessment pack.
    Agent(AgentArgs),

    /// Diagnose the local install state.
    Doctor(DoctorArgs),

    /// Generate a shell completion script.
    Completion {
        /// Target shell.
        shell: clap_complete::Shell,
    },

    /// Generate the troff man page (for packaging).
    #[command(hide = true)]
    Man,
}

/// `info <name>` — show a capability's score + findings.
#[derive(Debug, clap::Args)]
pub struct InfoArgs {
    /// Catalog item name (resolved via `?q=` + did-you-mean).
    pub name: String,

    /// Restrict resolution to a capability kind (e.g. `skill`, `mcp_server`).
    #[arg(long)]
    pub kind: Option<String>,
}

/// `install <name>` — install a capability to your detected agents.
#[derive(Debug, clap::Args)]
pub struct InstallArgs {
    /// Catalog item name.
    pub name: String,

    /// Install only to these agents (repeatable). Canonical ids.
    #[arg(long = "to")]
    pub to: Vec<String>,

    /// Install to every detected agent without prompting.
    #[arg(long)]
    pub all: bool,

    /// Write the repo-local config instead of the global one.
    #[arg(long)]
    pub project: bool,

    /// On a registry collision, update in place.
    #[arg(long)]
    pub update: bool,

    /// On a registry collision, reinstall from scratch.
    #[arg(long)]
    pub reinstall: bool,

    /// The score the user saw, for install-time drift re-prompt.
    #[arg(long = "seen-score")]
    pub seen_score: Option<u8>,

    /// Resolve + plan but write nothing.
    #[arg(long = "dry-run")]
    pub dry_run: bool,
}

/// `uninstall <name>`.
#[derive(Debug, clap::Args)]
pub struct UninstallArgs {
    /// Catalog item name.
    pub name: String,

    /// Only uninstall from this agent (canonical id); default removes from all.
    #[arg(long = "from")]
    pub from: Option<String>,
}

/// `update [name] [--all]`.
#[derive(Debug, clap::Args)]
pub struct UpdateArgs {
    /// A specific item to update (omit with `--all`).
    pub name: Option<String>,

    /// Update every installed item.
    #[arg(long)]
    pub all: bool,

    /// Non-interactively uninstall items that dropped to Red.
    #[arg(long = "prune-red")]
    pub prune_red: bool,
}

/// `list`.
#[derive(Debug, clap::Args)]
pub struct ListArgs {}

/// Catalog sort key — a thin clap mirror of the server's `SortKey`. The
/// `#[value(name = …)]` names are the exact snake_case query values the API
/// accepts, so [`SortArg::as_server_key`] is a passthrough.
#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
pub enum SortArg {
    /// Most installed (the trending default).
    #[value(name = "most_installed")]
    MostInstalled,
    /// Fewest installs.
    #[value(name = "least_installed")]
    LeastInstalled,
    /// Most recently added/updated.
    #[value(name = "recent")]
    Recent,
    /// Oldest first.
    #[value(name = "oldest")]
    Oldest,
    /// Highest security score first.
    #[value(name = "highest_score")]
    HighestScore,
    /// Lowest security score first.
    #[value(name = "lowest_score")]
    LowestScore,
    /// Most GitHub stars.
    #[value(name = "most_starred")]
    MostStarred,
    /// Name A→Z.
    #[value(name = "name_asc")]
    NameAsc,
    /// Name Z→A.
    #[value(name = "name_desc")]
    NameDesc,
    /// Most install activity (trailing quarter).
    #[value(name = "most_active")]
    MostActive,
    /// Least install activity.
    #[value(name = "least_active")]
    LeastActive,
}

impl SortArg {
    /// The exact server query value.
    pub fn as_server_key(self) -> &'static str {
        match self {
            SortArg::MostInstalled => "most_installed",
            SortArg::LeastInstalled => "least_installed",
            SortArg::Recent => "recent",
            SortArg::Oldest => "oldest",
            SortArg::HighestScore => "highest_score",
            SortArg::LowestScore => "lowest_score",
            SortArg::MostStarred => "most_starred",
            SortArg::NameAsc => "name_asc",
            SortArg::NameDesc => "name_desc",
            SortArg::MostActive => "most_active",
            SortArg::LeastActive => "least_active",
        }
    }
}

/// `search [query]` — interactive faceted catalog finder + installer (alias
/// `find`). With `--json` (or any non-TTY context) it runs headless: one fetch,
/// the catalog envelope printed as JSON to stdout, no TUI.
#[derive(Debug, clap::Args)]
pub struct SearchArgs {
    /// Seed query (FTS + fuzzy). Omit for the trending list.
    pub query: Option<String>,

    /// Restrict to these capability kinds (repeatable): `skill`, `mcp_server`,
    /// `hook`, `plugin`, `rules`.
    #[arg(long = "kind")]
    pub kind: Vec<String>,

    /// Restrict to these agent compatibilities (repeatable; canonical ids).
    #[arg(long = "agent")]
    pub agent: Vec<String>,

    /// Restrict to these scan tiers (repeatable): `green`, `yellow`, `orange`,
    /// `red`.
    #[arg(long = "scan-tier")]
    pub scan_tier: Vec<String>,

    /// Minimum aggregate score (0–100).
    #[arg(long = "score-min", value_parser = clap::value_parser!(u8).range(0..=100))]
    pub score_min: Option<u8>,

    /// Sort key (default: most_installed — trending).
    #[arg(long, value_enum)]
    pub sort: Option<SortArg>,

    /// Page size (1–100; default 50).
    #[arg(long, default_value_t = 50, value_parser = clap::value_parser!(u32).range(1..=100))]
    pub limit: u32,

    /// Include low/empty quality_tier items (default hides them).
    #[arg(long = "show-low-quality")]
    pub show_low_quality: bool,
}

/// `capability [TARGET]` — scan one artifact, or (no target) audit everything
/// installed across the detected agents.
#[derive(Debug, clap::Args)]
pub struct CapabilityArgs {
    /// A local path or a GitHub URL. Omit to audit every installed capability
    /// across your detected agents. Conflicts with `--to`.
    #[arg(conflicts_with = "to")]
    pub target: Option<String>,

    /// Scope the all-installed audit to these detected agents (repeatable;
    /// canonical ids). Conflicts with a positional TARGET.
    #[arg(long = "to", value_name = "AGENT", conflicts_with = "target")]
    pub to: Vec<String>,

    /// Keep the scan unlisted (token URL + expiry).
    #[arg(long)]
    pub private: bool,

    /// Expand the per-capability 5-axis breakdown + inline critical/high
    /// findings (the default report stays concise).
    #[arg(long)]
    pub detailed: bool,
}

/// `agent [--to <id>…]` — the behavioral Agent Scan. With no
/// `--to`, multi-select which detected agents to scan; each is scanned in turn.
#[derive(Debug, clap::Args)]
pub struct AgentArgs {
    /// Scan these agents (repeatable). Accepts any of the 8 known ids even when
    /// not detected. Omit to multi-select from the detected agents.
    #[arg(long = "to", value_name = "AGENT")]
    pub to: Vec<String>,

    /// Display name for the scanned agent (default: a stable memorable codename
    /// generated per machine + platform, e.g. `swift-otter`). On a multi-agent
    /// run the platform is appended (`my-bot-cursor`) so the reports stay distinct.
    #[arg(long, value_name = "NAME")]
    pub name: Option<String>,

    /// Keep the report unlisted (token URL + expiry).
    #[arg(long)]
    pub private: bool,

    /// Fail (exit 1) when the graded verdict crosses a threshold:
    /// `<severity>` | `score:<n>` | `band:<green|yellow|orange|red>`.
    #[arg(long = "fail-on", value_name = "THRESHOLD")]
    pub fail_on: Option<String>,

    /// A `.agentscanignore` file, or a prior report `.json`, whose findings are
    /// suppressed (default: `./.agentscanignore` if present).
    #[arg(long, value_name = "PATH")]
    pub baseline: Option<PathBuf>,

    /// Opt this run out of anonymous company-level telemetry.
    #[arg(long = "no-telemetry")]
    pub no_telemetry: bool,

    /// Skip the best-effort local-capability scan that populates the report's
    /// Component Scores tab (no upload of your installed skills/MCP/hooks).
    #[arg(long = "no-components")]
    pub no_components: bool,

    /// Print a static SKILL.md bootstrap (with a freshly-minted run + token) and exit.
    #[arg(long = "print-skill")]
    pub print_skill: bool,

    /// Submit a paste-back blob your agent printed (text file) and render the report.
    #[arg(long = "submit-blob", value_name = "FILE")]
    pub submit_blob: Option<PathBuf>,

    /// Minutes to wait for each agent to submit results before giving up. A real
    /// run (a human pasting the prompt + an LLM running ~20 tests) routinely takes
    /// 10–40 min; raise it for a slow agent. Ctrl-C bails early.
    #[arg(long, value_name = "MINUTES", default_value_t = 45, value_parser = clap::value_parser!(u64).range(1..=1440))]
    pub timeout: u64,
}

/// `doctor`.
#[derive(Debug, clap::Args)]
pub struct DoctorArgs {
    /// Re-apply any registry-vs-filesystem drift found (repair).
    #[arg(long)]
    pub fix: bool,
}

/// The resolved global interaction flags threaded into the gating commands
/// (`install` / `uninstall` / `update` / `doctor`).
#[derive(Debug, Clone, Copy)]
pub struct Interaction {
    /// Assume "yes" for confirmations up to `high` severity.
    pub yes: bool,
    /// Override every gate, including the critical type-name confirm.
    pub force: bool,
    /// Never prompt; fail fast naming the flag needed (also implied by --json).
    pub non_interactive: bool,
}

/// Resolve the interaction flags from the parsed CLI (`--json` ⇒ non-interactive).
pub fn interaction(cli: &Cli) -> Interaction {
    Interaction {
        yes: cli.yes,
        force: cli.force,
        non_interactive: cli.non_interactive || cli.json,
    }
}

/// Resolve the output configuration from parsed flags. `--json`
/// forces Json format AND disables color (machine output is never colorized).
pub fn build_output_config(cli: &Cli) -> OutputConfig {
    let format = if cli.json {
        OutputFormat::Json
    } else {
        cli.format
    };
    let color = if format == OutputFormat::Json || format == OutputFormat::Md {
        // Machine / Markdown output is never colorized.
        false
    } else {
        color::is_color_enabled(cli.color, cli.no_color)
    };
    OutputConfig {
        format,
        verbose: cli.verbose,
        quiet: cli.quiet,
        color,
    }
}

/// Map a parsed command to its stable telemetry label `(command, subcommand)`
/// — drawn from the grammar, never from flag values.
pub fn command_label(cmd: &Commands) -> (&'static str, Option<&'static str>) {
    match cmd {
        Commands::Info(_) => ("info", None),
        Commands::Install(_) => ("install", None),
        Commands::Uninstall(_) => ("uninstall", None),
        Commands::Update(_) => ("update", None),
        Commands::List(_) => ("list", None),
        Commands::Search(_) => ("search", None),
        Commands::Capability(_) => ("capability", None),
        Commands::Agent(_) => ("agent", None),
        Commands::Doctor(_) => ("doctor", None),
        Commands::Completion { .. } => ("completion", None),
        Commands::Man => ("man", None),
    }
}

/// Known top-level subcommands for the did-you-mean suggester.
pub const KNOWN_SUBCOMMANDS: &[&str] = &[
    "info",
    "check",
    "install",
    "uninstall",
    "update",
    "list",
    "search",
    "find",
    "capability",
    "agent",
    "doctor",
    "completion",
];

/// Suggest the closest known subcommand for an unknown input (jaro_winkler >
/// 0.7). Complements clap's own "did you mean".
pub fn suggest_subcommand(input: &str) -> Option<String> {
    let lower = input.to_ascii_lowercase();
    KNOWN_SUBCOMMANDS
        .iter()
        .map(|c| (*c, strsim::jaro_winkler(&lower, c)))
        .filter(|(_, score)| *score > 0.7)
        .max_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal))
        .map(|(c, _)| c.to_string())
}

#[cfg(test)]
mod tests {
    use super::*;
    use clap::CommandFactory;

    #[test]
    fn cli_definition_is_valid() {
        // Panics at test time if the derive produces an inconsistent command
        // tree (duplicate args, bad alias, etc.).
        Cli::command().debug_assert();
    }

    #[test]
    fn json_flag_forces_json_and_no_color() {
        let cli = Cli::parse_from(["saferskills", "--json", "info", "x"]);
        let cfg = build_output_config(&cli);
        assert!(cfg.is_json());
        assert!(!cfg.color);
    }

    #[test]
    fn info_has_check_alias() {
        let cli = Cli::parse_from(["saferskills", "check", "github-mcp"]);
        assert!(matches!(cli.command, Some(Commands::Info(_))));
    }

    #[test]
    fn suggest_subcommand_finds_close_typo() {
        assert_eq!(suggest_subcommand("instal").as_deref(), Some("install"));
        assert_eq!(suggest_subcommand("info").as_deref(), Some("info"));
        assert!(suggest_subcommand("zzzzzz").is_none());
    }

    #[test]
    fn command_label_from_grammar() {
        let cli = Cli::parse_from(["saferskills", "update", "--all"]);
        assert_eq!(
            command_label(cli.command.as_ref().unwrap()),
            ("update", None)
        );
    }
}