@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 +26 -0
- package/README.md +9 -2
- package/docs/cli.md +10 -3
- package/docs/concepts.md +5 -1
- package/docs/configuration.md +1 -2
- package/docs/getting-started.md +4 -0
- package/docs/skill-management.md +43 -8
- package/package.json +1 -1
- package/src/cli.ts +85 -10
- package/src/commands/init.ts +3 -3
- package/src/commands/install.ts +36 -13
- package/src/commands/project.ts +23 -3
- package/src/commands/scan.ts +18 -8
- package/src/commands/sync.ts +2 -1
- package/src/commands/target.ts +92 -7
- package/src/commands/update.ts +16 -9
- package/src/completions.ts +2 -2
- package/src/init.ts +8 -3
- package/src/manifest.ts +26 -3
- package/src/output.ts +3 -3
- package/src/scan.ts +34 -0
- package/src/sync.ts +135 -3
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
|
|
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
|
-
|
|
|
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
|
|
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
|
|
503
|
-
duplicated keys
|
|
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.
|
|
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
|
|
package/docs/configuration.md
CHANGED
|
@@ -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.
|
|
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
|
|
package/docs/getting-started.md
CHANGED
|
@@ -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
|
package/docs/skill-management.md
CHANGED
|
@@ -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
|
-
|
|
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 --
|
|
37
|
+
skillmux install owner/repo --yes
|
|
34
38
|
```
|
|
35
39
|
|
|
36
|
-
|
|
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 --
|
|
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.
|
|
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 --
|
|
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
|
|
83
|
-
|
|
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
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] [--
|
|
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] [--
|
|
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,
|
package/src/commands/init.ts
CHANGED
|
@@ -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
|
|
148
|
-
*
|
|
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({
|
package/src/commands/install.ts
CHANGED
|
@@ -14,32 +14,39 @@ import {
|
|
|
14
14
|
} from "../install";
|
|
15
15
|
import { emitSuccess } from "../output";
|
|
16
16
|
import { hashSkillContent, writeSkillOrigin } from "../provenance";
|
|
17
|
-
import {
|
|
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?:
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 ${
|
|
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,
|
package/src/commands/project.ts
CHANGED
|
@@ -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]) =>
|
|
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
|
-
|
|
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) =>
|
|
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]})`)
|
package/src/commands/scan.ts
CHANGED
|
@@ -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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
failOn =
|
|
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);
|
package/src/commands/sync.ts
CHANGED
|
@@ -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 =
|
|
133
|
+
const targetDir = resolveTargetDir(targetName, target);
|
|
133
134
|
|
|
134
135
|
if (restoreMonolith) {
|
|
135
136
|
const result = restoreMonolithTarget(targetDir, vaultPath);
|
package/src/commands/target.ts
CHANGED
|
@@ -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 ===
|
|
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]
|
|
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]
|
|
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
|
-
|
|
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
|
}
|
package/src/commands/update.ts
CHANGED
|
@@ -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 {
|
|
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?:
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
package/src/completions.ts
CHANGED
|
@@ -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
|
-
|
|
248
|
-
?
|
|
249
|
-
|
|
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
|
-
|
|
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}]
|
|
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
|
|
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
|
|
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
|
|
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 {
|
|
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
|
-
|
|
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
|