@klhapp/skillmux 1.9.2 → 1.10.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.
Files changed (47) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/README.md +1 -1
  3. package/docs/README.md +1 -1
  4. package/docs/cli.md +74 -3
  5. package/docs/concepts.md +1 -1
  6. package/docs/configuration.md +52 -1
  7. package/docs/deployment.md +10 -6
  8. package/docs/getting-started.md +1 -1
  9. package/docs/skill-management.md +49 -0
  10. package/package.json +1 -1
  11. package/src/adapters.ts +148 -2
  12. package/src/cli.ts +302 -1291
  13. package/src/clients.ts +17 -0
  14. package/src/commands/audit.ts +54 -51
  15. package/src/commands/config.ts +11 -12
  16. package/src/commands/context.ts +103 -0
  17. package/src/commands/core.ts +5 -1
  18. package/src/commands/doctor.ts +76 -0
  19. package/src/commands/eval.ts +10 -13
  20. package/src/commands/init.ts +621 -0
  21. package/src/commands/install.ts +132 -0
  22. package/src/commands/local-vault.ts +60 -0
  23. package/src/commands/models.ts +10 -0
  24. package/src/commands/outdated.ts +8 -5
  25. package/src/commands/project.ts +37 -11
  26. package/src/commands/report.ts +66 -0
  27. package/src/commands/scan.ts +61 -0
  28. package/src/commands/skill.ts +32 -0
  29. package/src/commands/sync.ts +232 -0
  30. package/src/commands/target.ts +18 -6
  31. package/src/commands/update.ts +11 -5
  32. package/src/concurrency-limiter.ts +61 -0
  33. package/src/config-service.ts +1 -51
  34. package/src/config.ts +5 -0
  35. package/src/context.ts +8 -3
  36. package/src/db-audit.ts +286 -0
  37. package/src/db-index.ts +238 -0
  38. package/src/db.ts +3 -413
  39. package/src/global-flags.ts +46 -0
  40. package/src/install.ts +15 -0
  41. package/src/logger.ts +26 -0
  42. package/src/output.ts +30 -5
  43. package/src/redact.ts +52 -0
  44. package/src/router-core.ts +8 -27
  45. package/src/server.ts +594 -267
  46. package/src/toml-writer.ts +51 -0
  47. package/src/types.ts +7 -0
