@klhapp/skillmux 1.11.0 → 1.11.1

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,19 @@ 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.1](https://github.com/klhq/skillmux/compare/v1.11.0...v1.11.1) (2026-09-03)
9
+
10
+
11
+ ### Added
12
+
13
+ * **cli:** install asks before writing to the vault ([#185](https://github.com/klhq/skillmux/issues/185)) ([7f12d02](https://github.com/klhq/skillmux/commit/7f12d02def0f306d963d37920f2407c8cffe1c04))
14
+
15
+
16
+ ### Fixed
17
+
18
+ * **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))
19
+ * **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))
20
+
8
21
  ## [1.11.0](https://github.com/klhq/skillmux/compare/v1.10.0...v1.11.0) (2026-09-02)
9
22
 
10
23
 
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
 
@@ -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
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@klhapp/skillmux",
3
- "version": "1.11.0",
3
+ "version": "1.11.1",
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
@@ -626,7 +653,12 @@ usage:
626
653
  skillmux target remove <name> --yes
627
654
 
628
655
  --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.`,
656
+ path: agent-skills, claude-code, codex. Any other <name> requires --dir.
657
+
658
+ A target is a directory, not a product. Several agents can map to the same
659
+ one (opencode, github-copilot and windsurf all share agent-skills), which is
660
+ why "skillmux project attach" accepts --agent as shorthand for the target
661
+ that agent resolves to.`,
630
662
 
631
663
  core: `core: pin or unpin core-tier skills
632
664
 
@@ -649,22 +681,60 @@ Accepts --context <name> / --server <url> to prune a remote deployment's audit d
649
681
  scan: `scan: check the vault for install-time or integrity issues
650
682
 
651
683
  usage:
652
- skillmux scan [path] [--format text|json] [--fail-on low|medium|high] [--json]`,
684
+ skillmux scan [path] [--fail-on low|medium|high|none] [--json]
685
+
686
+ Scans [path], or the configured vault when omitted. Reporting only: it
687
+ exits 0 whatever it finds unless --fail-on names a severity, which is why
688
+ it has no default threshold while install and update default to high.
689
+
690
+ --format text|json is deprecated: it emits JSON outside the standard
691
+ envelope. Use --json instead; --format will be removed in a future 1.x
692
+ release.`,
653
693
 
654
694
  install: `install: install a skill from a git source
655
695
 
656
696
  usage:
657
- skillmux install <repo>[/path] [--force] [--fail-on low|medium|high] [--dry-run] [--allow-local-source] [--json]`,
697
+ skillmux install <repo>[/path] [--yes] [--force] [--fail-on low|medium|high|none] [--dry-run] [--allow-local-source] [--json]
698
+
699
+ --yes approves writing the skill into the vault. Without it an interactive
700
+ run asks first, and a non-interactive one (no TTY, or --json) fails rather
701
+ than installing unattended, matching "skillmux update".
702
+
703
+ The fetched skill is scanned before it is written to the vault. --fail-on
704
+ sets the severity that aborts the install and defaults to high; pass
705
+ --fail-on none to install despite findings. A lower threshold is stricter:
706
+ low aborts on low, medium and high.
707
+
708
+ --force overwrites a skill that already exists in the vault instead of
709
+ refusing. --dry-run reports where the skill would land without writing.
710
+ --allow-local-source permits a file:// or local path source, which is
711
+ otherwise rejected.`,
658
712
 
659
713
  outdated: `outdated: list installed skills with a newer upstream version
660
714
 
661
715
  usage:
662
- skillmux outdated [--allow-local-source] [--json]`,
716
+ skillmux outdated [--allow-local-source] [--json]
717
+
718
+ Read-only: it reports what "skillmux update" would change and writes
719
+ nothing. --allow-local-source includes skills installed from a local or
720
+ file:// source, which are skipped by default because their upstream is a
721
+ path on this machine rather than a shared remote.`,
663
722
 
