@klhapp/skillmux 1.11.0 → 1.11.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,32 @@ All notable changes to this project are documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [1.11.2](https://github.com/klhq/skillmux/compare/v1.11.1...v1.11.2) (2026-09-05)
9
+
10
+
11
+ ### Added
12
+
13
+ * **target:** add built-in target migration ([#189](https://github.com/klhq/skillmux/issues/189)) ([fe85951](https://github.com/klhq/skillmux/commit/fe85951f1728907ce0faa9fe5df79677ea278973))
14
+ * **target:** add safe marker rehome workflow ([#187](https://github.com/klhq/skillmux/issues/187)) ([b8601aa](https://github.com/klhq/skillmux/commit/b8601aa9dc81d2c4a882c6d810d9922ba596e701))
15
+
16
+
17
+ ### Chores
18
+
19
+ * **release:** force version 1.11.2 ([1ee9cbc](https://github.com/klhq/skillmux/commit/1ee9cbcfbfcbd9650f8e7f2d2dbbd54cdb0777ac))
20
+
21
+ ## [1.11.1](https://github.com/klhq/skillmux/compare/v1.11.0...v1.11.1) (2026-09-03)
22
+
23
+
24
+ ### Added
25
+
26
+ * **cli:** install asks before writing to the vault ([#185](https://github.com/klhq/skillmux/issues/185)) ([7f12d02](https://github.com/klhq/skillmux/commit/7f12d02def0f306d963d37920f2407c8cffe1c04))
27
+
28
+
29
+ ### Fixed
30
+
31
+ * **cli:** reject full-vault agents on project attach, clarify agent vs target ([#184](https://github.com/klhq/skillmux/issues/184)) ([118aff5](https://github.com/klhq/skillmux/commit/118aff5f46fb9e83c8c0e068890b96fe0a02311d))
32
+ * **security:** gate install/update on scan findings, plus CLI help cleanup ([#182](https://github.com/klhq/skillmux/issues/182)) ([daffd8a](https://github.com/klhq/skillmux/commit/daffd8af89ebee1fbd4c05870c328baf747bbea3))
33
+
8
34
  ## [1.11.0](https://github.com/klhq/skillmux/compare/v1.10.0...v1.11.0) (2026-09-02)
9
35
 
10
36
 
package/README.md CHANGED
@@ -207,7 +207,14 @@ skillmux install owner/repo
207
207
  skillmux install owner/repo/path/to/skill
208
208
  ```
209
209
 
210
- Skillmux validates `SKILL.md` and scans candidate content before copying it into the vault. Use `--fail-on low|medium|high` to turn selected findings into an install gate.
210
+ Skillmux validates `SKILL.md` and scans candidate content before copying it
211
+ into the vault, then asks before writing. Pass `--yes` to approve up front;
212
+ a non-interactive run needs it, because `install` refuses to write unattended.
213
+
214
+ A high-severity finding aborts the install by default. `--fail-on
215
+ low|medium|high|none` moves that threshold, and it names the lowest severity
216
+ that still aborts, so a lower value is stricter: `low` aborts on low, medium
217
+ and high, while `none` installs despite any finding.
211
218
 
212
219
  Useful management commands:
213
220
 
@@ -260,7 +267,7 @@ Read [MCP routing](docs/mcp-routing.md) for transports, client instructions, ret
260
267
  | --- | --- | --- |
261
268
  | Claude Code | `~/.claude/skills` | Configure in the agent |
262
269
  | Codex | `$CODEX_HOME/skills` or `~/.codex/skills` | Configure in the agent |
263
- | Gemini CLI, OpenCode, GitHub Copilot, Windsurf | `~/.agents/skills` | Configure in the agent |
270
+ | OpenCode, GitHub Copilot, Windsurf | Shared `~/.agents/skills` | Configure in the agent |
264
271
  | Antigravity | `~/.gemini/config/skills` | Configure in the agent |
265
272
  | Goose, Hermes | Manual full-vault setup | Manual registration |
266
273
  | Custom agents | Any directory through a custom target | Stdio or Streamable HTTP |
package/docs/cli.md CHANGED
@@ -494,13 +494,20 @@ When `--json` or `SKILLMUX_JSON=true` is set, all output is emitted to `stdout`
494
494
  }
495
495
  ```
496
496
 
497
- The `context` field identifies the execution context (`"local"` or `{ "name": "<context>", "server": "<url>" }`). The `target` field carries identical content but is deprecated and slated for removal in the next major version; consumers should read `context`.
497
+ The `context` field identifies the execution context (`"local"` or `{ "name": "<context>", "server": "<url>" }`). The `target` field carries identical content but is deprecated; consumers should read `context`. It stays in the payload as a compatibility alias so existing automation keeps parsing, and no removal is planned.
498
498
 
499
499
  `skillmux init` additionally repeats its payload as deprecated top-level
500
500
  `command`, `phase`, `dry_run`, `applied`, `plan`, and `result` keys, which
501
501
  predate this envelope. They carry exactly the same content as `data` and are
502
- kept only so existing automation keeps working. Read `data` instead; the
503
- duplicated keys will be removed in the next major version.
502
+ kept only so existing automation keeps working. Read `data` instead. The
503
+ duplicated keys stay for the same reason as `target` above, and no removal
504
+ is planned.
505
+
506
+ `skillmux scan --format json` is the one exception to the envelope. It
507
+ predates this contract and prints a bare `{"scanned": N, "findings": [...]}`
508
+ object instead. It is deprecated and warns on stderr, which leaves stdout
509
+ parseable for existing consumers. Use the global `--json` for anything new,
510
+ and read `data` for the same fields.
504
511
 
505
512
  ### Exit codes
506
513
 
package/docs/concepts.md CHANGED
@@ -116,9 +116,13 @@ Skillmux maps it to the product's skill directory and safe instruction-file
116
116
  conventions.
117
117
 
118
118
  A **target** is a physical directory managed by sync. Several agents can map
119
- to one target. Gemini CLI, OpenCode, GitHub Copilot, and Windsurf share
119
+ to one target. OpenCode, GitHub Copilot, and Windsurf share
120
120
  `~/.agents/skills`, so Skillmux deduplicates that directory.
121
121
 
122
+ The mapping is not total. `goose` and `hermes` use full-vault delivery and
123
+ map to no target at all, so commands that take `--agent` as a stand-in for a
124
+ target reject them.
125
+
122
126
  Custom targets let you manage another directory without adding a product
123
127
  adapter:
124
128
 
@@ -291,7 +291,6 @@ paths = ["/Users/you/code/repo1"] # only synced for paths that exist locally
291
291
  skills = ["pdf-extractor"] # must not overlap [core]
292
292
 
293
293
  [targets.claude-code]
294
- dir = "/Users/you/.claude/skills"
295
294
  host = "workhorse" # optional; init adds the current hostname
296
295
  project_groups = ["repo1"] # which [project.*] groups materialize into this target; [] means none
297
296
  ```
@@ -299,7 +298,7 @@ project_groups = ["repo1"] # which [project.*] groups materialize into
299
298
  - `[core].skills`: symlinked into every `[targets.*]` dir on `sync`. Capped at 25 skills; `sync` fails if a listed skill id isn't actually in the vault.
300
299
  - `[project.<group>].skills`: symlinked only into `<path>/<relative path from $HOME to the target dir>`, for each `paths` entry, and only for targets whose `project_groups` names that group. `paths` entries must resolve under `$HOME` (that's how the pin path is derived). A skill can't appear in both `[core]` and the same `[project.*]` group.
301
300
  - `[project.<group>].paths` can list the same project's checkout on more than one machine (e.g. `["/home/alice/code/repo1", "/Users/alice/code/repo1"]`). `sync` silently skips any entry that doesn't exist on the machine it's running on (see below), so one shared manifest can span machines with different checkout locations without needing per-machine manifests.
302
- - `[targets.<name>]`: one entry per adopted surface. `skillmux init --agent <name> --yes` writes these for each supported agent and scopes newly added targets to the current hostname; `skillmux target add <name> --dir <dir> --yes` writes one directly for a directory not tied to any supported agent. Hand-editing is fine as long as `sync` is still allowed to own the directory (see below). An optional `host` limits the target to an exact machine-hostname match; omit it for a global, backward-compatible target. A host mismatch is reported and skipped before any target filesystem operation. `project_groups` is an explicit list, not a boolean: a target only receives the specific groups it names, never every group in the manifest.
301
+ - `[targets.<name>]`: one entry per adopted surface. Built-in names (`agent-skills`, `claude-code`, and `codex`) derive their directories from the name and omit `dir`. A custom target requires `dir`; create one with `skillmux target add <name> --dir <dir> --yes`. `skillmux target migrate --yes` removes legacy built-in `dir` fields without touching target files. An optional `host` limits the target to an exact machine-hostname match; omit it for a global, backward-compatible target. A host mismatch is reported and skipped before any target filesystem operation. `project_groups` is an explicit list, not a boolean: a target only receives the specific groups it names, never every group in the manifest.
303
302
 
304
303
  **Pin/unpin without hand-editing.** `skillmux core pin`/`unpin` mutate `[core]` for you, and `skillmux project pin`/`unpin` mutate `[project.*]`, validating with the same rules `sync` enforces (skill must resolve from `vault_path`, no duplicate pins, `[core]` stays under the 25-skill cap) before writing anything:
305
304
 
@@ -102,6 +102,10 @@ skillmux install owner/repo
102
102
  skillmux install owner/repo/path/to/skill
103
103
  ```
104
104
 
105
+ `install` scans the skill, shows what it found, and asks before writing to
106
+ the vault. Add `--yes` to approve up front, which a non-interactive run
107
+ requires. See [Managing skills](skill-management.md#install-from-git).
108
+
105
109
  Or create a small skill:
106
110
 
107
111
  ```sh
@@ -27,20 +27,32 @@ Skillmux clones into a temporary directory, validates the selected skill,
27
27
  scans its text files, and copies it to `vault_path`. Existing skill IDs require
28
28
  `--force`.
29
29
 
30
- Preview the destination without copying:
30
+ Nothing is written to the vault until you approve it. The scan result prints
31
+ first, then `install` asks for confirmation, so you decide with the findings
32
+ already on screen. Pass `--yes` to approve up front. A non-interactive run
33
+ (no TTY, or `--json`) fails instead of installing unattended, the same way
34
+ `skillmux update` behaves, so scripts and CI must opt in explicitly:
31
35
 
32
36
  ```sh
33
- skillmux install owner/repo --dry-run
37
+ skillmux install owner/repo --yes
34
38
  ```
35
39
 
36
- Set a scan gate when you want findings to block installation:
40
+ Preview the destination without copying. `--dry-run` writes nothing, so it
41
+ never asks for confirmation:
37
42
 
38
43
  ```sh
39
- skillmux install owner/repo --fail-on high
44
+ skillmux install owner/repo --dry-run
40
45
  ```
41
46
 
42
47
  The scanner detects suspicious prompt-injection patterns, secrets, and risky
43
- instructions. Findings remain advisory unless you pass `--fail-on`.
48
+ instructions. A high-severity finding aborts the install by default. Use
49
+ `--fail-on` to move that threshold, where a lower one is stricter, and
50
+ `--fail-on none` to install despite any finding:
51
+
52
+ ```sh
53
+ skillmux install owner/repo --fail-on low # also abort on low and medium
54
+ skillmux install owner/repo --fail-on none # never abort on findings
55
+ ```
44
56
 
45
57
  `install` refuses a `file://` source by default — a `file://` URL reaches the
46
58
  local filesystem directly, so honoring one unconditionally would let anything
@@ -74,13 +86,21 @@ Leaving `allowed_hosts` unset (the default) leaves both commands unrestricted.
74
86
  ```sh
75
87
  skillmux scan
76
88
  skillmux scan ~/skills/candidate
77
- skillmux scan --format json
89
+ skillmux scan --json
78
90
  skillmux scan --fail-on medium
79
91
  ```
80
92
 
81
93
  With no path, `scan` checks the configured vault. `--json` wraps the result in
82
- the standard CLI automation envelope, while `--format json` selects the
83
- scanner's raw JSON rendering.
94
+ the standard CLI automation envelope.
95
+
96
+ Unlike `install` and `update`, `scan` has no default threshold: it reports
97
+ what it finds and exits 0 regardless, so adding it to a pipeline never
98
+ changes that pipeline's exit code by itself. Pass `--fail-on` when you want
99
+ it to gate. The value names the lowest severity that still fails, so a lower
100
+ value is stricter.
101
+
102
+ `--format text|json` is deprecated. It still works, but it prints JSON
103
+ outside the standard envelope and now warns on stderr. Use `--json` instead.
84
104
 
85
105
  ## Check and pull upstream updates
86
106
 
@@ -278,6 +298,21 @@ command surface and route table.
278
298
  `skillmux target remove <name> --yes` removes the manifest record and preserves
279
299
  the target directory, marker, and files. Cleanup stays under your control.
280
300
 
301
+ Built-in targets (`agent-skills`, `claude-code`, and `codex`) resolve their
302
+ native directories from their names. Their manifest entries store only host and
303
+ project-group configuration. Custom targets retain an explicit `dir`. To remove
304
+ legacy built-in `dir` fields without changing any target files, run
305
+ `skillmux target migrate --dry-run` and then `skillmux target migrate --yes`.
306
+
307
+ When a managed target's marker still names a previous vault checkout after a
308
+ vault move, use `skillmux target rehome <name> --yes`. It validates every
309
+ recorded managed symlink in the target and its existing project pins before
310
+ updating their `.skillmux` markers to the configured `vault_path`. Use
311
+ `--dry-run` first to inspect the marker paths. Rehome never creates or removes
312
+ skill links. It retargets a link only when it resolves exactly to the previous
313
+ `vault_path` recorded in its marker, and it refuses legacy markers or any link
314
+ that cannot be proven to belong to either the previous or configured vault.
315
+
281
316
  Restore a managed target to one symlink that exposes the full vault:
282
317
 
283
318
  ```sh
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@klhapp/skillmux",
3
- "version": "1.11.0",
3
+ "version": "1.11.2",
4
4
  "description": "Skill management and retrieval for AI agents: sync native skills across clients and route the long tail over MCP",
5
5
  "type": "module",
6
6
  "private": false,
package/src/cli.ts CHANGED
@@ -543,6 +543,11 @@ usage:
543
543
  skillmux config diff
544
544
  skillmux config status
545
545
 
546
+ config init bootstraps this machine's config file from a populated vault. It
547
+ is not a prerequisite for anything: "skillmux init --vault <path>" runs the
548
+ same bootstrap when no config exists, so reach for config init only when you
549
+ are setting up the config without the guided init.
550
+
546
551
  Accepts --context <name> / --server <url> to target a remote deployment.`,
547
552
 
548
553
  completions: `completions: generate a shell completion script
@@ -566,7 +571,20 @@ usage:
566
571
  sync: `sync: apply the manifest to native agent target directories
567
572
 
568
573
  usage:
569
- skillmux sync [--dry-run] [--restore-monolith] [--install-hook] [--yes] [--json]`,
574
+ skillmux sync [--dry-run] [--restore-monolith] [--install-hook] [--yes] [--json]
575
+
576
+ --dry-run prints what would change without writing. --yes approves creating
577
+ a target directory that does not exist yet; without it, an unseen directory
578
+ is skipped rather than created.
579
+
580
+ --install-hook installs a git post-merge hook in the vault checkout so a
581
+ "git pull" re-syncs the targets automatically.
582
+
583
+ --restore-monolith undoes managed-pin delivery for a target: instead of
584
+ individual pinned skills, the target directory is replaced by a single
585
+ symlink to the whole vault. It refuses to touch a directory skillmux does
586
+ not own, one carrying a local_vault marker, or one whose marker points at a
587
+ different vault.`,
570
588
 
571
589
  init: `init: guided setup for native skill management
572
590
 
@@ -608,6 +626,15 @@ usage:
608
626
  skillmux project attach <group> (--agent <id>... | --target <name>...) --yes
609
627
  skillmux project detach <group> (--agent <id>... | --target <name>...) --yes
610
628
 
629
+ --agent and --target both name sync targets, and either may be repeated.
630
+ --target <name> names a target directly, including a custom one created by
631
+ "skillmux target add". --agent <id> is shorthand for "whatever target that
632
+ agent maps to", resolved from the targets init already configured, so it
633
+ fails if that agent was never set up or maps to no target at all (goose and
634
+ hermes use full-vault delivery and have none). Several agents can share one
635
+ target, so attaching two agents that map to the same directory attaches it
636
+ once.
637
+
611
638
  --register-mcp is the project-local counterpart to "skillmux init
612
639
  --register-mcp": only for claude-code (the only agent whose own CLI has a
613
640
  project MCP scope — codex's mcp add has no scope flag, so it's always
@@ -624,9 +651,16 @@ usage:
624
651
  skillmux target show <name>
625
652
  skillmux target add <name> [--dir <dir>] --yes
626
653
  skillmux target remove <name> --yes
654
+ skillmux target rehome <name> --yes
655
+ skillmux target migrate --yes
627
656
 
628
657
  --dir may be omitted when <name> is a built-in target with a deterministic
629
- path: agent-skills, claude-code, codex. Any other <name> requires --dir.`,
658
+ path: agent-skills, claude-code, codex. Any other <name> requires --dir.
659
+
660
+ A target is a directory, not a product. Several agents can map to the same
661
+ one (opencode, github-copilot and windsurf all share agent-skills), which is
662
+ why "skillmux project attach" accepts --agent as shorthand for the target
663
+ that agent resolves to.`,
630
664
 
631
665
  core: `core: pin or unpin core-tier skills
632
666
 
@@ -649,22 +683,60 @@ Accepts --context <name> / --server <url> to prune a remote deployment's audit d
649
683
  scan: `scan: check the vault for install-time or integrity issues
650
684
 
651
685
  usage:
652
- skillmux scan [path] [--format text|json] [--fail-on low|medium|high] [--json]`,
686
+ skillmux scan [path] [--fail-on low|medium|high|none] [--json]
687
+
688
+ Scans [path], or the configured vault when omitted. Reporting only: it
689
+ exits 0 whatever it finds unless --fail-on names a severity, which is why
690
+ it has no default threshold while install and update default to high.
691
+
692
+ --format text|json is deprecated: it emits JSON outside the standard
693
+ envelope. Use --json instead; --format will be removed in a future 1.x
694
+ release.`,
653
695
 
654
696
  install: `install: install a skill from a git source
655
697
 
656
698
  usage:
657
- skillmux install <repo>[/path] [--force] [--fail-on low|medium|high] [--dry-run] [--allow-local-source] [--json]`,
699
+ skillmux install <repo>[/path] [--yes] [--force] [--fail-on low|medium|high|none] [--dry-run] [--allow-local-source] [--json]
700
+
701
+ --yes approves writing the skill into the vault. Without it an interactive
702
+ run asks first, and a non-interactive one (no TTY, or --json) fails rather
703
+ than installing unattended, matching "skillmux update".
704
+
705
+ The fetched skill is scanned before it is written to the vault. --fail-on
706
+ sets the severity that aborts the install and defaults to high; pass
707
+ --fail-on none to install despite findings. A lower threshold is stricter:
708
+ low aborts on low, medium and high.
709
+
710
+ --force overwrites a skill that already exists in the vault instead of
711
+ refusing. --dry-run reports where the skill would land without writing.
712
+ --allow-local-source permits a file:// or local path source, which is
713
+ otherwise rejected.`,
658
714
 
659
715
  outdated: `outdated: list installed skills with a newer upstream version
660
716
 
661
717
  usage:
662
- skillmux outdated [--allow-local-source] [--json]`,
718
+ skillmux outdated [--allow-local-source] [--json]
719
+
720
+ Read-only: it reports what "skillmux update" would change and writes
721
+ nothing. --allow-local-source includes skills installed from a local or
722
+ file:// source, which are skipped by default because their upstream is a
723
+ path on this machine rather than a shared remote.`,
663
724
 
664
725
  update: `update: update one or all skills to their latest source version
665
726
 
666
727
  usage:
667
- skillmux update [skill-id] [--yes] [--dry-run] [--force] [--allow-local-source] [--fail-on low|medium|high] [--json]`,
728
+ skillmux update [skill-id] [--yes] [--dry-run] [--force] [--allow-local-source] [--fail-on low|medium|high|none] [--json]
729
+
730
+ Updates every installed skill, or just <skill-id>. --yes is required to
731
+ apply non-interactively. --dry-run prints the plan without writing.
732
+
733
+ --fail-on works exactly as it does for install and defaults to high, so a
734
+ skill whose new version carries a high-severity finding is skipped rather
735
+ than updated; --fail-on none restores the old permissive behavior.
736
+
737
+ --force updates a skill whose local content no longer matches the hash
738
+ recorded at install time, which otherwise blocks the update to avoid
739
+ discarding local edits. --allow-local-source permits local/file:// sources.`,
668
740
 
669
741
  eval: `eval: run retrieval evaluation against the holdout set
670
742
 
@@ -725,17 +797,20 @@ See docs/deployment.md for server deployment examples.`);
725
797
  console.log(`usage: skillmux <command> [options]
726
798
 
727
799
  Setup:
728
- skillmux config init --vault <path> --yes
729
800
  skillmux init [--agent <name>...] [--vault <path>] [--core <skill_id>...]
730
- [--migrate-full-vault] [--no-instructions] [--no-sync]
801
+ [--migrate-full-vault] [--show-mcp-setup] [--register-mcp]
802
+ [--no-instructions] [--no-sync]
731
803
  [--interactive|--yes|--dry-run] [--json]
732
804
  skillmux project init [path] [--name <group>] [--skill <skill_id>...]
733
805
  [--agent <name>...] [--target <name>...] [--no-sync]
734
806
  [--interactive|--yes|--dry-run] [--json]
735
807
  skillmux project <list|show|add-path|remove-path|pin|unpin|attach|detach>
736
- skillmux target <list|show|add|remove>
808
+ skillmux target <list|show|add|remove|rehome|migrate> (a target is a directory sync writes into)
737
809
  skillmux core <pin|unpin> <skill_id>... [--yes] [--dry-run] [--json]
738
810
  skillmux skill which <skill_id> (local vault shadow resolution; unrelated to MCP routing)
811
+ skillmux config init --vault <path> --yes
812
+ (bootstraps this machine's config on its own; not a
813
+ prerequisite, since "skillmux init --vault" does the same)
739
814
 
740
815
  Init agents:
741
816
  ${SUPPORTED_AGENT_IDS.join(", ")}
@@ -748,7 +823,7 @@ Operations:
748
823
  skillmux audit prune [--older-than <window>] [--dry-run] [--yes] [--json]
749
824
  skillmux eval promote --since <window> [--out <path>] [--dry-run] [--yes] [--json]
750
825
  skillmux outdated [--allow-local-source] [--json]
751
- skillmux update [skill-id] [--yes] [--dry-run] [--force] [--allow-local-source] [--fail-on low|medium|high] [--json]
826
+ skillmux update [skill-id] [--yes] [--dry-run] [--force] [--allow-local-source] [--fail-on low|medium|high|none] [--json]
752
827
 
753
828
  Commands:
754
829
  serve, index, sync, init, project, target, core, report, audit, scan, install, outdated, update,
@@ -144,9 +144,9 @@ interface InitJsonPayload {
144
144
  * through the shared handler and needs no bridging.
145
145
  *
146
146
  * DEPRECATED: the duplicated top-level `command`/`phase`/`dry_run`/`applied`/
147
- * `plan`/`result` keys should be dropped in the next major version, leaving
148
- * only the standard envelope. init is local-only, so `target` is always
149
- * "local".
147
+ * `plan`/`result` keys are a compatibility alias for `data`. They stay so
148
+ * existing automation keeps parsing, and no removal is planned. init is
149
+ * local-only, so `target` is always "local".
150
150
  */
151
151
  function initJsonEnvelope(payload: InitJsonPayload): string {
152
152
  return JSON.stringify({
@@ -14,32 +14,39 @@ import {
14
14
  } from "../install";
15
15
  import { emitSuccess } from "../output";
16
16
  import { hashSkillContent, writeSkillOrigin } from "../provenance";
17
- import { renderScanText, scanExitCode, type ScanSeverity } from "../scan";
17
+ import {
18
+ FAIL_ON_USAGE,
19
+ parseFailOn,
20
+ renderScanText,
21
+ resolveMutatingFailOn,
22
+ scanExitCode,
23
+ type FailOnOption,
24
+ } from "../scan";
18
25
  import { isGlobalFlag } from "../global-flags";
26
+ import { confirmIfNeeded } from "./shared";
19
27
 
20
28
  function parseInstallArgs(args: string[]): {
21
29
  repo?: string;
22
30
  force: boolean;
23
31
  dryRun: boolean;
24
- failOn?: ScanSeverity;
32
+ failOn?: FailOnOption;
25
33
  allowLocalSource: boolean;
34
+ yes: boolean;
26
35
  } {
27
36
  let repo: string | undefined;
28
37
  let force = false;
29
38
  let dryRun = false;
30
- let failOn: ScanSeverity | undefined;
39
+ let failOn: FailOnOption | undefined;
31
40
  let allowLocalSource = false;
41
+ let yes = false;
32
42
  for (let i = 0; i < args.length; i++) {
33
43
  const option = args[i];
34
44
  if (option === "--force") force = true;
45
+ else if (option === "--yes") yes = true;
35
46
  else if (option === "--dry-run") dryRun = true;
36
47
  else if (option === "--allow-local-source") allowLocalSource = true;
37
48
  else if (option === "--fail-on") {
38
- const value = args[++i];
39
- if (value !== "low" && value !== "medium" && value !== "high") {
40
- throw new Error("--fail-on must be low, medium, or high");
41
- }
42
- failOn = value;
49
+ failOn = parseFailOn(args[++i]);
43
50
  } else if (isGlobalFlag(option, "--json", "--verbose")) {
44
51
  // handled globally by main()'s isJson/isVerbose flags; recognized here so they aren't rejected
45
52
  } else if (option?.startsWith("--")) {
@@ -50,17 +57,17 @@ function parseInstallArgs(args: string[]): {
50
57
  repo = option;
51
58
  }
52
59
  }
53
- return { repo, force, dryRun, failOn, allowLocalSource };
60
+ return { repo, force, dryRun, failOn, allowLocalSource, yes };
54
61
  }
55
62
 
56
63
  export async function runInstall(
57
64
  args: string[],
58
65
  options: { isJson: boolean },
59
66
  ): Promise<void> {
60
- const { repo, force, dryRun, failOn, allowLocalSource } = parseInstallArgs(args);
67
+ const { repo, force, dryRun, failOn, allowLocalSource, yes } = parseInstallArgs(args);
61
68
  if (!repo) {
62
69
  throw new Error(
63
- "usage: skillmux install <repo>[/path] [--force] [--fail-on low|medium|high] [--dry-run] [--allow-local-source] [--json]",
70
+ `usage: skillmux install <repo>[/path] [--yes] [--force] [--fail-on ${FAIL_ON_USAGE}] [--dry-run] [--allow-local-source] [--json]`,
64
71
  );
65
72
  }
66
73
 
@@ -85,10 +92,16 @@ export async function runInstall(
85
92
  );
86
93
  if (!options.isJson) console.log(renderScanText({ scanned: 1, findings }));
87
94
 
88
- if (scanExitCode(findings, failOn) !== 0) {
95
+ // Defaults to blocking on a high-severity finding. Printing the findings
96
+ // and installing anyway is the wrong default for the command that pulls
97
+ // remote content into the vault.
98
+ const effectiveFailOn = resolveMutatingFailOn(failOn);
99
+ if (scanExitCode(findings, effectiveFailOn) !== 0) {
89
100
  process.exitCode = 1;
90
101
  console.error(
91
- `aborting install: a finding met the --fail-on ${failOn} threshold`,
102
+ `aborting install: a finding met the --fail-on ${effectiveFailOn} threshold` +
103
+ (failOn === undefined ? " (the default)" : "") +
104
+ `; re-run with --fail-on none to install anyway`,
92
105
  );
93
106
  return;
94
107
  }
@@ -107,6 +120,16 @@ export async function runInstall(
107
120
  return;
108
121
  }
109
122
 
123
+ // Confirm after the scan, so an interactive user decides with the findings
124
+ // already on screen, and after the --dry-run return, which writes nothing.
125
+ const proceed = await confirmIfNeeded({
126
+ confirmed: yes,
127
+ isJson: options.isJson,
128
+ prompt: `install:\n ${resolved.skillId} <- ${source.url}\n?`,
129
+ nonInteractiveError: "skillmux install requires --yes when run non-interactively",
130
+ });
131
+ if (!proceed) return;
132
+
110
133
  const commit = resolveCloneCommit(cloneDir);
111
134
  const targetDir = installIntoVault(
112
135
  vaultPath,
@@ -14,6 +14,7 @@ import {
14
14
  updateProjectTargets,
15
15
  upsertProject,
16
16
  validateManifest,
17
+ resolveTargetDir,
17
18
  writeManifestAtomic,
18
19
  } from "../manifest";
19
20
  import {
@@ -51,7 +52,7 @@ export function configuredTargetForSurface(
51
52
  ): string | undefined {
52
53
  if (manifest.targets[surface.targetName]) return surface.targetName;
53
54
  return Object.entries(manifest.targets).find(
54
- ([, target]) => expandHome(target.dir) === surface.path,
55
+ ([name, target]) => resolveTargetDir(name, target) === surface.path,
55
56
  )?.[0];
56
57
  }
57
58
 
@@ -59,7 +60,23 @@ function configuredTargetsForAgents(
59
60
  manifest: ReturnType<typeof parseManifest>,
60
61
  agents: readonly string[],
61
62
  ): string[] {
62
- return planAgentSurfaces(agents).surfaces.map((surface) => {
63
+ const plan = planAgentSurfaces(agents);
64
+
65
+ // planAgentSurfaces silently drops any agent with no surfaceId, which is how
66
+ // full-vault agents (goose, hermes) are modelled: they get the whole vault
67
+ // rather than a sync target directory. Dropping one here used to make
68
+ // "project attach --agent goose" a successful no-op, so refuse instead.
69
+ const covered = new Set<string>(plan.surfaces.flatMap((surface) => surface.agents));
70
+ for (const agent of agents) {
71
+ if (!covered.has(agent)) {
72
+ throw new Error(
73
+ `agent "${agent}" uses full-vault delivery and maps to no sync target; ` +
74
+ `name a target directly with --target instead`,
75
+ );
76
+ }
77
+ }
78
+
79
+ return plan.surfaces.map((surface) => {
63
80
  const target = configuredTargetForSurface(manifest, surface);
64
81
  if (target) return target;
65
82
  const agent = surface.agents[0]!;
@@ -309,7 +326,10 @@ export async function runProject(
309
326
  // agent-skills) — show the resolved directory, not just the target name,
310
327
  // so it's clear at confirmation time which physical folder this affects.
311
328
  const targetDirs = Object.fromEntries(
312
- targets.map((t) => [t, manifest.targets[t]?.dir ?? "(unknown)"]),
329
+ targets.map((t) => {
330
+ const target = manifest.targets[t];
331
+ return [t, target ? resolveTargetDir(t, target) : "(unknown)"];
332
+ }),
313
333
  );
314
334
  const targetsDisplay = targets
315
335
  .map((t) => `${t} (${targetDirs[t]})`)
@@ -1,6 +1,7 @@
1
1
  import { expandHome, loadConfig } from "../config";
2
- import { emitSuccess } from "../output";
2
+ import { emitSuccess, warn } from "../output";
3
3
  import {
4
+ parseFailOn,
4
5
  renderScanJson,
5
6
  renderScanText,
6
7
  scanExitCode,
@@ -12,10 +13,12 @@ import { isGlobalFlag } from "../global-flags";
12
13
  function parseScanArgs(args: string[]): {
13
14
  path?: string;
14
15
  format: "text" | "json";
16
+ formatExplicit: boolean;
15
17
  failOn?: ScanSeverity;
16
18
  } {
17
19
  let path: string | undefined;
18
20
  let format: "text" | "json" = "text";
21
+ let formatExplicit = false;
19
22
  let failOn: ScanSeverity | undefined;
20
23
  for (let i = 0; i < args.length; i++) {
21
24
  const option = args[i];
@@ -24,12 +27,13 @@ function parseScanArgs(args: string[]): {
24
27
  if (value !== "text" && value !== "json")
25
28
  throw new Error("--format must be text or json");
26
29
  format = value;
30
+ formatExplicit = true;
27
31
  } else if (option === "--fail-on") {
28
- const value = args[++i];
29
- if (value !== "low" && value !== "medium" && value !== "high") {
30
- throw new Error("--fail-on must be low, medium, or high");
31
- }
32
- failOn = value;
32
+ // scan accepts "none" for symmetry with install/update, where it is the
33
+ // opt-out. Here it is already the default: scan reports, and the caller
34
+ // opts into a non-zero exit code.
35
+ const parsed = parseFailOn(args[++i]);
36
+ failOn = parsed === "none" ? undefined : parsed;
33
37
  } else if (isGlobalFlag(option, "--json")) {
34
38
  // handled globally by main()'s isJson flag; recognized here so it isn't rejected
35
39
  } else if (option?.startsWith("--")) {
@@ -40,14 +44,20 @@ function parseScanArgs(args: string[]): {
40
44
  path = option;
41
45
  }
42
46
  }
43
- return { path, format, failOn };
47
+ return { path, format, formatExplicit, failOn };
44
48
  }
45
49
 
46
50
  export async function runScan(
47
51
  args: string[],
48
52
  options: { isJson: boolean },
49
53
  ): Promise<void> {
50
- const { path, format, failOn } = parseScanArgs(args);
54
+ const { path, format, formatExplicit, failOn } = parseScanArgs(args);
55
+ if (formatExplicit) {
56
+ // --format predates the shared --json envelope and is the last command
57
+ // flag that emits JSON outside it. Kept working for existing callers;
58
+ // the warning goes to stderr so stdout stays machine-parseable.
59
+ warn("--format is deprecated and will be removed in a future 1.x release; use --json instead");
60
+ }
51
61
  const rootPath = path
52
62
  ? expandHome(path)
53
63
  : expandHome((await loadConfig()).vault_path);
@@ -5,6 +5,7 @@ import {
5
5
  parseManifest,
6
6
  resolveManifestPath,
7
7
  validateManifest,
8
+ resolveTargetDir,
8
9
  } from "../manifest";
9
10
  import { emitSuccess, isInteractive, warn } from "../output";
10
11
  import {
@@ -129,7 +130,7 @@ export async function runSync(args: string[]): Promise<void> {
129
130
  targetSummaries.push({ target: targetName, status: "skipped_host_mismatch" });
130
131
  continue;
131
132
  }
132
- const targetDir = expandHome(target.dir);
133
+ const targetDir = resolveTargetDir(targetName, target);
133
134
 
134
135
  if (restoreMonolith) {
135
136
  const result = restoreMonolithTarget(targetDir, vaultPath);
@@ -1,3 +1,5 @@
1
+ import { existsSync } from "node:fs";
2
+ import { hostname } from "node:os";
1
3
  import { expandHome } from "../config";
2
4
  import {
3
5
  BUILT_IN_TARGET_NAMES,
@@ -6,8 +8,9 @@ import {
6
8
  SUPPORTED_AGENT_IDS,
7
9
  } from "../init-agents";
8
10
  import { planInitManifest, applyInit } from "../init";
9
- import { writeManifestAtomic } from "../manifest";
11
+ import { resolveTargetDir, writeManifestAtomic } from "../manifest";
10
12
  import { emitSuccess, unknownSubcommandError } from "../output";
13
+ import { applyTargetMarkerRehome, planTargetMarkerRehome, resolveProjectPinDir } from "../sync";
11
14
  import { confirmIfNeeded, loadManifestContext } from "./shared";
12
15
 
13
16
  export async function runTarget(
@@ -15,7 +18,7 @@ export async function runTarget(
15
18
  args: string[],
16
19
  options: { isJson: boolean; dryRun: boolean },
17
20
  ): Promise<void> {
18
- const { vaultPath, manifestPath, manifest } = await loadManifestContext();
21
+ const { config, vaultPath, manifestPath, manifest } = await loadManifestContext();
19
22
 
20
23
  if (subCommand === "list" || subCommand === "show") {
21
24
  const names =
@@ -27,9 +30,9 @@ export async function runTarget(
27
30
  const target = manifest.targets[name]!;
28
31
  const agents = SUPPORTED_AGENT_IDS.filter((agent) => {
29
32
  const surface = planAgentSurfaces([agent]).surfaces[0];
30
- return surface !== undefined && surface.path === expandHome(target.dir);
33
+ return surface !== undefined && surface.path === resolveTargetDir(name, target);
31
34
  });
32
- return { name, ...target, agents };
35
+ return { name, ...target, dir: resolveTargetDir(name, target), agents };
33
36
  });
34
37
  emitSuccess({ isJson: options.isJson }, { targets }, () => {
35
38
  if (targets.length === 0) {
@@ -106,7 +109,7 @@ export async function runTarget(
106
109
  if (options.dryRun) {
107
110
  emitSuccess(
108
111
  { isJson: options.isJson },
109
- { name, preserved_dir: manifest.targets[name]!.dir },
112
+ { name, preserved_dir: resolveTargetDir(name, manifest.targets[name]!) },
110
113
  () => console.log(`target remove: ${name} (files preserved, dry-run)`),
111
114
  );
112
115
  return;
@@ -122,7 +125,7 @@ export async function runTarget(
122
125
  )
123
126
  return;
124
127
  const targets = { ...manifest.targets };
125
- const removedDir = manifest.targets[name]!.dir;
128
+ const removedDir = resolveTargetDir(name, manifest.targets[name]!);
126
129
  delete targets[name];
127
130
  writeManifestAtomic(manifestPath, { ...manifest, targets });
128
131
  emitSuccess(
@@ -136,5 +139,87 @@ export async function runTarget(
136
139
  return;
137
140
  }
138
141
 
139
- throw unknownSubcommandError("target", subCommand, ["list", "show", "add", "remove"]);
142
+ if (subCommand === "rehome") {
143
+ const name = args[0];
144
+ const target = name ? manifest.targets[name] : undefined;
145
+ if (!name || !target) {
146
+ throw new Error(name ? `target "${name}" does not exist` : "usage: skillmux target rehome <name> --yes");
147
+ }
148
+ if (target.host !== undefined && target.host !== hostname()) {
149
+ throw new Error(`target "${name}" is scoped to host ${target.host}, not ${hostname()}`);
150
+ }
151
+
152
+ const targetDir = resolveTargetDir(name, target);
153
+ if (!existsSync(targetDir)) throw new Error(`target "${name}" directory does not exist: ${targetDir}`);
154
+ const dirs = [targetDir];
155
+ for (const groupName of target.project_groups) {
156
+ const group = manifest.project?.[groupName];
157
+ if (!group) continue;
158
+ for (const projectPath of group.paths) {
159
+ if (!existsSync(projectPath)) continue;
160
+ const pinDir = resolveProjectPinDir(targetDir, projectPath);
161
+ if (existsSync(pinDir)) dirs.push(pinDir);
162
+ }
163
+ }
164
+ const plans = dirs.map((dir) =>
165
+ planTargetMarkerRehome(dir, name, vaultPath, config.local_vault_paths.map(expandHome)),
166
+ );
167
+ const markerPaths = plans.map((plan) => plan.markerPath);
168
+ if (options.dryRun) {
169
+ emitSuccess(
170
+ { isJson: options.isJson },
171
+ { name, marker_paths: markerPaths },
172
+ () => console.log(`target rehome: ${name} (${markerPaths.length} markers, dry-run)`),
173
+ );
174
+ return;
175
+ }
176
+ if (
177
+ !(await confirmIfNeeded({
178
+ confirmed: args.includes("--yes"),
179
+ isJson: options.isJson,
180
+ prompt: `rehome ${markerPaths.length} ${name} marker(s) to ${vaultPath}?`,
181
+ nonInteractiveError: "skillmux target rehome requires --yes when run non-interactively",
182
+ }))
183
+ )
184
+ return;
185
+ applyTargetMarkerRehome(plans, vaultPath);
186
+ emitSuccess(
187
+ { isJson: options.isJson },
188
+ { name, marker_paths: markerPaths },
189
+ () => console.log(`target "${name}" rehomed ${markerPaths.length} marker(s) to ${vaultPath}`),
190
+ );
191
+ return;
192
+ }
193
+
194
+ if (subCommand === "migrate") {
195
+ const builtInTargets = Object.entries(manifest.targets).filter(([name]) =>
196
+ BUILT_IN_TARGET_NAMES.has(name),
197
+ );
198
+ if (options.dryRun) {
199
+ emitSuccess(
200
+ { isJson: options.isJson },
201
+ { migrated_targets: builtInTargets.map(([name]) => name) },
202
+ () => console.log(`target migrate: ${builtInTargets.length} built-in target(s) (dry-run)`),
203
+ );
204
+ return;
205
+ }
206
+ if (
207
+ !(await confirmIfNeeded({
208
+ confirmed: args.includes("--yes"),
209
+ isJson: options.isJson,
210
+ prompt: `remove redundant dir fields from ${builtInTargets.length} built-in target(s)?`,
211
+ nonInteractiveError: "skillmux target migrate requires --yes when run non-interactively",
212
+ }))
213
+ )
214
+ return;
215
+ writeManifestAtomic(manifestPath, manifest);
216
+ emitSuccess(
217
+ { isJson: options.isJson },
218
+ { migrated_targets: builtInTargets.map(([name]) => name) },
219
+ () => console.log(`target migrate: normalized ${builtInTargets.length} built-in target(s)`),
220
+ );
221
+ return;
222
+ }
223
+
224
+ throw unknownSubcommandError("target", subCommand, ["list", "show", "add", "remove", "rehome", "migrate"]);
140
225
  }
@@ -14,7 +14,15 @@ import {
14
14
  import { emitSuccess } from "../output";
15
15
  import { hashSkillContent, readSkillOrigin, writeSkillOrigin } from "../provenance";
16
16
  import type { SkillOrigin } from "../provenance";
17
- import { type ScanFinding, type ScanSeverity, scanExitCode } from "../scan";
17
+ import {
18
+ FAIL_ON_USAGE,
19
+ parseFailOn,
20
+ resolveMutatingFailOn,
21
+ scanExitCode,
22
+ type FailOnOption,
23
+ type ScanFinding,
24
+ type ScanSeverity,
25
+ } from "../scan";
18
26
  import { SKILL_ID_PATTERN } from "../vault";
19
27
  import { confirmIfNeeded } from "./shared";
20
28
  import { checkOutdated } from "./outdated";
@@ -175,14 +183,14 @@ function parseUpdateArgs(args: string[]): {
175
183
  yes: boolean;
176
184
  dryRun: boolean;
177
185
  force: boolean;
178
- failOn?: ScanSeverity;
186
+ failOn?: FailOnOption;
179
187
  allowLocalSource: boolean;
180
188
  } {
181
189
  let skillId: string | undefined;
182
190
  let yes = false;
183
191
  let dryRun = false;
184
192
  let force = false;
185
- let failOn: ScanSeverity | undefined;
193
+ let failOn: FailOnOption | undefined;
186
194
  let allowLocalSource = false;
187
195
  for (let i = 0; i < args.length; i++) {
188
196
  const arg = args[i];
@@ -191,11 +199,7 @@ function parseUpdateArgs(args: string[]): {
191
199
  else if (arg === "--force") force = true;
192
200
  else if (arg === "--allow-local-source") allowLocalSource = true;
193
201
  else if (arg === "--fail-on") {
194
- const value = args[++i];
195
- if (value !== "low" && value !== "medium" && value !== "high") {
196
- throw new Error("--fail-on must be low, medium, or high");
197
- }
198
- failOn = value;
202
+ failOn = parseFailOn(args[++i]);
199
203
  } else if (isGlobalFlag(arg, "--json")) {
200
204
  // handled globally
201
205
  } else if (arg?.startsWith("--")) {
@@ -215,7 +219,10 @@ export async function runUpdate(args: string[], options: { isJson: boolean }): P
215
219
  const vaultPath = expandHome(config.vault_path);
216
220
 
217
221
  const candidates = await resolveCandidateOrigins(vaultPath, skillId, allowLocalSource, config.egress?.allowed_hosts);
218
- const plan = await buildPlan(vaultPath, candidates, failOn, force, config.egress?.allowed_hosts);
222
+ // Same default as install: block on a high-severity finding unless the
223
+ // caller explicitly opts out with --fail-on none.
224
+ const effectiveFailOn = resolveMutatingFailOn(failOn);
225
+ const plan = await buildPlan(vaultPath, candidates, effectiveFailOn, force, config.egress?.allowed_hosts);
219
226
  try {
220
227
  const toWrite = plan.filter((item) => item.kind === "update");
221
228
 
@@ -138,7 +138,7 @@ ${commands}
138
138
  elif [[ "$words[2]" == "eval" && CURRENT == 3 ]]; then
139
139
  _values 'eval command' promote
140
140
  elif [[ "$words[2]" == "target" && CURRENT == 3 ]]; then
141
- _values 'target command' list show add remove
141
+ _values 'target command' list show add remove rehome migrate
142
142
  elif [[ "$words[2]" == "skill" && CURRENT == 3 ]]; then
143
143
  _values 'skill command' which
144
144
  elif [[ "$words[2]" == "core" && CURRENT == 3 ]]; then
@@ -185,7 +185,7 @@ complete -c skillmux -n "__fish_seen_subcommand_from eval; and __fish_seen_subco
185
185
  complete -c skillmux -n "__fish_seen_subcommand_from eval; and __fish_seen_subcommand_from promote" -l dry-run -d "Print the plan without writing"
186
186
  complete -c skillmux -n "__fish_seen_subcommand_from eval; and __fish_seen_subcommand_from promote" -l yes -d "Apply without prompts"
187
187
  complete -c skillmux -n "__fish_seen_subcommand_from eval; and __fish_seen_subcommand_from promote" -l json -d "Emit a JSON envelope"
188
- complete -c skillmux -n "__fish_seen_subcommand_from target" -a "list show add remove" -d "Manage targets"
188
+ complete -c skillmux -n "__fish_seen_subcommand_from target" -a "list show add remove rehome migrate" -d "Manage targets"
189
189
  complete -c skillmux -n "__fish_seen_subcommand_from core" -a "pin unpin" -d "Manage [core] pins"
190
190
  complete -c skillmux -n "__fish_seen_subcommand_from skill" -a "which" -d "Show which root resolves a skill_id"
191
191
  complete -c skillmux -n "__fish_seen_subcommand_from local-vault" -a "init" -d "Initialize a local_vault_paths marker"
package/src/init.ts CHANGED
@@ -22,6 +22,7 @@ import {
22
22
  CORE_SKILL_LIMIT,
23
23
  MANIFEST_FILENAME,
24
24
  } from "./manifest";
25
+ import { BUILT_IN_TARGET_NAMES } from "./init-agents";
25
26
  import {
26
27
  adoptTarget,
27
28
  preflightAdoptTarget,
@@ -244,9 +245,13 @@ export function planInitManifest(
244
245
  const existingTarget = existingManifest.targets[target.name];
245
246
  return [
246
247
  target.name,
247
- existingTarget
248
- ? { ...existingTarget, dir: target.dir }
249
- : { dir: target.dir, host: hostname(), project_groups: [] },
248
+ BUILT_IN_TARGET_NAMES.has(target.name)
249
+ ? existingTarget
250
+ ? { ...existingTarget, dir: undefined }
251
+ : { host: hostname(), project_groups: [] }
252
+ : existingTarget
253
+ ? { ...existingTarget, dir: target.dir }
254
+ : { dir: target.dir, host: hostname(), project_groups: [] },
250
255
  ];
251
256
  }),
252
257
  ),
package/src/manifest.ts CHANGED
@@ -2,6 +2,7 @@ import { existsSync, renameSync, rmSync, writeFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { z } from "zod";
4
4
  import { expandHome } from "./config";
5
+ import { BUILT_IN_TARGET_NAMES, resolveBuiltInTarget } from "./init-agents";
5
6
  import { resolveSkillRoot, SKILL_ID_PATTERN } from "./vault";
6
7
 
7
8
  export const MANIFEST_FILENAME = "skillmux.toml";
@@ -24,7 +25,7 @@ const projectGroupSchema = z.object({
24
25
  }).strict();
25
26
 
26
27
  const targetSchema = z.object({
27
- dir: z.string().min(1),
28
+ dir: z.string().min(1).optional(),
28
29
  host: z.string().min(1).optional(),
29
30
  project_groups: z.array(groupNameSchema).default([]),
30
31
  }).strict();
@@ -39,10 +40,31 @@ export type ProjectGroup = z.infer<typeof projectGroupSchema>;
39
40
  export type Target = z.infer<typeof targetSchema>;
40
41
  export type Manifest = z.infer<typeof manifestSchema>;
41
42
 
43
+ export function resolveTargetDir(
44
+ name: string,
45
+ target: Target,
46
+ options: { home?: string; codexHome?: string } = {},
47
+ ): string {
48
+ if (BUILT_IN_TARGET_NAMES.has(name)) {
49
+ return resolveBuiltInTarget(name, {
50
+ ...options,
51
+ codexHome: options.codexHome ?? (process.env.CODEX_HOME ? expandHome(process.env.CODEX_HOME) : undefined),
52
+ }).path;
53
+ }
54
+ if (!target.dir) throw new Error(`[targets.${name}] requires dir for a custom target`);
55
+ return expandHome(target.dir);
56
+ }
57
+
42
58
  export function parseManifest(toml: string): Manifest {
43
59
  const parsed = Bun.TOML.parse(toml) as Record<string, unknown>;
44
60
  try {
45
- return manifestSchema.parse(parsed);
61
+ const manifest = manifestSchema.parse(parsed);
62
+ for (const [name, target] of Object.entries(manifest.targets)) {
63
+ if (!BUILT_IN_TARGET_NAMES.has(name) && !target.dir) {
64
+ throw new Error(`[targets.${name}] requires dir for a custom target`);
65
+ }
66
+ }
67
+ return manifest;
46
68
  } catch (error) {
47
69
  if (error instanceof z.ZodError) {
48
70
  for (const issue of error.issues) {
@@ -85,9 +107,10 @@ export function serializeManifest(manifest: Manifest): string {
85
107
  }
86
108
 
87
109
  for (const [name, target] of Object.entries(manifest.targets)) {
110
+ const dir = BUILT_IN_TARGET_NAMES.has(name) ? "" : `\ndir = ${JSON.stringify(target.dir)}`;
88
111
  const host = target.host ? `\nhost = ${JSON.stringify(target.host)}` : "";
89
112
  sections.push(
90
- `[targets.${name}]\ndir = ${JSON.stringify(target.dir)}${host}\nproject_groups = ${tomlStringArray(target.project_groups)}`,
113
+ `[targets.${name}]${dir}${host}\nproject_groups = ${tomlStringArray(target.project_groups)}`,
91
114
  );
92
115
  }
93
116
 
package/src/output.ts CHANGED
@@ -4,7 +4,7 @@ export interface JsonEnvelope<T = any> {
4
4
  schema_version: 1;
5
5
  ok: boolean;
6
6
  context: string | { name: string; server: string };
7
- /** @deprecated Slated for removal in the next major version. Use `context` instead. */
7
+ /** @deprecated Use `context` instead. Retained as a compatibility alias; no removal planned. */
8
8
  target: string | { name: string; server: string };
9
9
  data: T | null;
10
10
  error: { code: string; message: string; details?: any } | null;
@@ -12,7 +12,7 @@ export interface JsonEnvelope<T = any> {
12
12
 
13
13
  export function formatJsonEnvelope<T>(opts: {
14
14
  ok: boolean;
15
- /** @deprecated Slated for removal in the next major version. Use `context` instead. */
15
+ /** @deprecated Use `context` instead. Retained as a compatibility alias; no removal planned. */
16
16
  target?: ResolvedContext | string | { name: string; server: string };
17
17
  context?: ResolvedContext | string | { name: string; server: string };
18
18
  data?: T;
@@ -62,7 +62,7 @@ export class CliError extends Error {
62
62
  export function emitSuccess<T>(
63
63
  ctx: {
64
64
  isJson: boolean;
65
- /** @deprecated Slated for removal in the next major version. Use `context` instead. */
65
+ /** @deprecated Use `context` instead. Retained as a compatibility alias; no removal planned. */
66
66
  target?: ResolvedContext | string | { name: string; server: string };
67
67
  context?: ResolvedContext | string | { name: string; server: string };
68
68
  },
package/src/scan.ts CHANGED
@@ -264,3 +264,37 @@ export function scanExitCode(findings: RuleMatch[], failOn: ScanSeverity | undef
264
264
  const threshold = SEVERITY_RANK[failOn];
265
265
  return findings.some((f) => SEVERITY_RANK[f.severity] >= threshold) ? 1 : 0;
266
266
  }
267
+
268
+ /**
269
+ * A parsed `--fail-on` value. "none" is accepted on the command line to mean
270
+ * "never fail" and is not a finding severity, which is why it is kept out of
271
+ * ScanSeverity itself.
272
+ *
273
+ * Note the ordering: a lower threshold is stricter. `low` fails on low, medium
274
+ * and high; `high` fails only on high.
275
+ */
276
+ export type FailOnOption = ScanSeverity | "none";
277
+
278
+ export const FAIL_ON_USAGE = "low|medium|high|none";
279
+
280
+ export function parseFailOn(value: string | undefined): FailOnOption {
281
+ if (value !== "low" && value !== "medium" && value !== "high" && value !== "none") {
282
+ throw new Error("--fail-on must be low, medium, high, or none");
283
+ }
284
+ return value;
285
+ }
286
+
287
+ /**
288
+ * Resolves the threshold for the commands that fetch remote content into the
289
+ * vault (install, update). They default to blocking on a high-severity finding,
290
+ * because printing a warning and proceeding anyway is the wrong default on the
291
+ * supply-chain path. `--fail-on none` restores the permissive behavior.
292
+ *
293
+ * `skillmux scan` deliberately does NOT use this: it is a reporting command
294
+ * whose exit code the caller opts into, and giving it a default would change
295
+ * the exit code of existing CI pipelines that just run `skillmux scan`.
296
+ */
297
+ export function resolveMutatingFailOn(failOn: FailOnOption | undefined): ScanSeverity | undefined {
298
+ if (failOn === undefined) return "high";
299
+ return failOn === "none" ? undefined : failOn;
300
+ }
package/src/sync.ts CHANGED
@@ -1,6 +1,20 @@
1
- import { chmodSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, symlinkSync, unlinkSync, writeFileSync } from "node:fs";
1
+ import {
2
+ chmodSync,
3
+ existsSync,
4
+ lstatSync,
5
+ mkdirSync,
6
+ readFileSync,
7
+ readlinkSync,
8
+ readdirSync,
9
+ realpathSync,
10
+ renameSync,
11
+ rmSync,
12
+ symlinkSync,
13
+ unlinkSync,
14
+ writeFileSync,
15
+ } from "node:fs";
2
16
  import { homedir } from "node:os";
3
- import { join, relative } from "node:path";
17
+ import { basename, join, relative } from "node:path";
4
18
  import { findSymlinks } from "./install";
5
19
  import { resolveSkillRoot } from "./vault";
6
20
 
@@ -76,7 +90,29 @@ function writeTargetMarker(
76
90
  const markerPath = join(dir, SKILLMUX_MARKER_FILENAME);
77
91
  const serialized = JSON.stringify(marker, null, 2);
78
92
  if (!existsSync(markerPath) || readFileSync(markerPath, "utf-8") !== serialized) {
79
- writeFileSync(markerPath, serialized);
93
+ writeFileAtomic(markerPath, serialized);
94
+ }
95
+ }
96
+
97
+ function writeFileAtomic(path: string, contents: string): void {
98
+ const temporaryPath = `${path}.${process.pid}.${Date.now()}.tmp`;
99
+ try {
100
+ writeFileSync(temporaryPath, contents);
101
+ renameSync(temporaryPath, path);
102
+ } catch (error) {
103
+ if (existsSync(temporaryPath)) unlinkSync(temporaryPath);
104
+ throw error;
105
+ }
106
+ }
107
+
108
+ function replaceSymlinkAtomic(path: string, target: string): void {
109
+ const temporaryPath = `${path}.${process.pid}.${Date.now()}.tmp`;
110
+ try {
111
+ symlinkSync(target, temporaryPath);
112
+ renameSync(temporaryPath, path);
113
+ } catch (error) {
114
+ if (existsSync(temporaryPath)) unlinkSync(temporaryPath);
115
+ throw error;
80
116
  }
81
117
  }
82
118
 
@@ -227,6 +263,102 @@ export function preflightAdoptTarget(dir: string, targetName: string, vaultPath:
227
263
  }
228
264
  }
229
265
 
266
+ export interface TargetMarkerRehomePlan {
267
+ dir: string;
268
+ markerPath: string;
269
+ marker: SkillmuxMarker;
270
+ links: TargetLinkRehomePlan[];
271
+ }
272
+
273
+ interface TargetLinkRehomePlan {
274
+ entryPath: string;
275
+ previousTarget: string;
276
+ nextTarget: string;
277
+ }
278
+
279
+ /**
280
+ * Proves every managed entry resolves either to its active source or exactly to
281
+ * the vault recorded in its marker. Only the latter links are re-pointed.
282
+ */
283
+ export function planTargetMarkerRehome(
284
+ dir: string,
285
+ targetName: string,
286
+ vaultPath: string,
287
+ localVaultPaths: string[] = [],
288
+ ): TargetMarkerRehomePlan {
289
+ const markerPath = join(dir, SKILLMUX_MARKER_FILENAME);
290
+ if (!existsSync(markerPath)) {
291
+ throw new Error(`${dir} has a legacy marker; rehome requires .skillmux ownership`);
292
+ }
293
+ const marker = readSkillmuxMarker(dir);
294
+ if (!marker) throw new Error(`${dir} is not owned by skillmux`);
295
+ if (marker.role !== "target") throw new Error(`${dir} has a local_vault marker, not target ownership`);
296
+ if (marker.schema_version !== 1) {
297
+ throw new Error(`${dir} has a legacy marker; rehome requires schema_version 1`);
298
+ }
299
+ if (marker.target !== targetName) {
300
+ throw new Error(`${dir} is owned by target "${marker.target}", not "${targetName}"`);
301
+ }
302
+
303
+ const links: TargetLinkRehomePlan[] = [];
304
+ for (const skillId of marker.managed_entries ?? []) {
305
+ if (basename(skillId) !== skillId) {
306
+ throw new Error(`${dir} marker has an invalid managed entry "${skillId}"`);
307
+ }
308
+ const entryPath = join(dir, skillId);
309
+ if (!lstatSync(entryPath).isSymbolicLink()) {
310
+ throw new Error(`${entryPath} is not the managed symlink recorded by ${dir}`);
311
+ }
312
+ const sourceRoot = resolveSkillRoot(skillId, vaultPath, localVaultPaths) ?? vaultPath;
313
+ const expectedPath = join(sourceRoot, skillId);
314
+ if (!existsSync(expectedPath)) {
315
+ throw new Error(`${entryPath} cannot be proven to resolve from the configured vault's ${skillId}`);
316
+ }
317
+ const resolvedEntryPath = realpathSync(entryPath);
318
+ if (resolvedEntryPath === realpathSync(expectedPath)) continue;
319
+
320
+ const previousPath = join(marker.vault_path!, skillId);
321
+ if (!existsSync(previousPath) || resolvedEntryPath !== realpathSync(previousPath)) {
322
+ throw new Error(`${entryPath} does not resolve to the configured vault's ${skillId}`);
323
+ }
324
+ links.push({ entryPath, previousTarget: readlinkSync(entryPath), nextTarget: expectedPath });
325
+ }
326
+
327
+ return { dir, markerPath, marker, links };
328
+ }
329
+
330
+ /** Applies only preflighted rehome plans. All plans are validated before the first write. */
331
+ export function applyTargetMarkerRehome(plans: TargetMarkerRehomePlan[], vaultPath: string): void {
332
+ const completedLinks: TargetLinkRehomePlan[] = [];
333
+ const completedMarkers: TargetMarkerRehomePlan[] = [];
334
+ try {
335
+ for (const plan of plans) {
336
+ for (const link of plan.links) {
337
+ replaceSymlinkAtomic(link.entryPath, link.nextTarget);
338
+ completedLinks.push(link);
339
+ }
340
+ }
341
+ for (const plan of plans) {
342
+ writeTargetMarker(
343
+ plan.dir,
344
+ plan.marker.target!,
345
+ vaultPath,
346
+ plan.marker.managed_entries ?? [],
347
+ plan.marker.created_at,
348
+ );
349
+ completedMarkers.push(plan);
350
+ }
351
+ } catch (error) {
352
+ for (const plan of completedMarkers.reverse()) {
353
+ writeFileAtomic(plan.markerPath, JSON.stringify(plan.marker, null, 2));
354
+ }
355
+ for (const link of completedLinks.reverse()) {
356
+ replaceSymlinkAtomic(link.entryPath, link.previousTarget);
357
+ }
358
+ throw error;
359
+ }
360
+ }
361
+
230
362
  /**
231
363
  * Marks an existing directory as skillmux-owned without touching its content —
232
364
  * the consented, one-time adoption skillmux init performs (see SkillmuxMarker in