All notable changes to this project will be documented in this file.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
skillctl remove— remove skills from the current project. Lists every removable skill (installed via skillctl, created locally, or an orphaned.skills.tomlentry whose folder is already gone), with each kind distinguished in the selection list, and lets you pick by interactive multi-select, by name (--skill <name>, repeatable), or all at once (--all). Deletes the selected skill folders and drops their.skills.tomlentries;.skills.tomlis only rewritten when a tracked entry actually changes. The command is project-only — it never touches the library or git.
- The removal path refuses to follow a symlink: the destination's type is re-checked immediately before deletion, so a folder swapped for a symlink cannot redirect the recursive delete outside the project (closes a TOCTOU window surfaced by the pre-release audit). A symlinked destination is treated as "no folder on disk" — only its manifest entry can be dropped, never deleted through.
--skill <name>fails closed: an unknown name errors, and a name shared by two skills errors as ambiguous rather than silently deleting the wrong folder. The project root,.git, and.skills.tomlcan never be selected for deletion.
skillctl remove was reviewed before release with the project's standard multi-agent security audit pass (FS-safety / untrusted-input / logic dimensions). 5 new unit tests; cargo test: 158 pass; clippy clean; cargo audit clean.
Close Phase 9.1 — five of the eight LOW findings that were explicitly deferred at v0.1.6. The remaining three (L11 cache-slug uniqueness, L14 fork destination prompt, plus the not-yet-numbered "deferred-with-reason" items from §10 of the internal audit) are gated on broader UX or migration decisions that warrant their own designs.
- APFS case-insensitive collision warning (L6). Two skills named
Fooandfooare distinct under skillctl's identifier-class validation (case-significant) but collapse to the same path on case-insensitive filesystems (APFS-CI on macOS external drives, HFS+, NTFS), so a subsequentaddwould silently clobber one with the other.skill::discovernow groups skills by their lowercased name and surfaces a warning per collision group. Doesn't reject — case-insensitivity is host-dependent and operators on case-sensitive ext4/APFS-CS are fine — but lets the operator notice before something disappears. - Homograph / mixed-script name warning (L3). New
unicode-scriptdependency (~20 KB).skill::discoverchecks each accepted skill name for characters spanning two or more distinct Unicode scripts (ignoringCommon/Inherited/Unknown— digits, punctuation, emoji are exempt). A name likeclаude(where theаis U+0430 Cyrillic, not U+0061 Latin) raises a warning so the operator can spot a homograph attack from a malicious library that publishes a skill visually indistinguishable from a legitimate one. - NFC normalisation of paths in dedup (L5). New
unicode-normalizationdependency (~100 KB).path_safety::normalize_lexicalnow Unicode-NFC-normalises every UTF-8 path component before returning. macOS HFS+ stores filenames in NFD (decomposed:é=e+ combining acute) while Linux stores NFC (one codepoint); without this, the lexical dedup indetectand thesafe_joincomparisons treat the same logical filename as two distinct paths when the project crosses platforms. Non-UTF-8 components pass through unchanged. skill::discovernow returns warnings instead ofeprintln!(infrastructure fix). The oversize-SKILL.md warning added in v0.1.6 usedeprintln!, which bypasses--jsongating.discovernow returns aDiscoverOutput { skills, warnings }; callers inadd/list/detectroute each warning throughui::log_warning, which silently no-ops in--jsonmode. Closes a latent v0.1.6 footgun and makes L3 + L6 warnings JSON-safe at the same time.actions/attest-build-provenance@v3on every release (L10). New.github/workflows/attest.ymltriggers on the GitHub Releasepublishedevent, downloads every binary asset, and generates a SLSA build-provenance attestation signed by GitHub Actions and recorded in Sigstore's transparency log. Users can verify a binary they downloaded withgh attestation verify <file> --repo umanio-agency/skillctl— a mismatch or missing attestation means the artifact wasn't produced by this repo's release workflow. Standalone workflow (not threaded into cargo-dist's generatedrelease.yml) so it survivescargo dist initregenerations.- Dependency policy documented (L9).
CONTRIBUTING.mdgains a "Dependency policy" section spelling out the caret-semantics convention, theCargo.lock-as-pinning trust path,cargo auditas a release gate, the no-auto-update transitive policy, and the response plan if a dep ships a semver-incorrect break.
12 new unit tests (4 case-collision/homograph in skill::tests, 5 mixed-script unit cases, 1 NFC equality in path_safety::tests, 2 collision-suppression). cargo test: 153 pass; clippy clean; cargo audit clean.
Deferred to a future release (with reasons):
- L11 (cache-slug uniqueness via hash suffix). Pre-v1 with one-library-at-a-time the slug collision is theoretical; revisit if/when multi-library support lands or two upstream
<owner>namespaces produce a real conflict on the same dev machine. - L14 (prompt operator on fork destination instead of inheriting source parent). UX question worth a dedicated
forkflow review rather than a one-line nudge.
Re-ship v0.1.6's content with the release pipeline unblocked. v0.1.6 was tagged but its cargo-dist plan job failed because the manual submodules: recursive → false edit (Phase 8.4 L12) in .github/workflows/release.yml made dist host --steps=create refuse to run ("out of date contents and needs to be regenerated"). v0.1.6 is on crates.io but has no GitHub Release artifacts and no Homebrew tap update; v0.1.7 fixes that.
allow-dirty = ["ci"]indist-workspace.toml. cargo-dist exposes an explicit allow-list to tolerate manual edits in the workflow it generates. Addingcito it preserves the L12 defense-in-depth (no recursive submodule pull from the release runner) while lettingdist hostproceed.
No code or test changes from v0.1.6.
Close the audit's Phase 8.4 "low-impact polish" batch: 9 of the 17 remaining LOW findings, plus a new "Trust model" section in SECURITY.md that documents the boundaries underlying all the v0.1.2 → v0.1.6 hardening work. The 8 deferred LOW items either need a new runtime dependency (homograph detection, Unicode normalization), require a release-workflow change (SLSA provenance), or interact with pre-v1 design questions (slug-collision uniqueness, fork-destination UX).
- Force HTTPS in library URLs (L1).
skillctl init http://github.com/owner/repowas previously accepted and silently downgraded to cleartext for the initial clone. A network attacker on the operator's link could MITM the response and serve modified content. Nowslug_for_urlrejectshttp://with a clear "use HTTPS instead" message. SSH (git@host:,ssh://) is unchanged. - UTF-8 BOM stripped before frontmatter parse (L2). Some editors (Notepad on Windows, occasionally VS Code) prepend a
\u{feff}BOM to UTF-8 files. The frontmatter parser saw\u{feff}---instead of---and treated the whole SKILL.md as "no frontmatter." Now the parser strips a leading BOM before checking the opening fence. - Balanced quotes enforced in
clean_value(L4).clean_valuewas usingtrim_matches(|c| c == '"' || c == '\'')which silently stripped mismatched quotes —"foo'becamefoo. Mismatched quotes now pass through unchanged so the operator sees the malformed value and can fix it. git pushfailure rolls back the just-created commit (L7). Whengit commitsucceeds butgit pushfails (network blip, auth expiry), the local commit sat orphaned in the cache, ahead of upstream. The nextfetch_and_fast_forwardwould silentlyreset --hard @{upstream}it away — or, post-M10, refuse to refresh because the working tree happened to get dirty in between. Newgit::reset_hard_to_parenthelper, wired into bothpushanddetect, restores the cache to a clean state on push failure.- SKILL.md read capped at 1 MiB (L8).
std::fs::read_to_stringfor SKILL.md was unbounded — a 5 GiB file would be slurped silently into RAM duringdiscover. Newread_skill_md_boundedhelper refuses to load more than 1 MiB and surfaces a per-skill warning instead. - Submodule recursion disabled (L12 + L13).
git clonenow passes--no-recurse-submodulesexplicitly so a malicious library with a.gitmodulespointing at attacker-controlled repos cannot pull-through duringskillctl init. The cargo-dist release workflow'sactions/checkoutsteps switched fromsubmodules: recursivetosubmodules: false(we have no submodules; this is defense-in-depth that survives the nextcargo dist initregeneration). Skills do not use submodules; if a legitimate use case appears, it can be opt-in via an explicit flag later. addcontinues on per-skill failure (L15). The apply loop inaddused?forfs::remove_dir_all,copy_dir_all, and thesource_pathstrip-prefix — a single per-skill failure aborted the whole batch, and.skills.tomlwas only saved at the end, so partial successes were untracked. Now each skill is wrapped in an IIFE that logs a warning + continues on failure, andproject_config::savealways runs (capturing partial state). Same pattern aspull(v0.1.4) andpush(v0.1.5).$HOMErendered as~/in displayed paths (L17). Absolute paths in error messages and JSON output (library cache not found at /Users/<operator>/Library/Caches/...) leaked the operator's Unix username into CI logs and agent-mode JSON. Newfs_util::display_path(&path)swaps a leading$HOMEwith~/and is applied at every "library cache not found" / cache-path-display site.list'seprintln!routed throughui::log_warning(L18). A single bareeprintln!("warning: could not refresh library cache (...)")inlistbypassed the--jsongating, polluting JSON consumers' stderr with non-JSON text. Now routed through the sharedui::log_warninghelper, which is JSON-aware.- SECURITY.md trust-model section. New section that explicitly names the three trust boundaries — Trusted (operator's machine, interactive flags, the binary itself), Semi-trusted (library URL and cache), Adversarial (frontmatter,
.skills.toml, git working tree, non-interactive flag values) — plus an explicit Out-of-scope list (compromised git binary, side-channel attacks). External auditors and contributors can now know where to look without reverse-engineering the code.
11 new unit tests (1 HTTPS-required, 1 BOM strip, 4 balanced-quote, 2 SKILL.md size cap, 3 $HOME rendering). cargo test: 147 pass; clippy clean; cargo audit clean.
Deferred to a future release (with reasons, since v0.1.6 explicitly chose to keep the scope minimal):
- L3 (homograph warning, e.g. Cyrillic
аvs Latinain skill names). Needs aunicode-confusables(or similar) dep; warrants its own decision before adding a runtime crate. - L5 (NFC normalisation of paths/names). Needs
unicode-normalization; same reasoning. - L6 (case-insensitive FS collision warning on APFS-CI). No new dep but ~30 lines of runtime logic; deferred to a UX-focused release.
- L9 (Cargo.toml caret-semantics doc). Documentation-only; will land alongside a broader contributor-docs pass.
- L10 (SLSA provenance / cosign attestations on release binaries). Release-workflow change; deserves its own PR + dry-run on a tag.
- L11 (cache-slug collision via hash suffix). Pre-v1 with one-library-at-a-time, slug collisions are theoretical only; revisit if multi-library support lands.
- L14 (prompt operator on fork destination instead of inheriting the source's parent). UX question best decided alongside a broader
forkflow review.
Close the comprehensive audit's Phase 8.3: 13 MEDIUM findings plus the deferred push-side half of H8. No item here is single-shot exploitable, but each closes a credibility-eroding leak (credentials in logs), DoS vector (unbounded parsers, recursive walkers), or footgun (silently-discarded state, hook execution via shared cache).
- Credentials stripped from stored
library.url(M1).skillctl init https://x-access-token:<PAT>@github.com/...would store the full URL — token and all — inconfig.toml, then echo it back in JSON output, error chains, and CI logs.initnow sanitises the URL (stripsuser[:password]@fromhttps:///http://authority sections) before persisting; the one-timegit clonestill uses the original URL for authentication, but the token never lands on disk or in any later command's output. SSH forms (git@host:...,ssh://git@host/...) are unchanged. - Git stderr scrubbed in every error chain (M3). Each
git-shell-out site usedString::from_utf8_lossy(&stderr).trim()— which would faithfully echo credential-helper banners, proxy URLs containing PATs, ANSI control sequences, and stack traces past the first line. The newgit::scrub_stderrhelper takes the first non-empty line, strips C0/C1/DEL/ESC control bytes, and redacts known token prefixes (ghp_*,gho_*,ghs_*,ghu_*,github_pat_*,x-access-token:*) to<prefix>***. Applied uniformly across every git invocation. core.hooksPathneutralised on every git call (M12). The library cache is a git repo whose.git/configis reachable from inside skill content. A malicious library that dropped a script at the operator's globally-configuredcore.hooksPathwould have it executed by anygit commitin the cache. EveryCommand::new("git")now goes through agit_cmd()helper that prepends-c core.hooksPath=/dev/null, so hook execution is impossible regardless of global or in-cache git config.git status --porcelaincheck beforereset --hard @{upstream}(M10).fetch_and_fast_forwardused to unconditionallygit reset --hard @{upstream}, silently destroying any uncommitted state left over from a previous skillctl run that crashed mid-commit (e.g.replace_folder_contentssucceeded butgit pushfailed). Now refuses to refresh when the cache reports any porcelain output, surfacing a clear "uncommitted changes — inspect withgit -C <cache> status" message so the operator can investigate before any destruction happens.- Frontmatter parser bounded at 200 lines (M4). A SKILL.md with an opening
---but no closing fence would force the parser to scan the entire (potentially multi-GiB) body — a cheap DoS reachable on everyskill::discovercall. Capped toMAX_FRONTMATTER_LINES = 200; unterminated frontmatter is now treated as "no frontmatter" (the skill is dropped from discovery rather than half-parsed). validate_fork_namerejects control characters and caps length (M5). The previous fork-name validator only rejected empty /./../ path separators — a name likefoo\0barwould panic insideCString::newwhen later passed toCommand. Now rejects any control char (NUL, ESC, ANSI, DEL, newline, CR, tab) and caps at 64 bytes. Consolidated assanitize::validate_fork_name(was duplicated betweenpush.rsandpull.rs)..skills.tomlrejects unknown fields, duplicates, and overflow (M6). Added#[serde(deny_unknown_fields)]onProjectConfigandInstalledSkill, so a malicious PR can no longer smuggle unknown keys (which might later be load-bearing for an unreleased feature) into the deserialiser. Duplicatenameordestinationentries are rejected at load — silent dedup would make every command ambiguous about which entry wins. Capped at 256 entries to bound the diff-classifier work.copy_dir_allis iterative and masks mode bits (M7 + M8). Converted from recursion to an explicitVec<(PathBuf, PathBuf)>work stack, so an adversarial skill with 10k-deep nesting can no longer blow Rust's default 8 MiB thread stack. On Unix, copied file modes are now masked to0o644 | (src_mode & 0o100)— only the user-execute bit propagates; setuid, setgid, sticky, group-write, world-write, group-execute and world-execute are stripped. A library that drop-ins a setuid binary cannot weaponise the round-trip into elevated privileges on the destination.detectdedup unions canonical AND lexical comparison (M9). The "already installed" set was built fromfs::canonicalizeonly — silently dropping entries whose destination had been deleted from disk. An attacker who removed.claude/skills/foo/and dropped a replacement at the same path would have it re-detected as a new skill on the nextdetect. Now compares by canonical path (when both ends exist) AND lexical path (covers the deleted-destination case via the newpath_safety::normalize_lexicalhelper).detectwalker respects.gitignoreand skips vendor dirs by default (M11). A malicious npm package shipping its ownSKILL.mdundernode_modules/...could be picked up byskillctl detect --allrunning in CI and uploaded to the library.skill::discovernow takes aninclude_vendoredparameter; the default (false) leans onignore::WalkBuilder's.gitignore/.ignorerespect plus a hard-skip onnode_modules/target. New CLI flagskillctl detect --include-vendoredfor the explicit opt-in.- Homebrew tap typo-squat documented (M13). Both README and SECURITY.md now prominently call out the canonical fully-qualified install (
brew install umanio-agency/homebrew-tap/skillctl) and explain that anyone can ship askillctl.rbformula under their ownhomebrew-taprepo. Pinning the owner avoids the typo-squat risk. push --allcontinues on per-skill failure (H8 push-side). The pre-v0.1.5 apply loop used?inside the per-skill body, so one failing skill aborted the entire batch and orphaned the cache's working tree for the successful early skills (commit + push never happened, cache stayed dirty until the nextfetch_and_fast_forwardreset it). Now each apply is wrapped in an IIFE: on per-skill failure, the change is rolled back withgit checkout HEAD -- <library_relative>, a warning is logged, and the loop continues. If all skills fail, the command exits cleanly with "nothing pushed". This closes the half of H8 deferred from v0.1.4.
13 new unit tests added (3 path_safety lexical normalisation, 3 sanitize fork-name hardening, 4 .skills.toml deny/dedup/cap, 3 discover gitignore/node_modules/include-vendored, 2 frontmatter bound, 7 git stderr scrub, 3 fs_util mode-mask + deep nesting). cargo test: 136 pass; clippy clean; cargo audit clean.
Close the seven HIGH atomicity / concurrency / DoS findings from the comprehensive audit's Phase 8.2. The headline items are not exploitable by an external attacker on a single-user box, but each represents a real data-loss or denial-of-service scenario under realistic conditions (Ctrl-C mid-operation, two concurrent skillctl runs, a malicious .skills.toml PR with an orphan source_sha).
- Atomic
replace_folder_contents. The copy primitive used byadd/pull/pushnow stages new content into a uniquely-named sibling of the destination, moves the old destination aside into a backup sibling, then atomically renames the staging dir over the destination. At any crash point, either the old or the new content is in place — never a half-written tree. Rolls the backup back if the final rename fails. Closes three HIGH findings (H5, H6, H7) with one primitive. - Atomic
.skills.tomlsave.project_config::savewrites to a sibling temp file thenfs::renames it over the target — a crash mid-write only leaves the temp file on disk, never a truncated.skills.toml. Used by every command that mutates the tracked-skills index. - Process-level locking on the library cache and
.skills.toml. Newsrc/lock.rsprovidesacquire_exclusive(dir, what)backed byfs4's cross-platformtry_lock_exclusive. Every command that touches the library cache (list/add/push/pull/detect) holds an exclusive lock on<cache>/.skillctl.lockfor the fullgit fetch → mutate → pushcritical section; every command that mutates.skills.tomladditionally locks<cwd>/.skillctl.lock. A second concurrentskillctlinvocation fails fast withAppError::Conflict("another skillctl is running") rather than racing on.git/index.lock. Closes H3 + H4. pushsaves.skills.tomlbefore any local rename. Post-git push, the apply loop is now split into three phases: in-memory mutations, atomic save, then local renames (now non-fatal). A Ctrl-C between push and save used to leave.skills.tomlreferencing the oldsource_sha, which the next run would reclassify asLibraryAheadand offer to wipe local edits silently. The new ordering reduces the failure window to "disk full or EACCES at save time"; local rename failures degrade to a warning ("library updated but local rename failed — rename the local folder by hand") rather than dropping the SHA mapping. Closes H6.pullfork-locally is now atomic. The pre-v0.1.4 sequence (fs::renameoriginal aside, thencopy_dir_alllibrary version) could lose the original on a mid-copy failure (rename succeeded, copy failed, original gone, library version not yet present). Rewritten with the same tempdir-swap pattern asreplace_folder_contentsvia the newfs_util::swap_with_bakhelper. Closes H7.- Orphan
source_shais per-skill, not a batch DoS. A malicious.skills.tomlentry withsource_sha = "0000…"(a valid-hex but unknown commit) used to makeclassifyreturnErrat the first such entry and abort the entire batch — weaponisable to DoS every other skill in the samepull --all/push --allrun.git::ls_tree_blobsnow returnsResult<Option<HashMap>>, withOk(None)for an unknown refspec; the classifier surfaces this as a newSkillStatus::SourceShaOrphanedvariant, andpush/pulllog a per-skill warning ("source_sha doesn't resolve in the library; skipping") while continuing with the rest. Closes H9. pull --allcontinues on per-skill failure. The apply loop now wraps each skill in an IIFE that logs a warning on error and continues..skills.tomlis saved at the end regardless, so successful per-skillsource_shaupdates persist even when a sibling apply fails. Closes H8 (pull side). The push-side equivalent (one-commit-per-run cleanup-on-failure) is deferred to a follow-up release.
3 new unit tests cover the atomic-replace contract (failure preserves dst, failure cleans up staging, swap_with_bak round-trip); 2 new tests cover the lock primitive. cargo test: 100 pass; clippy clean; cargo audit clean. New runtime dependency: fs4 = "0.13.1" (advisory file locks).
Fix five additional vulnerabilities surfaced by a comprehensive multi-angle audit (six parallel sub-agents, each covering one threat-model dimension: command injection, input parsing, FS safety 2nd pass + concurrency, output safety + agent-mode JSON, supply chain, logic / state-machine). These were independent of the firebaguette audit that motivated v0.1.2; together they close every CRITICAL and offensive HIGH finding identified by the audit.
source_shaargument injection ingit ls-tree(CRITICAL, four agents converged on this).InstalledSkill.source_shadeserialized from.skills.toml(committed, PR-mergeable) flowed unvalidated intogit ls-tree -r -z <refspec> -- <path>. Because the refspec sits before--, an attacker who slipped a malicious.skills.tomlinto a PR could setsource_sha = "--name-only"/--abbrev=0/--output=…and corrupt the diff classifier — which drivespull/pushdestructive decisions — or forge divergence state to trickpush --on-divergence overwriteinto clobbering the wrong content.InstalledSkill::validatenow rejects anysource_shathat isn't 40–64 hex characters (sha1 / sha256).- FIFO / device / socket DoS in
copy_dir_all(CRITICAL). The file-type branch only checkedis_dir()/is_symlink(); a FIFO inside a skill folder fell through tofs::copy, which blocks indefinitely waiting for a writer. A character device like/dev/zerowould read until OOM. Nowcopy_dir_allonly allows regular files and directories; anything else (FIFO, socket, device) is rejected withAppError::Config. add --destarbitrary-directory wipe in agent mode (HIGH).--destaccepted absolute paths and..traversal without validation, soskillctl add --dest /Users/victim/.ssh --on-conflict overwrite --skill <maliciously-named>would wipe arbitrary directories in one shot from any agent-driven invocation. Now--destrejects..unconditionally, and rejects absolute paths when running in non-interactive /--jsonmode (where the operator may be an LLM running on attacker-supplied input). Interactive use is unchanged.- Commit-message trailer forgery via skill names (HIGH). Skill names were spliced verbatim into
git commit -m "update skill: <name>"and into thecommit.messagefield of--jsonoutput. A library skill with a\nin its name (e.g.foo\nCo-Authored-By: evil@x) produced a forged trailer that downstream tooling (Linear, GitHub commit-bot, release-notes scrapers) would treat as real authorship metadata. The newsanitizemodule strict-validates everyname/tag(identifier-class: no control bytes, no newlines, no ESC) and lenient-validatesdescription/--message(allows\n/\t, rejects\r/ DEL / C0+C1 controls). Skills with poisoned names are dropped silently fromdiscover(a poisoned name can't be safely displayed either); poisoned tags or descriptions are stripped from otherwise-valid skills. - Hardlink exfiltration via the round-trip (HIGH).
fs::symlink_metadatareports a regular file for hardlinks (shared inode), andfs::copyreads the target content. An untrusted agent writing<project>/my-skill/dataas a hardlink to~/.ssh/id_rsawould have shipped the SSH key content to the library on the nextskillctl pushordetect.copy_dir_allnow checksnlink() > 1on regular files (Unix) and refuses to copy hardlinked content with the same fail-closed philosophy as symlinks.
Audit methodology and the full remaining backlog (10 MEDIUM + 18 LOW spread across atomicity, concurrency, output hardening, supply chain documentation) are tracked privately and will be addressed in 0.1.4 / 0.1.5. 23 new unit + integration tests cover each rejection class; cargo test: 95 pass; clippy clean; cargo audit clean.
Fix four path-safety vulnerabilities that, in combination, allowed a malicious skills library or a crafted .skills.toml (e.g. mergeable via PR) to exfiltrate arbitrary files through the round-trip (read on skillctl add, leak on skillctl push) and to delete arbitrary directories outside the project or library root on skillctl pull / push / detect. Reported privately on 2026-05-19 by firebaguette via the Umanio Discord; all four issues are addressed in this release.
- Symlink follow in
fs_util::copy_dir_all. A symlink inside a skill folder (e.g.niania → /home/user/.aws/credentials) bypassedentry.file_type().is_dir(), fell into the file branch, and was dereferenced byfs::copy— copying the symlink target into the project. A subsequentskillctl pushwould have published the secret to the (possibly public) library. Symlinks are now hard-rejected bycopy_dir_allat both the top-level source and any descendant entry, andreplace_folder_contentsrefuses a symlinked destination soremove_dir_allcannot be tricked. - Path traversal via
destinationandsource_pathin.skills.toml. Both fields were deserialized asPathBufwith zero validation. BecausePath::joinlets an absolute right-hand side replace the base, a.skills.tomlentry likedestination = "/home/seb/.ssh"madecwd.join(...)resolve outside the project andreplace_folder_contents→remove_dir_allwipe arbitrary directories...traversal was equally unguarded. NewInstalledSkill::validateruns atproject_config::loadtime and rejects absolute paths,.., and Windows-prefix components for both fields; the same check is wired (defense-in-depth) at every destructive call site inpush.rs/pull.rsvia the newpath_safety::safe_joinhelper. detect --targetaccepted..even though it rejected absolute paths. Validation incommands::detect::resolve_targetnow goes through the samevalidate_relative_subpathhelper, rejecting any non-Normal/CurDircomponent. The interactive "custom path" prompt was tightened to match.- Fork-name validation accepted
.and..literally.validate_fork_namein bothpush.rsandpull.rsonly rejected/and\, so a fork named..would have produced aPath::joinresolving to the parent directory, thenfs::renamecould have clobbered it..and..are now explicit rejections.
Threat-model note: the fix is purely lexical (component-level) plus an explicit symlink check at copy time. No filesystem canonicalize calls were added, avoiding TOCTOU windows and keeping the validation pure-functional (AppError::Config, exit code 2). 34 new unit tests cover each rejection class and each attack scenario end-to-end.
- README and crate description reframed around "agent skills" terminology to reflect the multi-tool nature of the
SKILL.mdconvention (Claude Code, Codex, Cursor, OpenCode, and others in the open agent skills ecosystem) — no behavior change.
- Published on crates.io:
cargo install skillctlnow works. - Pre-built binaries on GitHub Releases for macOS (x86_64, aarch64), Linux (x86_64, aarch64), and Windows (x86_64), built via
cargo-dist. - Homebrew tap at
umanio-agency/homebrew-tap:brew install umanio-agency/homebrew-tap/skillctl. - Shell + PowerShell
curl | sh-style installers wired into the release workflow.
- Crate renamed from
skills-clitoskillctlto publish on crates.io (theskills-clicrate name was already taken by an unrelated package). - GitHub repository renamed from
umanio-agency/skills-clitoumanio-agency/skillctl. GitHub redirects from the old URL still work for inbound links. - Companion skill folders moved:
.claude/skills/skills-cli-{project,usage}/→.claude/skills/skillctl-{project,usage}/. - Config and cache paths (
dev.umanio-agency.skills-cli,~/.config/skills-cli/,~/.cache/skills-cli/) intentionally kept to avoid breaking existing local state for no user-facing gain.
init— clone a GitHub-hosted skills library into a per-user cache.list— print every skill in the library with name, description, and tags.add— multi-select install with live-filter prompt; recordssource_shain.skills.tomlto enable round-trip diffing.push— diff installed skills against the library (git-blob-based), with fork-as-new and overwrite/skip on divergence; one commit per run.pull— refresh installed skills from the library; fork-locally on divergence preserves your edits under a new name.detect— find new localSKILL.mdfiles not in.skills.tomland contribute them to the library in a single commit.- Tag filtering (
--tag,--all-tags) on every multi-skill flow. Tags live inSKILL.mdfrontmatter (inline or block YAML). - Non-interactive (agent) mode: auto-detected via
IsTerminal, forceable via--no-interaction. Every interactive decision has a flag-driven equivalent. --jsonoutput mode with stable per-command schemas (init / list / add / push / pull / detect).- Granular exit codes:
0success,1generic,2config,3conflict,4git. - Live-filter multi-select prompt: type to narrow, ↑/↓/space/enter, Esc to cancel.
- Companion skills under
.claude/skills/:skills-cli-project(vision, architecture, decisions log) andskills-cli-usage(agent-facing CLI contract). - CI on GitHub Actions (
fmt --check,clippy -D warnings,build,test).
- Binary renamed from
skillstoskillctlto avoid shadowingvercel-labs/skills(thenpx skillsCLI) on$PATH. Crate name remainsskills-cli. - README repositioned as the contributor-side companion to
npx skills, with explicit comparison and pain-point-to-command mapping.