@@ -0,0 +1,232 @@
1
+ import { existsSync } from "node:fs";
2
+ import { hostname } from "node:os";
3
+ import { expandHome, loadConfig } from "../config";
4
+ import {
5
+ parseManifest,
6
+ resolveManifestPath,
7
+ validateManifest,
8
+ } from "../manifest";
9
+ import { emitSuccess, isInteractive, warn } from "../output";
10
+ import {
11
+ installPostMergeHook,
12
+ resolveProjectPinDir,
13
+ restoreMonolith as restoreMonolithTarget,
14
+ syncProjectTargets,
15
+ syncTarget,
16
+ type ProjectGroupInput,
17
+ } from "../sync";
18
+ import { confirmAction } from "./shared";
19
+
20
+ function parseSyncArgs(args: string[]): {
21
+ dryRun: boolean;
22
+ restoreMonolith: boolean;
23
+ installHook: boolean;
24
+ yes: boolean;
25
+ isJson: boolean;
26
+ } {
27
+ let dryRun = false;
28
+ let restoreMonolith = false;
29
+ let installHook = false;
30
+ let yes = false;
31
+ let isJson = false;
32
+ for (const arg of args) {
33
+ if (arg === "--dry-run") dryRun = true;
34
+ else if (arg === "--restore-monolith") restoreMonolith = true;
35
+ else if (arg === "--install-hook") installHook = true;
36
+ else if (arg === "--yes") yes = true;
37
+ else if (arg === "--json") isJson = true;
38
+ else throw new Error(`unknown sync option: ${arg}`);
39
+ }
40
+ return { dryRun, restoreMonolith, installHook, yes, isJson };
41
+ }
42
+
43
+ /**
44
+ * A target directory that doesn't exist yet is about to be created by `sync`.
45
+ * `manifest.targets[*].dir` is vault content — readable and writable by whatever
46
+ * populated the vault (a shared git-backed vault pulled in, or a hand-edit) — and
47
+ * `sync` can run unattended via the `--install-hook` post-merge hook. Without this
48
+ * gate, a tampered manifest naming a brand-new path gets that directory silently
49
+ * created (and populated with symlinks) the next time anyone pulls. Creation for
50
+ * an as-yet-unseen directory therefore requires either `--yes` or an interactive
51
+ * confirmation; once the directory exists, later syncs never hit this path again.
52
+ */
53
+ async function confirmNewSyncTarget(
54
+ label: string,
55
+ dir: string,
56
+ yes: boolean,
57
+ isJson: boolean,
58
+ ): Promise<boolean> {
59
+ if (yes) return true;
60
+ if (!isInteractive()) {
61
+ if (!isJson) {
62
+ console.log(
63
+ `${label}: skipped — ${dir} does not exist yet; creating it requires approval. Re-run "skillmux sync --yes", or run "skillmux sync" interactively, once you've confirmed this target is expected.`,
64
+ );
65
+ }
66
+ return false;
67
+ }
68
+ return confirmAction(`${label}: create new target directory ${dir}?`);
69
+ }
70
+
71
+ interface SyncTargetSummary {
72
+ target: string;
73
+ status:
74
+ | "synced"
75
+ | "skipped_host_mismatch"
76
+ | "restored"
77
+ | "not_owned"
78
+ | "skipped_not_approved";
79
+ added?: string[];
80
+ removed?: string[];
81
+ skipped?: string[];
82
+ projects?: {
83
+ group: string;
84
+ pin_dir: string;
85
+ added: string[];
86
+ removed: string[];
87
+ skipped: string[];
88
+ }[];
89
+ }
90
+
91
+ export async function runSync(args: string[]): Promise<void> {
92
+ const { dryRun, restoreMonolith, installHook, yes, isJson } = parseSyncArgs(args);
93
+ const config = await loadConfig();
94
+ const vaultPath = expandHome(config.vault_path);
95
+ const log = (line: string) => {
96
+ if (!isJson) console.log(line);
97
+ };
98
+ const warnLine = (line: string) => {
99
+ if (!isJson) warn(line);
100
+ };
101
+
102
+ let hookInstalled: boolean | undefined;
103
+ if (installHook) {
104
+ const result = installPostMergeHook(vaultPath);
105
+ hookInstalled = result.installed;
106
+ log(result.installed ? "installed post-merge hook" : "post-merge hook already installed");
107
+ }
108
+
109
+ const manifestPath = resolveManifestPath(vaultPath);
110
+ if (!manifestPath) {
111
+ emitSuccess({ isJson }, { hook_installed: hookInstalled ?? null, targets: [] }, () =>
112
+ console.log("no skillmux.toml found at vault root — nothing to sync"),
113
+ );
114
+ return;
115
+ }
116
+
117
+ const manifest = parseManifest(await Bun.file(manifestPath).text());
118
+ const localVaultPaths = config.local_vault_paths.map(expandHome);
119
+ const { notes } = validateManifest(manifest, vaultPath, localVaultPaths);
120
+ for (const note of notes) log(`note: ${note}`);
121
+
122
+ const currentHost = hostname();
123
+ const targetSummaries: SyncTargetSummary[] = [];
124
+ for (const [targetName, target] of Object.entries(manifest.targets)) {
125
+ if (target.host !== undefined && target.host !== currentHost) {
126
+ log(
127
+ `${targetName}: skipped (host ${target.host} does not match current host ${currentHost})`,
128
+ );
129
+ targetSummaries.push({ target: targetName, status: "skipped_host_mismatch" });
130
+ continue;
131
+ }
132
+ const targetDir = expandHome(target.dir);
133
+
134
+ if (restoreMonolith) {
135
+ const result = restoreMonolithTarget(targetDir, vaultPath);
136
+ log(
137
+ result.restored
138
+ ? `${targetName}: restored to a vault symlink`
139
+ : `${targetName}: not owned by skillmux, left untouched`,
140
+ );
141
+ targetSummaries.push({
142
+ target: targetName,
143
+ status: result.restored ? "restored" : "not_owned",
144
+ });
145
+ continue;
146
+ }
147
+
148
+ if (!dryRun && !existsSync(targetDir)) {
149
+ const approved = await confirmNewSyncTarget(targetName, targetDir, yes, isJson);
150
+ if (!approved) {
151
+ if (isInteractive()) {
152
+ log(`${targetName}: skipped — creating ${targetDir} was not approved`);
153
+ }
154
+ targetSummaries.push({ target: targetName, status: "skipped_not_approved" });
155
+ continue;
156
+ }
157
+ }
158
+
159
+ const suffix = dryRun ? " (dry-run)" : "";
160
+ const result = syncTarget(
161
+ {
162
+ vaultPath,
163
+ targetDir,
164
+ targetName,
165
+ coreSkillIds: manifest.core.skills,
166
+ localVaultPaths,
167
+ },
168
+ { dryRun },
169
+ );
170
+ log(`${targetName}: +${result.added.length} -${result.removed.length}${suffix}`);
171
+ if (result.skipped.length > 0) {
172
+ warnLine(`refused to sync ${result.skipped.join(", ")} — skill directory contains a symlink`);
173
+ }
174
+ const summary: SyncTargetSummary = {
175
+ target: targetName,
176
+ status: "synced",
177
+ added: result.added,
178
+ removed: result.removed,
179
+ skipped: result.skipped,
180
+ };
181
+
182
+ if (target.project_groups.length > 0) {
183
+ const allGroups = manifest.project ?? {};
184
+ const projectGroups: Record<string, ProjectGroupInput> = {};
185
+ for (const groupName of target.project_groups) {
186
+ const group = allGroups[groupName]!;
187
+ const approvedPaths: string[] = [];
188
+ for (const path of group.paths) {
189
+ // Mirror syncProjectTargets' own `if (!existsSync(path)) continue` so we
190
+ // never prompt for a project path it would silently skip anyway.
191
+ if (!existsSync(path)) continue;
192
+ const pinDir = resolveProjectPinDir(targetDir, path);
193
+ if (dryRun || existsSync(pinDir)) {
194
+ approvedPaths.push(path);
195
+ continue;
196
+ }
197
+ const approved = await confirmNewSyncTarget(`${targetName}/${groupName}`, pinDir, yes, isJson);
198
+ if (approved) approvedPaths.push(path);
199
+ }
200
+ projectGroups[groupName] = { ...group, paths: approvedPaths };
201
+ }
202
+ const projectResults = syncProjectTargets(
203
+ { vaultPath, targetDir, targetName, projectGroups, localVaultPaths },
204
+ { dryRun },
205
+ );
206
+ summary.projects = projectResults.map((projectResult) => ({
207
+ group: projectResult.group,
208
+ pin_dir: projectResult.pinDir,
209
+ added: projectResult.added,
210
+ removed: projectResult.removed,
211
+ skipped: projectResult.skipped,
212
+ }));
213
+ for (const projectResult of projectResults) {
214
+ log(
215
+ ` ${projectResult.group} -> ${projectResult.pinDir}: +${projectResult.added.length} -${projectResult.removed.length}${suffix}`,
216
+ );
217
+ if (projectResult.skipped.length > 0) {
218
+ warnLine(
219
+ `refused to sync ${projectResult.skipped.join(", ")} — skill directory contains a symlink`,
220
+ );
221
+ }
222
+ }
223
+ }
224
+ targetSummaries.push(summary);
225
+ }
226
+
227
+ emitSuccess(
228
+ { isJson },
229
+ { hook_installed: hookInstalled ?? null, notes, targets: targetSummaries },
230
+ () => {},
231
+ );
232
+ }
@@ -63,14 +63,16 @@ export async function runTarget(
63
63
  !(await confirmIfNeeded({
64
64
  confirmed: args.includes("--yes"),
65
65
  isJson: options.isJson,
66
- prompt: `Adopt target ${name} at ${path}?`,
66
+ prompt: `adopt target ${name} at ${path}?`,
67
67
  nonInteractiveError:
68
68
  "skillmux target add requires --yes when run non-interactively",
69
69
  }))
70
70
  )
