@firatcand/roster 1.0.0 → 1.0.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/bin/roster.js CHANGED
@@ -10,6 +10,7 @@ import { z } from "zod";
10
10
  import { createHash, randomBytes } from "node:crypto";
11
11
  import YAML, { parse } from "yaml";
12
12
  import { execFileSync, spawn, spawnSync } from "node:child_process";
13
+ import { createInterface } from "node:readline";
13
14
  //#region src/lib/paths.ts
14
15
  function findRosterRoot() {
15
16
  const here = dirname(fileURLToPath(import.meta.url));
@@ -99,12 +100,13 @@ var RosterError = class extends Error {
99
100
  function isRosterError(err) {
100
101
  return err instanceof RosterError;
101
102
  }
102
- function permissionError(targetPath, cause) {
103
+ function permissionError(targetPath, cause, scope) {
103
104
  const syscall = cause.syscall ? ` (${cause.syscall})` : "";
105
+ const remedy = scope === "project" ? ` Check filesystem permissions on the workspace, or run: chmod -R u+w ${targetPath}` : ` Re-run with sudo, or run: sudo chown -R "$USER" ${targetPath}`;
104
106
  return new RosterError({
105
107
  header: `${chalk.red.bold("roster:")} permission denied`,
106
108
  body: ` ${cause.code ?? "EACCES"}${syscall} writing ${targetPath}`,
107
- remedy: ` Re-run with sudo, or run: sudo chown -R "$USER" ${targetPath}`,
109
+ remedy,
108
110
  exitCode: 1
109
111
  });
110
112
  }
@@ -170,6 +172,32 @@ function userCancelledInstall() {
170
172
  exitCode: 2
171
173
  });
172
174
  }
175
+ function workspaceRequiredError(cwd) {
176
+ return new RosterError({
177
+ header: `${chalk.red.bold("roster:")} project-level install requires a roster workspace`,
178
+ body: [` CWD: ${cwd}`, ` Expected: config/project.yaml (created by ${chalk.bold("roster init")})`].join("\n"),
179
+ remedy: [
180
+ ` Either:`,
181
+ ` - cd into a roster workspace, or`,
182
+ ` - re-run with ${chalk.bold("--scope user")} to install to your home directory.`
183
+ ].join("\n"),
184
+ exitCode: 2
185
+ });
186
+ }
187
+ function toolsNotDetectedError(requestedKeys, detectedKeys) {
188
+ const missing = requestedKeys.filter((k) => !detectedKeys.includes(k));
189
+ const detectedLabel = detectedKeys.length === 0 ? "(none)" : detectedKeys.join(", ");
190
+ return new RosterError({
191
+ header: `${chalk.red.bold("roster:")} requested tool${missing.length === 1 ? "" : "s"} not detected: ${missing.join(", ")}`,
192
+ body: [` --tool requested: ${requestedKeys.join(", ")}`, ` detected on this machine: ${detectedLabel}`].join("\n"),
193
+ remedy: [
194
+ ` Either:`,
195
+ ` - install the missing tool${missing.length === 1 ? "" : "s"} first, or`,
196
+ ` - drop the missing key${missing.length === 1 ? "" : "s"} from ${chalk.bold("--tool")}.`
197
+ ].join("\n"),
198
+ exitCode: 3
199
+ });
200
+ }
173
201
  function linuxClaudeUnsupportedError() {
174
202
  return new RosterError({
175
203
  header: `${chalk.red.bold("roster:")} Claude Desktop scheduling is not available on Linux`,
@@ -460,7 +488,7 @@ async function prepareTargetForWrite(targetPath, kind, logger, confirm) {
460
488
  }
461
489
  return true;
462
490
  }
463
- async function copyOne(srcPath, targetPath, kind, logger, confirm) {
491
+ async function copyOne(srcPath, targetPath, kind, logger, confirm, scope) {
464
492
  try {
465
493
  if (!await prepareTargetForWrite(targetPath, kind, logger, confirm)) return false;
466
494
  await copy(srcPath, targetPath, {
@@ -469,17 +497,17 @@ async function copyOne(srcPath, targetPath, kind, logger, confirm) {
469
497
  });
470
498
  return true;
471
499
  } catch (err) {
472
- if (isEacces(err)) throw permissionError(targetPath, err);
500
+ if (isEacces(err)) throw permissionError(targetPath, err, scope);
473
501
  throw err;
474
502
  }
475
503
  }
476
- async function writeRenderedOne(targetPath, contents, kind, logger, confirm) {
504
+ async function writeRenderedOne(targetPath, contents, kind, logger, confirm, scope) {
477
505
  try {
478
506
  if (!await prepareTargetForWrite(targetPath, kind, logger, confirm)) return false;
479
507
  writeFileSync(targetPath, contents);
480
508
  return true;
481
509
  } catch (err) {
482
- if (isEacces(err)) throw permissionError(targetPath, err);
510
+ if (isEacces(err)) throw permissionError(targetPath, err, scope);
483
511
  throw err;
484
512
  }
485
513
  }
@@ -496,7 +524,7 @@ async function installToTool(tool, opts) {
496
524
  await ensureDir(tool.skillsTarget);
497
525
  if (tool.agentsTarget) await ensureDir(tool.agentsTarget);
498
526
  } catch (err) {
499
- if (isEacces(err)) throw permissionError(tool.skillsTarget, err);
527
+ if (isEacces(err)) throw permissionError(tool.skillsTarget, err, opts.scope);
500
528
  throw err;
501
529
  }
502
530
  let skillsCount = 0;
@@ -514,7 +542,7 @@ async function installToTool(tool, opts) {
514
542
  const renderedSkillMd = join(targetPath, "SKILL.md");
515
543
  assertWithinRoot(targetPath, tool.configRoot, "skill targetPath");
516
544
  info(chalk.dim(` + skill ${dirent.name} -> ${targetPath}`));
517
- if (await copyOne(srcPath, targetPath, "skill", logger, confirm)) {
545
+ if (await copyOne(srcPath, targetPath, "skill", logger, confirm, opts.scope)) {
518
546
  renderSkillFrontmatter(renderedSkillMd, tool.key);
519
547
  skillsCount++;
520
548
  }
@@ -543,15 +571,15 @@ async function installToTool(tool, opts) {
543
571
  throw err;
544
572
  }
545
573
  info(chalk.dim(` + agent ${dirent.name} -> ${tomlTarget}`));
546
- const wroteToml = await writeRenderedOne(tomlTarget, rendered.toml, "agent", logger, confirm);
547
- const wrotePersona = await writeRenderedOne(personaTarget, rendered.personaBody, "agent persona", logger, confirm);
574
+ const wroteToml = await writeRenderedOne(tomlTarget, rendered.toml, "agent", logger, confirm, opts.scope);
575
+ const wrotePersona = await writeRenderedOne(personaTarget, rendered.personaBody, "agent persona", logger, confirm, opts.scope);
548
576
  if (wroteToml && wrotePersona) agentsCount++;
549
577
  continue;
550
578
  }
551
579
  const targetPath = join(tool.agentsTarget, dirent.name);
552
580
  assertWithinRoot(targetPath, tool.configRoot, "agent targetPath");
553
581
  info(chalk.dim(` + agent ${dirent.name} -> ${targetPath}`));
554
- if (await copyOne(srcPath, targetPath, "agent", logger, confirm)) agentsCount++;
582
+ if (await copyOne(srcPath, targetPath, "agent", logger, confirm, opts.scope)) agentsCount++;
555
583
  }
556
584
  }
557
585
  return {
@@ -569,19 +597,46 @@ const KNOWN_TOOL_KEYS = [
569
597
  "gemini"
570
598
  ];
571
599
  const TOOL_LIST$1 = KNOWN_TOOL_KEYS.join(" | ");
600
+ const SCOPE_LIST$1 = "(project | user)";
572
601
  function isToolKey(value) {
573
602
  return KNOWN_TOOL_KEYS.includes(value);
574
603
  }
604
+ function isScope$1(value) {
605
+ return value === "project" || value === "user";
606
+ }
607
+ function parseToolValue(value) {
608
+ const parts = value.split(",").map((s) => s.trim());
609
+ if (parts.some((p) => p.length === 0)) return {
610
+ ok: false,
611
+ message: `--tool received an empty value (check for stray commas)`
612
+ };
613
+ const keys = [];
614
+ for (const part of parts) {
615
+ if (!isToolKey(part)) return {
616
+ ok: false,
617
+ message: `unknown tool '${part}'; expected one of: ${TOOL_LIST$1}`
618
+ };
619
+ if (!keys.includes(part)) keys.push(part);
620
+ }
621
+ return {
622
+ ok: true,
623
+ keys
624
+ };
625
+ }
575
626
  function parseInstallArgs(args) {
576
627
  let silent = false;
577
628
  let verbose = false;
629
+ let yes = false;
578
630
  let all = false;
579
631
  let toolValue = null;
580
632
  let toolFlagSeen = false;
633
+ let scopeValue = null;
634
+ let scopeFlagSeen = false;
581
635
  for (let i = 0; i < args.length; i++) {
582
636
  const arg = args[i];
583
637
  if (arg === "--silent") silent = true;
584
638
  else if (arg === "--verbose") verbose = true;
639
+ else if (arg === "--yes" || arg === "-y") yes = true;
585
640
  else if (arg === "--all") all = true;
586
641
  else if (arg === "--tool") {
587
642
  toolFlagSeen = true;
@@ -600,57 +655,150 @@ function parseInstallArgs(args) {
600
655
  message: `--tool requires a tool name (${TOOL_LIST$1})`
601
656
  };
602
657
  toolValue = value;
658
+ } else if (arg === "--scope") {
659
+ scopeFlagSeen = true;
660
+ const next = args[i + 1];
661
+ if (next === void 0 || next.startsWith("-")) return {
662
+ kind: "err",
663
+ message: `--scope requires a value: ${SCOPE_LIST$1}`
664
+ };
665
+ scopeValue = next;
666
+ i++;
667
+ } else if (arg.startsWith("--scope=")) {
668
+ scopeFlagSeen = true;
669
+ const value = arg.slice(8);
670
+ if (value === "") return {
671
+ kind: "err",
672
+ message: `--scope requires a value: ${SCOPE_LIST$1}`
673
+ };
674
+ scopeValue = value;
603
675
  }
604
676
  }
605
677
  if (all && toolFlagSeen) return {
606
678
  kind: "err",
607
679
  message: "flags --all and --tool are mutually exclusive"
608
680
  };
681
+ let scope = null;
682
+ if (scopeFlagSeen) {
683
+ if (scopeValue === null || !isScope$1(scopeValue)) return {
684
+ kind: "err",
685
+ message: `unknown scope '${scopeValue ?? ""}'; expected one of: ${SCOPE_LIST$1}`
686
+ };
687
+ scope = scopeValue;
688
+ }
689
+ let target;
609
690
  if (toolFlagSeen && toolValue !== null) {
610
- if (!isToolKey(toolValue)) return {
691
+ const parsed = parseToolValue(toolValue);
692
+ if (!parsed.ok) return {
611
693
  kind: "err",
612
- message: `unknown tool '${toolValue}'; expected one of: ${TOOL_LIST$1}`
694
+ message: parsed.message
613
695
  };
614
- return {
615
- kind: "ok",
616
- silent,
617
- verbose,
618
- target: {
619
- mode: "tool",
620
- key: toolValue
621
- }
696
+ target = {
697
+ mode: "tools",
698
+ keys: parsed.keys
622
699
  };
623
- }
624
- if (all) return {
700
+ } else if (all) target = { mode: "all" };
701
+ else target = { mode: "interactive" };
702
+ return {
625
703
  kind: "ok",
626
704
  silent,
627
705
  verbose,
628
- target: { mode: "all" }
706
+ yes,
707
+ scope,
708
+ target
629
709
  };
710
+ }
711
+ //#endregion
712
+ //#region src/lib/install-scope.ts
713
+ function detectWorkspace(cwd) {
714
+ return existsSync(join(cwd, "config", "project.yaml"));
715
+ }
716
+ function defaultScopeForContext(workspaceExists) {
717
+ return workspaceExists ? "project" : "user";
718
+ }
719
+ function projectPathsFor(toolKey, workspaceRoot, hasAgents) {
720
+ const agentsPath = (root) => hasAgents ? join(workspaceRoot, root, "agents") : null;
721
+ switch (toolKey) {
722
+ case "claude": return {
723
+ configRoot: join(workspaceRoot, ".claude"),
724
+ skillsTarget: join(workspaceRoot, ".claude", "skills"),
725
+ agentsTarget: agentsPath(".claude")
726
+ };
727
+ case "codex": return {
728
+ configRoot: join(workspaceRoot, ".codex"),
729
+ skillsTarget: join(workspaceRoot, ".codex", "skills"),
730
+ agentsTarget: agentsPath(".codex")
731
+ };
732
+ case "gemini": return {
733
+ configRoot: join(workspaceRoot, ".gemini"),
734
+ skillsTarget: join(workspaceRoot, ".gemini", "extensions"),
735
+ agentsTarget: agentsPath(".gemini")
736
+ };
737
+ }
738
+ }
739
+ function toolForScope(tool, scope, workspaceRoot) {
740
+ if (scope === "user") return tool;
741
+ if (workspaceRoot === void 0) throw new Error("toolForScope: workspaceRoot is required when scope is \"project\"");
742
+ const hasAgents = tool.agentsTarget !== null;
743
+ const paths = projectPathsFor(tool.key, workspaceRoot, hasAgents);
630
744
  return {
631
- kind: "ok",
632
- silent,
633
- verbose,
634
- target: { mode: "interactive" }
745
+ ...tool,
746
+ ...paths
635
747
  };
636
748
  }
637
749
  //#endregion
638
750
  //#region src/lib/doctor-args.ts
751
+ const SCOPE_LIST = "(project | user)";
752
+ function isScope(value) {
753
+ return value === "project" || value === "user";
754
+ }
639
755
  function parseDoctorArgs(args) {
640
756
  let json = false;
641
757
  let silent = false;
642
758
  let fix = false;
643
759
  let dryRun = false;
644
- for (const arg of args) if (arg === "--json") json = true;
645
- else if (arg === "--silent") silent = true;
646
- else if (arg === "--fix") fix = true;
647
- else if (arg === "--dry-run") dryRun = true;
760
+ let scopeValue = null;
761
+ let scopeFlagSeen = false;
762
+ for (let i = 0; i < args.length; i++) {
763
+ const arg = args[i];
764
+ if (arg === "--json") json = true;
765
+ else if (arg === "--silent") silent = true;
766
+ else if (arg === "--fix") fix = true;
767
+ else if (arg === "--dry-run") dryRun = true;
768
+ else if (arg === "--scope") {
769
+ scopeFlagSeen = true;
770
+ const next = args[i + 1];
771
+ if (next === void 0 || next.startsWith("-")) return {
772
+ kind: "err",
773
+ message: `--scope requires a value: ${SCOPE_LIST}`
774
+ };
775
+ scopeValue = next;
776
+ i++;
777
+ } else if (arg.startsWith("--scope=")) {
778
+ scopeFlagSeen = true;
779
+ const value = arg.slice(8);
780
+ if (value === "") return {
781
+ kind: "err",
782
+ message: `--scope requires a value: ${SCOPE_LIST}`
783
+ };
784
+ scopeValue = value;
785
+ }
786
+ }
787
+ let scope = null;
788
+ if (scopeFlagSeen) {
789
+ if (scopeValue === null || !isScope(scopeValue)) return {
790
+ kind: "err",
791
+ message: `unknown scope '${scopeValue ?? ""}'; expected one of: ${SCOPE_LIST}`
792
+ };
793
+ scope = scopeValue;
794
+ }
648
795
  return {
649
796
  kind: "ok",
650
797
  json,
651
798
  silent,
652
799
  fix,
653
- dryRun
800
+ dryRun,
801
+ scope
654
802
  };
655
803
  }
656
804
  const KEBAB_RE$1 = /^[a-z0-9]+(-[a-z0-9]+)*$/;
@@ -2716,7 +2864,7 @@ async function executeInit(opts) {
2716
2864
  const totalChanged = filesWritten.length + filesUpdated.length + filesLinked.length;
2717
2865
  if (!silent) {
2718
2866
  info("");
2719
- info(`${chalk.green("✓")} Initialized ${chalk.bold(projectName)} in ${opts.cwd}`);
2867
+ info(`${chalk.green("✓")} Initialized roster workspace ${chalk.bold(`'${projectName}'`)} in ${opts.cwd} ${chalk.dim("(current directory)")}`);
2720
2868
  if (totalChanged > 8) info(chalk.dim(`Files: ${totalChanged} written/linked`));
2721
2869
  else {
2722
2870
  const changed = [
@@ -2729,7 +2877,7 @@ async function executeInit(opts) {
2729
2877
  for (const w of warnings) info(chalk.yellow(w));
2730
2878
  if (gitInitialized) info(chalk.dim("Git: initialized .git/"));
2731
2879
  info("");
2732
- info(`${chalk.dim("Next: ")}${chalk.bold("open in Claude Code")}${chalk.dim(" and run ")}${chalk.bold("/chief-of-staff audit-repo")}`);
2880
+ info(`${chalk.dim("Next: ")}you are already in this directory — ${chalk.bold("open Claude Code here")}${chalk.dim(" and run ")}${chalk.bold("/chief-of-staff audit-repo")}`);
2733
2881
  }
2734
2882
  return {
2735
2883
  status: "ok",
@@ -3151,7 +3299,7 @@ function validateSchedulesInCwd(cwd) {
3151
3299
  }
3152
3300
  //#endregion
3153
3301
  //#region src/lib/dotenv-parse.ts
3154
- const KEY_RE = /^([A-Za-z_][A-Za-z0-9_]*)\s*=\s*/;
3302
+ const KEY_RE$1 = /^([A-Za-z_][A-Za-z0-9_]*)\s*=\s*/;
3155
3303
  function parseEnvFile(content) {
3156
3304
  const out = /* @__PURE__ */ new Map();
3157
3305
  if (content.charCodeAt(0) === 65279) content = content.slice(1);
@@ -3159,7 +3307,7 @@ function parseEnvFile(content) {
3159
3307
  const trimmed = rawLine.replace(/^\s+/, "");
3160
3308
  if (trimmed.length === 0 || trimmed.startsWith("#")) continue;
3161
3309
  const candidate = trimmed.startsWith("export ") ? trimmed.slice(7).replace(/^\s+/, "") : trimmed;
3162
- const m = candidate.match(KEY_RE);
3310
+ const m = candidate.match(KEY_RE$1);
3163
3311
  if (m === null) continue;
3164
3312
  const key = m[1];
3165
3313
  const parsed = parseValue(candidate.slice(m[0].length));
@@ -3244,6 +3392,376 @@ function isWindows() {
3244
3392
  return getPlatform() === "win32";
3245
3393
  }
3246
3394
  //#endregion
3395
+ //#region src/lib/agent-config-schema.ts
3396
+ const AGENT_RE = /^[a-z][a-z0-9-]*\/[a-z][a-z0-9-]*$/;
3397
+ const ENV_VAR_RE = /^[A-Z][A-Z0-9_]*$/;
3398
+ const FORBIDDEN_FS_PREFIXES = [
3399
+ "/Users/",
3400
+ "/home/",
3401
+ "/etc/",
3402
+ "/var/",
3403
+ "/tmp/",
3404
+ "/opt/"
3405
+ ];
3406
+ const workspaceRootedPath = z.string().refine((p) => p.startsWith("/") && !FORBIDDEN_FS_PREFIXES.some((pfx) => p.startsWith(pfx)), { message: "must be a workspace-root-relative path starting with '/' (rejected: literal absolute fs paths /Users/ /home/ /etc/ /var/ /tmp/ /opt/)" });
3407
+ const toolBindingSchema = z.object({
3408
+ env_var: z.string().regex(ENV_VAR_RE, { message: "env_var: must be SCREAMING_SNAKE_CASE" }),
3409
+ required: z.boolean()
3410
+ }).strict();
3411
+ const agentConfigSchema = z.object({
3412
+ agent: z.string().regex(AGENT_RE, { message: "agent: must match '<function>/<agent>' with kebab-case segments" }),
3413
+ plans_dir: z.string().min(1),
3414
+ guideline_refs: z.record(z.string().min(1), workspaceRootedPath).nullish(),
3415
+ tools: z.record(z.string().min(1), toolBindingSchema).nullish()
3416
+ }).strict();
3417
+ function isInsideRoot(root, p) {
3418
+ const rel = relative(root, p);
3419
+ return rel === "" || !rel.startsWith("..") && !isAbsolute(rel);
3420
+ }
3421
+ function loadAgentConfig(workspaceRoot, agentPath) {
3422
+ if (!AGENT_RE.test(agentPath)) return {
3423
+ ok: false,
3424
+ errors: [{
3425
+ kind: "invalid-agent-path",
3426
+ message: `agentPath '${agentPath}' must match '<function>/<agent>' with kebab-case segments`
3427
+ }]
3428
+ };
3429
+ let realRoot;
3430
+ try {
3431
+ realRoot = realpathSync(workspaceRoot);
3432
+ } catch (err) {
3433
+ return {
3434
+ ok: false,
3435
+ errors: [{
3436
+ kind: "missing-file",
3437
+ message: `workspaceRoot '${workspaceRoot}' not accessible: ${err.message}`,
3438
+ path: workspaceRoot
3439
+ }]
3440
+ };
3441
+ }
3442
+ const configPath = join(realRoot, agentPath, "config.yaml");
3443
+ let raw;
3444
+ try {
3445
+ raw = readFileSync(configPath, "utf8");
3446
+ } catch (err) {
3447
+ const e = err;
3448
+ if (e.code === "ENOENT") return {
3449
+ ok: false,
3450
+ errors: [{
3451
+ kind: "missing-file",
3452
+ message: `config.yaml not found at ${configPath}`,
3453
+ path: configPath
3454
+ }]
3455
+ };
3456
+ return {
3457
+ ok: false,
3458
+ errors: [{
3459
+ kind: "missing-file",
3460
+ message: `failed to read ${configPath}: ${e.message}`,
3461
+ path: configPath
3462
+ }]
3463
+ };
3464
+ }
3465
+ let doc;
3466
+ try {
3467
+ doc = YAML.parse(raw);
3468
+ } catch (err) {
3469
+ return {
3470
+ ok: false,
3471
+ errors: [{
3472
+ kind: "yaml-parse",
3473
+ message: `YAML parse error in ${configPath}: ${err.message}`,
3474
+ path: configPath
3475
+ }]
3476
+ };
3477
+ }
3478
+ const parsed = agentConfigSchema.safeParse(doc);
3479
+ if (!parsed.success) return {
3480
+ ok: false,
3481
+ errors: parsed.error.issues.map((issue) => ({
3482
+ kind: "schema",
3483
+ message: issue.message,
3484
+ path: issue.path.length > 0 ? issue.path.join(".") : void 0
3485
+ }))
3486
+ };
3487
+ const config = parsed.data;
3488
+ const errors = [];
3489
+ if (config.agent !== agentPath) errors.push({
3490
+ kind: "agent-field-mismatch",
3491
+ message: `agent field '${config.agent}' does not match agentPath argument '${agentPath}'`,
3492
+ path: "agent"
3493
+ });
3494
+ const refs = config.guideline_refs ?? {};
3495
+ for (const [key, ref] of Object.entries(refs)) {
3496
+ const wantsDirectory = ref.endsWith("/");
3497
+ const stripped = ref.replace(/^\/+/, "");
3498
+ const absolute = resolve(realRoot, stripped);
3499
+ if (!isInsideRoot(realRoot, absolute)) {
3500
+ errors.push({
3501
+ kind: "ref-escapes-workspace",
3502
+ message: `guideline_refs.${key}: '${ref}' escapes outside workspace root after resolution`,
3503
+ path: `guideline_refs.${key}`,
3504
+ ref
3505
+ });
3506
+ continue;
3507
+ }
3508
+ let realAbsolute;
3509
+ try {
3510
+ realAbsolute = realpathSync(absolute);
3511
+ } catch (err) {
3512
+ const e = err;
3513
+ if (e.code === "ENOENT") errors.push({
3514
+ kind: "ref-not-found",
3515
+ message: `guideline_refs.${key}: '${ref}' resolves to ${absolute} which does not exist`,
3516
+ path: `guideline_refs.${key}`,
3517
+ ref
3518
+ });
3519
+ else errors.push({
3520
+ kind: "ref-not-found",
3521
+ message: `guideline_refs.${key}: realpath ${absolute} failed: ${e.message}`,
3522
+ path: `guideline_refs.${key}`,
3523
+ ref
3524
+ });
3525
+ continue;
3526
+ }
3527
+ if (!isInsideRoot(realRoot, realAbsolute)) {
3528
+ errors.push({
3529
+ kind: "ref-escapes-workspace",
3530
+ message: `guideline_refs.${key}: '${ref}' resolves via symlink to ${realAbsolute} outside workspace root`,
3531
+ path: `guideline_refs.${key}`,
3532
+ ref
3533
+ });
3534
+ continue;
3535
+ }
3536
+ let stat;
3537
+ try {
3538
+ stat = statSync(realAbsolute);
3539
+ } catch (err) {
3540
+ errors.push({
3541
+ kind: "ref-not-found",
3542
+ message: `guideline_refs.${key}: stat ${realAbsolute} failed: ${err.message}`,
3543
+ path: `guideline_refs.${key}`,
3544
+ ref
3545
+ });
3546
+ continue;
3547
+ }
3548
+ if (wantsDirectory && !stat.isDirectory()) errors.push({
3549
+ kind: "ref-shape-mismatch",
3550
+ message: `guideline_refs.${key}: '${ref}' ends with '/' but ${realAbsolute} is not a directory`,
3551
+ path: `guideline_refs.${key}`,
3552
+ ref
3553
+ });
3554
+ else if (!wantsDirectory && !stat.isFile()) errors.push({
3555
+ kind: "ref-shape-mismatch",
3556
+ message: `guideline_refs.${key}: '${ref}' has no trailing '/' but ${realAbsolute} is not a regular file (add '/' if you meant a directory)`,
3557
+ path: `guideline_refs.${key}`,
3558
+ ref
3559
+ });
3560
+ }
3561
+ if (errors.length > 0) return {
3562
+ ok: false,
3563
+ errors
3564
+ };
3565
+ return {
3566
+ ok: true,
3567
+ config,
3568
+ refsChecked: Object.keys(refs).length
3569
+ };
3570
+ }
3571
+ //#endregion
3572
+ //#region src/lib/env-merge.ts
3573
+ function resolveAgentEnv(workspaceRoot, agentPath) {
3574
+ const workspace = tryReadAndParse(join(workspaceRoot, ".env"));
3575
+ const agent = tryReadAndParse(join(workspaceRoot, agentPath, ".env"));
3576
+ const out = {};
3577
+ for (const [k, v] of workspace) {
3578
+ if (v.length === 0) continue;
3579
+ out[k] = v;
3580
+ }
3581
+ for (const [k, v] of agent) if (v.length === 0) delete out[k];
3582
+ else out[k] = v;
3583
+ return out;
3584
+ }
3585
+ function tryReadAndParse(path) {
3586
+ try {
3587
+ return parseEnvFile(readFileSync(path, "utf8"));
3588
+ } catch {
3589
+ return /* @__PURE__ */ new Map();
3590
+ }
3591
+ }
3592
+ //#endregion
3593
+ //#region src/lib/doctor-agent-env-audit.ts
3594
+ const SKIP_TOP$1 = new Set([
3595
+ "roster",
3596
+ "node_modules",
3597
+ "plans",
3598
+ "spec",
3599
+ "docs",
3600
+ "bin",
3601
+ "lib",
3602
+ "skills",
3603
+ "agents",
3604
+ "templates",
3605
+ "test",
3606
+ "src"
3607
+ ]);
3608
+ function collectAgentEnvFiles(cwd) {
3609
+ const out = [];
3610
+ let topEntries;
3611
+ try {
3612
+ topEntries = readdirSync(cwd);
3613
+ } catch {
3614
+ return [];
3615
+ }
3616
+ for (const top of topEntries) {
3617
+ if (top.startsWith(".")) continue;
3618
+ if (SKIP_TOP$1.has(top)) continue;
3619
+ const topDir = join(cwd, top);
3620
+ let topSt;
3621
+ try {
3622
+ topSt = statSync(topDir);
3623
+ } catch {
3624
+ continue;
3625
+ }
3626
+ if (!topSt.isDirectory()) continue;
3627
+ const topEnv = join(topDir, ".env");
3628
+ try {
3629
+ if (statSync(topEnv).isFile()) out.push(topEnv);
3630
+ } catch {}
3631
+ let agents;
3632
+ try {
3633
+ agents = readdirSync(topDir);
3634
+ } catch {
3635
+ continue;
3636
+ }
3637
+ for (const agent of agents) {
3638
+ if (agent.startsWith(".")) continue;
3639
+ const agentDir = join(topDir, agent);
3640
+ let aSt;
3641
+ try {
3642
+ aSt = statSync(agentDir);
3643
+ } catch {
3644
+ continue;
3645
+ }
3646
+ if (!aSt.isDirectory()) continue;
3647
+ const envPath = join(agentDir, ".env");
3648
+ let eSt;
3649
+ try {
3650
+ eSt = statSync(envPath);
3651
+ } catch {
3652
+ continue;
3653
+ }
3654
+ if (!eSt.isFile()) continue;
3655
+ out.push(envPath);
3656
+ }
3657
+ }
3658
+ return out;
3659
+ }
3660
+ const KEY_RE = /^([A-Za-z_][A-Za-z0-9_]*)\s*=/;
3661
+ function findLastLineForKey(rawContent, key) {
3662
+ let content = rawContent;
3663
+ if (content.charCodeAt(0) === 65279) content = content.slice(1);
3664
+ const lines = content.split(/\r?\n/);
3665
+ let lastMatch = null;
3666
+ for (let i = 0; i < lines.length; i++) {
3667
+ const trimmed = (lines[i] ?? "").replace(/^\s+/, "");
3668
+ if (trimmed.length === 0 || trimmed.startsWith("#")) continue;
3669
+ const m = (trimmed.startsWith("export ") ? trimmed.slice(7).replace(/^\s+/, "") : trimmed).match(KEY_RE);
3670
+ if (m === null) continue;
3671
+ if (m[1] === key) lastMatch = i + 1;
3672
+ }
3673
+ return lastMatch;
3674
+ }
3675
+ function auditAgentEnvRedundancy(cwd) {
3676
+ let workspaceMap;
3677
+ try {
3678
+ workspaceMap = parseEnvFile(readFileSync(join(cwd, ".env"), "utf8"));
3679
+ } catch {
3680
+ return {
3681
+ status: "ok",
3682
+ items: []
3683
+ };
3684
+ }
3685
+ const items = [];
3686
+ for (const envPath of collectAgentEnvFiles(cwd)) {
3687
+ let raw;
3688
+ try {
3689
+ raw = readFileSync(envPath, "utf8");
3690
+ } catch {
3691
+ continue;
3692
+ }
3693
+ const agentMap = parseEnvFile(raw);
3694
+ for (const [key, value] of agentMap) {
3695
+ const wsValue = workspaceMap.get(key);
3696
+ if (wsValue === void 0) continue;
3697
+ if (wsValue !== value) continue;
3698
+ const line = findLastLineForKey(raw, key);
3699
+ if (line === null) continue;
3700
+ items.push({
3701
+ agentEnvPath: relative(cwd, envPath),
3702
+ line,
3703
+ key
3704
+ });
3705
+ }
3706
+ }
3707
+ items.sort((a, b) => {
3708
+ if (a.agentEnvPath !== b.agentEnvPath) return a.agentEnvPath.localeCompare(b.agentEnvPath);
3709
+ return a.line - b.line;
3710
+ });
3711
+ return {
3712
+ status: items.length === 0 ? "ok" : "warn",
3713
+ items
3714
+ };
3715
+ }
3716
+ function removeLineForKey(absPath, oneBasedLine, expectedKey, expectedValue, dryRun) {
3717
+ let raw;
3718
+ try {
3719
+ raw = readFileSync(absPath, "utf8");
3720
+ } catch (err) {
3721
+ return {
3722
+ kind: "error",
3723
+ message: err.message
3724
+ };
3725
+ }
3726
+ const hadTrailingNewline = raw.length > 0 && raw.charCodeAt(raw.length - 1) === 10;
3727
+ const newline = /\r\n/.test(raw) ? "\r\n" : "\n";
3728
+ const lines = raw.split(/\r?\n/);
3729
+ const effectiveLineCount = hadTrailingNewline ? lines.length - 1 : lines.length;
3730
+ if (oneBasedLine < 1 || oneBasedLine > effectiveLineCount) return {
3731
+ kind: "changed",
3732
+ reason: "line out of range"
3733
+ };
3734
+ const targetLine = lines[oneBasedLine - 1] ?? "";
3735
+ const trimmed = targetLine.replace(/^\s+/, "");
3736
+ const m = (trimmed.startsWith("export ") ? trimmed.slice(7).replace(/^\s+/, "") : trimmed).match(KEY_RE);
3737
+ if (m === null || m[1] !== expectedKey) return {
3738
+ kind: "changed",
3739
+ reason: "line no longer declares expected key"
3740
+ };
3741
+ const parsedAtLine = parseEnvFile(targetLine).get(expectedKey);
3742
+ if (parsedAtLine === void 0 || parsedAtLine !== expectedValue) return {
3743
+ kind: "changed",
3744
+ reason: "line value no longer matches workspace"
3745
+ };
3746
+ if (dryRun) return { kind: "would-remove" };
3747
+ const next = lines.slice(0, oneBasedLine - 1).concat(lines.slice(oneBasedLine)).join(newline);
3748
+ let mode = 384;
3749
+ try {
3750
+ mode = statSync(absPath).mode & 511;
3751
+ } catch {}
3752
+ const tmpPath = absPath + ".tmp." + String(process.pid);
3753
+ try {
3754
+ writeFileSync(tmpPath, next, { mode });
3755
+ renameSync(tmpPath, absPath);
3756
+ } catch (err) {
3757
+ return {
3758
+ kind: "error",
3759
+ message: err.message
3760
+ };
3761
+ }
3762
+ return { kind: "removed" };
3763
+ }
3764
+ //#endregion
3247
3765
  //#region src/lib/doctor-secrets-audit.ts
3248
3766
  function formatMode(mode) {
3249
3767
  return "0" + (mode & 511).toString(8).padStart(3, "0");
@@ -3275,85 +3793,142 @@ function auditEnvPermissions(cwd) {
3275
3793
  autoFixable: true
3276
3794
  };
3277
3795
  }
3278
- const VAR_REF_RE = /\$(?:\{([A-Za-z_][A-Za-z0-9_]*)\}|([A-Za-z_][A-Za-z0-9_]*))/g;
3279
- function extractVarRefs(content) {
3280
- const out = /* @__PURE__ */ new Map();
3281
- const lines = content.split(/\r?\n/);
3282
- for (let i = 0; i < lines.length; i++) {
3283
- const line = lines[i];
3284
- for (const m of line.matchAll(VAR_REF_RE)) {
3285
- const key = m[1] ?? m[2] ?? "";
3286
- if (key.length === 0) continue;
3287
- const list = out.get(key);
3288
- if (list) list.push(i + 1);
3289
- else out.set(key, [i + 1]);
3290
- }
3291
- }
3292
- return out;
3796
+ function classifyAgentEnvMode(modeBits) {
3797
+ if (modeBits === 384) return "ok";
3798
+ if ((modeBits & 18) !== 0) return "fail";
3799
+ return "warn";
3293
3800
  }
3294
- const SKIP_TOP = new Set([
3295
- "roster",
3296
- "node_modules",
3297
- "plans",
3298
- "spec",
3299
- "docs",
3300
- "bin",
3301
- "lib",
3302
- "skills",
3303
- "agents",
3304
- "templates",
3305
- "test",
3306
- "src"
3307
- ]);
3308
- function collectConfigYamls(cwd) {
3801
+ function listAgentDirs(cwd) {
3309
3802
  const out = [];
3310
3803
  let topEntries;
3311
3804
  try {
3312
3805
  topEntries = readdirSync(cwd);
3313
3806
  } catch {
3314
- return [];
3807
+ return out;
3315
3808
  }
3316
3809
  for (const top of topEntries) {
3317
3810
  if (top.startsWith(".")) continue;
3318
3811
  if (SKIP_TOP.has(top)) continue;
3319
- const fnDir = join(cwd, top);
3320
- let st;
3812
+ const topDir = join(cwd, top);
3813
+ let topStat;
3321
3814
  try {
3322
- st = statSync(fnDir);
3815
+ topStat = statSync(topDir);
3323
3816
  } catch {
3324
3817
  continue;
3325
3818
  }
3326
- if (!st.isDirectory()) continue;
3327
- let agents;
3819
+ if (!topStat.isDirectory()) continue;
3820
+ out.push(topDir);
3821
+ let children;
3328
3822
  try {
3329
- agents = readdirSync(fnDir);
3823
+ children = readdirSync(topDir);
3330
3824
  } catch {
3331
3825
  continue;
3332
3826
  }
3333
- for (const agent of agents) {
3334
- if (agent.startsWith(".")) continue;
3335
- const projectsDir = join(fnDir, agent, "projects");
3336
- let projects;
3827
+ for (const child of children) {
3828
+ if (child.startsWith(".")) continue;
3829
+ const childDir = join(topDir, child);
3830
+ let childStat;
3337
3831
  try {
3338
- projects = readdirSync(projectsDir);
3832
+ childStat = statSync(childDir);
3339
3833
  } catch {
3340
3834
  continue;
3341
3835
  }
3342
- for (const project of projects) {
3343
- if (project.startsWith(".")) continue;
3344
- const configDir = join(projectsDir, project, "config");
3345
- let configFiles;
3346
- try {
3347
- configFiles = readdirSync(configDir);
3348
- } catch {
3349
- continue;
3350
- }
3351
- for (const f of configFiles) if (f.endsWith(".yaml") || f.endsWith(".yml")) out.push(join(configDir, f));
3352
- }
3836
+ if (!childStat.isDirectory()) continue;
3837
+ out.push(childDir);
3838
+ }
3839
+ }
3840
+ return out;
3841
+ }
3842
+ function auditAgentEnvPermissions(cwd) {
3843
+ if (isWindows()) return {
3844
+ status: "skip-platform",
3845
+ reason: "win32-mode-bits-not-portable"
3846
+ };
3847
+ const items = [];
3848
+ let aggregate = "ok";
3849
+ for (const agentDir of listAgentDirs(cwd)) {
3850
+ const envPath = join(agentDir, ".env");
3851
+ let st;
3852
+ try {
3853
+ st = statSync(envPath);
3854
+ } catch {
3855
+ continue;
3856
+ }
3857
+ if (!st.isFile()) continue;
3858
+ const modeBits = st.mode & 511;
3859
+ const mode = formatMode(st.mode);
3860
+ const agentPath = relative(cwd, agentDir);
3861
+ const cls = classifyAgentEnvMode(modeBits);
3862
+ if (cls === "ok") items.push({
3863
+ status: "ok",
3864
+ agentPath,
3865
+ envPath,
3866
+ mode
3867
+ });
3868
+ else if (cls === "warn") {
3869
+ items.push({
3870
+ status: "warn",
3871
+ agentPath,
3872
+ envPath,
3873
+ mode,
3874
+ expected: "0600",
3875
+ autoFixable: true
3876
+ });
3877
+ if (aggregate === "ok") aggregate = "warn";
3878
+ } else {
3879
+ items.push({
3880
+ status: "fail",
3881
+ agentPath,
3882
+ envPath,
3883
+ mode,
3884
+ expected: "0600",
3885
+ autoFixable: true
3886
+ });
3887
+ aggregate = "fail";
3888
+ }
3889
+ }
3890
+ return {
3891
+ status: aggregate,
3892
+ items
3893
+ };
3894
+ }
3895
+ const VAR_REF_RE = /\$(?:\{([A-Za-z_][A-Za-z0-9_]*)\}|([A-Za-z_][A-Za-z0-9_]*))/g;
3896
+ function extractVarRefs(content) {
3897
+ const out = /* @__PURE__ */ new Map();
3898
+ const lines = content.split(/\r?\n/);
3899
+ for (let i = 0; i < lines.length; i++) {
3900
+ const line = lines[i];
3901
+ for (const m of line.matchAll(VAR_REF_RE)) {
3902
+ const key = m[1] ?? m[2] ?? "";
3903
+ if (key.length === 0) continue;
3904
+ const list = out.get(key);
3905
+ if (list) list.push(i + 1);
3906
+ else out.set(key, [i + 1]);
3353
3907
  }
3354
3908
  }
3355
3909
  return out;
3356
3910
  }
3911
+ const SKIP_TOP = new Set([
3912
+ "roster",
3913
+ "node_modules",
3914
+ "plans",
3915
+ "spec",
3916
+ "docs",
3917
+ "bin",
3918
+ "lib",
3919
+ "skills",
3920
+ "agents",
3921
+ "templates",
3922
+ "test",
3923
+ "src",
3924
+ "config",
3925
+ "guidelines",
3926
+ "logs",
3927
+ "scripts"
3928
+ ]);
3929
+ function collectConfigYamls(cwd) {
3930
+ return listV1AgentPaths(cwd).map((rel) => join(cwd, rel, "config.yaml"));
3931
+ }
3357
3932
  const SHELL_VARS = new Set([
3358
3933
  "HOME",
3359
3934
  "PATH",
@@ -3528,20 +4103,203 @@ function auditPromptLeak(cwd, schedules) {
3528
4103
  items
3529
4104
  };
3530
4105
  }
4106
+ const AGENT_SEGMENT_RE = /^[a-z][a-z0-9-]*$/;
4107
+ function listV1AgentPaths(cwd) {
4108
+ const out = [];
4109
+ let topEntries;
4110
+ try {
4111
+ topEntries = readdirSync(cwd);
4112
+ } catch {
4113
+ return [];
4114
+ }
4115
+ for (const top of topEntries) {
4116
+ if (top.startsWith(".")) continue;
4117
+ if (SKIP_TOP.has(top)) continue;
4118
+ if (!AGENT_SEGMENT_RE.test(top)) continue;
4119
+ const fnDir = join(cwd, top);
4120
+ let st;
4121
+ try {
4122
+ st = statSync(fnDir);
4123
+ } catch {
4124
+ continue;
4125
+ }
4126
+ if (!st.isDirectory()) continue;
4127
+ const topCfg = join(fnDir, "config.yaml");
4128
+ let topIsLeafAgent = false;
4129
+ try {
4130
+ if (statSync(topCfg).isFile()) {
4131
+ out.push(top);
4132
+ topIsLeafAgent = true;
4133
+ }
4134
+ } catch {}
4135
+ if (topIsLeafAgent) continue;
4136
+ let children;
4137
+ try {
4138
+ children = readdirSync(fnDir);
4139
+ } catch {
4140
+ continue;
4141
+ }
4142
+ for (const child of children) {
4143
+ if (child.startsWith(".")) continue;
4144
+ if (!AGENT_SEGMENT_RE.test(child)) continue;
4145
+ const agentDir = join(fnDir, child);
4146
+ let agentSt;
4147
+ try {
4148
+ agentSt = statSync(agentDir);
4149
+ } catch {
4150
+ continue;
4151
+ }
4152
+ if (!agentSt.isDirectory()) continue;
4153
+ const cfg = join(agentDir, "config.yaml");
4154
+ try {
4155
+ if (statSync(cfg).isFile()) out.push(top + "/" + child);
4156
+ } catch {
4157
+ continue;
4158
+ }
4159
+ }
4160
+ }
4161
+ out.sort();
4162
+ return out;
4163
+ }
4164
+ function auditAgentEnvRefs(cwd) {
4165
+ const errors = [];
4166
+ const warns = [];
4167
+ for (const agent of listV1AgentPaths(cwd)) {
4168
+ const loaded = loadAgentConfig(cwd, agent);
4169
+ if (!loaded.ok) continue;
4170
+ const tools = loaded.config.tools ?? {};
4171
+ const resolved = resolveAgentEnv(cwd, agent);
4172
+ for (const [binding, t] of Object.entries(tools)) {
4173
+ if (Object.prototype.hasOwnProperty.call(resolved, t.env_var)) continue;
4174
+ const miss = {
4175
+ agent,
4176
+ binding,
4177
+ key: t.env_var,
4178
+ required: t.required
4179
+ };
4180
+ if (t.required) errors.push(miss);
4181
+ else warns.push(miss);
4182
+ }
4183
+ }
4184
+ return {
4185
+ status: errors.length > 0 ? "fail" : warns.length > 0 ? "warn" : "ok",
4186
+ errors,
4187
+ warns
4188
+ };
4189
+ }
3531
4190
  function runSecretsAudit(opts) {
3532
4191
  const envPermissions = auditEnvPermissions(opts.cwd);
4192
+ const agentEnvPermissions = auditAgentEnvPermissions(opts.cwd);
3533
4193
  const envKeyReferences = auditEnvKeyReferences(opts.cwd);
3534
4194
  const templateSecretLiterals = auditTemplateSecretLiterals(opts.rosterRoot);
3535
4195
  const promptLeak = auditPromptLeak(opts.cwd, opts.schedules);
4196
+ const agentEnvRefs = auditAgentEnvRefs(opts.cwd);
4197
+ const agentEnvRedundancy = auditAgentEnvRedundancy(opts.cwd);
4198
+ const envOk = envPermissions.status === "ok" || envPermissions.status === "absent" || envPermissions.status === "skip-platform";
4199
+ const agentEnvOk = agentEnvPermissions.status !== "fail";
3536
4200
  return {
3537
- ok: (envPermissions.status === "ok" || envPermissions.status === "absent" || envPermissions.status === "skip-platform") && envKeyReferences.status === "ok" && templateSecretLiterals.status === "ok",
4201
+ ok: envOk && agentEnvOk && envKeyReferences.status === "ok" && templateSecretLiterals.status === "ok" && agentEnvRefs.errors.length === 0,
3538
4202
  envPermissions,
4203
+ agentEnvPermissions,
3539
4204
  envKeyReferences,
3540
4205
  templateSecretLiterals,
3541
- promptLeak
4206
+ promptLeak,
4207
+ agentEnvRefs,
4208
+ agentEnvRedundancy
3542
4209
  };
3543
4210
  }
3544
4211
  //#endregion
4212
+ //#region src/lib/agent-env-fix-prompt.ts
4213
+ async function confirmAndDeleteRedundantLines(items, cwd, deps, dryRun) {
4214
+ const out = {
4215
+ deleted: [],
4216
+ failed: [],
4217
+ skipped: [],
4218
+ nonTtySkipped: false
4219
+ };
4220
+ if (items.length === 0) return out;
4221
+ if (!deps.isTTY) {
4222
+ out.nonTtySkipped = true;
4223
+ return out;
4224
+ }
4225
+ let workspaceMap = /* @__PURE__ */ new Map();
4226
+ try {
4227
+ workspaceMap = parseEnvFile(readFileSync(join(cwd, ".env"), "utf8"));
4228
+ } catch {}
4229
+ const rl = createInterface({
4230
+ input: deps.stdin,
4231
+ output: deps.stdout,
4232
+ terminal: false
4233
+ });
4234
+ let stdinClosed = false;
4235
+ rl.once("close", () => {
4236
+ stdinClosed = true;
4237
+ });
4238
+ try {
4239
+ for (const item of items) {
4240
+ const absPath = join(cwd, item.agentEnvPath);
4241
+ const label = `${item.agentEnvPath}:${item.line} ${item.key}`;
4242
+ if (stdinClosed) {
4243
+ out.skipped.push(`${label}: stdin closed`);
4244
+ continue;
4245
+ }
4246
+ deps.stdout.write(`Delete ${label}? [y/N] `);
4247
+ const answer = await readOneLine(rl);
4248
+ if (answer === null) {
4249
+ stdinClosed = true;
4250
+ out.skipped.push(`${label}: stdin closed`);
4251
+ continue;
4252
+ }
4253
+ const normalized = answer.trim().toLowerCase();
4254
+ if (normalized !== "y" && normalized !== "yes") {
4255
+ out.skipped.push(label);
4256
+ continue;
4257
+ }
4258
+ const expectedValue = workspaceMap.get(item.key);
4259
+ if (expectedValue === void 0) {
4260
+ out.failed.push({
4261
+ what: label,
4262
+ error: "workspace .env no longer declares this key"
4263
+ });
4264
+ continue;
4265
+ }
4266
+ const result = removeLineForKey(absPath, item.line, item.key, expectedValue, dryRun);
4267
+ if (result.kind === "removed") out.deleted.push(`${label}: removed`);
4268
+ else if (result.kind === "would-remove") out.deleted.push(`${label}: would remove (dry-run)`);
4269
+ else if (result.kind === "changed") out.failed.push({
4270
+ what: label,
4271
+ error: `file changed (${result.reason})`
4272
+ });
4273
+ else out.failed.push({
4274
+ what: label,
4275
+ error: result.message
4276
+ });
4277
+ }
4278
+ } finally {
4279
+ rl.close();
4280
+ }
4281
+ return out;
4282
+ }
4283
+ function readOneLine(rl) {
4284
+ return new Promise((resolve) => {
4285
+ let settled = false;
4286
+ const onLine = (line) => {
4287
+ if (settled) return;
4288
+ settled = true;
4289
+ rl.off("close", onClose);
4290
+ resolve(line);
4291
+ };
4292
+ const onClose = () => {
4293
+ if (settled) return;
4294
+ settled = true;
4295
+ rl.off("line", onLine);
4296
+ resolve(null);
4297
+ };
4298
+ rl.once("line", onLine);
4299
+ rl.once("close", onClose);
4300
+ });
4301
+ }
4302
+ //#endregion
3545
4303
  //#region src/lib/codex-preflight.ts
3546
4304
  function readAuthJson(codexHome) {
3547
4305
  const authPath = join(codexHome, "auth.json");
@@ -4512,7 +5270,29 @@ function runSchedulingDriftAudit(opts) {
4512
5270
  }
4513
5271
  //#endregion
4514
5272
  //#region src/commands/doctor.ts
4515
- function tildify$4(path) {
5273
+ function listSkillDirNames(target) {
5274
+ try {
5275
+ return readdirSync(target, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => d.name).sort();
5276
+ } catch {
5277
+ return [];
5278
+ }
5279
+ }
5280
+ function detectShadowCollisions(workspaceRoot) {
5281
+ const collisions = [];
5282
+ for (const userTool of allTools()) {
5283
+ const projectTool = toolForScope(userTool, "project", workspaceRoot);
5284
+ const userNames = new Set(listSkillDirNames(userTool.skillsTarget));
5285
+ const projectNames = listSkillDirNames(projectTool.skillsTarget);
5286
+ for (const name of projectNames) if (userNames.has(name)) collisions.push({
5287
+ tool: userTool.key,
5288
+ skillName: name,
5289
+ userPath: join(userTool.skillsTarget, name),
5290
+ projectPath: join(projectTool.skillsTarget, name)
5291
+ });
5292
+ }
5293
+ return collisions;
5294
+ }
5295
+ function tildify$3(path) {
4516
5296
  const home = homedir();
4517
5297
  return path.startsWith(home) ? "~" + path.slice(home.length) : path;
4518
5298
  }
@@ -4553,7 +5333,7 @@ function workspaceLabel(status) {
4553
5333
  function renderSchedulingSection(report) {
4554
5334
  if (report.files.length === 0) return [];
4555
5335
  const lines = [""];
4556
- lines.push(`Scheduling ${tildify$4(report.cwd)}`);
5336
+ lines.push(`Scheduling ${tildify$3(report.cwd)}`);
4557
5337
  for (const file of report.files) if (file.status === "pass") {
4558
5338
  const entryWord = file.entryCount === 1 ? "entry" : "entries";
4559
5339
  lines.push(` ${chalk.green("✓")} ${file.relativePath} ${chalk.dim("OK")} ${chalk.dim(`(${file.entryCount} ${entryWord})`)}`);
@@ -4566,7 +5346,7 @@ function renderSchedulingSection(report) {
4566
5346
  function renderWorkspaceSection(audit) {
4567
5347
  if (!audit.contextMdExists && audit.items.length === 0) return [];
4568
5348
  const lines = [""];
4569
- lines.push(`Workspace ${tildify$4(audit.cwd)}`);
5349
+ lines.push(`Workspace ${tildify$3(audit.cwd)}`);
4570
5350
  if (audit.contextMdExists) lines.push(` ${chalk.green("✓")} CONTEXT.md ${chalk.dim("present")}`);
4571
5351
  else lines.push(` ${chalk.red("✗")} CONTEXT.md ${chalk.red("MISSING")}`);
4572
5352
  for (const item of audit.items) {
@@ -4593,7 +5373,7 @@ const NO_FIX_REQUESTED = Object.freeze({
4593
5373
  fixed: [],
4594
5374
  failed: []
4595
5375
  });
4596
- function runFixes(cwd, workspace, envPerms, dryRun = false) {
5376
+ function runFixes(cwd, workspace, envPerms, agentEnvPerms, dryRun = false) {
4597
5377
  const fixed = [];
4598
5378
  const failed = [];
4599
5379
  const applied = true;
@@ -4630,12 +5410,142 @@ function runFixes(cwd, workspace, envPerms, dryRun = false) {
4630
5410
  error: err.message
4631
5411
  });
4632
5412
  }
5413
+ if (agentEnvPerms.status === "warn" || agentEnvPerms.status === "fail") for (const item of agentEnvPerms.items) {
5414
+ if (item.status === "ok") continue;
5415
+ const label = `${item.agentPath}/.env`;
5416
+ if (dryRun) {
5417
+ fixed.push(`${label}: would chmod 0600`);
5418
+ continue;
5419
+ }
5420
+ try {
5421
+ chmodSync(item.envPath, 384);
5422
+ fixed.push(`${label}: chmod 0600`);
5423
+ } catch (err) {
5424
+ failed.push({
5425
+ what: label,
5426
+ error: err.message
5427
+ });
5428
+ }
5429
+ }
4633
5430
  return {
4634
5431
  applied,
4635
5432
  fixed,
4636
5433
  failed
4637
5434
  };
4638
5435
  }
5436
+ function uniqueKeysFromRefs(refs) {
5437
+ const byKey = /* @__PURE__ */ new Map();
5438
+ for (const m of [...refs.errors, ...refs.warns]) {
5439
+ const list = byKey.get(m.key);
5440
+ if (list) list.push(m);
5441
+ else byKey.set(m.key, [m]);
5442
+ }
5443
+ return Array.from(byKey.entries()).sort(([a], [b]) => a.localeCompare(b)).map(([key, refs]) => ({
5444
+ key,
5445
+ refs
5446
+ }));
5447
+ }
5448
+ function appendKeysToEnvFile(cwd, keys) {
5449
+ const envPath = join(cwd, ".env");
5450
+ const added = [];
5451
+ const skipped = [];
5452
+ let existing = "";
5453
+ let hadFile = false;
5454
+ try {
5455
+ existing = readFileSync(envPath, "utf8");
5456
+ hadFile = true;
5457
+ } catch {}
5458
+ let existingKeys = /* @__PURE__ */ new Set();
5459
+ try {
5460
+ existingKeys = new Set(parseEnvKeys(existing));
5461
+ } catch {}
5462
+ const newLines = [];
5463
+ for (const key of keys) {
5464
+ if (existingKeys.has(key)) {
5465
+ skipped.push(key);
5466
+ continue;
5467
+ }
5468
+ newLines.push(`${key}=`);
5469
+ added.push(key);
5470
+ }
5471
+ if (newLines.length === 0) return {
5472
+ added,
5473
+ skipped
5474
+ };
5475
+ let body = existing;
5476
+ if (body.length > 0 && !body.endsWith("\n")) body += "\n";
5477
+ body += newLines.join("\n") + "\n";
5478
+ writeFileSync(envPath, body, { encoding: "utf8" });
5479
+ if (!hadFile && !isWindows()) try {
5480
+ chmodSync(envPath, 384);
5481
+ } catch {}
5482
+ return {
5483
+ added,
5484
+ skipped
5485
+ };
5486
+ }
5487
+ async function defaultAgentEnvPrompt(uniqueKeys) {
5488
+ const { checkbox } = await import("@inquirer/prompts");
5489
+ return checkbox({
5490
+ message: "Select env keys to append to /.env (empty value — fill in after):",
5491
+ choices: uniqueKeys.map(({ key, refs }) => {
5492
+ return {
5493
+ value: key,
5494
+ name: `${key} ${refs.some((r) => r.required) ? "(required)" : "(optional)"} ← ${refs.map((r) => r.agent).join(", ")}`
5495
+ };
5496
+ })
5497
+ });
5498
+ }
5499
+ async function applyAgentEnvFix(cwd, refs, opts) {
5500
+ const fixed = [];
5501
+ const failed = [];
5502
+ const uniqueKeys = uniqueKeysFromRefs(refs);
5503
+ if (uniqueKeys.length === 0) return {
5504
+ fixed,
5505
+ failed
5506
+ };
5507
+ if (opts.dryRun) {
5508
+ for (const { key, refs: agents } of uniqueKeys) {
5509
+ const agentList = agents.map((r) => r.agent).join(", ");
5510
+ fixed.push(`/.env: would append ${key}= ${chalk.dim(`(referenced by ${agentList})`)}`);
5511
+ }
5512
+ return {
5513
+ fixed,
5514
+ failed
5515
+ };
5516
+ }
5517
+ let selected;
5518
+ try {
5519
+ selected = await (opts.prompt ?? defaultAgentEnvPrompt)(uniqueKeys);
5520
+ } catch (err) {
5521
+ failed.push({
5522
+ what: "/.env (interactive prompt)",
5523
+ error: err.message
5524
+ });
5525
+ return {
5526
+ fixed,
5527
+ failed
5528
+ };
5529
+ }
5530
+ if (selected.length === 0) return {
5531
+ fixed,
5532
+ failed
5533
+ };
5534
+ try {
5535
+ const { added, skipped } = appendKeysToEnvFile(cwd, selected);
5536
+ for (const k of added) fixed.push(`/.env: appended ${k}=`);
5537
+ for (const k of skipped) fixed.push(`/.env: ${k} already present (skipped)`);
5538
+ } catch (err) {
5539
+ failed.push({
5540
+ what: "/.env",
5541
+ error: err.message
5542
+ });
5543
+ }
5544
+ return {
5545
+ fixed,
5546
+ failed
5547
+ };
5548
+ }
4639
5549
  function renderFixSection(outcome) {
4640
5550
  if (!outcome.applied) return [];
4641
5551
  if (outcome.fixed.length === 0 && outcome.failed.length === 0) return [];
@@ -4707,10 +5617,15 @@ function renderSafetySection(audit) {
4707
5617
  }
4708
5618
  function renderSecretsSection(audit) {
4709
5619
  const env = audit.envPermissions;
5620
+ const agentEnv = audit.agentEnvPermissions;
4710
5621
  const refs = audit.envKeyReferences;
4711
5622
  const templates = audit.templateSecretLiterals;
4712
5623
  const leak = audit.promptLeak;
4713
- if ((env.status === "absent" || env.status === "skip-platform") && refs.status === "ok" && templates.status === "ok" && leak.status === "ok") return [];
5624
+ const agentRefs = audit.agentEnvRefs;
5625
+ const redundancy = audit.agentEnvRedundancy;
5626
+ const noEnv = env.status === "absent" || env.status === "skip-platform";
5627
+ const agentEnvClean = agentEnv.status === "skip-platform" || agentEnv.status === "ok" && agentEnv.items.length === 0;
5628
+ if (noEnv && agentEnvClean && refs.status === "ok" && templates.status === "ok" && leak.status === "ok" && agentRefs.status === "ok" && redundancy.status === "ok") return [];
4714
5629
  const lines = [""];
4715
5630
  lines.push(chalk.bold("Secrets"));
4716
5631
  if (env.status === "ok") lines.push(` ${chalk.green("✓")} .env permissions ${chalk.dim("OK")} ${chalk.dim(`(${env.mode})`)}`);
@@ -4718,6 +5633,28 @@ function renderSecretsSection(audit) {
4718
5633
  lines.push(` ${chalk.red("✗")} .env permissions ${chalk.red("FAIL")} ${chalk.dim(`(got ${env.mode}, expected ${env.expected})`)}`);
4719
5634
  lines.push(` ${chalk.dim("→ Run `roster doctor --fix` to chmod 0600.")}`);
4720
5635
  } else if (env.status === "skip-platform") lines.push(` ${chalk.dim("-")} .env permissions ${chalk.dim("SKIPPED")} ${chalk.dim("(windows mode bits not portable)")}`);
5636
+ if (agentEnv.status === "ok" && agentEnv.items.length > 0) lines.push(` ${chalk.green("✓")} agent .env perms ${chalk.dim("OK")} ${chalk.dim(`(${agentEnv.items.length} agent .env${agentEnv.items.length === 1 ? "" : "s"})`)}`);
5637
+ else if (agentEnv.status === "warn") {
5638
+ const warnCount = agentEnv.items.filter((i) => i.status === "warn").length;
5639
+ lines.push(` ${chalk.yellow("!")} agent .env perms ${chalk.yellow("WARN")} ${chalk.dim(`(${warnCount} agent .env${warnCount === 1 ? "" : "s"} not 0600)`)}`);
5640
+ for (const item of agentEnv.items.slice(0, 10)) {
5641
+ if (item.status === "ok") continue;
5642
+ lines.push(` ${chalk.yellow("-")} ${item.agentPath}/.env ${chalk.dim(`(got ${item.mode}, expected 0600)`)}`);
5643
+ }
5644
+ lines.push(` ${chalk.dim("→ Run `roster doctor --fix` to chmod 0600.")}`);
5645
+ } else if (agentEnv.status === "fail") {
5646
+ const failCount = agentEnv.items.filter((i) => i.status === "fail").length;
5647
+ const warnCount = agentEnv.items.filter((i) => i.status === "warn").length;
5648
+ const detail = failCount === 1 ? "1 world-writable" : `${failCount} world-writable`;
5649
+ const extra = warnCount > 0 ? `, ${warnCount} other not 0600` : "";
5650
+ lines.push(` ${chalk.red("✗")} agent .env perms ${chalk.red("FAIL")} ${chalk.dim(`(${detail}${extra})`)}`);
5651
+ for (const item of agentEnv.items.slice(0, 10)) {
5652
+ if (item.status === "ok") continue;
5653
+ const marker = item.status === "fail" ? chalk.red("-") : chalk.yellow("-");
5654
+ lines.push(` ${marker} ${item.agentPath}/.env ${chalk.dim(`(got ${item.mode}, expected 0600)`)}`);
5655
+ }
5656
+ lines.push(` ${chalk.dim("→ Run `roster doctor --fix` to chmod 0600.")}`);
5657
+ } else if (agentEnv.status === "skip-platform") lines.push(` ${chalk.dim("-")} agent .env perms ${chalk.dim("SKIPPED")} ${chalk.dim("(windows mode bits not portable)")}`);
4721
5658
  if (refs.status === "fail") {
4722
5659
  lines.push(` ${chalk.red("✗")} env-key refs ${chalk.red("FAIL")} ${chalk.dim(`(${refs.missing.length} unreferenced)`)}`);
4723
5660
  for (const m of refs.missing.slice(0, 10)) {
@@ -4733,6 +5670,28 @@ function renderSecretsSection(audit) {
4733
5670
  lines.push(` ${chalk.yellow("!")} prompt-leak ${chalk.yellow("WARN")} ${chalk.dim(`(${leak.items.length} reference${leak.items.length === 1 ? "" : "s"})`)}`);
4734
5671
  for (const item of leak.items.slice(0, 5)) lines.push(` ${chalk.yellow("-")} ${item.schedule}: ${item.reference} ${chalk.dim("in " + item.file + ":" + item.line)}`);
4735
5672
  }
5673
+ if (agentRefs.status === "fail" || agentRefs.status === "warn") {
5674
+ const e = agentRefs.errors.length;
5675
+ const w = agentRefs.warns.length;
5676
+ const counts = [];
5677
+ if (e > 0) counts.push(`${e} error${e === 1 ? "" : "s"}`);
5678
+ if (w > 0) counts.push(`${w} warn${w === 1 ? "" : "s"}`);
5679
+ const tag = agentRefs.status === "fail" ? chalk.red("FAIL") : chalk.yellow("WARN");
5680
+ const symbol = agentRefs.status === "fail" ? chalk.red("✗") : chalk.yellow("!");
5681
+ lines.push(` ${symbol} agent-env-refs ${tag} ${chalk.dim(`(${counts.join(", ")})`)}`);
5682
+ for (const m of agentRefs.errors.slice(0, 10)) lines.push(` ${chalk.red("-")} ${m.agent}: ${m.key} ${chalk.dim(`(tools.${m.binding}, required)`)}`);
5683
+ if (agentRefs.errors.length > 10) lines.push(` ${chalk.dim(`… (${agentRefs.errors.length - 10} more errors)`)}`);
5684
+ for (const m of agentRefs.warns.slice(0, 5)) lines.push(` ${chalk.yellow("-")} ${m.agent}: ${m.key} ${chalk.dim(`(tools.${m.binding}, optional)`)}`);
5685
+ if (agentRefs.warns.length > 5) lines.push(` ${chalk.dim(`… (${agentRefs.warns.length - 5} more warns)`)}`);
5686
+ if (e > 0 || w > 0) lines.push(` ${chalk.dim("→ Run `roster doctor --fix` to append missing keys to /.env.")}`);
5687
+ }
5688
+ if (redundancy.status === "warn") {
5689
+ const n = redundancy.items.length;
5690
+ lines.push(` ${chalk.yellow("!")} agent .env redundancy ${chalk.yellow("WARN")} ${chalk.dim(`(${n} entr${n === 1 ? "y" : "ies"})`)}`);
5691
+ for (const item of redundancy.items.slice(0, 10)) lines.push(` ${chalk.yellow("-")} ${item.agentEnvPath}:${item.line} ${item.key} ${chalk.dim("matches workspace .env")}`);
5692
+ if (n > 10) lines.push(` ${chalk.dim(`… (${n - 10} more)`)}`);
5693
+ lines.push(` ${chalk.dim("→ Run `roster doctor --fix` to prompt removal of redundant lines.")}`);
5694
+ }
4736
5695
  return lines;
4737
5696
  }
4738
5697
  function renderStaleFiresSection(stale) {
@@ -4774,12 +5733,13 @@ function renderSchedulingDriftSection(audit) {
4774
5733
  }
4775
5734
  return lines;
4776
5735
  }
4777
- function renderText$1(results, summary, workarounds) {
5736
+ function renderText$1(results, summary, workarounds, scope) {
4778
5737
  const lines = [""];
4779
5738
  lines.push(chalk.bold("roster doctor"));
5739
+ lines.push(chalk.dim(`Install scope: ${scope} (${scope === "project" ? "workspace-local" : "home directory"})`));
4780
5740
  for (const r of results) {
4781
5741
  lines.push("");
4782
- lines.push(`${chalk.bold(r.toolName)} ${chalk.dim(tildify$4(r.configRoot))}`);
5742
+ lines.push(`${chalk.bold(r.toolName)} ${chalk.dim(tildify$3(r.configRoot))}`);
4783
5743
  for (const item of r.items) {
4784
5744
  const kindCol = chalk.dim(item.kind.padEnd(5));
4785
5745
  const nameCol = item.name.padEnd(22);
@@ -4803,8 +5763,38 @@ function renderText$1(results, summary, workarounds) {
4803
5763
  }
4804
5764
  return lines;
4805
5765
  }
4806
- function executeDoctor(opts) {
4807
- const detected = detectTools();
5766
+ function renderShadowSection(shadows) {
5767
+ if (shadows.length === 0) return [];
5768
+ const lines = [""];
5769
+ lines.push(chalk.bold("Shadow collisions") + chalk.dim(` (${shadows.length})`));
5770
+ lines.push(chalk.dim(" Same skill installed at both scopes. The user-scope copy wins;"));
5771
+ lines.push(chalk.dim(" the workspace skill is silently ignored. Remove one."));
5772
+ for (const s of shadows) {
5773
+ lines.push(` ${chalk.yellow("!")} ${chalk.bold(s.skillName)} ${chalk.dim("(")}${s.tool}${chalk.dim(")")}`);
5774
+ lines.push(` user: ${tildify$3(s.userPath)}`);
5775
+ lines.push(` project: ${s.projectPath}`);
5776
+ }
5777
+ return lines;
5778
+ }
5779
+ function renderInteractiveFixSection(outcome) {
5780
+ if (!(outcome.deleted.length > 0 || outcome.failed.length > 0 || outcome.skipped.length > 0 || outcome.nonTtySkipped)) return [];
5781
+ const lines = [""];
5782
+ lines.push(chalk.bold("agent .env redundancy --fix"));
5783
+ if (outcome.nonTtySkipped) {
5784
+ lines.push(` ${chalk.dim("-")} skipped — re-run \`roster doctor --fix\` in an interactive terminal`);
5785
+ return lines;
5786
+ }
5787
+ for (const f of outcome.deleted) lines.push(` ${chalk.green("✓")} ${f}`);
5788
+ for (const f of outcome.skipped) lines.push(` ${chalk.dim("-")} ${f}: kept`);
5789
+ for (const f of outcome.failed) lines.push(` ${chalk.red("✗")} ${f.what}: ${f.error}`);
5790
+ return lines;
5791
+ }
5792
+ async function executeDoctor(opts) {
5793
+ const workspaceExists = detectWorkspace(opts.cwd);
5794
+ const effectiveScope = opts.scope ?? defaultScopeForContext(workspaceExists);
5795
+ const userScopeTools = detectTools();
5796
+ const detected = effectiveScope === "project" ? allTools().map((t) => toolForScope(t, "project", opts.cwd)).filter((t) => existsSync(t.configRoot)) : userScopeTools;
5797
+ const shadows = workspaceExists ? detectShadowCollisions(opts.cwd) : [];
4808
5798
  const sources = {
4809
5799
  skills: join(ROSTER_ROOT, "skills"),
4810
5800
  agents: join(ROSTER_ROOT, "agents")
@@ -4837,11 +5827,8 @@ function executeDoctor(opts) {
4837
5827
  const workarounds = computeWorkarounds(results);
4838
5828
  let workspaceFinal = workspace;
4839
5829
  const initialEnvPerms = auditEnvPermissions(opts.cwd);
5830
+ const initialAgentEnvPerms = auditAgentEnvPermissions(opts.cwd);
4840
5831
  let fixOutcome = NO_FIX_REQUESTED;
4841
- if (opts.fix) {
4842
- fixOutcome = runFixes(opts.cwd, workspace, initialEnvPerms, opts.dryRun);
4843
- if (!opts.dryRun && fixOutcome.fixed.length > 0) workspaceFinal = auditWorkspace(opts.cwd);
4844
- }
4845
5832
  const schedulesForLeak = listAllScheduleEntries(opts.cwd);
4846
5833
  const codexHomeOverride = process.env["ROSTER_CODEX_HOME"];
4847
5834
  const safety = runSafetyAudit({
@@ -4852,20 +5839,70 @@ function executeDoctor(opts) {
4852
5839
  env: process.env,
4853
5840
  ...codexHomeOverride !== void 0 && codexHomeOverride !== "" ? { codexHome: codexHomeOverride } : {}
4854
5841
  });
4855
- const secrets = runSecretsAudit({
5842
+ const initialSecrets = runSecretsAudit({
4856
5843
  cwd: opts.cwd,
4857
5844
  rosterRoot: ROSTER_ROOT,
4858
5845
  schedules: schedulesForLeak
4859
5846
  });
5847
+ if (opts.fix) {
5848
+ fixOutcome = runFixes(opts.cwd, workspace, initialEnvPerms, initialAgentEnvPerms, opts.dryRun);
5849
+ const refs = initialSecrets.agentEnvRefs;
5850
+ const anyMissing = refs.errors.length > 0 || refs.warns.length > 0;
5851
+ const canInteract = !opts.json && process.stdin.isTTY === true;
5852
+ if (anyMissing) if (opts.dryRun) {
5853
+ const out = await applyAgentEnvFix(opts.cwd, refs, { dryRun: true });
5854
+ fixOutcome.fixed.push(...out.fixed);
5855
+ fixOutcome.failed.push(...out.failed);
5856
+ } else if (canInteract) {
5857
+ const out = await applyAgentEnvFix(opts.cwd, refs, { dryRun: false });
5858
+ fixOutcome.fixed.push(...out.fixed);
5859
+ fixOutcome.failed.push(...out.failed);
5860
+ } else {
5861
+ const reason = opts.json ? "interactive prompt suppressed under --json" : "no TTY (non-interactive shell)";
5862
+ fixOutcome.failed.push({
5863
+ what: "/.env (agent-env-refs)",
5864
+ error: `--fix skipped: ${reason}. Rerun in an interactive shell to append missing keys.`
5865
+ });
5866
+ }
5867
+ if (!opts.dryRun && fixOutcome.fixed.length > 0) workspaceFinal = auditWorkspace(opts.cwd);
5868
+ }
5869
+ const secrets = opts.fix && !opts.dryRun && fixOutcome.fixed.length > 0 ? runSecretsAudit({
5870
+ cwd: opts.cwd,
5871
+ rosterRoot: ROSTER_ROOT,
5872
+ schedules: schedulesForLeak
5873
+ }) : initialSecrets;
4860
5874
  const schedulingDrift = runSchedulingDriftAudit({
4861
5875
  cwd: opts.cwd,
4862
5876
  homeDir: home
4863
5877
  });
4864
5878
  const allOk = results.every((r) => r.ok) && workspaceFinal.ok && scheduling.ok && safety.ok && secrets.ok && schedulingDrift.ok;
5879
+ if (!opts.json && !opts.silent) {
5880
+ for (const line of renderText$1(results, summary, workarounds, effectiveScope)) console.log(line);
5881
+ for (const line of renderShadowSection(shadows)) console.log(line);
5882
+ for (const line of renderWorkspaceSection(workspaceFinal)) console.log(line);
5883
+ for (const line of renderSchedulingSection(scheduling)) console.log(line);
5884
+ for (const line of renderSchedulingDriftSection(schedulingDrift)) console.log(line);
5885
+ for (const line of renderStaleFiresSection(schedulingDrift.staleFires)) console.log(line);
5886
+ for (const line of renderSafetySection(safety)) console.log(line);
5887
+ for (const line of renderSecretsSection(secrets)) console.log(line);
5888
+ for (const line of renderFixSection(fixOutcome)) console.log(line);
5889
+ }
5890
+ let interactiveOutcome = null;
5891
+ if (opts.fix && secrets.agentEnvRedundancy.items.length > 0) {
5892
+ const isTTY = !opts.silent && !opts.json && (process.stdin.isTTY ?? false);
5893
+ interactiveOutcome = await confirmAndDeleteRedundantLines(secrets.agentEnvRedundancy.items, opts.cwd, {
5894
+ isTTY,
5895
+ stdin: process.stdin,
5896
+ stdout: process.stdout
5897
+ }, opts.dryRun);
5898
+ if (!opts.json && !opts.silent) for (const line of renderInteractiveFixSection(interactiveOutcome)) console.log(line);
5899
+ }
4865
5900
  if (opts.json) {
4866
5901
  const payload = {
4867
5902
  ok: allOk,
4868
5903
  rosterVersion: getPackageVersion(),
5904
+ scope: effectiveScope,
5905
+ shadows,
4869
5906
  tools: results,
4870
5907
  summary,
4871
5908
  workspace: workspaceFinal,
@@ -4874,20 +5911,11 @@ function executeDoctor(opts) {
4874
5911
  safety,
4875
5912
  secrets,
4876
5913
  scheduling_drift: schedulingDrift,
4877
- fix: fixOutcome
5914
+ fix: fixOutcome,
5915
+ interactive_fix: interactiveOutcome
4878
5916
  };
4879
5917
  console.log(JSON.stringify(payload, null, 2));
4880
- } else if (!opts.silent) {
4881
- for (const line of renderText$1(results, summary, workarounds)) console.log(line);
4882
- for (const line of renderWorkspaceSection(workspaceFinal)) console.log(line);
4883
- for (const line of renderSchedulingSection(scheduling)) console.log(line);
4884
- for (const line of renderSchedulingDriftSection(schedulingDrift)) console.log(line);
4885
- for (const line of renderStaleFiresSection(schedulingDrift.staleFires)) console.log(line);
4886
- for (const line of renderSafetySection(safety)) console.log(line);
4887
- for (const line of renderSecretsSection(secrets)) console.log(line);
4888
- for (const line of renderFixSection(fixOutcome)) console.log(line);
4889
- if (opts.dryRun) console.log(chalk.dim(opts.fix ? "--dry-run: nothing applied; lines above are what `--fix` would have done." : "--dry-run: read-only audit; pass `--fix` to preview repairs."));
4890
- }
5918
+ } else if (!opts.silent && opts.dryRun) console.log(chalk.dim(opts.fix ? "--dry-run: nothing applied; lines above are what `--fix` would have done." : "--dry-run: read-only audit; pass `--fix` to preview repairs."));
4891
5919
  return allOk ? 0 : 1;
4892
5920
  }
4893
5921
  //#endregion
@@ -5152,7 +6180,7 @@ function buildListReport(cwd, now = /* @__PURE__ */ new Date()) {
5152
6180
  warnings
5153
6181
  };
5154
6182
  }
5155
- function tildify$3(path) {
6183
+ function tildify$2(path) {
5156
6184
  return path;
5157
6185
  }
5158
6186
  function fmtTs$1(d) {
@@ -5169,7 +6197,7 @@ function lastStatusCell(row) {
5169
6197
  function renderListText(report) {
5170
6198
  const lines = [""];
5171
6199
  lines.push(chalk.bold("roster schedule list"));
5172
- lines.push(chalk.dim(`cwd: ${tildify$3(report.cwd)}`));
6200
+ lines.push(chalk.dim(`cwd: ${tildify$2(report.cwd)}`));
5173
6201
  if (report.rows.length === 0) {
5174
6202
  lines.push("");
5175
6203
  lines.push(chalk.dim("(no schedules registered)"));
@@ -5911,7 +6939,7 @@ async function executeRun(opts) {
5911
6939
  //#endregion
5912
6940
  //#region src/commands/schedule.ts
5913
6941
  const READONLY_DRYRUN_LINE = chalk.dim("--dry-run: read-only command; nothing would be written.");
5914
- function tildify$2(path) {
6942
+ function tildify$1(path) {
5915
6943
  const home = homedir();
5916
6944
  return path.startsWith(home) ? "~" + path.slice(home.length) : path;
5917
6945
  }
@@ -5921,7 +6949,7 @@ function countTotalErrors(report) {
5921
6949
  function renderText(report) {
5922
6950
  const lines = [""];
5923
6951
  lines.push(chalk.bold("roster schedule validate"));
5924
- lines.push(chalk.dim(`cwd: ${tildify$2(report.cwd)}`));
6952
+ lines.push(chalk.dim(`cwd: ${tildify$1(report.cwd)}`));
5925
6953
  if (report.files.length === 0) {
5926
6954
  lines.push("");
5927
6955
  lines.push(chalk.dim("No roster/<function>/schedules.yaml files found."));
@@ -6592,14 +7620,14 @@ function installHook(host) {
6592
7620
  }
6593
7621
  //#endregion
6594
7622
  //#region src/commands/hooks.ts
6595
- function tildify$1(path) {
7623
+ function tildify(path) {
6596
7624
  const home = homedir();
6597
7625
  return path.startsWith(home) ? "~" + path.slice(home.length) : path;
6598
7626
  }
6599
7627
  function summarize(result) {
6600
7628
  const hostLabel = result.host === "claude" ? "Claude Code" : "Codex CLI";
6601
- if (result.status === "installed") return `${chalk.green("✓")} ${chalk.bold(hostLabel)} — SessionStart hook → ${tildify$1(result.configFile)}`;
6602
- if (result.status === "already-present") return `${chalk.dim("·")} ${chalk.bold(hostLabel)} — SessionStart hook already installed (${tildify$1(result.configFile)})`;
7629
+ if (result.status === "installed") return `${chalk.green("✓")} ${chalk.bold(hostLabel)} — SessionStart hook → ${tildify(result.configFile)}`;
7630
+ if (result.status === "already-present") return `${chalk.dim("·")} ${chalk.bold(hostLabel)} — SessionStart hook already installed (${tildify(result.configFile)})`;
6603
7631
  return `${chalk.yellow("⚠")} ${chalk.bold(hostLabel)} — skipped: ${result.reason ?? "host not detected"}`;
6604
7632
  }
6605
7633
  function hostsForTarget(target) {
@@ -7763,9 +8791,12 @@ const SUBCOMMANDS = new Set([
7763
8791
  "migrate",
7764
8792
  "pending"
7765
8793
  ]);
7766
- function tildify(path) {
8794
+ function displayPath(path, cwd) {
7767
8795
  const home = homedir();
7768
- return path.startsWith(home) ? "~" + path.slice(home.length) : path;
8796
+ if (path.startsWith(home)) return "~" + path.slice(home.length);
8797
+ const rel = relative(cwd, path);
8798
+ if (!rel.startsWith("..") && !rel.startsWith("/")) return "./" + rel;
8799
+ return path;
7769
8800
  }
7770
8801
  function printBanner(version) {
7771
8802
  console.log();
@@ -7798,8 +8829,10 @@ function printHelp(version) {
7798
8829
  ` -v, --version ${chalk.dim("Print version and exit")}`,
7799
8830
  ` --silent ${chalk.dim("Suppress non-error output (install)")}`,
7800
8831
  ` --verbose ${chalk.dim("Log each file path written (install)")}`,
7801
- ` --all ${chalk.dim("Install to every detected tool (install)")}`,
7802
- ` --tool <name> ${chalk.dim("Install to a single tool: claude | codex | gemini (install)")}`,
8832
+ ` --all ${chalk.dim("Install to every detected tool (alias of --tool all) (install)")}`,
8833
+ ` --tool <name[,name...]> ${chalk.dim("Install to one or more tools: claude | codex | gemini (install)")}`,
8834
+ ` --scope <project|user> ${chalk.dim("Install at workspace-local or home-dir scope (install)")}`,
8835
+ ` --yes, -y ${chalk.dim("Skip prompts; use safe defaults (install)")}`,
7803
8836
  ` --tool <name> ${chalk.dim("Required scheduler tool: claude | codex (schedule install)")}`,
7804
8837
  ` --migrate ${chalk.dim("Upgrade pre-CONTEXT.md workspace, preserving CLAUDE.md content (init)")}`,
7805
8838
  ` --json ${chalk.dim("Emit machine-readable JSON (doctor, schedule validate)")}`,
@@ -7835,23 +8868,28 @@ function toolHints(tools) {
7835
8868
  installLink: t.installLink
7836
8869
  }));
7837
8870
  }
7838
- function summarizeInstall(tool, result) {
7839
- const skillsLine = `${result.skillsCount} skills → ${tildify(result.skillsTarget)}`;
7840
- const agentsLine = result.agentsTarget ? `${result.agentsCount} agents → ${tildify(result.agentsTarget)}` : `${result.agentsCount} agents → (n/a)`;
8871
+ function summarizeInstall(tool, result, cwd) {
8872
+ const skillsLine = `${result.skillsCount} skills → ${displayPath(result.skillsTarget, cwd)}`;
8873
+ const agentsLine = result.agentsTarget ? `${result.agentsCount} agents → ${displayPath(result.agentsTarget, cwd)}` : `${result.agentsCount} agents → (n/a)`;
7841
8874
  return `${chalk.green("✓")} ${chalk.bold(tool.name)} — ${skillsLine}, ${agentsLine}`;
7842
8875
  }
7843
- async function promptForTools(detected) {
7844
- if (detected.length === 1) return detected;
8876
+ async function promptForTools(detected, undetected) {
8877
+ if (detected.length === 1 && undetected.length === 0) return detected;
7845
8878
  const { checkbox, confirm } = await import("@inquirer/prompts");
8879
+ const choices = [...detected.map((t) => ({
8880
+ name: t.name,
8881
+ value: t.key,
8882
+ checked: true
8883
+ })), ...undetected.map((t) => ({
8884
+ name: t.name,
8885
+ value: t.key,
8886
+ disabled: "(not detected)"
8887
+ }))];
7846
8888
  let selectedKeys;
7847
8889
  try {
7848
8890
  selectedKeys = await checkbox({
7849
8891
  message: "Install roster into which AI tools?",
7850
- choices: detected.map((t) => ({
7851
- name: t.name,
7852
- value: t.key,
7853
- checked: true
7854
- }))
8892
+ choices
7855
8893
  });
7856
8894
  } catch {
7857
8895
  return null;
@@ -7867,10 +8905,31 @@ async function promptForTools(detected) {
7867
8905
  return null;
7868
8906
  }
7869
8907
  if (exitAnyway) return null;
7870
- return promptForTools(detected);
8908
+ return promptForTools(detected, undetected);
7871
8909
  }
7872
8910
  return detected.filter((t) => selectedKeys.includes(t.key));
7873
8911
  }
8912
+ async function promptForScope(workspaceExists, cwd) {
8913
+ const { select } = await import("@inquirer/prompts");
8914
+ const projectHint = workspaceExists ? `workspace-local — skills land in ${displayPath(join(cwd, ".<tool>"), cwd)}/skills/` : "workspace-local — REQUIRES roster init (config/project.yaml not found here)";
8915
+ try {
8916
+ return await select({
8917
+ message: "Install at which scope?",
8918
+ choices: [{
8919
+ name: "project",
8920
+ value: "project",
8921
+ description: projectHint
8922
+ }, {
8923
+ name: "user",
8924
+ value: "user",
8925
+ description: "home directory — skills land in ~/.<tool>/, visible to every Claude Code project on this machine"
8926
+ }],
8927
+ default: workspaceExists ? "project" : "user"
8928
+ });
8929
+ } catch {
8930
+ return null;
8931
+ }
8932
+ }
7874
8933
  async function runInstall(args) {
7875
8934
  const parsed = parseInstallArgs(args);
7876
8935
  if (parsed.kind === "err") throw new RosterError({
@@ -7879,39 +8938,58 @@ async function runInstall(args) {
7879
8938
  remedy: ` Run ${chalk.bold("roster --help")} for usage.`,
7880
8939
  exitCode: 1
7881
8940
  });
7882
- const { silent, verbose, target } = parsed;
8941
+ const { silent, verbose, yes, scope: requestedScope, target } = parsed;
7883
8942
  const version = getPackageVersion();
7884
8943
  if (!silent) printBanner(version);
8944
+ const cwd = process.cwd();
8945
+ const isTTY = process.stdin.isTTY === true;
8946
+ const workspaceExists = detectWorkspace(cwd);
8947
+ const nonInteractive = yes || !isTTY;
7885
8948
  const detected = detectTools();
7886
- if (detected.length === 0) throw noToolsError(toolHints(allTools()));
7887
8949
  let targetTools;
7888
- if (target.mode === "all") targetTools = detected;
7889
- else if (target.mode === "tool") {
7890
- const match = detected.find((t) => t.key === target.key);
7891
- if (!match) throw new RosterError({
7892
- header: `${chalk.red.bold("roster:")} ${target.key} not detected on this machine`,
7893
- body: ` --tool ${target.key} was requested, but no ${target.key} install was found.`,
7894
- remedy: ` Install it first, or omit ${chalk.bold("--tool")} to install to all detected tools.`,
7895
- exitCode: 3
7896
- });
7897
- targetTools = [match];
7898
- } else targetTools = await promptForTools(detected);
7899
- if (targetTools === null) throw userCancelledInstall();
8950
+ if (target.mode === "all") {
8951
+ if (detected.length === 0) throw noToolsError(toolHints(allTools()));
8952
+ targetTools = detected;
8953
+ } else if (target.mode === "tools") {
8954
+ const detectedKeys = detected.map((t) => t.key);
8955
+ if (target.keys.filter((k) => !detectedKeys.includes(k)).length > 0) throw toolsNotDetectedError(target.keys, detectedKeys);
8956
+ targetTools = detected.filter((t) => target.keys.includes(t.key));
8957
+ } else {
8958
+ if (detected.length === 0) throw noToolsError(toolHints(allTools()));
8959
+ if (nonInteractive) targetTools = detected;
8960
+ else {
8961
+ const picked = await promptForTools(detected, allTools().filter((t) => !detected.some((d) => d.key === t.key)));
8962
+ if (picked === null) throw userCancelledInstall();
8963
+ targetTools = picked;
8964
+ }
8965
+ }
8966
+ let scope;
8967
+ if (requestedScope !== null) scope = requestedScope;
8968
+ else if (nonInteractive) scope = defaultScopeForContext(workspaceExists);
8969
+ else {
8970
+ const picked = await promptForScope(workspaceExists, cwd);
8971
+ if (picked === null) throw userCancelledInstall();
8972
+ scope = picked;
8973
+ }
8974
+ if (scope === "project" && !workspaceExists) throw workspaceRequiredError(cwd);
7900
8975
  const skillsSrc = join(ROSTER_ROOT, "skills");
7901
8976
  const agentsSrc = join(ROSTER_ROOT, "agents");
7902
- const confirmFn = target.mode !== "interactive" ? async () => false : void 0;
8977
+ const confirmFn = nonInteractive ? async () => false : void 0;
7903
8978
  for (const tool of targetTools) {
7904
- const result = await installToTool(tool, {
8979
+ const scopedTool = scope === "project" ? toolForScope(tool, "project", cwd) : tool;
8980
+ const result = await installToTool(scopedTool, {
7905
8981
  skills: skillsSrc,
7906
8982
  agents: agentsSrc,
7907
8983
  silent: !verbose,
8984
+ scope,
7908
8985
  ...confirmFn ? { confirm: confirmFn } : {}
7909
8986
  });
7910
- if (!silent) console.log(summarizeInstall(tool, result));
8987
+ if (!silent) console.log(summarizeInstall(scopedTool, result, cwd));
7911
8988
  }
7912
8989
  if (!silent) {
7913
8990
  console.log();
7914
- console.log(`${chalk.dim("Next: ")}${chalk.bold("roster init")}${chalk.dim(" to scaffold a workspace.")}`);
8991
+ if (scope === "project") console.log(`${chalk.dim("Next: ")}${chalk.bold("open Claude Code (or your AI tool) in this directory")}${chalk.dim(" skills are workspace-local.")}`);
8992
+ else console.log(`${chalk.dim("Next: ")}${chalk.bold("roster init")}${chalk.dim(" to scaffold a workspace, then re-run install at project scope.")}`);
7915
8993
  }
7916
8994
  return 0;
7917
8995
  }
@@ -8072,7 +9150,7 @@ async function runHooks(args) {
8072
9150
  exitCode: 1
8073
9151
  });
8074
9152
  }
8075
- function runDoctor(args) {
9153
+ async function runDoctor(args) {
8076
9154
  const parsed = parseDoctorArgs(args);
8077
9155
  if (parsed.kind === "err") throw new RosterError({
8078
9156
  header: `${chalk.red.bold("roster:")} ${parsed.message}`,
@@ -8080,12 +9158,13 @@ function runDoctor(args) {
8080
9158
  remedy: ` Run ${chalk.bold("roster --help")} for usage.`,
8081
9159
  exitCode: 1
8082
9160
  });
8083
- const code = executeDoctor({
9161
+ const code = await executeDoctor({
8084
9162
  json: parsed.json,
8085
9163
  silent: parsed.silent,
8086
9164
  fix: parsed.fix,
8087
9165
  dryRun: parsed.dryRun,
8088
- cwd: process.cwd()
9166
+ cwd: process.cwd(),
9167
+ scope: parsed.scope
8089
9168
  });
8090
9169
  if (code === 3 && !parsed.json) throw noToolsError(toolHints(allTools()));
8091
9170
  return code;
@@ -8111,7 +9190,7 @@ async function main() {
8111
9190
  if (isSubcommand(first)) {
8112
9191
  if (first === "install") return runInstall(rest);
8113
9192
  if (first === "init") return await runInit(rest);
8114
- if (first === "doctor") return runDoctor(rest);
9193
+ if (first === "doctor") return await runDoctor(rest);
8115
9194
  if (first === "schedule") return await runSchedule(rest);
8116
9195
  if (first === "review") return await runReview(rest);
8117
9196
  if (first === "hooks") return await runHooks(rest);