skillrepo 3.0.0 → 3.1.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.
Files changed (52) hide show
  1. package/README.md +74 -6
  2. package/bin/skillrepo.mjs +14 -0
  3. package/package.json +1 -1
  4. package/src/commands/init.mjs +184 -19
  5. package/src/commands/remove.mjs +8 -13
  6. package/src/commands/session-sync.mjs +152 -0
  7. package/src/commands/uninstall.mjs +484 -0
  8. package/src/commands/update.mjs +125 -8
  9. package/src/lib/artifact-registry.mjs +305 -0
  10. package/src/lib/cli-config.mjs +78 -0
  11. package/src/lib/config.mjs +6 -3
  12. package/src/lib/file-write.mjs +8 -3
  13. package/src/lib/fs-utils.mjs +90 -9
  14. package/src/lib/mergers/session-hook.mjs +378 -0
  15. package/src/lib/paths.mjs +21 -0
  16. package/src/lib/platform.mjs +124 -0
  17. package/src/lib/removers/claude-mcp.mjs +67 -0
  18. package/src/lib/removers/cursor-mcp.mjs +60 -0
  19. package/src/lib/removers/env-local.mjs +55 -0
  20. package/src/lib/removers/gitignore.mjs +108 -0
  21. package/src/lib/removers/settings.mjs +183 -0
  22. package/src/lib/removers/vscode-mcp.mjs +87 -0
  23. package/src/lib/removers/windsurf-mcp.mjs +65 -0
  24. package/src/lib/sync.mjs +26 -0
  25. package/src/test/commands/add.test.mjs +10 -4
  26. package/src/test/commands/get.test.mjs +10 -4
  27. package/src/test/commands/init.test.mjs +428 -4
  28. package/src/test/commands/list.test.mjs +10 -4
  29. package/src/test/commands/remove.test.mjs +10 -4
  30. package/src/test/commands/search.test.mjs +10 -4
  31. package/src/test/commands/session-sync.test.mjs +352 -0
  32. package/src/test/commands/uninstall.test.mjs +774 -0
  33. package/src/test/commands/update.test.mjs +168 -4
  34. package/src/test/helpers/sandbox-home.mjs +161 -0
  35. package/src/test/helpers/skillrepo-shim.mjs +133 -0
  36. package/src/test/integration/file-write.integration.test.mjs +10 -4
  37. package/src/test/lib/artifact-registry.test.mjs +268 -0
  38. package/src/test/lib/cli-config.test.mjs +126 -5
  39. package/src/test/lib/config.test.mjs +10 -4
  40. package/src/test/lib/file-write.test.mjs +24 -10
  41. package/src/test/lib/mcp-merge.test.mjs +10 -4
  42. package/src/test/lib/paths.test.mjs +10 -4
  43. package/src/test/lib/platform.test.mjs +135 -0
  44. package/src/test/lib/sync.test.mjs +20 -4
  45. package/src/test/mergers/session-hook.test.mjs +1175 -0
  46. package/src/test/mergers/uninstall-claude-mcp.test.mjs +145 -0
  47. package/src/test/mergers/uninstall-cursor-mcp.test.mjs +108 -0
  48. package/src/test/mergers/uninstall-env-local.test.mjs +144 -0
  49. package/src/test/mergers/uninstall-gitignore.test.mjs +209 -0
  50. package/src/test/mergers/uninstall-settings.test.mjs +296 -0
  51. package/src/test/mergers/uninstall-vscode-mcp.test.mjs +215 -0
  52. package/src/test/mergers/uninstall-windsurf-mcp.test.mjs +128 -0
@@ -20,12 +20,18 @@ import { readConfig } from "../../lib/config.mjs";
20
20
  import { CliError, EXIT_AUTH, EXIT_VALIDATION } from "../../lib/errors.mjs";
21
21
  import { createMockServer } from "../e2e/mock-server.mjs";
22
22
  import { createCaptureStream } from "../helpers/capture-stream.mjs";
23
+ import {
24
+ captureHome,
25
+ setSandboxHome,
26
+ restoreHome,
27
+ } from "../helpers/sandbox-home.mjs";
23
28
 
24
29
  let sandbox;
25
30
  let server;
26
31
  let serverUrl;
27
32
  let originalCwd;
28
- let originalHome;
33
+ /** @type {import("../helpers/sandbox-home.mjs").HomeEnvSnapshot} */
34
+ let originalHomeEnv;
29
35
  let stdout;