71
71
  return;
72
72
  applyInit(vaultPath, [{ name, dir: path }]);
73
- console.log(`target "${name}" added at ${path}`);
73
+ emitSuccess({ isJson: options.isJson }, { name, dir: path }, () =>
74
+ console.log(`target "${name}" added at ${path}`),
75
+ );
74
76
  return;
75
77
  }
76
78
 
@@ -84,24 +86,34 @@ export async function runTarget(
84
86
  );
85
87
  }
86
88
  if (options.dryRun) {
87
- console.log(`target remove: ${name} (files preserved, dry-run)`);
89
+ emitSuccess(
90
+ { isJson: options.isJson },
91
+ { name, preserved_dir: manifest.targets[name]!.dir },
92
+ () => console.log(`target remove: ${name} (files preserved, dry-run)`),
93
+ );
88
94
  return;
89
95
  }
90
96
  if (
91
97
  !(await confirmIfNeeded({
92
98
  confirmed: args.includes("--yes"),
93
99
  isJson: options.isJson,
94
- prompt: `Remove target ${name} from the manifest and preserve its files?`,
100
+ prompt: `remove target ${name} from the manifest and preserve its files?`,
95
101
  nonInteractiveError:
96
102
  "skillmux target remove requires --yes when run non-interactively",
97
103
  }))
98
104
  )
