use std::path::Path;
use anyhow::{bail, Context, Result};
use clap::{CommandFactory, Parser, ValueEnum};
use skillpack::cli::{resolve_targets, Cli, Commands, LogFormat, Target};
use skillpack::config::Config;
use skillpack::exit;
use skillpack::generate::{coerce_kebab, render_all, GeneratedFileOutput};
use skillpack::interview;
use skillpack::introspect;
use skillpack::types;
use skillpack::verify::{self, VerifyInput, VerifyReport};
fn main() {
skillpack::spawn::reset_sigpipe();
let cli = Cli::parse();
init_logging(cli.effective_log_filter(), cli.log_format);
let code = match std::panic::catch_unwind(|| match cli.command {
Commands::Init {
root,
non_interactive,
auto,
accept_warnings,
license,
target,
force,
dry_run,
template_dir,
description,
trigger,
author,
invocation,
import,
format,
} => run_init(
&root,
cli.verbose,
non_interactive,
auto,
accept_warnings,
license,
target,
force,
dry_run,
template_dir.as_deref(),
description,
trigger,
author,
invocation,
import,
format,
),
Commands::Verify {
root,
format,
fix,
min_score,
watch,
template_dir,
} => run_verify(
&root,
cli.verbose,
format,
fix,
min_score,
watch,
template_dir.as_deref(),
),
Commands::Doctor { root, format } => run_doctor(&root, cli.verbose, format),
Commands::Update {
root,
target,
force,
template_dir,
format,
} => run_update(
&root,
cli.verbose,
target,
force,
template_dir.as_deref(),
format,
),
Commands::Diff {
root,
target,
force,
template_dir,
format,
} => run_diff(
&root,
cli.verbose,
target,
force,
template_dir.as_deref(),
format,
),
Commands::Add {
name,
root,
non_interactive,
description,
trigger,
author,
invocation,
import,
license,
target,
force,
template_dir,
} => run_add(
&root,
cli.verbose,
&name,
non_interactive,
description,
trigger,
author,
invocation,
import,
license,
target,
force,
template_dir.as_deref(),
),
Commands::Config { root, validate } => run_config(&root, validate),
Commands::Completions { shell } => {
let mut cmd = <Cli as CommandFactory>::command();
clap_complete::generate(shell, &mut cmd, "skillpack", &mut std::io::stdout());
exit::INIT_OK
}
}) {
Ok(code) => code,
Err(payload) => {
eprintln!(
"fatal: skillpack crashed (panic): {}",
panic_message(&*payload)
);
std::process::exit(exit::INIT_FATAL)
}
};
std::process::exit(code);
}
fn panic_message(payload: &(dyn std::any::Any + Send)) -> String {
if let Some(s) = payload.downcast_ref::<&str>() {
(*s).to_string()
} else if let Some(s) = payload.downcast_ref::<String>() {
s.clone()
} else {
"unknown panic (re-run with RUST_BACKTRACE=1 for a backtrace)".to_string()
}
}
fn init_logging(filter: tracing::level_filters::LevelFilter, format: LogFormat) {
let builder = tracing_subscriber::fmt()
.with_max_level(filter)
.with_writer(std::io::stderr);
match format {
LogFormat::Human => builder.compact().without_time().init(),
LogFormat::Json => builder.json().init(),
}
}
fn trace_detected(profile: &types::ProjectProfile) {
tracing::debug!(
name = %profile.name,
language = %profile.language.as_str(),
secondary = profile.secondary_languages.len(),
has_cli = profile.has_cli,
diag_notes = profile.diag.0.len(),
"detected"
);
}
fn auto_intents(
profile: &types::ProjectProfile,
triggers: &[String],
import: Option<&str>,
) -> Result<Vec<(String, types::Intent)>> {
let primary_name = coerce_kebab(&profile.name);
let mut out = vec![(
primary_name.clone(),
auto_intent(profile, triggers, import)?,
)];
for lang in &profile.secondary_languages {
let lang_str = lang.as_str();
out.push((
format!("{primary_name}-{lang_str}"),
types::Intent {
one_line_description: format!("Manage the {lang_str} surface of {primary_name}"),
when_to_use_phrases: vec![format!("touch the {lang_str} code")],
invocation_command: None,
import_pattern: None,
author: profile.authors.clone(),
license: profile.license.clone().or_else(|| Some("MIT".to_string())),
..Default::default()
},
));
}
Ok(out)
}
fn auto_intent(
profile: &types::ProjectProfile,
triggers: &[String],
import: Option<&str>,
) -> Result<types::Intent> {
let one_line_description = profile
.description_hint
.clone()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.ok_or_else(|| {
anyhow::anyhow!(
"--auto could not derive a description (no README hint found). \
Pass --description, or run `skillpack init` interactively."
)
})?;
let when_to_use_phrases: Vec<String> = if triggers.is_empty() {
vec![one_line_description.clone()]
} else {
triggers
.iter()
.flat_map(|t| t.split([',', ';']))
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect()
};
let (invocation_command, import_pattern) = if profile.has_cli {
let invocation = profile
.cli_command
.as_ref()
.and_then(|c| c.first())
.filter(|s| Path::new(s).is_file())
.and_then(|s| Path::new(s).file_stem())
.and_then(|s| s.to_str())
.map(String::from)
.unwrap_or_else(|| profile.name.clone());
(Some(invocation), None)
} else {
match import.map(str::trim).filter(|s| !s.is_empty()) {
Some(p) => (None, Some(p.to_string())),
None => bail!(
"--auto: no CLI detected, so this looks like a library; pass \
--import <PATTERN> so the skill can document how an agent \
consumes it"
),
}
};
Ok(types::Intent {
one_line_description,
when_to_use_phrases,
invocation_command,
import_pattern,
author: profile.authors.clone(),
license: profile.license.clone().or(Some("MIT".to_string())),
..Default::default()
})
}
fn bootstrap_intent(
profile: &types::ProjectProfile,
description: Option<&str>,
triggers: &[String],
author: Option<&str>,
invocation: Option<&str>,
import: Option<&str>,
) -> Result<types::Intent> {
let one_line_description = description.unwrap_or("").trim().to_string();
if one_line_description.is_empty() {
bail!(
"--non-interactive bootstrap needs --description <TEXT> \
(one sentence describing the task an agent would use this for)"
);
}
let when_to_use_phrases: Vec<String> = triggers
.iter()
.flat_map(|t| t.split([',', ';']))
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect();
if when_to_use_phrases.is_empty() {
bail!(
"--non-interactive bootstrap needs at least one --trigger <PHRASE> \
(repeat the flag, or comma/semicolon-separate values inside one)"
);
}
if invocation.is_some() && import.is_some() {
bail!(
"pass only one of --invocation (CLI project) or --import (library \
project), not both"
);
}
let invocation = invocation.map(str::trim).filter(|s| !s.is_empty());
let import = import.map(str::trim).filter(|s| !s.is_empty());
let (invocation_command, import_pattern) = match (invocation, import) {
(Some(cmd), None) => (Some(cmd.to_string()), None),
(None, Some(pat)) => (None, Some(pat.to_string())),
(None, None) => {
if profile.has_cli {
bail!(
"--non-interactive bootstrap needs --invocation <CMD> \
(the exact command an agent should run)"
);
} else {
bail!(
"--non-interactive bootstrap needs --import <PATTERN> \
(the import pattern an agent should use)"
);
}
}
(Some(_), Some(_)) => unreachable!("guarded above"),
};
Ok(types::Intent {
one_line_description,
when_to_use_phrases,
invocation_command,
import_pattern,
author: author
.map(str::trim)
.filter(|s| !s.is_empty())
.map(String::from),
license: Some("MIT".to_string()),
..Default::default()
})
}
#[allow(clippy::too_many_arguments)]
fn run_init(
root: &Path,
verbose: bool,
non_interactive: bool,
auto: bool,
accept_warnings: bool,
license_override: Option<String>,
raw_targets: Vec<String>,
force: bool,
dry_run: bool,
template_dir: Option<&Path>,
description: Option<String>,
triggers: Vec<String>,
author: Option<String>,
invocation: Option<String>,
import: Option<String>,
format: verify::OutputFormat,
) -> i32 {
if let Some(code) = handle_list_request(&raw_targets) {
return code;
}
match run_init_inner(
root,
verbose,
non_interactive,
auto,
accept_warnings,
license_override,
raw_targets,
force,
dry_run,
template_dir,
description,
triggers,
author,
invocation,
import,
format,
) {
Ok(c) => c,
Err(e) => {
eprintln!("fatal: {e:#}");
exit::INIT_FATAL
}
}
}
#[allow(clippy::too_many_arguments)]
fn run_init_inner(
root: &Path,
verbose: bool,
non_interactive: bool,
auto: bool,
accept_warnings: bool,
license_override: Option<String>,
raw_targets: Vec<String>,
force: bool,
dry_run: bool,
template_dir: Option<&Path>,
description: Option<String>,
triggers: Vec<String>,
author: Option<String>,
invocation: Option<String>,
import: Option<String>,
format: verify::OutputFormat,
) -> Result<i32> {
reject_report_format(format)?;
let profile = introspect::introspect(root).context("introspecting repo")?;
if verbose {
print_profile(&profile, false);
}
let non_interactive = non_interactive || auto;
let targets = if raw_targets.is_empty() {
vec![Target::Claude]
} else {
resolve_targets(&raw_targets)?
};
if verbose {
let names: Vec<String> = targets
.iter()
.map(|t| t.to_possible_value().unwrap().get_name().to_string())
.collect();
eprintln!("targets: {}", names.join(", "));
}
trace_detected(&profile);
let existing_cfg = Config::load(root)?;
let mut skills: Vec<(String, types::Intent)> = if let Some(cfg) = &existing_cfg {
let s = cfg.to_intents();
if s.is_empty() {
if auto || non_interactive {
bail!(
"skillpack.toml at {} is missing its [skill] table.\n\
To fix: re-run `skillpack init` interactively.",
Config::path(root).display()
);
}
vec![(coerce_kebab(&profile.name), interview_run(&profile)?)]
} else {
s
}
} else if auto {
auto_intents(&profile, &triggers, import.as_deref())?
} else if non_interactive {
vec![(
coerce_kebab(&profile.name),
bootstrap_intent(
&profile,
description.as_deref(),
&triggers,
author.as_deref(),
invocation.as_deref(),
import.as_deref(),
)?,
)]
} else {
vec![(coerce_kebab(&profile.name), interview_run(&profile)?)]
};
if let Some(ref lic) = license_override {
if let Some((_, intent)) = skills.first_mut() {
intent.license = Some(lic.clone());
}
}
let files = render_all(&profile, &skills, &targets, template_dir)
.context("rendering distribution files")?;
let verify_stdin = skills.first().and_then(|(_, i)| i.verify_stdin.clone());
let report = verify_rendered(&files, &profile, root, verify_stdin)?;
if report.has_critical_failure() {
eprintln!("\n❌ pre-commit verification FAILED. skillpack will NOT write files.");
eprintln!("{}", verify::render(&report));
if non_interactive {
eprintln!(
"Critical checks failed in --non-interactive mode; refusing to write. \
Fix the issues above and re-run."
);
return Ok(exit::INIT_FIXABLE);
}
let proceed = CONFIRM.keep_anyway();
if !proceed {
eprintln!("Aborted. No files written.");
return Ok(exit::INIT_FIXABLE);
}
} else {
let (_pass, warn, _fail, _skip) = report.counts();
if warn > 0 {
eprintln!("\n⚠ verification passed with warnings:");
eprintln!("{}", verify::render(&report));
if !accept_warnings && !non_interactive {
let proceed = CONFIRM.proceed_with_warnings();
if !proceed {
eprintln!("Aborted. No files written.");
return Ok(exit::INIT_ABORTED);
}
} else if non_interactive {
eprintln!(
"Written in --non-interactive mode (warnings are advisory; \
use --accept-warnings to suppress this notice)."
);
}
}
}
print_diff_preview(root, &files);
if dry_run {
if is_json(format) {
println!(
"{}",
serde_json::json!({
"command": "init",
"dry_run": true,
"written": [],
"skipped": [],
"would_write": files.iter().map(|f| &f.rel_path).collect::<Vec<_>>(),
})
);
return Ok(exit::INIT_OK);
}
println!(
"dry run: would write {} file(s) under {} (no changes made):",
files.len(),
root.display()
);
for f in &files {
println!(" - {}", f.rel_path);
}
return Ok(exit::INIT_OK);
}
let (written, skipped) = write_files(root, &files, force)?;
Config::from_intents(&skills).save_if_changed(root)?;
if is_json(format) {
println!(
"{}",
serde_json::json!({
"command": "init",
"dry_run": false,
"written": written.iter().map(|f| &f.rel_path).collect::<Vec<_>>(),
"skipped": skipped.iter().map(|f| &f.rel_path).collect::<Vec<_>>(),
"config": Config::path(root).display().to_string(),
})
);
return Ok(exit::INIT_OK);
}
println!(
"✓ wrote {} file(s) under {}:",
written.len(),
root.display()
);
for f in &written {
println!(" - {}", f.rel_path);
}
println!(" - {}", Config::path(root).display());
if !skipped.is_empty() {
eprintln!(
"ℹ skipped {} target file(s) (existing file held; pass --force to overwrite):",
skipped.len()
);
for f in &skipped {
eprintln!(" - {}", f.rel_path);
}
}
Ok(exit::INIT_OK)
}
fn is_json(format: verify::OutputFormat) -> bool {
!matches!(format, verify::OutputFormat::Human)
}
fn reject_report_format(format: verify::OutputFormat) -> Result<()> {
if matches!(
format,
verify::OutputFormat::Sarif | verify::OutputFormat::Github
) {
bail!(
"--format sarif/github is only valid for `verify`; this command \
supports `human` or `json`"
);
}
Ok(())
}
fn handle_list_request(raw: &[String]) -> Option<i32> {
if raw.iter().any(|r| r == "list") {
println!("supported --target values (repeat the flag; `all` = every target):");
for name in skillpack::cli::target_names() {
println!(" {name}");
}
Some(exit::INIT_OK)
} else {
None
}
}
fn interview_run(profile: &types::ProjectProfile) -> Result<types::Intent> {
println!("\nNo skillpack.toml found. A few quick questions to scaffold your skill pack.\n");
let prompter = interview::DialoguerPrompter;
let intent = interview::run(profile, &prompter).context("during interview")?;
Ok(intent)
}
fn verify_rendered(
files: &[GeneratedFileOutput],
profile: &types::ProjectProfile,
root: &Path,
verify_stdin: Option<String>,
) -> Result<VerifyReport> {
let tmp = tempfile::tempdir().context("creating temp dir for pre-commit verify")?;
for f in files {
let p = tmp.path().join(&f.rel_path);
if let Some(parent) = p.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("creating {}", parent.display()))?;
}
std::fs::write(&p, &f.contents).with_context(|| format!("writing {}", p.display()))?;
}
let input = VerifyInput {
root: tmp.path().to_path_buf(),
spawn_root: root.to_path_buf(),
cli_command: profile.cli_command.clone(),
repo_url: profile.repo_url.clone(),
profile_name: Some(coerce_kebab(&profile.name)),
verify_stdin,
};
verify::run(&input)
}
fn ensure_no_symlink_ancestors(root: &Path, rel_path: &str) -> Result<()> {
let mut cur = root.to_path_buf();
for comp in Path::new(rel_path).components() {
cur.push(comp.as_os_str());
if let Ok(meta) = std::fs::symlink_metadata(&cur) {
if meta.file_type().is_symlink() {
bail!(
"refusing to write through a symlink at {}; remove it or re-run in a non-symlinked checkout",
cur.display()
);
}
}
}
Ok(())
}
fn write_files<'a>(
root: &Path,
files: &'a [GeneratedFileOutput],
force: bool,
) -> Result<(Vec<&'a GeneratedFileOutput>, Vec<&'a GeneratedFileOutput>)> {
let mut written = Vec::new();
let mut skipped = Vec::new();
for f in files {
let p = root.join(&f.rel_path);
ensure_no_symlink_ancestors(root, &f.rel_path)?;
if is_collision_guarded(&f.rel_path) && p.exists() && !force {
eprintln!(
"⚠ {} already exists at {}; skipping (pass --force to overwrite).",
f.rel_path,
p.display()
);
skipped.push(f);
continue;
}
if let Some(parent) = p.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("creating {}", parent.display()))?;
}
std::fs::write(&p, &f.contents).with_context(|| format!("writing {}", p.display()))?;
written.push(f);
}
Ok((written, skipped))
}
fn print_diff_preview(root: &Path, files: &[GeneratedFileOutput]) {
let mut new = Vec::new();
let mut changed = Vec::new();
let mut unchanged = 0u32;
for f in files {
let p = root.join(&f.rel_path);
match std::fs::read_to_string(&p) {
Ok(existing) if existing == f.contents => unchanged += 1,
Ok(_) => changed.push(&f.rel_path),
Err(_) => new.push(&f.rel_path),
}
}
if new.is_empty() && changed.is_empty() {
return;
}
eprintln!("\n📝 distribution file preview:");
for r in &new {
eprintln!(" + {r} (new)");
}
for r in &changed {
eprintln!(" ~ {r} (changed)");
}
if unchanged > 0 {
eprintln!(" = {unchanged} file(s) unchanged");
}
}
trait Confirm {
fn confirm(&self, prompt: &str) -> bool;
fn keep_anyway(&self) -> bool {
self.confirm(&prompt_keep_anyway_text())
}
fn proceed_with_warnings(&self) -> bool {
self.confirm(
"Verification passed with warnings (see above). \
Write the files? [y/N] ",
)
}
}
struct StdinConfirm;
impl Confirm for StdinConfirm {
fn confirm(&self, prompt: &str) -> bool {
use std::io::{self, Write};
let mut input = String::new();
print!("{prompt}");
let _ = io::stdout().flush();
if io::stdin().read_line(&mut input).is_err() {
return false;
}
matches!(input.trim().to_lowercase().as_str(), "y" | "yes")
}
}
thread_local! {
static CONFIRM_REF: std::cell::RefCell<Box<dyn Confirm>> =
std::cell::RefCell::new(Box::new(StdinConfirm));
}
#[cfg(test)]
struct ConfirmGuard;
#[cfg(test)]
impl Drop for ConfirmGuard {
fn drop(&mut self) {
CONFIRM_REF.with(|c| c.replace(Box::new(StdinConfirm)));
}
}
struct ConfirmDispatch;
impl Confirm for ConfirmDispatch {
fn confirm(&self, prompt: &str) -> bool {
CONFIRM_REF.with(|c| c.borrow().confirm(prompt))
}
}
static CONFIRM: ConfirmDispatch = ConfirmDispatch;
fn prompt_keep_anyway_text() -> String {
"Critical verification failures were found (see above).\n\
Write the files anyway? [y/N] "
.to_string()
}
#[cfg(test)]
struct CannedConfirm(bool);
#[cfg(test)]
impl Confirm for CannedConfirm {
fn confirm(&self, _p: &str) -> bool {
self.0
}
}
#[cfg(test)]
pub(crate) fn with_confirm<R>(answer: bool, f: impl FnOnce() -> R) -> R {
CONFIRM_REF.with(|c| c.replace(Box::new(CannedConfirm(answer))));
let _g = ConfirmGuard;
f()
}
fn print_profile(profile: &types::ProjectProfile, to_stderr: bool) {
macro_rules! emit {
($($arg:tt)*) => {
if to_stderr {
eprintln!($($arg)*);
} else {
println!($($arg)*);
}
};
}
emit!("introspection");
emit!(" name: {}", profile.name);
emit!(" language: {}", profile.language.as_str());
if !profile.secondary_languages.is_empty() {
let langs: Vec<&str> = profile
.secondary_languages
.iter()
.map(|l| l.as_str())
.collect();
emit!(" secondary: {}", langs.join(", "));
}
emit!(" has_cli: {}", profile.has_cli);
if let Some(cmd) = &profile.cli_command {
emit!(" cli_command: {}", cmd.join(" "));
}
if let Some(url) = &profile.repo_url {
emit!(" repo_url: {url}");
}
if let Some(lic) = &profile.license {
emit!(" license: {lic}");
}
if let Some(hint) = &profile.description_hint {
if hint.chars().count() > 120 {
emit!(
" desc_hint: {}…",
hint.chars().take(120).collect::<String>()
);
} else {
emit!(" desc_hint: {hint}");
}
}
}
#[allow(clippy::too_many_arguments)]
fn run_verify(
root: &Path,
verbose: bool,
format: verify::OutputFormat,
fix: bool,
min_score: Option<u8>,
watch: bool,
template_dir: Option<&Path>,
) -> i32 {
if watch {
if format != verify::OutputFormat::Human {
eprintln!("error: --watch is only valid with --format human");
return exit::VERIFY_USAGE;
}
return run_verify_watch(root, verbose, format, fix, min_score, template_dir);
}
match run_verify_inner(root, verbose, format, fix, min_score, template_dir) {
Ok(c) => c,
Err(e) => {
eprintln!("fatal: {e:#}");
exit::INIT_FATAL
}
}
}
fn run_verify_inner(
root: &Path,
verbose: bool,
format: verify::OutputFormat,
fix: bool,
min_score: Option<u8>,
template_dir: Option<&Path>,
) -> Result<i32> {
let profile = introspect::introspect(root).context("introspecting repo for verify")?;
let verify_stdin = Config::load(root)
.ok()
.and_then(|opt| opt.and_then(|cfg| cfg.to_intent()))
.and_then(|intent| intent.verify_stdin);
if verbose {
print_profile(
&profile,
matches!(
format,
verify::OutputFormat::Json | verify::OutputFormat::Sarif
),
);
}
let render = |report: &verify::VerifyReport| match format {
verify::OutputFormat::Human => verify::render(report),
verify::OutputFormat::Json => format!("{}\n", verify::render_json(report)),
verify::OutputFormat::Sarif => format!("{}\n", verify::render_sarif(report)),
verify::OutputFormat::Github => verify::render_github_annotations(report),
};
let run_verify = || -> Result<verify::VerifyReport> {
let input = VerifyInput {
root: root.to_path_buf(),
spawn_root: root.to_path_buf(),
cli_command: profile.cli_command.clone(),
profile_name: Some(coerce_kebab(&profile.name)),
verify_stdin: verify_stdin.clone(),
repo_url: profile.repo_url.clone(),
};
verify::run(&input)
};
let report = run_verify()?;
let (final_report, applied_summary) = if !fix {
(report, None)
} else {
let actions: Vec<_> = report
.results
.iter()
.filter(|r| {
matches!(
r.severity,
verify::result::Severity::Warn | verify::result::Severity::Error
)
})
.filter_map(|r| verify::fix::action_for(&r.check_id).map(|a| (a, r.location.clone())))
.collect();
if actions.is_empty() {
(report, None)
} else {
let mut written: Vec<String> = Vec::new();
for (action, loc) in actions {
let outcome = verify::fix::apply(action, root, loc.as_ref(), template_dir)
.context("applying a `--fix` action")?;
written.extend(outcome.files_written);
}
let summary: Vec<String> = verify::fix::FixOutcome {
files_written: written,
}
.unique_sorted();
let summary_line = format!(
"✓ applied {} fix(es), wrote: {}",
summary.len(),
summary.join(", ")
);
(run_verify()?, Some(summary_line))
}
};
if let Some(line) = applied_summary {
eprintln!("{line}");
}
print!("{}", render(&final_report));
let code = if final_report.has_critical_failure() {
exit::VERIFY_FAIL
} else if let Some(min) = min_score {
let actual = final_report.discoverability_score();
if actual < min {
eprintln!(
"verify: discoverability score {actual} is below the --min-score {min} threshold"
);
exit::VERIFY_SCORE_BELOW_MIN
} else {
exit::VERIFY_OK
}
} else {
exit::VERIFY_OK
};
Ok(code)
}
fn run_verify_watch(
root: &Path,
verbose: bool,
format: verify::OutputFormat,
fix: bool,
min_score: Option<u8>,
template_dir: Option<&Path>,
) -> i32 {
use notify::{EventKind, RecursiveMode, Watcher};
use std::sync::mpsc;
use std::time::{Duration, Instant};
let (tx, rx) = mpsc::channel::<notify::Result<notify::Event>>();
let mut watcher = match notify::recommended_watcher(tx) {
Ok(w) => w,
Err(e) => {
eprintln!("fatal: cannot initialize file watcher: {e}");
return exit::INIT_FATAL;
}
};
if let Err(e) = watcher.watch(root, RecursiveMode::Recursive) {
eprintln!("fatal: cannot watch {}: {e}", root.display());
return exit::INIT_FATAL;
}
eprintln!(
"🔍 watching {} for changes (Ctrl-C to stop)…\n",
root.display()
);
let _ = run_verify_single(root, verbose, format, fix, min_score, template_dir);
let debounce = Duration::from_secs(1);
let mut last_event: Option<Instant> = None;
let is_noise = |path: &std::path::Path| -> bool {
path.components().any(|c| {
matches!(
c,
std::path::Component::Normal(s)
if s == "target" || s == ".git" || s == "node_modules"
)
})
};
loop {
match rx.recv_timeout(Duration::from_millis(500)) {
Ok(Ok(event)) => {
if matches!(
event.kind,
EventKind::Create(_) | EventKind::Modify(_) | EventKind::Remove(_)
) && !event.paths.iter().all(|p| is_noise(p))
{
last_event = Some(Instant::now());
}
}
Ok(Err(_)) | Err(mpsc::RecvTimeoutError::Timeout) => {
if let Some(t) = last_event {
if t.elapsed() >= debounce {
last_event = None;
if std::io::IsTerminal::is_terminal(&std::io::stdout()) {
print!("\x1b[2J\x1b[H");
}
let _ =
run_verify_single(root, verbose, format, fix, min_score, template_dir);
}
}
}
Err(mpsc::RecvTimeoutError::Disconnected) => {
break;
}
}
}
eprintln!("\nstopped.");
exit::VERIFY_OK
}
fn run_verify_single(
root: &Path,
verbose: bool,
format: verify::OutputFormat,
fix: bool,
min_score: Option<u8>,
template_dir: Option<&Path>,
) -> i32 {
match run_verify_inner(root, verbose, format, fix, min_score, template_dir) {
Ok(c) => c,
Err(e) => {
eprintln!("error: {e:#}");
exit::VERIFY_FAIL
}
}
}
fn run_doctor(root: &Path, verbose: bool, format: crate::verify::OutputFormat) -> i32 {
match run_doctor_inner(root, verbose, format) {
Ok(c) => c,
Err(e) => {
eprintln!("fatal: {e:#}");
exit::INIT_FATAL
}
}
}
fn run_doctor_inner(
root: &Path,
verbose: bool,
format: crate::verify::OutputFormat,
) -> Result<i32> {
let profile = introspect::introspect(root).context("introspecting repo for doctor")?;
match format {
crate::verify::OutputFormat::Json => {
println!(
"{}",
serde_json::to_string_pretty(&profile)
.context("serializing doctor profile to JSON")?
);
}
crate::verify::OutputFormat::Human => render_doctor_human(&profile, verbose),
crate::verify::OutputFormat::Sarif | crate::verify::OutputFormat::Github => {
bail!("doctor does not support this format; use `verify` for machine-readable reports")
}
}
Ok(exit::VERIFY_OK)
}
fn render_doctor_human(profile: &types::ProjectProfile, verbose: bool) {
trace_detected(profile);
if verbose {
print_profile(profile, false);
} else {
println!("skillpack doctor");
println!(" name: {}", profile.name);
println!(" language: {}", profile.language.as_str());
if !profile.secondary_languages.is_empty() {
let langs: Vec<&str> = profile
.secondary_languages
.iter()
.map(|l| l.as_str())
.collect();
println!(" secondary: {}", langs.join(", "));
}
println!(" has_cli: {}", profile.has_cli);
if let Some(cmd) = &profile.cli_command {
println!(" cli: {}", cmd.join(" "));
}
}
println!();
if profile.diag.0.is_empty() {
if profile.has_cli {
println!("decision trace: (empty; CLI detected cleanly, no falsy branches fired)");
} else {
println!("decision trace: (empty; no candidate notes were pushed)");
println!();
println!("hint: candidate fns only push notes on falsy branches, so an empty trace");
println!(" means either detection succeeded silently or this language has no");
println!(" probed candidate. Check --verbose for the raw profile.");
}
} else {
println!("decision trace ({}):", profile.diag.0.len());
for note in &profile.diag.0 {
if note.note.contains("run `") {
println!(" 💡 [{}] {}", note.stage, note.note);
} else {
println!(" [{}] {}", note.stage, note.note);
}
}
}
println!();
println!("verify category preview (run `skillpack verify` after `init` for the real score):");
println!(" discovery.*: structural validation of every generated file per ecosystem");
println!(" (Claude plugin + native skills, Codex, Cursor, OpenCode, Copilot, AGENTS.md,");
println!(" CLAUDE.md, GEMINI.md, Windsurf, Aider, Cline, Roo Code, Kilo Code, Goose)");
if profile.has_cli {
println!(" invocation.*: runs the CLI: --help, flag drift, subcommand drift");
println!(" --version drift (advisory)");
} else {
println!(" invocation.*: N/A (no CLI detected; checks will be skipped)");
}
println!();
println!(
"next step: `skillpack init --target all --auto` to scaffold guidance for every ecosystem."
);
}
fn run_update(
root: &Path,
verbose: bool,
raw_targets: Vec<String>,
force: bool,
template_dir: Option<&Path>,
format: verify::OutputFormat,
) -> i32 {
if let Some(code) = handle_list_request(&raw_targets) {
return code;
}
match run_update_inner(root, verbose, raw_targets, force, template_dir, format) {
Ok(code) => code,
Err(e) => {
eprintln!("fatal: {e:#}");
exit::INIT_FATAL
}
}
}
struct CandidateResult<'a> {
file: &'a GeneratedFileOutput,
candidate: String,
committed: Option<String>,
status: CandidateStatus,
held: bool,
}
#[derive(PartialEq, Eq)]
enum CandidateStatus {
Missing,
Clean,
Drifted,
}
fn compute_candidates<'f>(
root: &Path,
files: &'f [GeneratedFileOutput],
force: bool,
) -> Result<Vec<CandidateResult<'f>>> {
let mut results = Vec::with_capacity(files.len());
for file in files {
let disk_path = root.join(&file.rel_path);
if is_collision_guarded(&file.rel_path) && disk_path.exists() && !force {
results.push(CandidateResult {
file,
candidate: file.contents.clone(),
committed: None,
status: CandidateStatus::Clean,
held: true,
});
continue;
}
if !disk_path.exists() {
results.push(CandidateResult {
file,
candidate: file.contents.clone(),
committed: None,
status: CandidateStatus::Missing,
held: false,
});
continue;
}
let committed = std::fs::read_to_string(&disk_path)
.with_context(|| format!("reading {}", disk_path.display()))?
.replace("\r\n", "\n");
let committed = skillpack::verify::discovery::strip_bom(&committed).to_string();
let candidate = if is_frontmatter_target(&file.rel_path) {
let fresh_fm = skillpack::verify::fix::split_frontmatter(&file.contents)
.map(|(fm, _body)| fm)
.unwrap_or_else(|| file.contents.clone());
let preserved_body = skillpack::verify::fix::split_frontmatter(&committed)
.map(|(_fm, body)| body)
.unwrap_or_default();
format!("{fresh_fm}\n{preserved_body}")
} else {
file.contents.clone()
};
let status = if committed == candidate {
CandidateStatus::Clean
} else {
CandidateStatus::Drifted
};
results.push(CandidateResult {
file,
candidate,
committed: Some(committed),
status,
held: false,
});
}
Ok(results)
}
fn detect_present_targets(root: &Path) -> Vec<Target> {
let mut present = Vec::new();
for (target, marker) in [
(Target::Claude, ".claude-plugin"),
(Target::Cursor, ".cursor/rules"),
(Target::Codex, ".codex/skills"),
(Target::OpenCode, ".opencode/agents"),
(Target::Windsurf, ".windsurf/rules"),
(Target::Cline, ".clinerules"),
(Target::Roo, ".roo/rules"),
(Target::Kilo, ".kilocode/rules"),
] {
if root.join(marker).exists() {
present.push(target);
}
}
present
}
fn default_refresh_targets(root: &Path) -> Result<Vec<Target>> {
let present = detect_present_targets(root);
if present.is_empty() {
resolve_targets(&["all".to_string()])
} else {
Ok(present)
}
}
#[allow(clippy::type_complexity)]
fn render_from_config(
root: &Path,
raw_targets: &[String],
template_dir: Option<&Path>,
) -> Result<(
types::ProjectProfile,
Vec<(String, types::Intent)>,
Vec<GeneratedFileOutput>,
)> {
let profile = introspect::introspect(root).context("introspecting repo")?;
let existing_cfg = Config::load(root)?.ok_or_else(|| {
anyhow::anyhow!(
"no skillpack.toml at {}: a committed config is required.\n\
To fix: run `skillpack init` first to seed it.",
Config::path(root).display()
)
})?;
let skills = existing_cfg.to_intents();
if skills.is_empty() {
bail!(
"skillpack.toml at {} is missing its [skill] table.\n\
To fix: re-run `skillpack init` interactively to regenerate the config.",
Config::path(root).display()
);
}
let targets = if raw_targets.is_empty() {
default_refresh_targets(root)?
} else {
resolve_targets(raw_targets)?
};
let files = render_all(&profile, &skills, &targets, template_dir)
.context("rendering distribution files")?;
Ok((profile, skills, files))
}
fn run_update_inner(
root: &Path,
verbose: bool,
raw_targets: Vec<String>,
force: bool,
template_dir: Option<&Path>,
format: verify::OutputFormat,
) -> Result<i32> {
reject_report_format(format)?;
let (profile, skills, files) = render_from_config(root, &raw_targets, template_dir)?;
if verbose {
print_profile(&profile, false);
}
trace_detected(&profile);
let results = compute_candidates(root, &files, force)?;
let mut written: Vec<&GeneratedFileOutput> = Vec::new();
let mut unchanged = 0usize;
let mut skipped: Vec<&GeneratedFileOutput> = Vec::new();
for r in &results {
if r.held {
skipped.push(r.file);
continue;
}
match r.status {
CandidateStatus::Missing => {
let disk_path = root.join(&r.file.rel_path);
ensure_no_symlink_ancestors(root, &r.file.rel_path)?;
if let Some(parent) = disk_path.parent() {
std::fs::create_dir_all(parent).with_context(|| {
format!("creating parent dir for {}", disk_path.display())
})?;
}
std::fs::write(&disk_path, &r.candidate)
.with_context(|| format!("writing {}", disk_path.display()))?;
written.push(r.file);
}
CandidateStatus::Clean => {
unchanged += 1;
}
CandidateStatus::Drifted => {
let disk_path = root.join(&r.file.rel_path);
ensure_no_symlink_ancestors(root, &r.file.rel_path)?;
std::fs::write(&disk_path, &r.candidate)
.with_context(|| format!("writing {}", disk_path.display()))?;
written.push(r.file);
}
}
}
Config::from_intents(&skills).save_if_changed(root)?;
if is_json(format) {
println!(
"{}",
serde_json::json!({
"command": "update",
"written": written.iter().map(|f| &f.rel_path).collect::<Vec<_>>(),
"unchanged": unchanged,
"skipped": skipped.iter().map(|f| &f.rel_path).collect::<Vec<_>>(),
})
);
return Ok(exit::INIT_OK);
}
println!(
"✓ updated {} file(s), {} unchanged, under {}:",
written.len(),
unchanged,
root.display()
);
for f in &written {
println!(" - {}", f.rel_path);
}
if unchanged > 0 {
eprintln!(" ({unchanged} file(s) already up-to-date)");
}
if !skipped.is_empty() {
eprintln!(
"ℹ skipped {} target file(s) (existing file held; pass --force to overwrite):",
skipped.len()
);
for f in &skipped {
eprintln!(" - {}", f.rel_path);
}
}
Ok(exit::INIT_OK)
}
fn run_diff(
root: &Path,
verbose: bool,
raw_targets: Vec<String>,
force: bool,
template_dir: Option<&Path>,
format: verify::OutputFormat,
) -> i32 {
if let Some(code) = handle_list_request(&raw_targets) {
return code;
}
match run_diff_inner(root, verbose, &raw_targets, force, template_dir, format) {
Ok(code) => code,
Err(e) => {
eprintln!("fatal: {e:#}");
exit::INIT_FATAL
}
}
}
fn run_diff_inner(
root: &Path,
verbose: bool,
raw_targets: &[String],
force: bool,
template_dir: Option<&Path>,
format: verify::OutputFormat,
) -> Result<i32> {
reject_report_format(format)?;
let (profile, _skills, files) = render_from_config(root, raw_targets, template_dir)?;
if verbose {
print_profile(&profile, false);
}
trace_detected(&profile);
let results = compute_candidates(root, &files, force)?;
let mut drifted = 0usize;
let mut missing = 0usize;
let mut unchanged = 0usize;
let mut held = 0usize;
for r in &results {
if r.held {
held += 1;
eprintln!(" held: {} (pass --force to check)", r.file.rel_path);
continue;
}
match r.status {
CandidateStatus::Missing => {
missing += 1;
eprintln!(" missing: {}", r.file.rel_path);
}
CandidateStatus::Clean => {
unchanged += 1;
}
CandidateStatus::Drifted => {
drifted += 1;
let first_diff =
first_differing_line(r.committed.as_deref().unwrap_or_default(), &r.candidate);
eprintln!(" drifted: {} (first diff: {first_diff})", r.file.rel_path);
}
}
}
if is_json(format) {
println!(
"{}",
serde_json::json!({
"command": "diff",
"clean": drifted == 0 && missing == 0,
"drifted": drifted,
"missing": missing,
"unchanged": unchanged,
"held": held,
})
);
return Ok(if drifted == 0 && missing == 0 {
exit::INIT_OK
} else {
exit::DIFF_DRIFT
});
}
if drifted == 0 && missing == 0 {
println!(
"✓ all {unchanged} file(s) up-to-date ({})",
if held > 0 {
format!("{held} held")
} else {
"none held".into()
},
);
Ok(exit::INIT_OK)
} else {
eprintln!(
"\n✗ {drifted} drifted, {missing} missing, {unchanged} up-to-date{}: \
run `skillpack update{}` to fix.",
if held > 0 {
format!(", {held} held")
} else {
String::new()
},
if force { " --force" } else { "" },
);
Ok(exit::DIFF_DRIFT)
}
}
fn first_differing_line(committed: &str, candidate: &str) -> String {
for (c, n) in committed.lines().zip(candidate.lines()) {
if c != n {
return format!("- {c}\n+ {n}");
}
}
let extra = if committed.lines().count() > candidate.lines().count() {
committed
} else {
candidate
};
extra
.lines()
.nth(committed.lines().count().min(candidate.lines().count()))
.map(|l| format!("± {l}"))
.unwrap_or_else(|| "(no lines differ)".into())
}
fn is_frontmatter_target(rel_path: &str) -> bool {
if rel_path.starts_with(".clinerules/")
|| rel_path.starts_with(".roo/rules/")
|| rel_path.starts_with(".kilocode/rules/")
{
return false;
}
rel_path.ends_with("SKILL.md")
|| rel_path.ends_with(".mdc")
|| (rel_path.ends_with(".md")
&& !rel_path.ends_with("AGENTS.md")
&& !rel_path.ends_with("copilot-instructions.md")
&& !rel_path.ends_with("CLAUDE.md")
&& !rel_path.ends_with("GEMINI.md")
&& !rel_path.ends_with("CONVENTIONS.md")
&& !rel_path.ends_with("instructions.md"))
}
fn is_collision_guarded(rel_path: &str) -> bool {
matches!(
rel_path,
crate::verify::schema::AGENTS_MD_PATH
| crate::verify::schema::CLAUDE_MD_PATH
| crate::verify::schema::GEMINI_MD_PATH
| crate::verify::schema::CONVENTIONS_MD_PATH
| crate::verify::schema::GOOSE_INSTRUCTIONS_PATH
| crate::verify::schema::COPILOT_INSTRUCTIONS_PATH
)
}
#[allow(clippy::too_many_arguments)]
fn run_add(
root: &Path,
verbose: bool,
name: &str,
non_interactive: bool,
description: Option<String>,
triggers: Vec<String>,
author: Option<String>,
invocation: Option<String>,
import: Option<String>,
license_override: Option<String>,
raw_targets: Vec<String>,
force: bool,
template_dir: Option<&Path>,
) -> i32 {
if let Some(code) = handle_list_request(&raw_targets) {
return code;
}
match run_add_inner(
root,
verbose,
name,
non_interactive,
description,
triggers,
author,
invocation,
import,
license_override,
raw_targets,
force,
template_dir,
) {
Ok(code) => code,
Err(e) => {
eprintln!("fatal: {e:#}");
exit::INIT_FATAL
}
}
}
#[allow(clippy::too_many_arguments)]
fn run_add_inner(
root: &Path,
verbose: bool,
name: &str,
non_interactive: bool,
description: Option<String>,
triggers: Vec<String>,
author: Option<String>,
invocation: Option<String>,
import: Option<String>,
license_override: Option<String>,
raw_targets: Vec<String>,
force: bool,
template_dir: Option<&Path>,
) -> Result<i32> {
let profile = introspect::introspect(root).context("introspecting repo for add")?;
let Some(cfg) = Config::load(root)? else {
bail!(
"no skillpack.toml at {}: `add` appends to an existing pack.\n\
To fix: run `skillpack init` first to seed the pack, then `skillpack add <name>`.",
root.display()
);
};
let skill_name = coerce_add_name(name)?;
let mut intents = cfg.to_intents();
if intents.iter().any(|(n, _)| coerce_kebab(n) == skill_name) {
bail!("skill `{skill_name}` already exists in skillpack.toml; pick a different name");
}
let mut intent = if non_interactive {
bootstrap_intent(
&profile,
description.as_deref(),
&triggers,
author.as_deref(),
invocation.as_deref(),
import.as_deref(),
)?
} else {
interview_run(&profile)?
};
if let Some(lic) = license_override {
intent.license = Some(lic);
}
intents.push((skill_name, intent));
Config::from_intents(&intents).save_if_changed(root)?;
run_update_inner(
root,
verbose,
raw_targets,
force,
template_dir,
verify::OutputFormat::Human,
)
}
fn coerce_add_name(name: &str) -> Result<String> {
let trimmed = name.trim();
if trimmed.is_empty() {
bail!("skill name must not be empty (run `skillpack add <name>` with a kebab-case name)");
}
if !trimmed.chars().any(|c| c.is_ascii_alphabetic()) {
bail!(
"skill name `{trimmed}` contains no letters; use a name like `my-tool` \
(skillpack coerces it to kebab-case)"
);
}
Ok(coerce_kebab(name))
}
fn run_config(root: &Path, validate: bool) -> i32 {
match Config::load(root) {
Ok(Some(cfg)) => {
let intents = cfg.to_intents();
if validate {
println!("skillpack.toml is valid ({} skill(s))", intents.len());
} else {
println!("skillpack.toml summary:");
println!(" skills: {}", intents.len());
for (name, intent) in &intents {
println!(" - {name}: {}", intent.one_line_description);
}
if let Some(a) = &cfg.defaults.author {
println!(" defaults.author: {a}");
}
if let Some(l) = &cfg.defaults.license {
println!(" defaults.license: {l}");
}
}
exit::INIT_OK
}
Ok(None) => {
eprintln!(
"no skillpack.toml at {} (run `skillpack init` first)",
Config::path(root).display()
);
exit::INIT_FATAL
}
Err(e) => {
eprintln!("invalid skillpack.toml: {e:#}");
exit::INIT_FATAL
}
}
}
#[cfg(test)]
mod confirm_tests {
use super::*;
#[test]
fn keep_anyway_routes_through_overridable_confirm() {
assert!(!with_confirm(false, || CONFIRM.keep_anyway()));
assert!(with_confirm(true, || CONFIRM.keep_anyway()));
}
#[test]
fn proceed_with_warnings_routes_through_overridable_confirm() {
assert!(!with_confirm(false, || CONFIRM.proceed_with_warnings()));
assert!(with_confirm(true, || CONFIRM.proceed_with_warnings()));
}
#[test]
fn auto_intent_uses_resolved_binary_stem_for_renamed_bins() {
let bin = std::env::current_exe().unwrap(); let stem = bin.file_stem().unwrap().to_str().unwrap().to_string();
let profile = types::ProjectProfile {
name: "fd-find".into(),
language: types::Language::Rust,
secondary_languages: Vec::new(),
has_cli: true,
cli_command: Some(vec![bin.to_string_lossy().to_string(), "--help".into()]),
cli_help_output: Some("usage".into()),
cli_subcommand_tree: Vec::new(),
repo_url: None,
license: Some("MIT".into()),
version: None,
authors: None,
description_hint: Some("Find files by name".into()),
diag: types::DiagTrace::default(),
};
let intent = auto_intent(&profile, &[], None).unwrap();
assert_eq!(
intent.invocation_command.as_deref(),
Some(stem.as_str()),
"renamed bin must be documented as its real name"
);
let mut go = profile.clone();
go.cli_command = Some(vec!["go".into(), "run".into(), ".".into()]);
let intent = auto_intent(&go, &[], None).unwrap();
assert_eq!(intent.invocation_command.as_deref(), Some("fd-find"));
}
#[test]
fn print_profile_multibyte_desc_hint_does_not_panic() {
let mut hint = "x".repeat(118);
hint.push('🦀');
let profile = types::ProjectProfile {
name: "test".into(),
language: types::Language::Rust,
secondary_languages: Vec::new(),
has_cli: false,
cli_command: None,
cli_help_output: None,
cli_subcommand_tree: Vec::new(),
repo_url: None,
license: Some("MIT".into()),
version: None,
authors: None,
description_hint: Some(hint),
diag: types::DiagTrace::default(),
};
print_profile(&profile, false);
}
#[test]
fn coerce_add_name_rejects_garbage_and_coerces_valid() {
assert!(coerce_add_name("").is_err());
assert!(coerce_add_name(" ").is_err());
assert!(coerce_add_name("!!!").is_err());
assert!(coerce_add_name("123").is_err());
assert!(coerce_add_name("123-456").is_err());
assert_eq!(coerce_add_name("My Tool").unwrap(), "my-tool");
assert_eq!(coerce_add_name("tool").unwrap(), "tool");
assert_eq!(coerce_add_name("123-foo").unwrap(), "foo");
}
#[test]
fn reject_report_format_allows_only_human_and_json() {
assert!(reject_report_format(verify::OutputFormat::Human).is_ok());
assert!(reject_report_format(verify::OutputFormat::Json).is_ok());
assert!(reject_report_format(verify::OutputFormat::Sarif).is_err());
assert!(reject_report_format(verify::OutputFormat::Github).is_err());
}
#[test]
fn panic_message_reads_str_and_string_payloads() {
let s: Box<dyn std::any::Any + Send> = Box::new("boom");
assert_eq!(panic_message(&*s), "boom");
let s: Box<dyn std::any::Any + Send> = Box::new("boom".to_string());
assert_eq!(panic_message(&*s), "boom");
}
fn scratch_dir(tag: &str) -> std::path::PathBuf {
static SEQ: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
let n = SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let dir = std::env::temp_dir().join(format!(
"skillpack-targets-{tag}-{}-{n}",
std::process::id()
));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
dir
}
#[test]
fn detect_present_targets_finds_generated_ecosystems_only() {
let root = scratch_dir("present");
for d in [".claude-plugin", ".cursor/rules", ".codex/skills"] {
std::fs::create_dir_all(root.join(d)).unwrap();
}
std::fs::write(root.join("AGENTS.md"), "# hand-written").unwrap();
let present = detect_present_targets(&root);
assert!(present.contains(&Target::Claude));
assert!(present.contains(&Target::Cursor));
assert!(present.contains(&Target::Codex));
assert!(
!present.contains(&Target::AgentsMd),
"collision-guarded AGENTS.md must not be probed as present"
);
assert!(!present.contains(&Target::Copilot));
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn default_refresh_targets_falls_back_to_all_when_nothing_present() {
let root = scratch_dir("empty");
let targets = default_refresh_targets(&root).unwrap();
assert_eq!(targets.len(), 14, "fallback must be the full target set");
assert!(targets.contains(&Target::Claude));
assert!(targets.contains(&Target::Goose));
let _ = std::fs::remove_dir_all(&root);
}
}