30
36
  let stderr;
31
37
  const VALID_KEY = "sk_live_init_test";
@@ -38,9 +44,9 @@ async function setup() {
38
44
  mkdirSync(join(sandbox, "project", ".claude"), { recursive: true });
39
45
  mkdirSync(join(sandbox, "home"), { recursive: true });
40
46
  originalCwd = process.cwd();
41
- originalHome = process.env.HOME;
47
+ originalHomeEnv = captureHome();
42
48
  process.chdir(join(sandbox, "project"));
43
- process.env.HOME = join(sandbox, "home");
49
+ setSandboxHome(join(sandbox, "home"));
44
50
  delete process.env.SKILLREPO_ACCESS_KEY;
45
51
  delete process.env.SKILLREPO_URL;
46
52
 
@@ -55,7 +61,7 @@ async function setup() {
55
61
  async function teardown() {
56
62
  if (server) await server.stop();
57
63
  process.chdir(originalCwd);
58
- process.env.HOME = originalHome;
64
+ restoreHome(originalHomeEnv);
59
65
  if (sandbox) rmSync(sandbox, { recursive: true, force: true });
60
66
  server = null;
61
67
  }
@@ -484,3 +490,421 @@ describe("runInit — stale-key handling", () => {
484
490
  );
485
491
  });
486
492
  });
