use std::path::Path;
use anyhow::{bail, Context, Result};
use clap::{CommandFactory, Parser, ValueEnum};
use skillpack::cli::{resolve_targets, Cli, Commands, Target};
use skillpack::config::Config;
use skillpack::exit;
use skillpack::generate::{coerce_kebab, render_all, render_targets, GeneratedFileOutput};
use skillpack::interview;
use skillpack::introspect;
use skillpack::types;
use skillpack::verify::{self, VerifyInput, VerifyReport};
fn main() {
let cli = Cli::parse();
let code = if let Ok(code) = std::panic::catch_unwind(|| match cli.command {
Commands::Init {
root,
non_interactive,
auto,
accept_warnings,
license,
target,
force,
template_dir,
description,
trigger,
author,
invocation,
import,
} => run_init(
&root,
cli.verbose,
cli.debug,
non_interactive,
auto,
accept_warnings,
license,
target,
force,
template_dir.as_deref(),
description,
trigger,
author,
invocation,
import,
),
Commands::Verify {
root,
format,
fix,
min_score,
watch,
template_dir,
} => run_verify(
&root,
cli.verbose,
cli.debug,
format,
fix,
min_score,
watch,
template_dir.as_deref(),
),
Commands::Doctor { root, format } => run_doctor(&root, cli.verbose, cli.debug, format),
Commands::Update {
root,
target,
force,
template_dir,
} => run_update(
&root,
cli.verbose,
cli.debug,
target,
force,
template_dir.as_deref(),
),
Commands::Diff {
root,
target,
force,
template_dir,
} => run_diff(
&root,
cli.verbose,
cli.debug,
target,
force,
template_dir.as_deref(),
),
Commands::Completions { shell } => {
let mut cmd = <Cli as CommandFactory>::command();
clap_complete::generate(shell, &mut cmd, "skillpack", &mut std::io::stdout());
exit::INIT_OK
}
}) {
code
} else {
eprintln!("fatal: skillpack crashed (panic)");
std::process::exit(exit::INIT_FATAL)
};
std::process::exit(code);
}
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,
debug: bool,
non_interactive: bool,
auto: bool,
accept_warnings: bool,
license_override: Option<String>,
raw_targets: Vec<String>,
force: bool,
template_dir: Option<&Path>,
description: Option<String>,
triggers: Vec<String>,
author: Option<String>,
invocation: Option<String>,
import: Option<String>,
) -> i32 {
match run_init_inner(
root,
verbose,
debug,
non_interactive,
auto,
accept_warnings,
license_override,
raw_targets,
force,
template_dir,
description,
triggers,
author,
invocation,
import,
) {
Ok(c) => c,
Err(e) => {
eprintln!("fatal: {e:#}");
exit::INIT_FATAL
}
}
}
#[allow(clippy::too_many_arguments)]
fn run_init_inner(
root: &Path,
verbose: bool,
debug: bool,
non_interactive: bool,
auto: bool,
accept_warnings: bool,
license_override: Option<String>,
raw_targets: Vec<String>,
force: bool,
template_dir: Option<&Path>,
description: Option<String>,
triggers: Vec<String>,
author: Option<String>,
invocation: Option<String>,
import: Option<String>,
) -> Result<i32> {
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(", "));
}
if debug {
eprintln!(
"[debug] detected name={} language={} has_cli={}",
profile.name,
profile.language.as_str(),
profile.has_cli
);
}
let existing_cfg = Config::load(root)?;
let intent = if auto || non_interactive {
match &existing_cfg {
Some(cfg) => match cfg.to_intent() {
Some(i) => i,
None => bail!(
"skillpack.toml at {} is missing its [skill] table.\n\
To fix: re-run `skillpack init` interactively.",
Config::path(root).display()
),
},
None if auto => auto_intent(&profile, &triggers, import.as_deref())?,
None => bootstrap_intent(
&profile,
description.as_deref(),
&triggers,
author.as_deref(),
invocation.as_deref(),
import.as_deref(),
)?,
}
} else if let Some(cfg) = &existing_cfg {
if let Some(i) = cfg.to_intent() {
i
} else {
interview_run(&profile)?
}
} else {
interview_run(&profile)?
};
let mut intent = intent;
if let Some(ref lic) = license_override {
intent.license = Some(lic.clone());
}
let files = render_targets(&profile, &intent, &targets, template_dir)
.context("rendering distribution files")?;
let report = verify_rendered(&files, &profile, root, debug, intent.verify_stdin.clone())?;
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);
let (written, skipped) = write_files(root, &files, force)?;
let name = coerce_kebab(&profile.name);
Config::from_intent(&name, &intent).save(root)?;
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 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,
debug: bool,
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)),
debug,
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());
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,
debug: 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, debug, format, fix, min_score, template_dir);
}
match run_verify_inner(root, verbose, debug, 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,
debug: 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)),
};
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)),
debug,
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,
debug: 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, debug, 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;
print!("\x1b[2J\x1b[H");
let _ = run_verify_single(
root,
verbose,
debug,
format,
fix,
min_score,
template_dir,
);
}
}
}
Err(mpsc::RecvTimeoutError::Disconnected) => {
break;
}
}
}
eprintln!("\nstopped.");
exit::VERIFY_OK
}
fn run_verify_single(
root: &Path,
verbose: bool,
debug: bool,
format: verify::OutputFormat,
fix: bool,
min_score: Option<u8>,
template_dir: Option<&Path>,
) -> i32 {
match run_verify_inner(root, verbose, debug, format, fix, min_score, template_dir) {
Ok(c) => c,
Err(e) => {
eprintln!("error: {e:#}");
exit::VERIFY_FAIL
}
}
}
fn run_doctor(root: &Path, verbose: bool, debug: bool, format: crate::verify::OutputFormat) -> i32 {
match run_doctor_inner(root, verbose, debug, format) {
Ok(c) => c,
Err(e) => {
eprintln!("fatal: {e:#}");
exit::INIT_FATAL
}
}
}
fn run_doctor_inner(
root: &Path,
verbose: bool,
debug: 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, debug),
crate::verify::OutputFormat::Sarif => {
bail!("doctor does not support SARIF output; use `verify --format sarif`")
}
}
Ok(exit::VERIFY_OK)
}
fn render_doctor_human(profile: &types::ProjectProfile, verbose: bool, debug: bool) {
if debug {
eprintln!(
"[debug] detected name={} language={} has_cli={} diag_notes={}",
profile.name,
profile.language.as_str(),
profile.has_cli,
profile.diag.0.len()
);
}
if verbose {
print_profile(profile, false);
} else {
println!("skillpack doctor");
println!(" name: {}", profile.name);
println!(" language: {}", profile.language.as_str());
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 generated files per ecosystem");
println!(" (marketplace.json, plugin.json, SKILL.md frontmatter, .mdc, AGENTS.md");
println!(" presence, copilot-instructions.md)");
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)");
}
}
fn run_update(
root: &Path,
verbose: bool,
debug: bool,
raw_targets: Vec<String>,
force: bool,
template_dir: Option<&Path>,
) -> i32 {
match run_update_inner(root, verbose, debug, raw_targets, force, template_dir) {
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)
}
#[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() {
vec![Target::Claude]
} 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,
debug: bool,
raw_targets: Vec<String>,
force: bool,
template_dir: Option<&Path>,
) -> Result<i32> {
let (profile, skills, files) = render_from_config(root, &raw_targets, template_dir)?;
if verbose {
print_profile(&profile, false);
}
if debug {
eprintln!(
"[debug] detected name={} language={} has_cli={}",
profile.name,
profile.language.as_str(),
profile.has_cli
);
}
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)?;
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,
debug: bool,
raw_targets: Vec<String>,
force: bool,
template_dir: Option<&Path>,
) -> i32 {
match run_diff_inner(root, verbose, debug, &raw_targets, force, template_dir) {
Ok(code) => code,
Err(e) => {
eprintln!("fatal: {e:#}");
exit::INIT_FATAL
}
}
}
fn run_diff_inner(
root: &Path,
verbose: bool,
debug: bool,
raw_targets: &[String],
force: bool,
template_dir: Option<&Path>,
) -> Result<i32> {
let (profile, _skills, files) = render_from_config(root, raw_targets, template_dir)?;
if verbose {
print_profile(&profile, false);
}
if debug {
eprintln!(
"[debug] detected name={} language={} has_cli={}",
profile.name,
profile.language.as_str(),
profile.has_cli
);
}
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 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 {
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"))
}
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
)
}
#[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,
has_cli: true,
cli_command: Some(vec![bin.to_string_lossy().to_string(), "--help".into()]),
cli_help_output: Some("usage".into()),
cli_subcommand_help: 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,
has_cli: false,
cli_command: None,
cli_help_output: None,
cli_subcommand_help: 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);
}
}