664
723
  update: `update: update one or all skills to their latest source version
665
724
 
666
725
  usage:
667
- skillmux update [skill-id] [--yes] [--dry-run] [--force] [--allow-local-source] [--fail-on low|medium|high] [--json]`,
726
+ skillmux update [skill-id] [--yes] [--dry-run] [--force] [--allow-local-source] [--fail-on low|medium|high|none] [--json]
727
+
728
+ Updates every installed skill, or just <skill-id>. --yes is required to
729
+ apply non-interactively. --dry-run prints the plan without writing.
730
+
731
+ --fail-on works exactly as it does for install and defaults to high, so a
732
+ skill whose new version carries a high-severity finding is skipped rather
733
+ than updated; --fail-on none restores the old permissive behavior.
734
+
735
+ --force updates a skill whose local content no longer matches the hash
736
+ recorded at install time, which otherwise blocks the update to avoid
737
+ discarding local edits. --allow-local-source permits local/file:// sources.`,
668
738
 
669
739
  eval: `eval: run retrieval evaluation against the holdout set
670
740
 
@@ -725,17 +795,20 @@ See docs/deployment.md for server deployment examples.`);
725
795
  console.log(`usage: skillmux <command> [options]
726
796
 
727
797
  Setup:
728
- skillmux config init --vault <path> --yes
729
798
  skillmux init [--agent <name>...] [--vault <path>] [--core <skill_id>...]
730
- [--migrate-full-vault] [--no-instructions] [--no-sync]
799
+ [--migrate-full-vault] [--show-mcp-setup] [--register-mcp]
800
+ [--no-instructions] [--no-sync]
731
801
  [--interactive|--yes|--dry-run] [--json]
732
802
  skillmux project init [path] [--name <group>] [--skill <skill_id>...]
733
803
  [--agent <name>...] [--target <name>...] [--no-sync]
734
804
  [--interactive|--yes|--dry-run] [--json]
735
805
  skillmux project <list|show|add-path|remove-path|pin|unpin|attach|detach>
736
- skillmux target <list|show|add|remove>
806
+ skillmux target <list|show|add|remove> (a target is a directory sync writes into)
737
807
  skillmux core <pin|unpin> <skill_id>... [--yes] [--dry-run] [--json]
738
808
  skillmux skill which <skill_id> (local vault shadow resolution; unrelated to MCP routing)
809
+ skillmux config init --vault <path> --yes
810
+ (bootstraps this machine's config on its own; not a
811
+ prerequisite, since "skillmux init --vault" does the same)
739
812
 
740
813
  Init agents:
741
814
  ${SUPPORTED_AGENT_IDS.join(", ")}
@@ -748,7 +821,7 @@ Operations:
748
821
  skillmux audit prune [--older-than <window>] [--dry-run] [--yes] [--json]
749
822
  skillmux eval promote --since <window> [--out <path>] [--dry-run] [--yes] [--json]
750
823
  skillmux outdated [--allow-local-source] [--json]
751
- skillmux update [skill-id] [--yes] [--dry-run] [--force] [--allow-local-source] [--fail-on low|medium|high] [--json]
824
+ skillmux update [skill-id] [--yes] [--dry-run] [--force] [--allow-local-source] [--fail-on low|medium|high|none] [--json]
752
825
 
753
826
  Commands:
754
827
  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,
@@ -59,7 +59,23 @@ function configuredTargetsForAgents(
59
59
  manifest: ReturnType<typeof parseManifest>,
60
60
  agents: readonly string[],
61
61
  ): string[] {
62
- return planAgentSurfaces(agents).surfaces.map((surface) => {
62
+ const plan = planAgentSurfaces(agents);
63
+
64
+ // planAgentSurfaces silently drops any agent with no surfaceId, which is how
65
+ // full-vault agents (goose, hermes) are modelled: they get the whole vault
66
+ // rather than a sync target directory. Dropping one here used to make
67
+ // "project attach --agent goose" a successful no-op, so refuse instead.
68
+ const covered = new Set<string>(plan.surfaces.flatMap((surface) => surface.agents));
69
+ for (const agent of agents) {
70
+ if (!covered.has(agent)) {
71
+ throw new Error(
72
+ `agent "${agent}" uses full-vault delivery and maps to no sync target; ` +
73
+ `name a target directly with --target instead`,
74
+ );
75
+ }
76
+ }
77
+
78
+ return plan.surfaces.map((surface) => {
63
79
  const target = configuredTargetForSurface(manifest, surface);
64
80
  if (target) return target;
65
81
  const agent = surface.agents[0]!;
@@ -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);
@@ -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
 
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
+ }