493
+
494
+ // ── Session-sync step 6 (#884) ────────────────────────────────────────
495
+ //
496
+ // INTENT-based coverage of the new step 6 added in v3.1.0. Tests use
497
+ // the PATH-shim trick from session-sync.test.mjs to make
498
+ // `which skillrepo` resolve deterministically: a fake `skillrepo`
499
+ // executable is dropped into `$HOME/bin` and prepended to PATH.
500
+ // Without this, the behavior of these tests would depend on whether
501
+ // a global install exists on the developer's machine.
502
+ //
503
+ // Lower-level installer correctness (hook shape, idempotency,
504
+ // round-trip with remover) is covered in session-hook.test.mjs. These
505
+ // init tests verify the ORCHESTRATION: --yes path, --no-session-sync
506
+ // opt-out, --json output shape, and the non-fatal disk-error path.
507
+
508
+ import { SESSION_HOOK_FINGERPRINT as _FINGERPRINT } from "../../lib/artifact-registry.mjs";
509
+ import { installShim, uninstallShim } from "../helpers/skillrepo-shim.mjs";
510
+
511
+ /** @type {ReturnType<typeof installShim> | undefined} */
512
+ let _shimHandle;
513
+
514
+ async function setupWithShim() {
515
+ await setup();
516
+ // Drop a cross-platform `skillrepo` shim into HOME/bin so the
517
+ // CLI's binary resolver (which/where) finds it rather than
518
+ // depending on whether the dev/CI machine has a global install.
519
+ _shimHandle = installShim(process.env.HOME);
520
+ }
521
+
522
+ async function teardownWithShim() {
523
+ uninstallShim(_shimHandle);
524
+ _shimHandle = undefined;
525
+ await teardown();
526
+ }
527
+
528
+ describe("runInit — session sync (#884)", () => {
529
+ beforeEach(setupWithShim);
530
+ afterEach(teardownWithShim);
531
+
532
+ it("--yes installs the hook at step 6 by default", async () => {
533
+ // INTENT: the architect-designed default for --yes mode is
534
+ // "install the hook." CI/onboarding scripts passing --yes should
535
+ // get a fully-configured project including session sync.
536
+ await runInit(
537
+ ["--key", VALID_KEY, "--url", serverUrl, "--yes"],
538
+ { stdout, stderr },
539
+ );
540
+
541
+ const settingsPath = join(process.cwd(), ".claude", "settings.local.json");
542
+ assert.ok(existsSync(settingsPath), "settings.local.json must exist");
543
+
544
+ const parsed = JSON.parse(readFileSync(settingsPath, "utf-8"));
545
+ const hasHook = parsed.hooks.SessionStart.flatMap((g) => g.hooks).some(
546
+ (h) => h.command.includes(_FINGERPRINT),
547
+ );
548
+ assert.ok(hasHook, "SkillRepo SessionStart hook must be installed");
549
+ assert.match(stdout.text(), /SessionStart hook installed/);
550
+ });
551
+
552
+ it("--no-session-sync skips the hook install even with --yes", async () => {
553
+ // INTENT: the only way CI scripts that bootstrap a project
554
+ // without starting Claude Code sessions can opt out. Must work
555
+ // alongside --yes (otherwise --yes would force hook install).
556
+ await runInit(
557
+ ["--key", VALID_KEY, "--url", serverUrl, "--yes", "--no-session-sync"],
558
+ { stdout, stderr },
559
+ );
560
+
561
+ const settingsPath = join(process.cwd(), ".claude", "settings.local.json");
562
+ assert.ok(
563
+ !existsSync(settingsPath),
564
+ "settings.local.json must NOT be written under --no-session-sync",
565
+ );
566
+ assert.match(stdout.text(), /Session sync skipped \(--no-session-sync\)/);
567
+ });
568
+
569
+ it("re-running init is idempotent — exactly one hook entry", async () => {
570
+ // INTENT: users re-run init for many reasons (switching keys,
571
+ // updating the config). A duplicate hook would fire sync twice
572
+ // per session — waste at best, race at worst.
573
+ await runInit(
574
+ ["--key", VALID_KEY, "--url", serverUrl, "--yes"],
575
+ { stdout, stderr },
576
+ );
577
+ stdout.clear();
578
+ await runInit(
579
+ ["--key", VALID_KEY, "--url", serverUrl, "--yes"],
580
+ { stdout, stderr },
581
+ );
582
+
583
+ const parsed = JSON.parse(
584
+ readFileSync(
585
+ join(process.cwd(), ".claude", "settings.local.json"),
586
+ "utf-8",
587
+ ),
588
+ );
589
+ const skillrepoHooks = parsed.hooks.SessionStart.flatMap(
590
+ (g) => g.hooks,
591
+ ).filter((h) => h.command.includes(_FINGERPRINT));
592
+ assert.equal(skillrepoHooks.length, 1, "exactly one SkillRepo hook");
593
+ });
594
+
595
+ it("--json includes a sessionSync block with action + path", async () => {
596
+ // INTENT: automation scripts need to know whether session sync
597
+ // was installed, opted out, or failed. The JSON contract is the
598
+ // machine-readable channel for that.
599
+ await runInit(
600
+ ["--key", VALID_KEY, "--url", serverUrl, "--yes", "--json"],
601
+ { stdout, stderr },
602
+ );
603
+ const json = JSON.parse(stdout.text());
604
+ assert.ok(json.sessionSync, "sessionSync must be in --json output");
605
+ assert.equal(json.sessionSync.action, "installed");
606
+ assert.equal(json.sessionSync.path, ".claude/settings.local.json");
607
+ });
608
+
609
+ it("--json with --no-session-sync reports action: 'opted-out'", async () => {
610
+ await runInit(
611
+ [
612
+ "--key",
613
+ VALID_KEY,
614
+ "--url",
615
+ serverUrl,
616
+ "--yes",
617
+ "--no-session-sync",
618
+ "--json",
619
+ ],
620
+ { stdout, stderr },
621
+ );
622
+ const json = JSON.parse(stdout.text());
623
+ assert.equal(json.sessionSync.action, "opted-out");
624
+ assert.equal(json.sessionSync.path, null);
625
+ });
626
+
627
+ it("skips session sync entirely when only non-Claude-Code IDEs are targeted (cross-PR review fix)", async () => {
628
+ // Cross-PR review flagged: before this guard, a user running
629
+ // `skillrepo init --ide cursor` would get a Claude Code-specific
630
+ // SessionStart hook written to `.claude/settings.local.json`.
631
+ // Cursor never reads that file, so the hook was silent useless
632
+ // state that `skillrepo uninstall` later had to clean up.
633
+ //
634
+ // The guard in init.mjs step 6 now skips the install when
635
+ // `claudeCode` is not in the resolved vendors list AND
636
+ // `--global` is not passed. This test proves the skip fires.
637
+ //
638
+ // Use --ide cursor to force vendors = ["cursor"]. Bypass the
639
+ // .claude/ auto-detection by creating .cursor/ instead.
640
+ mkdirSync(join(process.cwd(), ".cursor"), { recursive: true });
641
+ rmSync(join(process.cwd(), ".claude"), { recursive: true, force: true });
642
+
643
+ await runInit(
644
+ [
645
+ "--key",
646
+ VALID_KEY,
647
+ "--url",
648
+ serverUrl,
649
+ "--yes",
650
+ "--ide",
651
+ "cursor",
652
+ "--json",
653
+ ],
654
+ { stdout, stderr },
655
+ );
656
+
657
+ const json = JSON.parse(stdout.text());
658
+ assert.equal(
659
+ json.sessionSync.action,
660
+ "not-applicable",
661
+ "session sync must report 'not-applicable' for non-Claude-Code targets",
662
+ );
663
+ assert.equal(json.sessionSync.path, null);
664
+ // Critical: the settings.local.json file must NOT have been
665
+ // written. A Cursor user should never see this Claude-specific
666
+ // file materialize from `skillrepo init`.
667
+ assert.ok(
668
+ !existsSync(join(process.cwd(), ".claude", "settings.local.json")),
669
+ ".claude/settings.local.json must NOT be written for Cursor-only init",
670
+ );
671
+ });
672
+
673
+ it("still installs session sync under --global even without claudeCode in vendors", async () => {
674
+ // INTENT: `--global` writes to `~/.claude/settings.local.json`,
675
+ // which IS Claude Code's user-wide settings path. A user who
676
+ // runs `skillrepo init --global` (even without `--ide claude`)
677
+ // is implicitly targeting Claude Code. The guard must allow
678
+ // this path so `--global` users still get auto-sync.
679
+ //
680
+ // Note: the setup() helper already creates `.claude/` in the
681
+ // project, which would normally push vendors to include
682
+ // claudeCode. Force vendors = ["cursor"] via --ide to exercise
683
+ // the "--global overrides vendors" branch.
684
+ await runInit(
685
+ [
686
+ "--key",
687
+ VALID_KEY,
688
+ "--url",
689
+ serverUrl,
690
+ "--yes",
691
+ "--global",
692
+ "--ide",
693
+ "cursor",
694
+ "--json",
695
+ ],
696
+ { stdout, stderr },
697
+ );
698
+
699
+ const json = JSON.parse(stdout.text());
700
+ assert.equal(
701
+ json.sessionSync.action,
702
+ "installed",
703
+ "--global must install the hook even when vendors doesn't include claudeCode",
704
+ );
705
+ assert.equal(json.sessionSync.path, "~/.claude/settings.local.json");
706
+ });
707
+ });
708
+
709
+ // ── v3.1.1 patch fixes: init UX bugs surfaced by real-world npx use ──
710
+ //
711
+ // Real-user `npx skillrepo@latest init` session surfaced four bugs in
712
+ // the v3.1.0 init output. The tests below lock each fix as a
713
+ // behavioral contract:
714
+ //
715
+ // 1. "Next steps" hardcoded `skillrepo` even under npx — would fail
716
+ // with "command not found" for users without a global install.
717
+ // 2. Session-sync hook installed under npx with a cache-temporary
718
+ // path that breaks on cache eviction. (Covered in
719
+ // session-hook.test.mjs via isNpxInvocation guard test.)
720
+ // 3. Step-7 zero-delta message conflated "empty library" with
721
+ // "nothing changed since last sync" — lied to users who had
722
+ // synced skills but no server-side changes since.
723
+ // 4. `--verbose` rejected as Unknown argument by resolveFlags.
724
+ // (Covered in cli-config.test.mjs.)
725
+
726
+ describe("runInit — v3.1.1 zero-delta message (bug 3)", () => {
727
+ beforeEach(setup);
728
+ afterEach(teardown);
729
+
730
+ it("reports 'Library is up to date' when a delta sync returns zero changes", async () => {
731
+ // Reproduces the real-user bug: a user with an existing
732
+ // .last-sync from a prior v3.0.0 session runs `skillrepo init`
733
+ // again. Init's step 7 sends `since=<prior syncedAt>`. Server
734
+ // returns empty skills[] because nothing changed server-side.
735
+ // Counters (added/updated/removed) all zero.
736
+ //
737
+ // Before the fix: init printed "No skills in library yet"
738
+ // regardless of whether the library was empty or fully synced.
739
+ // After: init distinguishes via the new `fullSync` field in
740
+ // SyncSummary and prints "Library is up to date (no changes
741
+ // since last sync)" for the delta case.
742
+ //
743
+ // Pre-seed a .last-sync file simulating the prior sync state.
744
+ mkdirSync(join(process.env.HOME, ".claude", "skillrepo"), { recursive: true });
745
+ writeFileSync(
746
+ join(process.env.HOME, ".claude", "skillrepo", ".last-sync"),
747
+ JSON.stringify({
748
+ schemaVersion: 1,
749
+ etag: '"old-v300-format-etag"',
750
+ syncedAt: "2026-04-15T12:00:00.000Z",
751
+ }),
752
+ );
753
+
754
+ // Server returns empty skills[] (no changes since last sync).
755
+ server.setLibraryResponse({ skills: [], removals: [], syncedAt: "2026-04-16T12:00:00.000Z" });
756
+
757
+ await runInit(
758
+ ["--key", VALID_KEY, "--url", serverUrl, "--yes"],
759
+ { stdout, stderr },
760
+ );
761
+
762
+ const out = stdout.text();
763
+ assert.match(
764
+ out,
765
+ /Library is up to date/,
766
+ "zero-delta on delta sync must report 'up to date', not 'no skills in library yet'",
767
+ );
768
+ assert.doesNotMatch(
769
+ out,
770
+ /No skills in library yet/,
771
+ "the misleading 'no skills' message must NOT fire when .last-sync existed",
772
+ );
773
+ });
774
+
775
+ it("still reports 'No skills in library yet' on a true first-run full sync with zero skills", async () => {
776
+ // The other side of bug 3: when there's NO prior .last-sync and
777
+ // the server returns zero skills, the library IS genuinely
778
+ // empty. The message should still say so.
779
+ server.setLibraryResponse({ skills: [], removals: [], syncedAt: "2026-04-16T12:00:00.000Z" });
780
+
781
+ await runInit(
782
+ ["--key", VALID_KEY, "--url", serverUrl, "--yes"],
783
+ { stdout, stderr },
784
+ );
785
+
786
+ const out = stdout.text();
787
+ assert.match(out, /No skills in library yet/);
788
+ });
789
+
790
+ it("304 Not Modified reports 'Library is up to date' (neutral phrasing)", async () => {
791
+ // 304 path: server returned "nothing changed" at the HTTP
792
+ // level. Definitively up-to-date regardless of whether library
793
+ // is populated or empty.
794
+ mkdirSync(join(process.env.HOME, ".claude", "skillrepo"), { recursive: true });
795
+ writeFileSync(
796
+ join(process.env.HOME, ".claude", "skillrepo", ".last-sync"),
797
+ JSON.stringify({
798
+ schemaVersion: 1,
799
+ etag: '"match-me"',
800
+ syncedAt: "2026-04-15T12:00:00.000Z",
801
+ }),
802
+ );
803
+ server.setEtag('"match-me"');
804
+
805
+ await runInit(
806
+ ["--key", VALID_KEY, "--url", serverUrl, "--yes"],
807
+ { stdout, stderr },
808
+ );
809
+
810
+ assert.match(stdout.text(), /Library is up to date/);
811
+ });
812
+ });
813
+
814
+ describe("runInit — v3.1.1 Next steps prefix (bug 1)", () => {
815
+ // Process-state isolation: the tests below depend on controlling
816
+ // isNpxInvocation()'s outputs. The test host's shell environment
817
+ // may have `_` set to some launching command (the enclosing `npm
818
+ // run check`, `node --test`, etc.). beforeEach force-clears the
819
+ // two isNpxInvocation signals (argv[1] and `_`) plus the
820
+ // now-unused `npm_command` — the latter is cleared defensively
821
+ // because older versions of this codebase treated it as an npx
822
+ // signal, and belt-and-suspenders cleanup costs nothing.
823
+ let originalArgv;
824
+ let originalNpmCommand;
825
+ let originalUnderscore;
826
+
827
+ beforeEach(async () => {
828
+ await setup();
829
+ originalArgv = process.argv;
830
+ originalNpmCommand = process.env.npm_command;
831
+ originalUnderscore = process.env._;
832
+ // Non-npx state:
833
+ process.argv = ["/usr/local/bin/node", "/usr/local/bin/skillrepo"];
834
+ delete process.env.npm_command;
835
+ delete process.env._;
836
+ });
837
+
838
+ afterEach(async () => {
839
+ process.argv = originalArgv;
840
+ if (originalNpmCommand === undefined) delete process.env.npm_command;
841
+ else process.env.npm_command = originalNpmCommand;
842
+ if (originalUnderscore === undefined) delete process.env._;
843
+ else process.env._ = originalUnderscore;
844
+ await teardown();
845
+ });
846
+
847
+ it("shows bare `skillrepo` prefix when running from a stable install", async () => {
848
+ // With all npx signals cleared (beforeEach above), isNpxInvocation
849
+ // returns false and the bare prefix is used in the Next Steps
850
+ // block.
851
+ //
852
+ // NOTE ON REGEX: "npm install -g skillrepo" appears in TWO places
853
+ // that look alike but come from different code paths:
854
+ //
855
+ // 1. Step 6/7 session-sync skipped message (when the binary
856
+ // can't be resolved — in the test environment, `which
857
+ // skillrepo` doesn't find anything real).
858
+ // 2. Next Steps tip (only shown under npx).
859
+ //
860
+ // We want to assert only #2 is absent. Match the EXACT "Tip:"
861
+ // prefix to scope the assertion to Next Steps.
862
+ server.setLibraryResponse({ skills: [], removals: [], syncedAt: "x" });
863
+ await runInit(
864
+ ["--key", VALID_KEY, "--url", serverUrl, "--yes"],
865
+ { stdout, stderr },
866
+ );
867
+
868
+ const out = stdout.text();
869
+ // The commands in Next Steps are bare, not prefixed.
870
+ assert.match(out, /^ +• skillrepo list/m);
871
+ assert.doesNotMatch(
872
+ out,
873
+ /npx skillrepo list/,
874
+ "bare install must NOT show the npx-prefixed hints",
875
+ );
876
+ // The "Tip: …" line appears only under npx.
877
+ assert.doesNotMatch(
878
+ out,
879
+ /Tip: `npm install -g skillrepo`/,
880
+ "bare install must NOT show the npx-mode global-install tip",
881
+ );
882
+ });
883
+
884
+ it("shows `npx skillrepo` prefix and global-install tip under npx invocation", async () => {
885
+ // Simulate npx by stuffing argv[1] with an _npx cache path.
886
+ // isNpxInvocation() returns true and the output changes shape.
887
+ process.argv = [
888
+ "/usr/local/bin/node",
889
+ "/Users/alice/.npm/_npx/dc129a78aca3fc9c/node_modules/.bin/skillrepo",
890
+ ];
891
+
892
+ server.setLibraryResponse({ skills: [], removals: [], syncedAt: "x" });
893
+ await runInit(
894
+ ["--key", VALID_KEY, "--url", serverUrl, "--yes"],
895
+ { stdout, stderr },
896
+ );
897
+
898
+ const out = stdout.text();
899
+ assert.match(
900
+ out,
901
+ /npx skillrepo list/,
902
+ "npx invocation must prefix commands with `npx`",
903
+ );
904
+ assert.match(
905
+ out,
906
+ /Tip: `npm install -g skillrepo`/,
907
+ "npx invocation must show the Next-Steps global-install tip",
908
+ );
909
+ });
910
+ });
@@ -12,12 +12,18 @@ import { runList } from "../../commands/list.mjs";
12
12
  import { CliError, EXIT_AUTH } from "../../lib/errors.mjs";