99
105
  return;
100
106
  const targets = { ...manifest.targets };
107
+ const removedDir = manifest.targets[name]!.dir;
101
108
  delete targets[name];
102
109
  writeManifestAtomic(manifestPath, { ...manifest, targets });
103
- console.log(
104
- `target "${name}" removed from the manifest; files preserved at ${manifest.targets[name]!.dir}`,
110
+ emitSuccess(
111
+ { isJson: options.isJson },
112
+ { name, preserved_dir: removedDir },
113
+ () =>
114
+ console.log(
115
+ `target "${name}" removed from the manifest; files preserved at ${removedDir}`,
116
+ ),
105
117
  );
106
118
  return;
107
119
  }
@@ -2,6 +2,7 @@ import { rmSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { expandHome, loadConfig } from "../config";
4
4
  import {
5
+ assertHostAllowed,
5
6
  cloneToTemp,
6
7
  installIntoVault,
7
8
  isLocalFileUrl,
@@ -17,6 +18,7 @@ import { type ScanFinding, type ScanSeverity, scanExitCode } from "../scan";
17
18
  import { SKILL_ID_PATTERN } from "../vault";
18
19
  import { confirmIfNeeded } from "./shared";
19
20
  import { checkOutdated } from "./outdated";
21
+ import { isGlobalFlag } from "../global-flags";
20
22
 
21
23
  type UpdateKind = "update" | "up_to_date" | "skip_drift" | "skip_scan_failed" | "skip_read_error";
22
24
 
@@ -37,6 +39,7 @@ async function resolveCandidateOrigins(
37
39
  vaultPath: string,
38
40
  skillId: string | undefined,
39
41
  allowLocalSource: boolean,
42
+ allowedHosts: string[] | undefined,
40
43
  ): Promise<{ skillId: string; origin: SkillOrigin }[]> {
41
44
  if (skillId) {
42
45
  // skillId (the CLI's positional <skill-id>) is joined straight into vaultPath
@@ -65,7 +68,7 @@ async function resolveCandidateOrigins(
65
68
  }
66
69
  return [{ skillId, origin }];
67
70
  }
68
- const outdated = await checkOutdated(vaultPath, { allowLocalSource });
71
+ const outdated = await checkOutdated(vaultPath, { allowLocalSource, allowedHosts });
69
72
  return outdated
70
73
  .filter((result) => result.status === "outdated")
71
74
  .map((result) => ({ skillId: result.skill_id, origin: readSkillOrigin(join(vaultPath, result.skill_id))! }));
@@ -76,6 +79,7 @@ async function buildPlan(
76
79
  candidates: { skillId: string; origin: SkillOrigin }[],
77
80
  failOn: ScanSeverity | undefined,
78
81
  force: boolean,
82
+ allowedHosts: string[] | undefined,
79
83
  ): Promise<UpdatePlanItem[]> {
80
84
  const plan: UpdatePlanItem[] = [];
81
85
  for (const { skillId, origin } of candidates) {
@@ -118,6 +122,7 @@ async function buildPlan(
118
122
  continue;
119
123
  }
120
124
 
125
+ assertHostAllowed(origin.source_url, allowedHosts);
121
126
  const cloneDir = await cloneToTemp(origin.source_url);
122
127
  const resolved = resolveSkillDir(cloneDir, skillId, origin.skill_path);
123
128
  const base = {
@@ -191,7 +196,7 @@ function parseUpdateArgs(args: string[]): {
191
196
  throw new Error("--fail-on must be low, medium, or high");
192
197
  }
193
198
  failOn = value;
194
- } else if (arg === "--json") {
199
+ } else if (isGlobalFlag(arg, "--json")) {
195
200
  // handled globally
196
201
  } else if (arg?.startsWith("--")) {
197
202
  throw new Error(`unknown update option: ${arg}`);
@@ -206,10 +211,11 @@ function parseUpdateArgs(args: string[]): {
206
211
 
207
212
  export async function runUpdate(args: string[], options: { isJson: boolean }): Promise<void> {
208
213
  const { skillId, yes, dryRun, force, failOn, allowLocalSource } = parseUpdateArgs(args);
209
- const vaultPath = expandHome((await loadConfig()).vault_path);
214
+ const config = await loadConfig();
215
+ const vaultPath = expandHome(config.vault_path);
210
216
 
211
- const candidates = await resolveCandidateOrigins(vaultPath, skillId, allowLocalSource);
212
- const plan = await buildPlan(vaultPath, candidates, failOn, force);
217
+ const candidates = await resolveCandidateOrigins(vaultPath, skillId, allowLocalSource, config.egress?.allowed_hosts);
218
+ const plan = await buildPlan(vaultPath, candidates, failOn, force, config.egress?.allowed_hosts);
213
219
  try {
214
220
  const toWrite = plan.filter((item) => item.kind === "update");
215
221
 
@@ -0,0 +1,61 @@
1
+ export class ConcurrencyLimiter {
2
+ private inFlight = 0;
3
+
4
+ constructor(private readonly max: number) {}
5
+
6
+ tryAcquire(): boolean {
7
+ if (this.inFlight >= this.max) return false;
8
+ this.inFlight++;
9
+ return true;
10
+ }
11
+
12
+ release(): void {
13
+ this.inFlight = Math.max(0, this.inFlight - 1);
14
+ }
15
+ }
16
+
17
+ /**
18
+ * Wraps a response body so `release` fires when the stream actually finishes
19
+ * (fully drained or cancelled by a client disconnect) instead of as soon as
20
+ * the Response object is constructed. For a buffered body this happens almost
21
+ * immediately; for an open SSE stream it defers release until the connection
22
+ * really closes, so a concurrency limiter reflects true connection lifetime.
23
+ */
24
+ export function releaseOnStreamClose(
25
+ body: ReadableStream<Uint8Array> | null,
26
+ release: () => void,
27
+ ): ReadableStream<Uint8Array> | null {
28
+ if (!body) {
29
+ release();
30
+ return null;
31
+ }
32
+
33
+ const reader = body.getReader();
34
+ let released = false;
35
+ const releaseOnce = () => {
36
+ if (released) return;
37
+ released = true;
38
+ release();
39
+ };
40
+
41
+ return new ReadableStream<Uint8Array>({
42
+ async pull(controller) {
43
+ try {
44
+ const { done, value } = await reader.read();
45
+ if (done) {
46
+ controller.close();
47
+ releaseOnce();
48
+ return;
49
+ }
50
+ controller.enqueue(value);
51
+ } catch (error) {
52
+ controller.error(error);
53
+ releaseOnce();
54
+ }
55
+ },
56
+ cancel(reason) {
57
+ releaseOnce();
58
+ return reader.cancel(reason);
59
+ },
60
+ });
61
+ }
@@ -4,6 +4,7 @@ import { dirname, join } from "node:path";
4
4
  import { DEFAULT_CONFIG_PATH, expandHome, loadConfig } from "./config";
5
5
  import { describeDeployment } from "./deployment";
6
6
  import type { Config } from "./types";
7
+ import { stringifyToml } from "./toml-writer";
7
8
 
8
9
  export type ConfigSource = "default" | "toml" | "environment";
9
10
  export type ConfigSourceMap = Record<string, ConfigSource>;
@@ -373,57 +374,6 @@ export async function setDottedKey(
373
374
  };
374
375
  }
375
376
 
376
- export function stringifyToml(obj: Record<string, any>): string {
377
- let out = "";
378
- const topLevel: Record<string, any> = {};
379
- const sections: Record<string, any> = {};
380
-
381
- for (const [k, v] of Object.entries(obj)) {
382
- if (typeof v === "object" && v !== null && !Array.isArray(v)) {
383
- sections[k] = v;
384
- } else {
385
- topLevel[k] = v;
386
- }
387
- }
388
-
389
- for (const [k, v] of Object.entries(topLevel)) {
390
- out += `${k} = ${formatTomlVal(v)}\n`;
391
- }
392
- if (Object.keys(topLevel).length > 0) out += "\n";
393
-
394
- for (const [secName, secObj] of Object.entries(sections)) {
395
- out += stringifyTomlSection([secName], secObj);
396
- }
397
-
398
- return out;
399
- }
400
-
401
- function stringifyTomlSection(path: string[], obj: Record<string, any>): string {
402
- let out = `[${path.join(".")}]\n`;
403
- const subSections: Record<string, any> = {};
404
-
405
- for (const [k, v] of Object.entries(obj)) {
406
- if (typeof v === "object" && v !== null && !Array.isArray(v)) {
407
- subSections[k] = v;
408
- } else {
409
- out += `${k} = ${formatTomlVal(v)}\n`;
410
- }
411
- }
412
- out += "\n";
413
-
414
- for (const [subName, subObj] of Object.entries(subSections)) {
415
- out += stringifyTomlSection([...path, subName], subObj);
416
- }
417
-
418
- return out;
419
- }
420
-
421
- function formatTomlVal(v: unknown): string {
422
- if (typeof v === "string") return JSON.stringify(v);
423
- if (typeof v === "boolean" || typeof v === "number") return String(v);
424
- if (Array.isArray(v)) return JSON.stringify(v);
425
- return JSON.stringify(v);
426
- }
427
377
 
428
378
  export async function getLocalConfigStatus(configPath?: string): Promise<ConfigStatusResponse> {
429
379
  const { effective } = await getEffectiveConfig(configPath);
package/src/config.ts CHANGED
@@ -87,10 +87,15 @@ const configSchema = z.object({
87
87
  enabled: z.boolean(),
88
88
  token_env: z.string().min(1),
89
89
  }).strict().optional(),
90
+ max_body_bytes: z.number().int().positive().optional(),
91
+ max_concurrent_requests: z.number().int().min(0).optional(),
90
92
  }).strict().optional(),
91
93
  audit: z.object({
92
94
  retention_days: z.number().int().min(0).default(90),
93
95
  }).strict().default({ retention_days: 90 }),
96
+ egress: z.object({
97
+ allowed_hosts: z.array(z.string().min(1)).optional(),
98
+ }).strict().optional(),
94
99
  }).strict().refine((cfg) => {
95
100
  const hasReranker = cfg.inference.mode === "remote" && !!cfg.inference.reranker;
96
101
  if (hasReranker && cfg.output.max_top_k > cfg.recall.k_rerank) {
package/src/context.ts CHANGED
@@ -12,7 +12,12 @@ export interface ContextConfig {
12
12
  contexts: Record<string, ContextRecord>;
13
13
  }
14
14
 
15
- export type ResolvedTarget =
15
+ /**
16
+ * Context resolution: `local` = this CLI process has the Skillmux runtime (vault,
17
+ * index, audit db, embeddings/reranker clients) loaded in-process; `remote` = this
18
+ * CLI process is a thin network client to a separate process elsewhere that owns that runtime.
19
+ */
20
+ export type ResolvedContext =
16
21
  | { type: "local"; name: "local" }
17
22
  | { type: "remote"; name: string; server: string; token_env?: string };
18
23
 
@@ -127,10 +132,10 @@ export async function useContext(name: string, filePath?: string): Promise<void>
127
132
  await saveContextConfig(config, filePath);
128
133
  }
129
134
 
130
- export async function resolveTarget(
135
+ export async function resolveContext(
131
136
  flags: { context?: string; server?: string },
132
137
  filePath?: string
133
- ): Promise<ResolvedTarget> {
138
+ ): Promise<ResolvedContext> {
134
139
  // Precedence 1: Explicit flags
135
140
  if (flags.context && flags.server) {
136
141
  throw new Error("Cannot specify both --context and --server");