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
//! clap argument parsing + subcommand dispatch. Per design §6.3, supports a
//! `--non-interactive` flag for CI, plus `--verbose` / `--debug` diagnostics.
use std::path::PathBuf;
use clap::{Parser, Subcommand, ValueEnum};
#[derive(Debug, Parser)]
#[command(
name = "skillpack",
bin_name = "skillpack",
version,
about = "Generate and verify the agent-distribution layer for any OSS project (Claude Code, Cursor, Codex, OpenCode, GitHub Copilot, AGENTS.md)."
)]
pub struct Cli {
#[command(subcommand)]
pub command: Commands,
/// Print what skillpack detected in the repo (introspection output).
#[arg(long, global = true)]
pub verbose: bool,
/// Print every subprocess call skillpack makes.
#[arg(long, global = true)]
pub debug: bool,
}
#[derive(Debug, Subcommand)]
pub enum Commands {
/// Scaffold the distribution layer (introspect → interview → generate).
Init {
/// Project root to operate on. Defaults to the current directory.
#[arg(long, value_name = "DIR", default_value = ".")]
root: PathBuf,
/// Skip interactive prompts. Intended for CI — never offers to keep
/// unverified output. Uses a committed `skillpack.toml` when present;
/// otherwise bootstraps the intent from `--description`/`--trigger`/
/// `--author` + `--invocation` or `--import` so the FIRST init can run
/// on a fresh checkout without a TTY.
#[arg(long)]
non_interactive: bool,
/// Accept the pre-commit verification and write files even when `verify`
/// flags warnings. Critical (`fail`) results still block the write.
/// Without this flag, any non-pass result prompts the user.
#[arg(long)]
accept_warnings: bool,
/// Zero-interaction init: derive the intent entirely from the repo —
/// description from the README, author from `git config`, license from
/// the LICENSE file (or `--license`), invocation from the detected
/// CLI — and write without any prompts. Pass `--trigger` to set the
/// when-to-use phrases (otherwise the description hint is used), and
/// `--import` for a pure library. Fails with an actionable message
/// when something essential can't be derived (e.g. no README hint).
#[arg(long)]
auto: bool,
/// One-sentence task description — `--non-interactive` bootstrap when
/// no `skillpack.toml` exists (ignored when a config is present).
#[arg(long, value_name = "TEXT")]
description: Option<String>,
/// Trigger phrase for `when_to_use`; repeatable (`--trigger a --trigger b`)
/// and comma/semicolon-separated values are split. For `--non-interactive`
/// bootstrap when no `skillpack.toml` exists (ignored when a config is
/// present).
#[arg(long, value_name = "PHRASE")]
trigger: Vec<String>,
/// Author name for plugin.json — `--non-interactive` bootstrap when no
/// `skillpack.toml` exists (ignored when a config is present). Optional;
/// omit and verify's `discovery.plugin.author` warns (advisory).
#[arg(long, value_name = "NAME")]
author: Option<String>,
/// Exact CLI invocation for CLI projects — `--non-interactive` bootstrap
/// when no `skillpack.toml` exists (ignored when a config is present).
/// Pass exactly one of `--invocation` / `--import`.
#[arg(long, value_name = "CMD")]
invocation: Option<String>,
/// Import pattern for library projects — `--non-interactive` bootstrap
/// when no `skillpack.toml` exists (ignored when a config is present).
/// Pass exactly one of `--invocation` / `--import`.
#[arg(long, value_name = "PATTERN")]
import: Option<String>,
/// Override the license SPDX id for this run (writes it to skillpack.toml).
#[arg(long, value_name = "SPDX")]
license: Option<String>,
/// Agent ecosystem(s) to generate distribution files for. Repeat to
/// emit multiple: `--target claude --target cursor`. Defaults to
/// `claude` only (backward compatible). Pass the special value
/// `all` to emit every supported target.
#[arg(long, num_args = 1.., value_name = "ECOSYSTEM")]
target: Vec<String>,
/// Overwrite an existing root-level `AGENTS.md` (the `--target agentsmd`
/// target writes to repo root, not a skillpack-owned directory). Without
/// `--force`, an existing `AGENTS.md` is skipped with a warning. Has no
/// effect on other targets — their paths are always skillpack-owned.
#[arg(long)]
force: bool,
/// Override one or more Tera templates from a directory of `.tera`
/// files. Missing templates fall back to embedded defaults — override
/// just the files you need. Filenames must match the templates/
/// directory (e.g. `SKILL.md.tera`, `plugin.json.tera`).
#[arg(long, value_name = "DIR")]
template_dir: Option<PathBuf>,
},
/// Check the distribution files against the agent schemas + CLI drift.
Verify {
#[arg(long, value_name = "DIR", default_value = ".")]
root: PathBuf,
/// Output format. `human` (default) prints a readable report; `json`
/// prints a machine-readable object with per-check ids for CI gating.
#[arg(long, value_enum, default_value_t = crate::verify::OutputFormat::Human)]
format: crate::verify::OutputFormat,
/// Apply mechanical fixes for detected drift (e.g. regenerate a stale
/// `.claude-plugin/plugin.json` whose `version` drifted from the
/// project manifest). Surgical: only the file the drift lives in is
/// rewritten — your hand-tailored `SKILL.md` / `marketplace.json`
/// stay intact. After fixes are applied, verify re-runs and prints
/// the post-fix report. Use `skillpack init` for wholesale regen.
#[arg(long)]
fix: bool,
/// Minimum discoverability score (0–100) the verify run must reach
/// to exit zero. Independent of `--fix`: if `--fix` is also passed,
/// the gate runs against the post-fix report. Omitted by default —
/// projects opt in to harder enforcement (a low score is otherwise
/// surfaced but never fails the run). Useful as a CI gate: pair with
/// `--format json` for a structured exit.
#[arg(long, value_name = "N", value_parser = clap::value_parser!(u8).range(0..=100))]
min_score: Option<u8>,
/// Watch for file changes and re-run verify on each change (debounced).
/// Useful during iterative SKILL.md / skillpack.toml edits — get
/// instant feedback without manually re-running verify each time.
/// Ctrl-C stops the watcher (terminates the process). Only valid
/// mode prints a new report per cycle; JSON output isn't meaningful
/// for a streaming watcher).
#[arg(long)]
watch: bool,
/// Override embedded Tera templates during `--fix` re-rendering. Same
/// semantics as `init --template-dir`. Without this flag, `--fix`
/// always uses embedded templates — if you initialized with custom
/// templates, pass the same `--template-dir` here or `--fix` will
/// produce output that drifts from your custom-rendered files.
#[arg(long, value_name = "DIR")]
template_dir: Option<PathBuf>,
},
/// Diagnose why introspection chose `has_cli` / language as it did.
/// Prints the detected profile + a chronological trace of the decision
/// branches that fired (which candidate was tried, why it was rejected,
/// what would make it succeed). Read-only — never writes files.
Doctor {
/// Project root to operate on. Defaults to the current directory.
#[arg(long, value_name = "DIR", default_value = ".")]
root: PathBuf,
/// Output format. `human` (default) prints the readable diagnosis;
/// `json` emits the serialized `ProjectProfile` (including the
/// decision trace) for CI/scripts. Mirrors `verify --format`.
#[arg(long, value_enum, default_value_t = crate::verify::OutputFormat::Human)]
format: crate::verify::OutputFormat,
},
/// Incrementally regenerate distribution files from an existing
/// `skillpack.toml` (no interview, no pre-commit verify gate — it's a
/// refresh, not a scaffold). Re-introspects the repo, re-renders every
/// target, and writes ONLY files whose content changed. For
/// frontmatter-bearing files (`SKILL.md`, cursor `.mdc`, opencode
/// `.md`) the body prose is preserved by splicing the fresh
/// frontmatter onto the committed body — same surgery the auto-fix
/// applies for `SKILL.md`. Note: the frontmatter block is regenerated
/// wholesale, so any hand-tailored frontmatter fields skillpack
/// doesn't manage (e.g. cursor `globs`, opencode `mode`) are
/// overwritten. To preserve those, edit the template or keep them in
/// `skillpack.toml`-driven fields.
Update {
/// Project root to operate on. Defaults to the current directory.
#[arg(long, value_name = "DIR", default_value = ".")]
root: PathBuf,
/// Agent ecosystem(s) to regenerate for. Defaults to `claude`
/// only. Pass `all` to refresh every supported target. Repeats.
#[arg(long, num_args = 1.., value_name = "ECOSYSTEM")]
target: Vec<String>,
/// Overwrite an existing root-level `AGENTS.md` (same collision guard
/// as `init --target agentsmd`). Without `--force`, an existing
/// `AGENTS.md` is skipped with a warning. No effect on other targets.
#[arg(long)]
force: bool,
/// Override one or more Tera templates from a directory of `.tera`
/// files. Missing templates fall back to embedded defaults. Same
/// semantics as `init --template-dir`.
#[arg(long, value_name = "DIR")]
template_dir: Option<PathBuf>,
},
/// Check whether distribution files are stale: re-render every target
/// in memory, compare against on-disk content, report files that
/// differ, and exit 1 if any do (0 if all clean). A CI gate for
/// stale distribution files — run after `init`/`update` to verify
/// committed artifacts match what skillpack would regenerate. Uses
/// the same candidate computation as `update` (frontmatter splice
/// for body files, wholesale overwrite for fully-generated files),
/// so `diff` and `update` agree on what counts as drift.
Diff {
/// Project root to operate on. Defaults to the current directory.
#[arg(long, value_name = "DIR", default_value = ".")]
root: PathBuf,
/// Agent ecosystem(s) to check. Defaults to `claude` only.
/// Pass `all` to check every target. Repeats.
#[arg(long, num_args = 1.., value_name = "ECOSYSTEM")]
target: Vec<String>,
/// Check `AGENTS.md` too (same collision guard as `update` —
/// skipped without `--force` if it exists). No effect on other
/// targets.
#[arg(long)]
force: bool,
/// Override one or more Tera templates from a directory of `.tera`
/// files. Missing templates fall back to embedded defaults. Same
/// semantics as `init --template-dir`. Use when `diff` is checking
/// a pack generated with custom templates.
#[arg(long, value_name = "DIR")]
template_dir: Option<PathBuf>,
},
}
/// Which agent ecosystem to generate distribution files for.
/// Per design §10 (Phase 4: multi-ecosystem delivery).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, clap::ValueEnum, Default)]
pub enum Target {
/// Claude Code: `.claude-plugin/` + `skills/<name>/SKILL.md`.
#[default]
Claude,
/// Cursor: `.cursor/rules/<name>.mdc` rule file.
Cursor,
Codex,
/// OpenCode: `.opencode/agents/<name>.md` agent definition file.
/// Per opencode.ai/docs/agents — frontmatter (`description` required,
/// `mode`/`temperature`/`permissions` optional); no `.claude-plugin/`.
#[clap(name = "opencode")]
OpenCode,
/// GitHub Copilot: `.github/copilot-instructions.md` custom instructions.
/// Per docs.github.com/copilot — plain markdown, no frontmatter.
Copilot,
/// AGENTS.md: a root-level `AGENTS.md` instructions file read natively by
/// 60k+ projects' agents (Codex, Cursor, Windsurf, Copilot, Aider, Zed,
/// Warp, JetBrains Junie, Freebuff, etc.). Per agents.md (Linux Foundation
/// stewarded) — plain markdown, no frontmatter, no required fields.
#[clap(name = "agentsmd")]
AgentsMd,
/// CLAUDE.md: root-level instructions file read by Claude Code, Cline,
/// Roo Code, and the Claude Code ecosystem of forks. Plain markdown.
#[clap(name = "claude-md")]
ClaudeMd,
/// GEMINI.md: root-level instructions file read natively by the Gemini
/// CLI. Per google-gemini.github.io/gemini-cli/docs/cli/gemini-md.html —
/// plain markdown, no frontmatter.
Gemini,
/// Windsurf (Cascade): `.windsurf/rules/<name>.md` rule files with the
/// same frontmatter shape as Cursor rules (`description` required,
/// `globs`/`alwaysApply` optional). Per the Windsurf docs + the
/// cursor↔windsurf converter projects.
Windsurf,
/// Aider: a root-level `CONVENTIONS.md` the aider coding agent reads for
/// repo conventions. Plain markdown, no frontmatter.
Aider,
}
/// Expand a list of targets, resolving the string `"all"` into every concrete
/// target. Called from `run_init_inner` after the init subcommand parses
/// `--target` values; the hidden `all` value clears here so dispatch sites
/// (`generate::run`, verify, fix) never see a synthetic variant.
pub fn resolve_targets(raw: &[String]) -> anyhow::Result<Vec<Target>> {
let mut out = Vec::with_capacity(raw.len());
for r in raw {
if r == "all" {
// Canonical order — `Target` declaration order minus the sentinel.
out.extend([
Target::Claude,
Target::Cursor,
Target::Codex,
Target::OpenCode,
Target::Copilot,
Target::AgentsMd,
Target::ClaudeMd,
Target::Gemini,
Target::Windsurf,
Target::Aider,
]);
} else if r == "freebuff" || r == "agents.md" || r == "agents-md" {
out.push(Target::AgentsMd);
} else {
out.push(Target::from_str(r, true).map_err(|s| {
anyhow::anyhow!(
"invalid --target `{s}`; expected claude|cursor|codex|opencode|copilot|agentsmd|claude-md|gemini|windsurf|aider|freebuff|all"
)
})?);
}
}
// Dedup preserving canonical order — `--target all --target claude`
// must not emit Claude twice (double-writes files).
let mut seen = Vec::new();
for t in out {
if !seen.contains(&t) {
seen.push(t);
}
}
Ok(seen)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn resolve_targets_handles_freebuff_and_aliases() {
let targets = resolve_targets(&["freebuff".to_string(), "agents.md".to_string()]).unwrap();
assert_eq!(targets, vec![Target::AgentsMd]);
let all = resolve_targets(&["all".to_string()]).unwrap();
assert_eq!(all.len(), 10);
assert!(all.contains(&Target::AgentsMd));
}
}