@klhapp/skillmux 0.5.0 → 1.0.0

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.
@@ -1,14 +1,37 @@
1
1
  export type ShellType = "bash" | "zsh" | "fish";
2
2
 
3
+ const TOP_LEVEL_COMMANDS: { name: string; description: string }[] = [
4
+ { name: "context", description: "Manage connection contexts" },
5
+ { name: "config", description: "Manage configuration" },
6
+ { name: "calibrate", description: "Manage policy calibration" },
7
+ { name: "serve", description: "Start MCP server" },
8
+ { name: "index", description: "Rebuild local search index" },
9
+ { name: "sync", description: "Synchronize vault skills" },
10
+ { name: "init", description: "Configure this machine and its clients" },
11
+ { name: "project", description: "Configure project-scoped skills" },
12
+ { name: "target", description: "Manage advanced skill-delivery targets" },
13
+ { name: "core", description: "Pin/unpin skills into [core]" },
14
+ { name: "report", description: "Generate usage stats" },
15
+ { name: "scan", description: "Audit skills for issues" },
16
+ { name: "install", description: "Install skills into vault" },
17
+ { name: "eval", description: "Evaluate search accuracy" },
18
+ { name: "doctor", description: "Check runtime health" },
19
+ { name: "skill", description: "Show which root resolves a skill_id" },
20
+ { name: "local-vault", description: "Manage local_vault_paths discoverability markers" },
21
+ { name: "models", description: "Manage local models" },
22
+ { name: "completions", description: "Generate shell completions" },
23
+ ];
24
+
3
25
  export function generateCompletions(shell: ShellType): string {
4
26
  if (shell === "bash") {
27
+ const opts = [...TOP_LEVEL_COMMANDS.map((c) => c.name), "--context", "--server", "--json", "--allow-insecure", "--verbose", "--dry-run", "--help"].join(" ");
5
28
  return `# bash completion for skillmux
6
29
  _skillmux_completions() {
7
30
  local cur prev opts
8
31
  COMPREPLY=()
9
32
  cur="\${COMP_WORDS[COMP_CWORD]}"
10
33
  prev="\${COMP_WORDS[COMP_CWORD-1]}"
11
- opts="context config calibrate serve index sync init report scan install eval doctor which manifest local-vault models completions --context --server --json --allow-insecure --verbose --dry-run --help"
34
+ opts="${opts}"
12
35
 
13
36
  if [ "$COMP_CWORD" -eq 1 ]; then
14
37
  COMPREPLY=( $(compgen -W "$opts" -- "$cur") )
@@ -28,9 +51,18 @@ _skillmux_completions() {
28
51
  completions)
29
52
  COMPREPLY=( $(compgen -W "bash zsh fish" -- "$cur") )
30
53
  ;;
31
- manifest)
54
+ core)
32
55
  COMPREPLY=( $(compgen -W "pin unpin" -- "$cur") )
33
56
  ;;
57
+ skill)
58
+ COMPREPLY=( $(compgen -W "which" -- "$cur") )
59
+ ;;
60
+ project)
61
+ COMPREPLY=( $(compgen -W "init list show add-path remove-path pin unpin attach detach" -- "$cur") )
62
+ ;;
63
+ target)
64
+ COMPREPLY=( $(compgen -W "list show add remove" -- "$cur") )
65
+ ;;
34
66
  local-vault)
35
67
  COMPREPLY=( $(compgen -W "init" -- "$cur") )
36
68
  ;;
@@ -42,7 +74,10 @@ _skillmux_completions() {
42
74
  ;;
43
75
  esac
44
76
  if [ "\${COMP_WORDS[1]}" = "init" ] && [ "\${#COMPREPLY[@]}" -eq 0 ]; then
45
- COMPREPLY=( $(compgen -W "--client --target --path --vault --core --migrate-full-vault --yes --dry-run --json" -- "$cur") )
77
+ COMPREPLY=( $(compgen -W "--client --target --dir --vault --core --migrate-full-vault --no-instructions --no-sync --interactive --yes --dry-run --json" -- "$cur") )
78
+ fi
79
+ if [ "\${COMP_WORDS[1]}" = "project" ] && [ "\${COMP_WORDS[2]}" = "init" ]; then
80
+ COMPREPLY=( $(compgen -W "--name --skill --client --target --no-sync --interactive --yes --dry-run --json" -- "$cur") )
46
81
  fi
47
82
  }
48
83
  complete -F _skillmux_completions skillmux
@@ -50,41 +85,51 @@ complete -F _skillmux_completions skillmux
50
85
  }
51
86
 
52
87
  if (shell === "zsh") {
88
+ const commands = TOP_LEVEL_COMMANDS.map((c) => ` '${c.name}:${c.description}'`).join("\n");
53
89
  return `#compdef skillmux
54
90
  _skillmux() {
55
91
  local -a commands
56
92
  commands=(
57
- 'context:Manage connection contexts'
58
- 'config:Manage configuration'
59
- 'calibrate:Manage policy calibration'
60
- 'serve:Start MCP server'
61
- 'index:Rebuild local search index'
62
- 'sync:Synchronize vault skills'
63
- 'init:Initialize project targets'
64
- 'report:Generate usage stats'
65
- 'scan:Audit skills for issues'
66
- 'install:Install skills into vault'
67
- 'eval:Evaluate search accuracy'
68
- 'doctor:Check runtime health'
69
- 'which:Show which root resolves a skill_id'
70
- 'manifest:Pin/unpin skills into [core] or [project.*]'
71
- 'local-vault:Manage local_vault_paths discoverability markers'
72
- 'models:Manage local models'
73
- 'completions:Generate shell completions'
93
+ ${commands}
74
94
  )
75
95
  if (( CURRENT == 2 )); then
76
96
  _describe -t commands 'skillmux command' commands
77
97
  elif [[ "$words[2]" == "init" ]]; then
78
- _arguments \
79
- '*--client[select a client]:client:(claude-code codex gemini-cli opencode github-copilot windsurf antigravity goose hermes skillmux-mcp)' \
80
- '*--target[select a target]:target:(agent-skills claude-code codex custom)' \
81
- '--path[custom target directory]:directory:_directories' \
82
- '--vault[vault directory]:directory:_directories' \
83
- '*--core[seed a core skill]:skill id:' \
84
- '--migrate-full-vault[convert a full-vault symlink to managed pins]' \
85
- '--yes[apply without prompts]' \
86
- '--dry-run[print the plan without writing]' \
98
+ _arguments \\
99
+ '*--client[select a client]:client:(claude-code codex gemini-cli opencode github-copilot windsurf antigravity goose hermes skillmux-mcp)' \\
100
+ '*--target[select a delivery target]:target:(agent-skills claude-code codex custom)' \\
101
+ '--dir[custom target directory]:directory:_directories' \\
102
+ '--vault[vault directory]:directory:_directories' \\
103
+ '*--core[seed a core skill]:skill id:' \\
104
+ '--migrate-full-vault[convert a full-vault symlink to managed pins]' \\
105
+ '--no-instructions[skip managed instruction files]' \\
106
+ '--no-sync[save setup without synchronizing targets]' \\
107
+ '--interactive[force guided setup]' \\
108
+ '--yes[apply without prompts]' \\
109
+ '--dry-run[print the plan without writing]' \\
110
+ '--json[emit a JSON envelope]'
111
+ elif [[ "$words[2]" == "project" && "$words[3]" == "init" ]]; then
112
+ _arguments \\
113
+ '1:project directory:_directories' \\
114
+ '--name[project group name]:group:' \\
115
+ '*--skill[project skill]:skill id:' \\
116
+ '*--client[select a client]:client:(claude-code codex gemini-cli opencode github-copilot windsurf antigravity)' \\
117
+ '*--target[select an advanced target]:target:' \\
118
+ '--no-sync[save setup without synchronizing targets]' \\
119
+ '--interactive[force guided setup]' \\
120
+ '--yes[apply without prompts]' \\
121
+ '--dry-run[print the plan without writing]' \\
87
122
  '--json[emit a JSON envelope]'
123
+ elif [[ "$words[2]" == "project" && CURRENT == 3 ]]; then
124
+ _values 'project command' init list show add-path remove-path pin unpin attach detach
125
+ elif [[ "$words[2]" == "target" && CURRENT == 3 ]]; then
126
+ _values 'target command' list show add remove
127
+ elif [[ "$words[2]" == "skill" && CURRENT == 3 ]]; then
128
+ _values 'skill command' which
129
+ elif [[ "$words[2]" == "core" && CURRENT == 3 ]]; then
130
+ _values 'core command' pin unpin
131
+ elif [[ "$words[2]" == "local-vault" && CURRENT == 3 ]]; then
132
+ _values 'local-vault command' init
88
133
  fi
89
134
  }
90
135
  _skillmux "$@"
@@ -92,22 +137,36 @@ _skillmux "$@"
92
137
  }
93
138
 
94
139
  if (shell === "fish") {
140
+ const topLevel = TOP_LEVEL_COMMANDS.map(
141
+ (c) => `complete -c skillmux -n "__fish_use_subcommand" -a ${c.name} -d "${c.description}"`,
142
+ ).join("\n");
95
143
  return `# fish completion for skillmux
96
144
  complete -c skillmux -f
97
- complete -c skillmux -n "__fish_use_subcommand" -a context -d "Manage connection contexts"
98
- complete -c skillmux -n "__fish_use_subcommand" -a config -d "Manage configuration"
99
- complete -c skillmux -n "__fish_use_subcommand" -a calibrate -d "Manage policy calibration"
100
- complete -c skillmux -n "__fish_use_subcommand" -a serve -d "Start MCP server"
101
- complete -c skillmux -n "__fish_use_subcommand" -a completions -d "Generate shell completions"
145
+ ${topLevel}
102
146
  complete -c skillmux -n "__fish_seen_subcommand_from init" -l client -x -a "claude-code codex gemini-cli opencode github-copilot windsurf antigravity goose hermes skillmux-mcp" -d "Select a client"
103
- complete -c skillmux -n "__fish_seen_subcommand_from init" -l target -x -a "agent-skills claude-code codex custom" -d "Select a target"
104
- complete -c skillmux -n "__fish_seen_subcommand_from init" -l path -r -d "Custom target directory"
147
+ complete -c skillmux -n "__fish_seen_subcommand_from init" -l target -x -a "agent-skills claude-code codex custom" -d "Select a delivery target"
148
+ complete -c skillmux -n "__fish_seen_subcommand_from init" -l dir -r -d "Custom target directory"
105
149
  complete -c skillmux -n "__fish_seen_subcommand_from init" -l vault -r -d "Vault directory"
106
150
  complete -c skillmux -n "__fish_seen_subcommand_from init" -l core -x -d "Seed a core skill"
107
151
  complete -c skillmux -n "__fish_seen_subcommand_from init" -l migrate-full-vault -d "Convert a full-vault symlink"
152
+ complete -c skillmux -n "__fish_seen_subcommand_from init" -l no-instructions -d "Skip managed instruction files"
153
+ complete -c skillmux -n "__fish_seen_subcommand_from init" -l no-sync -d "Save without synchronizing"
154
+ complete -c skillmux -n "__fish_seen_subcommand_from init" -l interactive -d "Force guided setup"
108
155
  complete -c skillmux -n "__fish_seen_subcommand_from init" -l yes -d "Apply without prompts"
109
156
  complete -c skillmux -n "__fish_seen_subcommand_from init" -l dry-run -d "Print the plan without writing"
110
157
  complete -c skillmux -n "__fish_seen_subcommand_from init" -l json -d "Emit a JSON envelope"
158
+ complete -c skillmux -n "__fish_seen_subcommand_from project" -a "init list show add-path remove-path pin unpin attach detach" -d "Manage projects"
159
+ complete -c skillmux -n "__fish_seen_subcommand_from project" -l name -x -d "Project group name"
160
+ complete -c skillmux -n "__fish_seen_subcommand_from project" -l skill -x -d "Project skill"
161
+ complete -c skillmux -n "__fish_seen_subcommand_from project" -l client -x -a "claude-code codex gemini-cli opencode github-copilot windsurf antigravity" -d "Select a client"
162
+ complete -c skillmux -n "__fish_seen_subcommand_from project" -l target -x -d "Select an advanced delivery target"
163
+ complete -c skillmux -n "__fish_seen_subcommand_from project" -l no-sync -d "Save without synchronizing"
164
+ complete -c skillmux -n "__fish_seen_subcommand_from project" -l interactive -d "Force guided setup"
165
+ complete -c skillmux -n "__fish_seen_subcommand_from project" -l yes -d "Apply without prompts"
166
+ complete -c skillmux -n "__fish_seen_subcommand_from target" -a "list show add remove" -d "Manage targets"
167
+ complete -c skillmux -n "__fish_seen_subcommand_from core" -a "pin unpin" -d "Manage [core] pins"
168
+ complete -c skillmux -n "__fish_seen_subcommand_from skill" -a "which" -d "Show which root resolves a skill_id"
169
+ complete -c skillmux -n "__fish_seen_subcommand_from local-vault" -a "init" -d "Initialize a local_vault_paths marker"
111
170
  `;
112
171
  }
113
172
 
@@ -14,6 +14,7 @@ export const LIVE_RELOAD_KEYS = new Set([
14
14
  "inference.thresholds.match_score",
15
15
  "inference.thresholds.match_margin",
16
16
  "inference.thresholds.candidate_floor",
17
+ "inference.calibration.run_id",
17
18
  "recall.k_lexical",
18
19
  "recall.k_vector",
19
20
  "thresholds.candidate_limit",
@@ -53,6 +54,47 @@ const DEBOUNCE_MS = 300;
53
54
  const STABLE_STAT_INTERVAL_MS = 80;
54
55
  const STABLE_STAT_MAX_TRIES = 8;
55
56
 
57
+ function isRecord(value: unknown): value is Record<string, unknown> {
58
+ return typeof value === "object" && value !== null && !Array.isArray(value);
59
+ }
60
+
61
+ function changedConfigKeys(
62
+ previous: Record<string, unknown>,
63
+ next: Record<string, unknown>,
64
+ prefix = "",
65
+ ): string[] {
66
+ const changed: string[] = [];
67
+ const keys = new Set([...Object.keys(previous), ...Object.keys(next)]);
68
+
69
+ for (const key of keys) {
70
+ const path = prefix ? `${prefix}.${key}` : key;
71
+ const previousValue = previous[key];
72
+ const nextValue = next[key];
73
+ if (!(key in previous) || !(key in next)) {
74
+ if (isRecord(previousValue) || isRecord(nextValue)) {
75
+ changed.push(
76
+ ...changedConfigKeys(
77
+ isRecord(previousValue) ? previousValue : {},
78
+ isRecord(nextValue) ? nextValue : {},
79
+ path,
80
+ ),
81
+ );
82
+ } else {
83
+ changed.push(path);
84
+ }
85
+ continue;
86
+ }
87
+
88
+ if (isRecord(previousValue) && isRecord(nextValue)) {
89
+ changed.push(...changedConfigKeys(previousValue, nextValue, path));
90
+ } else if (JSON.stringify(previousValue) !== JSON.stringify(nextValue)) {
91
+ changed.push(path);
92
+ }
93
+ }
94
+
95
+ return changed;
96
+ }
97
+
56
98
  /**
57
99
  * Watch the parent directory of a TOML config file for changes.
58
100
  * Parent-dir watching catches both direct writes (change events) and
@@ -79,6 +121,7 @@ export class ConfigWatcher {
79
121
  private constructor(
80
122
  private readonly tomlPath: string,
81
123
  private readonly opts: ConfigWatcherOptions,
124
+ private activeConfig: Config,
82
125
  ) {
83
126
  const dir = dirname(tomlPath);
84
127
  const filename = tomlPath.split(/[/\\]/).pop()!;
@@ -110,7 +153,8 @@ export class ConfigWatcher {
110
153
  tomlPath: string,
111
154
  opts: ConfigWatcherOptions,
112
155
  ): Promise<ConfigWatcher> {
113
- return new ConfigWatcher(tomlPath, opts);
156
+ const activeConfig = await loadConfig(tomlPath);
157
+ return new ConfigWatcher(tomlPath, opts, activeConfig);
114
158
  }
115
159
 
116
160
  private scheduleReload(): void {
@@ -130,10 +174,27 @@ export class ConfigWatcher {
130
174
 
131
175
  try {
132
176
  const config = await loadConfig(this.tomlPath);
177
+ const restartRequiredKeys = changedConfigKeys(
178
+ this.activeConfig as unknown as Record<string, unknown>,
179
+ config as unknown as Record<string, unknown>,
180
+ )
181
+ .filter((key) => !LIVE_RELOAD_KEYS.has(key))
182
+ .sort();
183
+
184
+ if (restartRequiredKeys.length > 0) {
185
+ this.status = {
186
+ ...this.status,
187
+ last_reload_error: null,
188
+ restart_required_keys: restartRequiredKeys,
189
+ };
190
+ return;
191
+ }
192
+
193
+ this.activeConfig = config;
133
194
  this.status = {
134
195
  last_successful_reload_at: new Date().toISOString(),
135
196
  last_reload_error: null,
136
- restart_required_keys: this.status.restart_required_keys,
197
+ restart_required_keys: [],
137
198
  };
138
199
  this.opts.onReload(config);
139
200
  } catch (err) {
package/src/doctor.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { existsSync, mkdirSync } from "node:fs";
2
2
  import { createClients } from "./clients";
3
3
  import { embeddingDimension, expandHome } from "./config";
4
- import { resolveManifestPath } from "./manifest";
4
+ import { parseManifest, resolveManifestPath, validateManifest } from "./manifest";
5
5
  import { readSkillmuxMarker } from "./sync";
6
6
  import type { Config } from "./types";
7
7
  import { findShadowedSkills } from "./vault";
@@ -62,6 +62,24 @@ export async function diagnose(config: Config): Promise<DoctorReport> {
62
62
  });
63
63
  }
64
64
 
65
+ const vaultPath = expandHome(config.vault_path);
66
+ const manifestPath = resolveManifestPath(vaultPath);
67
+ if (!manifestPath) {
68
+ checks.push({ name: "manifest", ok: true, detail: "not yet initialized" });
69
+ } else {
70
+ try {
71
+ const manifest = parseManifest(await Bun.file(manifestPath).text());
72
+ validateManifest(manifest, vaultPath, config.local_vault_paths.map(expandHome));
73
+ checks.push({ name: "manifest", ok: true, detail: manifestPath });
74
+ } catch (error) {
75
+ checks.push({
76
+ name: `manifest:${manifestPath}`,
77
+ ok: false,
78
+ detail: error instanceof Error ? error.message : String(error),
79
+ });
80
+ }
81
+ }
82
+
65
83
  try {
66
84
  mkdirSync(expandHome(config.state_dir), { recursive: true });
67
85
  const probe = Bun.file(expandHome(`${config.state_dir}/.doctor`));
@@ -1,3 +1,4 @@
1
+ import { existsSync } from "node:fs";
1
2
  import { homedir } from "node:os";
2
3
  import { join } from "node:path";
3
4
 
@@ -17,6 +18,11 @@ export const SUPPORTED_CLIENT_IDS = [
17
18
  export type ClientId = (typeof SUPPORTED_CLIENT_IDS)[number];
18
19
  export type DeliveryMode = "managed-pins" | "full-vault" | "mcp";
19
20
 
21
+ export interface DetectedClient {
22
+ client: ClientId;
23
+ evidence: string;
24
+ }
25
+
20
26
  interface ClientDefinition {
21
27
  id: ClientId;
22
28
  surfaceId?: "agent-skills" | "claude-code" | "codex" | "antigravity";
@@ -69,6 +75,31 @@ const CLIENTS: Record<ClientId, ClientDefinition> = {
69
75
  "skillmux-mcp": { id: "skillmux-mcp", deliveryMode: "mcp" },
70
76
  };
71
77
 
78
+ export function detectInstalledClients(
79
+ options: {
80
+ home?: string;
81
+ codexHome?: string;
82
+ exists?: (path: string) => boolean;
83
+ } = {},
84
+ ): DetectedClient[] {
85
+ const home = options.home ?? homedir();
86
+ const codexHome = options.codexHome ?? join(home, ".codex");
87
+ const exists = options.exists ?? existsSync;
88
+ const candidates: Array<[ClientId, string]> = [
89
+ ["claude-code", join(home, ".claude")],
90
+ ["codex", codexHome],
91
+ ["gemini-cli", join(home, ".gemini")],
92
+ ["opencode", join(home, ".config", "opencode")],
93
+ ["github-copilot", join(home, ".config", "github-copilot")],
94
+ ["windsurf", join(home, ".codeium", "windsurf")],
95
+ ["goose", join(home, ".config", "goose")],
96
+ ["hermes", join(home, ".hermes")],
97
+ ];
98
+ return candidates
99
+ .filter(([, evidence]) => exists(evidence))
100
+ .map(([client, evidence]) => ({ client, evidence }));
101
+ }
102
+
72
103
  function surfacePath(
73
104
  surfaceId: NonNullable<ClientDefinition["surfaceId"]>,
74
105
  options: { home: string; codexHome?: string },
package/src/manifest.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { existsSync } from "node:fs";
1
+ 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";
@@ -32,7 +32,7 @@ const targetSchema = z.object({
32
32
  const manifestSchema = z.object({
33
33
  core: z.object({ skills: z.array(skillIdSchema) }).strict(),
34
34
  project: z.record(groupNameSchema, projectGroupSchema).optional(),
35
- targets: z.record(groupNameSchema, targetSchema),
35
+ targets: z.record(groupNameSchema, targetSchema).default({}),
36
36
  }).strict();
37
37
 
38
38
  export type ProjectGroup = z.infer<typeof projectGroupSchema>;
@@ -94,6 +94,16 @@ export function serializeManifest(manifest: Manifest): string {
94
94
  return `${sections.join("\n\n")}\n`;
95
95
  }
96
96
 
97
+ export function writeManifestAtomic(path: string, manifest: Manifest): void {
98
+ const temporaryPath = `${path}.${process.pid}.${crypto.randomUUID()}.tmp`;
99
+ try {
100
+ writeFileSync(temporaryPath, serializeManifest(manifest), "utf8");
101
+ renameSync(temporaryPath, path);
102
+ } finally {
103
+ rmSync(temporaryPath, { force: true });
104
+ }
105
+ }
106
+
97
107
  function findExistingPin(manifest: Manifest, skillId: string): string | null {
98
108
  if (manifest.core.skills.includes(skillId)) return "[core]";
99
109
  for (const [groupName, group] of Object.entries(manifest.project ?? {})) {
@@ -167,6 +177,95 @@ export function unpinProject(manifest: Manifest, skillId: string, group: string)
167
177
  };
168
178
  }
169
179
 
180
+ export interface UpsertProjectOptions {
181
+ name: string;
182
+ paths: string[];
183
+ skills: string[];
184
+ targets: string[];
185
+ }
186
+
187
+ export function upsertProject(manifest: Manifest, options: UpsertProjectOptions): Manifest {
188
+ if (!groupNameSchema.safeParse(options.name).success) {
189
+ throw new Error(
190
+ `invalid group name "${options.name}" — must match /^[a-z][a-z0-9_-]*$/ (max 64 chars)`,
191
+ );
192
+ }
193
+
194
+ const existingGroup = manifest.project?.[options.name] ?? { paths: [], skills: [] };
195
+ for (const skillId of options.skills) {
196
+ if (!skillIdSchema.safeParse(skillId).success) {
197
+ throw new Error(`invalid skill ID "${skillId}"`);
198
+ }
199
+ if (existingGroup.skills.includes(skillId)) continue;
200
+ const existing = findExistingPin(manifest, skillId);
201
+ if (existing) throw new Error(`skill "${skillId}" already pinned in ${existing}`);
202
+ }
203
+
204
+ const targets = { ...manifest.targets };
205
+ for (const targetName of options.targets) {
206
+ const target = targets[targetName];
207
+ if (!target) throw new Error(`target "${targetName}" does not exist`);
208
+ targets[targetName] = {
209
+ ...target,
210
+ project_groups: [...new Set([...target.project_groups, options.name])],
211
+ };
212
+ }
213
+
214
+ return {
215
+ ...manifest,
216
+ project: {
217
+ ...manifest.project,
218
+ [options.name]: {
219
+ paths: [...new Set([...existingGroup.paths, ...options.paths])],
220
+ skills: [...new Set([...existingGroup.skills, ...options.skills])],
221
+ },
222
+ },
223
+ targets,
224
+ };
225
+ }
226
+
227
+ export function updateProjectPaths(
228
+ manifest: Manifest,
229
+ group: string,
230
+ changes: { add?: string[]; remove?: string[] },
231
+ ): Manifest {
232
+ const existingGroup = manifest.project?.[group];
233
+ if (!existingGroup) throw new Error(`[project.${group}] does not exist`);
234
+ const removed = new Set(changes.remove ?? []);
235
+ const paths = [...new Set([...existingGroup.paths, ...(changes.add ?? [])])]
236
+ .filter((path) => !removed.has(path));
237
+ return {
238
+ ...manifest,
239
+ project: {
240
+ ...manifest.project,
241
+ [group]: { ...existingGroup, paths },
242
+ },
243
+ };
244
+ }
245
+
246
+ export function updateProjectTargets(
247
+ manifest: Manifest,
248
+ group: string,
249
+ changes: { attach?: string[]; detach?: string[] },
250
+ ): Manifest {
251
+ if (!manifest.project?.[group]) throw new Error(`[project.${group}] does not exist`);
252
+ const attach = new Set(changes.attach ?? []);
253
+ const detach = new Set(changes.detach ?? []);
254
+ const requested = new Set([...attach, ...detach]);
255
+ for (const target of requested) {
256
+ if (!manifest.targets[target]) throw new Error(`target "${target}" does not exist`);
257
+ }
258
+ return {
259
+ ...manifest,
260
+ targets: Object.fromEntries(Object.entries(manifest.targets).map(([name, target]) => {
261
+ let groups = target.project_groups;
262
+ if (attach.has(name)) groups = [...new Set([...groups, group])];
263
+ if (detach.has(name)) groups = groups.filter((item) => item !== group);
264
+ return [name, { ...target, project_groups: groups }];
265
+ })),
266
+ };
267
+ }
268
+
170
269
  export interface ManifestValidationResult {
171
270
  notes: string[];
172
271
  }
package/src/output.ts CHANGED
@@ -36,33 +36,31 @@ export function formatJsonEnvelope<T>(opts: {
36
36
  };
37
37
  }
38
38
 
39
- export function mapExitCode(err: unknown): number {
40
- if (!err) return 0;
41
- const msg = err instanceof Error ? err.message : String(err);
42
- const lower = msg.toLowerCase();
43
-
44
- if (
45
- lower.includes("conflict") ||
46
- lower.includes("revision") ||
47
- lower.includes("externally managed") ||
48
- lower.includes("config_revision_conflict") ||
49
- lower.includes("config_externally_managed")
50
- ) {
51
- return 4;
39
+ export class CliError extends Error {
40
+ exitCode: number;
41
+
42
+ constructor(message: string, exitCode: number) {
43
+ super(message);
44
+ this.name = "CliError";
45
+ this.exitCode = exitCode;
52
46
  }
47
+ }
53
48
 
54
- if (
55
- lower.includes("unreachable") ||
56
- lower.includes("failed to reach") ||
57
- lower.includes("unauthorized") ||
58
- lower.includes("unauthenticated") ||
59
- lower.includes("401") ||
60
- lower.includes("403") ||
61
- lower.includes("connection refused")
62
- ) {
63
- return 3;
49
+ export function emitSuccess<T>(
50
+ ctx: { isJson: boolean; target?: ResolvedTarget | string | { name: string; server: string } },
51
+ data: T,
52
+ renderText: () => void,
53
+ ): void {
54
+ if (ctx.isJson) {
55
+ console.log(JSON.stringify(formatJsonEnvelope({ ok: true, target: ctx.target ?? "local", data })));
56
+ } else {
57
+ renderText();
64
58
  }
59
+ }
65
60
 
61
+ export function mapExitCode(err: unknown): number {
62
+ if (!err) return 0;
63
+ if (err instanceof CliError) return err.exitCode;
66
64
  return 2;
67
65
  }
68
66
 
@@ -110,12 +108,11 @@ export function suggestCorrection(input: string, candidates: string[]): string |
110
108
  return bestMatch;
111
109
  }
112
110
 
113
- export function isInteractive(): boolean {
114
- return (
115
- process.stdout.isTTY === true &&
116
- !process.env.NO_COLOR &&
117
- process.env.TERM !== "dumb"
118
- );
111
+ export function isInteractive(
112
+ env: NodeJS.ProcessEnv = process.env,
113
+ stdoutIsTTY = process.stdout.isTTY,
114
+ ): boolean {
115
+ return stdoutIsTTY === true && env.TERM !== "dumb";
119
116
  }
120
117
 
121
118
  export function renderTargetBanner(target: ResolvedTarget): void {
@@ -0,0 +1,36 @@
1
+ import { resolve } from "node:path";
2
+
3
+ interface ResolveProjectDirectoryOptions {
4
+ cwd?: string;
5
+ findGitRoot?: (cwd: string) => string | null;
6
+ }
7
+
8
+ function findGitRoot(cwd: string): string | null {
9
+ const result = Bun.spawnSync(["git", "rev-parse", "--show-toplevel"], {
10
+ cwd,
11
+ stdout: "pipe",
12
+ stderr: "ignore",
13
+ });
14
+ if (result.exitCode !== 0) return null;
15
+ const root = result.stdout.toString().trim();
16
+ return root || null;
17
+ }
18
+
19
+ export function resolveProjectDirectory(
20
+ explicitPath?: string,
21
+ options: ResolveProjectDirectoryOptions = {},
22
+ ): string {
23
+ const cwd = options.cwd ?? process.cwd();
24
+ if (explicitPath) return resolve(explicitPath);
25
+ const gitRoot = (options.findGitRoot ?? findGitRoot)(cwd);
26
+ return resolve(gitRoot ?? cwd);
27
+ }
28
+
29
+ export function suggestProjectName(directoryName: string): string {
30
+ const slug = directoryName
31
+ .toLowerCase()
32
+ .replace(/[^a-z0-9_-]+/g, "-")
33
+ .replace(/^-+|-+$/g, "");
34
+ const prefixed = /^[a-z]/.test(slug) ? slug : `project-${slug || "workspace"}`;
35
+ return prefixed.slice(0, 64).replace(/-+$/g, "");
36
+ }