13
13
  import { createMockServer } from "../e2e/mock-server.mjs";
14
14
  import { createCaptureStream } from "../helpers/capture-stream.mjs";
15
+ import {
16
+ captureHome,
17
+ setSandboxHome,
18
+ restoreHome,
19
+ } from "../helpers/sandbox-home.mjs";
15
20
 
16
21
  let sandbox;
17
22
  let server;
18
23
  let serverUrl;
19
24
  let originalCwd;
20
- let originalHome;
25
+ /** @type {import("../helpers/sandbox-home.mjs").HomeEnvSnapshot} */
26
+ let originalHomeEnv;
21
27
  let stdout;
22
28
  const VALID_KEY = "sk_live_test";
23
29
 
@@ -37,9 +43,9 @@ async function setup() {
37
43
  mkdirSync(join(sandbox, "project"), { recursive: true });
38
44
  mkdirSync(join(sandbox, "home"), { recursive: true });
39
45
  originalCwd = process.cwd();
40
- originalHome = process.env.HOME;
46
+ originalHomeEnv = captureHome();
41
47
  process.chdir(join(sandbox, "project"));
42
- process.env.HOME = join(sandbox, "home");
48
+ setSandboxHome(join(sandbox, "home"));
43
49
  delete process.env.SKILLREPO_ACCESS_KEY;
44
50
 
45
51
  server = createMockServer({});
@@ -52,7 +58,7 @@ async function setup() {
52
58
  async function teardown() {
53
59
  if (server) await server.stop();
54
60
  process.chdir(originalCwd);
55
- process.env.HOME = originalHome;
61
+ restoreHome(originalHomeEnv);
56
62
  if (sandbox) rmSync(sandbox, { recursive: true, force: true });
57
63
  server = null;
58
64
  }
@@ -13,12 +13,18 @@ import { writeSkillDir, resolvePlacementDir } from "../../lib/file-write.mjs";
13
13
  import { CliError, EXIT_VALIDATION, EXIT_AUTH, EXIT_SCOPE } from "../../lib/errors.mjs";
14
14
  import { createMockServer } from "../e2e/mock-server.mjs";
15
15
  import { createCaptureStream } from "../helpers/capture-stream.mjs";
16
+ import {
17
+ captureHome,
18
+ setSandboxHome,
19
+ restoreHome,
20
+ } from "../helpers/sandbox-home.mjs";
16
21
 
17
22
  let sandbox;
18
23
  let server;
19
24
  let serverUrl;
20
25
  let originalCwd;
21
- let originalHome;
26
+ /** @type {import("../helpers/sandbox-home.mjs").HomeEnvSnapshot} */
27
+ let originalHomeEnv;
22
28
  let stdout;
23
29
  const VALID_KEY = "sk_live_test";
24
30
 
@@ -46,9 +52,9 @@ async function setup() {
46
52
  mkdirSync(join(sandbox, "project"), { recursive: true });
47
53
  mkdirSync(join(sandbox, "home"), { recursive: true });
48
54
  originalCwd = process.cwd();
49
- originalHome = process.env.HOME;
55
+ originalHomeEnv = captureHome();
50
56
  process.chdir(join(sandbox, "project"));
51
- process.env.HOME = join(sandbox, "home");
57
+ setSandboxHome(join(sandbox, "home"));
52
58
  delete process.env.SKILLREPO_ACCESS_KEY;
53
59
 
54
60
  server = createMockServer({});
@@ -61,7 +67,7 @@ async function setup() {
61
67
  async function teardown() {
62
68
  if (server) await server.stop();
63
69
  process.chdir(originalCwd);
64
- process.env.HOME = originalHome;
70
+ restoreHome(originalHomeEnv);
65
71
  if (sandbox) rmSync(sandbox, { recursive: true, force: true });
66
72
  server = null;
67
73
  }
@@ -12,12 +12,18 @@ import { runSearch } from "../../commands/search.mjs";
12
12
  import { CliError, EXIT_VALIDATION, EXIT_AUTH } from "../../lib/errors.mjs";
13
13
  import { createMockServer } from "../e2e/mock-server.mjs";
14
14
  import { createCaptureStream } from "../helpers/capture-stream.mjs";
15
+ import {
16
+ captureHome,
17
+ setSandboxHome,
18
+ restoreHome,
19
+ } from "../helpers/sandbox-home.mjs";
15
20
 
16
21
  let sandbox;
17
22
  let server;
18
23
  let serverUrl;
19
24
  let originalCwd;
20
- let originalHome;
25
+ /** @type {import("../helpers/sandbox-home.mjs").HomeEnvSnapshot} */
26
+ let originalHomeEnv;
21
27
  let stdout;
22
28
  let stderr;
23
29
  const VALID_KEY = "sk_live_test";
@@ -42,9 +48,9 @@ async function setup() {
42
48
  mkdirSync(join(sandbox, "project"), { recursive: true });
43
49
  mkdirSync(join(sandbox, "home"), { recursive: true });
44
50
  originalCwd = process.cwd();
45
- originalHome = process.env.HOME;
51
+ originalHomeEnv = captureHome();
46
52
  process.chdir(join(sandbox, "project"));
47
- process.env.HOME = join(sandbox, "home");
53
+ setSandboxHome(join(sandbox, "home"));
48
54
  delete process.env.SKILLREPO_ACCESS_KEY;
49
55
 
50
56
  server = createMockServer({});
@@ -58,7 +64,7 @@ async function setup() {
58
64
  async function teardown() {
59
65
  if (server) await server.stop();
60
66
  process.chdir(originalCwd);
61
- process.env.HOME = originalHome;
67
+ restoreHome(originalHomeEnv);
62
68
  if (sandbox) rmSync(sandbox, { recursive: true, force: true });
63
69
  server = null;
64
70
  }