replen 1.0.40 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -72,7 +72,7 @@ Every data command accepts `--json` for scripting.
72
72
  Three layers, defence-in-depth — at least one will fire:
73
73
 
74
74
  1. **SessionStart hook** runs `npx replen check-new --hook` at the start of every Claude Code session. If there's anything new, the JSON output appears in the agent's opening context.
75
- 2. **MCP tool** `replen_check_new` is exposed to the agent at all times. The instructions in `CLAUDE.md` tell it to call this tool early in each session.
75
+ 2. **MCP tool** `replen_match` is exposed to the agent at all times. The instructions in `CLAUDE.md` tell it to call this once early in each session (it returns the footnote + the candidate inventory).
76
76
  3. **CLAUDE.md / AGENTS.md instruction** (idempotent, marker-versioned) is injected into every tracked repo on setup. This is the most reliable layer — survives Claude Code version churn.
77
77
 
78
78
  Calm-cadence by design: most days are silent. 1-3 actionable matches a month per project.
package/dist/commands.js CHANGED
@@ -335,7 +335,7 @@ export async function runCheckNew(argv) {
335
335
  console.log(` ${m.oneLine}`);
336
336
  }
337
337
  console.log("");
338
- console.log("Call replen_today for the full writeups.");
338
+ console.log("Open /replen (replen_match) for the full writeups.");
339
339
  }
340
340
  // Watch for new matches in the background. Polls /api/mcp/today, diffs against
341
341
  // the matchIds seen on the previous poll, prints anything new, and rings the
package/dist/index.js CHANGED
@@ -26,6 +26,12 @@ Usage:
26
26
  ~/.replen/atlas/ (projects, capabilities,
27
27
  decisions, themes, all cross-linked)
28
28
  npx replen logout Forget saved auth
29
+ npx replen uninstall Remove Replen's local footprint (MCP wiring,
30
+ [--dry-run] [-y] skills, per-repo doc blocks, ~/.replen). Asks
31
+ [--root PATH ...] before each category; nothing goes without a yes.
32
+ Server-side profile is untouched (delete that at
33
+ app.replen.dev). --dry-run previews, -y skips
34
+ prompts.
29
35
  npx replen --help This help
30
36
 
31
37
  Use replen from a plain shell (no Claude Code / Codex needed):
@@ -96,6 +102,11 @@ async function main() {
96
102
  console.log(`\nRestart Claude Code to pick up the change.`);
97
103
  return;
98
104
  }
105
+ if (cmd === "uninstall") {
106
+ const { runUninstall } = await import("./uninstall.js");
107
+ await runUninstall(argv.slice(1));
108
+ return;
109
+ }
99
110
  if (cmd === "logout") {
100
111
  const { unlink } = await import("node:fs/promises");
101
112
  try {
package/dist/init.js CHANGED
@@ -141,7 +141,14 @@ export async function runInit() {
141
141
  savedAt: new Date().toISOString(),
142
142
  });
143
143
  console.log(` ✓ Saved auth to ${configPath()}`);
144
- await setupMcp(exchange.token, exchange.base);
144
+ // Let the user choose, up front, which repos Replen works on. Default is
145
+ // ALL; a subset (or none) gates BOTH the doc-injection inside setupMcp
146
+ // AND the project registration below, so we never touch a repo they
147
+ // opted out of. Non-interactive / single-repo / no-repo cases keep the
148
+ // default automatically.
149
+ const { chooseRepoScope } = await import("./select-repos.js");
150
+ const { onlyRepos } = await chooseRepoScope();
151
+ await setupMcp(exchange.token, exchange.base, { onlyRepos });
145
152
  // Phase A: auto-discover the user's local projects, extract tags
146
153
  // from manifests, and register them in one shot. Replaces the
147
154
  // legacy "paste a GitHub PAT and let us call api.github.com" flow
@@ -149,7 +156,7 @@ export async function runInit() {
149
156
  console.log("");
150
157
  console.log(" Scanning your local repos for projects…");
151
158
  const { syncDiscoveredProjects } = await import("./sync-projects.js");
152
- await syncDiscoveredProjects({ token: exchange.token, base: exchange.base });
159
+ await syncDiscoveredProjects({ token: exchange.token, base: exchange.base, onlyRepos });
153
160
  // Phase B: trigger the first ingest and stream progress until the
154
161
  // discovered pool is ready (~30-60s). Without this, a new user
155
162
  // would open Claude Code and find replen_match returning nothing —
@@ -81,11 +81,6 @@ On your **very first response** of each session:
81
81
  - The user directly asked for Replen output ("anything from replen
82
82
  today?", "what\'s new from replen?"). Just answer.
83
83
 
84
- **Do NOT use \`replen_check_new\` for the conversational footnote.**
85
- That tool is cursor-based and meant for SessionStart shell hooks.
86
- \`replen_match\` queries inventory state directly and is the right
87
- primitive here.
88
-
89
84
  **Don\'t call \`replen_match\` again on subsequent turns** — once
90
85
  per session at start only. The user explicitly types
91
86
  \`/replen\` if they want a fresh triage mid-session.
@@ -111,12 +106,21 @@ to say "you know I use scrapling elsewhere" — knowing is your job.
111
106
  // only repos that Replen can't match against anyway — the injected
112
107
  // instruction tells the agent to call `replen_match`, which is a no-op
113
108
  // without a registered project.
114
- async function discoverRepos(explicitRoots) {
109
+ async function discoverRepos(explicitRoots, onlyRepos) {
115
110
  // Reuse sync-projects' resolveAndWalk via the previewDiscovery helper
116
111
  // so inject and sync-projects find the exact same set of repos.
117
112
  const { previewDiscovery } = await import("./sync-projects.js");
118
113
  const result = await previewDiscovery(explicitRoots);
119
- return result.projects.map((p) => p.localPath);
114
+ let paths = result.projects.map((p) => p.localPath);
115
+ // Honour an explicit onboarding scope choice: when the user picked a
116
+ // subset of repos (or "none"), restrict doc-injection to exactly that
117
+ // set so we never edit a CLAUDE.md in a repo they opted out of.
118
+ // `undefined` means "no scope choice made" → inject into all (default).
119
+ if (onlyRepos !== undefined) {
120
+ const allow = new Set(onlyRepos);
121
+ paths = paths.filter((p) => allow.has(p));
122
+ }
123
+ return paths;
120
124
  }
121
125
  function applyToClaudeMd(claudeMdPath) {
122
126
  if (!existsSync(claudeMdPath)) {
@@ -256,7 +260,7 @@ async function promptYes(question) {
256
260
  });
257
261
  }
258
262
  export async function injectInstructions(opts = {}) {
259
- const repos = await discoverRepos(opts.explicitRoots ?? []);
263
+ const repos = await discoverRepos(opts.explicitRoots ?? [], opts.onlyRepos);
260
264
  const outcome = {
261
265
  scanned: repos.length,
262
266
  created: 0,
package/dist/mcp-setup.js CHANGED
@@ -55,25 +55,27 @@ const CODEX_CONFIG = join(homedir(), ".codex", "config.toml");
55
55
  const GEMINI_CONFIG = join(homedir(), ".gemini", "settings.json");
56
56
  // Read/triage replen MCP tools that are safe to auto-allow so the proactive
57
57
  // footnote (replen_match) and in-session triage don't trigger a permission
58
- // prompt every session. Deliberately EXCLUDES replen_run / replen_handoff /
59
- // replen_feedback those trigger pipeline runs / open PRs and should keep
60
- // prompting for explicit consent.
58
+ // prompt every session. Deliberately EXCLUDES replen_handoff it opens PRs and
59
+ // should keep prompting for explicit consent.
61
60
  const REPLEN_AUTO_ALLOW = [
62
61
  "mcp__replen__replen_match",
63
- "mcp__replen__replen_check_new",
64
- "mcp__replen__replen_analyze",
65
- "mcp__replen__replen_state",
66
62
  "mcp__replen__replen_record_triage",
67
- "mcp__replen__replen_today",
68
- "mcp__replen__replen_search",
69
- "mcp__replen__replen_starred",
70
- "mcp__replen__replen_status",
63
+ "mcp__replen__replen_state",
64
+ "mcp__replen__replen_capture_insight",
65
+ "mcp__replen__replen_recall",
66
+ "mcp__replen__replen_leaps",
67
+ "mcp__replen__replen_queue",
68
+ "mcp__replen__replen_onboard_state",
69
+ "mcp__replen__replen_set_tags",
70
+ "mcp__replen__replen_set_capabilities",
71
+ "mcp__replen__replen_set_versions",
72
+ "mcp__replen__replen_set_product",
71
73
  "mcp__replen__replen_help",
72
74
  ];
73
75
  // ============================================================================
74
76
  // Public entry point
75
77
  // ============================================================================
76
- export async function setupMcp(token, base) {
78
+ export async function setupMcp(token, base, opts = {}) {
77
79
  console.log(` Wiring replen MCP into agent configs…`);
78
80
  // Pin the exact latest MCP version (deterministic; avoids npx serving a stale
79
81
  // `@^1` build and the spurious upgrade nudge that follows).
@@ -99,10 +101,10 @@ export async function setupMcp(token, base) {
99
101
  installSkills();
100
102
  // CLAUDE.md / AGENTS.md / GEMINI.md inject is the proactive-surfacing
101
103
  // mechanism for each host. Without it, the agent doesn't know to
102
- // call replen_check_new at session start. Idempotent + versioned.
104
+ // call replen_match at session start. Idempotent + versioned.
103
105
  console.log("");
104
106
  const { injectInstructions, summariseOutcome } = await import("./inject-instruction.js");
105
- const outcome = await injectInstructions();
107
+ const outcome = await injectInstructions({ onlyRepos: opts.onlyRepos });
106
108
  const summary = summariseOutcome(outcome);
107
109
  if (summary)
108
110
  console.log(summary);
@@ -25,12 +25,11 @@ Total length target: 200-500 words, bullet points over paragraphs.
25
25
 
26
26
  Also append a short "## Replen integration" section with this exact guidance:
27
27
 
28
- At the start of every session, if the \`replen_check_new\` tool is
29
- available, call it once before asking the user what to work on.
30
- If \`hasNew\` is true, summarise the count + repos in 1-2 lines and
31
- ask if the user wants the full writeups (which they get via
32
- \`replen_today\`). If \`hasNew\` is false, say nothing silence is
33
- the correct response, not "no new matches found".
28
+ At the start of every session, if the \`replen_match\` tool is
29
+ available, call it once (no repo arg it defaults to the cwd repo)
30
+ before asking the user what to work on. Relay the USER-FACING MESSAGE
31
+ block it returns VERBATIM if it's non-empty; if it's empty, say
32
+ nothing silence is the correct response, not "no new matches found".
34
33
 
35
34
  This instruction is a fallback for AI tools that don't run Claude Code's SessionStart hooks (e.g. Codex, Cursor, Aider). For Claude Code users the hook calls \`replen check-new\` automatically and Claude Code injects the result into the opening context — but the CLAUDE.md guidance is durable across tools and survives hook misconfiguration.`;
36
35
  export function runProjectInit() {
@@ -0,0 +1,108 @@
1
+ // Onboarding repo-scope choice. Run once during `npx replen`, AFTER auth
2
+ // and BEFORE we wire MCP / inject docs / register projects, so the user
3
+ // decides up front which of their repos Replen works on.
4
+ //
5
+ // Default is ALL — the cross-repo view (Atlas, Recall, far-away matches)
6
+ // is the product's value, and most users want everything in. But a
7
+ // cautious first-time user (or anyone testing) can scope to a subset, or
8
+ // opt out entirely, and that choice gates BOTH server registration AND
9
+ // the per-repo CLAUDE.md/AGENTS.md/GEMINI.md doc edits — Replen never
10
+ // touches a repo they didn't pick.
11
+ //
12
+ // Returns `{ onlyRepos }`:
13
+ // - undefined → "all" (no scope restriction; the default everywhere)
14
+ // - string[] → exactly these absolute localPaths (may be empty = none)
15
+ import { createInterface } from "node:readline/promises";
16
+ import { previewDiscovery } from "./sync-projects.js";
17
+ export async function chooseRepoScope() {
18
+ // Discover the same set inject + sync will see.
19
+ let projects = [];
20
+ try {
21
+ const result = await previewDiscovery([]);
22
+ projects = result.projects;
23
+ }
24
+ catch {
25
+ return {}; // discovery failed — fall back to default (all)
26
+ }
27
+ // Nothing to choose, or non-interactive (CI / hook / piped): keep the
28
+ // default. We never block onboarding on a prompt that can't be answered.
29
+ if (projects.length === 0)
30
+ return {};
31
+ if (!process.stdin.isTTY)
32
+ return {};
33
+ // A single repo isn't worth a selection menu — just include it.
34
+ if (projects.length === 1)
35
+ return {};
36
+ const home = process.env.HOME ?? "";
37
+ const label = (p) => {
38
+ const disp = home && p.localPath.startsWith(home) ? "~" + p.localPath.slice(home.length) : p.localPath;
39
+ return p.githubFullName ? `${p.githubFullName} (${disp})` : disp;
40
+ };
41
+ console.log("");
42
+ console.log(` Replen found ${projects.length} repos it can work with.`);
43
+ console.log(" Replen works best across all of them — that's how it spots a tool you");
44
+ console.log(" use in one project that fits another. You can also start with a subset.");
45
+ console.log("");
46
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
47
+ try {
48
+ const choice = (await rl.question(" Include [A]ll (default) · [s]elect a subset · [n]one for now? "))
49
+ .trim()
50
+ .toLowerCase();
51
+ if (choice === "" || choice === "a" || choice === "all") {
52
+ return {}; // all
53
+ }
54
+ if (choice === "n" || choice === "none") {
55
+ console.log(" · None for now. Run `npx replen sync-projects` anytime to add them.");
56
+ return { onlyRepos: [] };
57
+ }
58
+ // Subset: show the numbered list and parse an inclusion spec.
59
+ console.log("");
60
+ projects.forEach((p, i) => console.log(` ${String(i + 1).padStart(2)}. ${label(p)}`));
61
+ console.log("");
62
+ const spec = (await rl.question(" Numbers to include (e.g. 1,3,5 or 1-4,7), blank = all: ")).trim();
63
+ if (spec === "")
64
+ return {}; // blank ⇒ all
65
+ const picked = parseSelection(spec, projects.length);
66
+ if (picked.size === 0) {
67
+ console.log(" · Nothing parsed from that — including all.");
68
+ return {};
69
+ }
70
+ const onlyRepos = projects.filter((_, i) => picked.has(i + 1)).map((p) => p.localPath);
71
+ console.log(` ✓ Including ${onlyRepos.length} of ${projects.length} repos.`);
72
+ return { onlyRepos };
73
+ }
74
+ catch {
75
+ return {}; // any prompt error ⇒ safe default (all)
76
+ }
77
+ finally {
78
+ rl.close();
79
+ }
80
+ }
81
+ // Parse "1,3,5" / "1-4" / "2-3,7" into a set of 1-based indices, clamped
82
+ // to [1, max]. Tolerant of spaces and stray separators.
83
+ export function parseSelection(spec, max) {
84
+ const out = new Set();
85
+ for (const part of spec.split(",")) {
86
+ const token = part.trim();
87
+ if (!token)
88
+ continue;
89
+ const range = token.match(/^(\d+)\s*-\s*(\d+)$/);
90
+ if (range) {
91
+ let lo = parseInt(range[1], 10);
92
+ let hi = parseInt(range[2], 10);
93
+ if (lo > hi)
94
+ [lo, hi] = [hi, lo];
95
+ for (let n = lo; n <= hi; n++)
96
+ if (n >= 1 && n <= max)
97
+ out.add(n);
98
+ continue;
99
+ }
100
+ const single = token.match(/^\d+$/);
101
+ if (single) {
102
+ const n = parseInt(token, 10);
103
+ if (n >= 1 && n <= max)
104
+ out.add(n);
105
+ }
106
+ }
107
+ return out;
108
+ }
@@ -9,8 +9,14 @@
9
9
  import { readConfig, writeConfig } from "./config.js";
10
10
  import { discoverProjects } from "./discover-projects.js";
11
11
  import { promptForRoot, rootsFromClaudeJson, rootsFromConfig, rootsFromCwdWalkUp, rootsFromEnv, rootsFromFlag, rootsFromHardcoded, } from "./discover-roots.js";
12
- export async function syncDiscoveredProjects({ token, base, explicitRoots = [], }) {
12
+ export async function syncDiscoveredProjects({ token, base, explicitRoots = [], onlyRepos, }) {
13
13
  const { result, source, prompted } = await resolveAndWalk(explicitRoots);
14
+ // Apply the onboarding scope choice before reporting / registering, so
15
+ // a user who picked a subset never has the opted-out repos registered.
16
+ if (onlyRepos !== undefined) {
17
+ const allow = new Set(onlyRepos);
18
+ result.projects = result.projects.filter((p) => allow.has(p.localPath));
19
+ }
14
20
  // Tell the user what we did, regardless of outcome.
15
21
  reportDiscovery(result, source);
16
22
  if (result.projects.length === 0) {
@@ -0,0 +1,504 @@
1
+ // `npx replen uninstall` — reverse every local change `npx replen` made,
2
+ // interactively and per-category, so a tester (or anyone) can cleanly back
3
+ // out. The inverse of mcp-setup.ts + skill-install.ts + inject-instruction.ts.
4
+ //
5
+ // Design constraints (deliberate):
6
+ // - INTERACTIVE BY DEFAULT. Every destructive category asks its own
7
+ // y/N question, and the default is always NO. There is no single
8
+ // "nuke everything" keystroke — the user has to acknowledge each
9
+ // class of change so they understand what's being removed.
10
+ // - LOCAL ONLY. This touches the user's machine; it does NOT delete
11
+ // server-side data (project profiles, capabilities, matches). There
12
+ // is no account-delete endpoint yet — we tell the user how to request
13
+ // that separately rather than pretending we did it.
14
+ // - BACKUP BEFORE WRITE. Any host config we edit gets a timestamped
15
+ // `.bak` first, mirroring mcp-setup's contract, so even an unwanted
16
+ // removal is recoverable.
17
+ // - Per-repo doc edits are git-tracked, so stripping the Replen section
18
+ // shows up as a normal diff the user can review or revert.
19
+ //
20
+ // Flags:
21
+ // --yes / -y Skip the per-category prompts (scripted / CI use). Still
22
+ // prints exactly what it removed.
23
+ // --dry-run Show what WOULD be removed, change nothing.
24
+ // --root PATH Extra root(s) to scan for repos with injected doc blocks
25
+ // (same semantics as `sync-projects --root`).
26
+ import { readFileSync, writeFileSync, existsSync, rmSync, renameSync, chmodSync, mkdirSync, } from "node:fs";
27
+ import { homedir } from "node:os";
28
+ import { join, dirname, basename } from "node:path";
29
+ import { createInterface } from "node:readline/promises";
30
+ import { parse as parseToml, stringify as stringifyToml } from "smol-toml";
31
+ import { configPath } from "./config.js";
32
+ const SERVER_NAME = "replen";
33
+ const CLAUDE_CONFIG = join(homedir(), ".claude.json");
34
+ const CLAUDE_SETTINGS = join(homedir(), ".claude", "settings.json");
35
+ const CODEX_CONFIG = join(homedir(), ".codex", "config.toml");
36
+ const GEMINI_CONFIG = join(homedir(), ".gemini", "settings.json");
37
+ const CLAUDE_SKILLS = join(homedir(), ".claude", "skills");
38
+ const REPLEN_DIR = join(homedir(), ".replen");
39
+ // Must match mcp-setup.ts: the tools it adds to permissions.allow and the
40
+ // substring marker on the SessionStart hook command.
41
+ const REPLEN_AUTO_ALLOW_PREFIX = "mcp__replen__";
42
+ const HOOK_MARKER = "check-new --hook";
43
+ // Must match inject-instruction.ts.
44
+ const SECTION_HEADER = "## Replen integration";
45
+ const MARKER_RE = /<!--\s*replen-integration:\s*v(\d+)\s*-->/;
46
+ // The auto-generated stub header we write into a freshly-created doc, so we
47
+ // can recognise a file that exists ONLY because Replen created it.
48
+ const STUB_HINT = "the Replen integration section below is auto-managed";
49
+ const SKILL_DIRS = ["replen", "replen-onboard"];
50
+ export async function runUninstall(argv) {
51
+ const opts = {
52
+ yes: argv.includes("--yes") || argv.includes("-y"),
53
+ dryRun: argv.includes("--dry-run"),
54
+ explicitRoots: collectRoots(argv),
55
+ };
56
+ banner(opts);
57
+ // Top-level gate. Even with the per-category prompts below, the user
58
+ // confirms once up front that they understand what "uninstall" means.
59
+ if (!opts.yes && !opts.dryRun) {
60
+ const go = await confirm("Continue with uninstall?", false);
61
+ if (!go) {
62
+ console.log("\n Nothing changed. Replen is still installed.");
63
+ return;
64
+ }
65
+ }
66
+ await removeMcpWiring(opts);
67
+ await removeSkills(opts);
68
+ await removeDocBlocks(opts);
69
+ await removeLocalConfig(opts);
70
+ console.log("");
71
+ if (opts.dryRun) {
72
+ console.log(" Dry run — nothing was changed. Re-run without --dry-run to apply.");
73
+ }
74
+ else {
75
+ console.log(" Done. Restart Claude Code / Codex / Gemini to drop the MCP server.");
76
+ }
77
+ console.log("");
78
+ console.log(" Note: this removed Replen from THIS machine only. Your server-side");
79
+ console.log(" profile (project capabilities, match history) still exists. To delete");
80
+ console.log(" that too, sign in at https://app.replen.dev and use account deletion,");
81
+ console.log(" or email support@replen.dev to request a full purge.");
82
+ }
83
+ // ============================================================================
84
+ // Category 1 — MCP wiring across the three host configs + the allowlist + hook
85
+ // ============================================================================
86
+ async function removeMcpWiring(opts) {
87
+ // Probe what's actually present so we don't prompt about nothing.
88
+ const targets = [
89
+ {
90
+ label: "Claude Code MCP + SessionStart hook",
91
+ path: CLAUDE_CONFIG,
92
+ present: () => jsonHasServer(CLAUDE_CONFIG) || jsonHasHook(CLAUDE_CONFIG),
93
+ remove: () => stripClaudeConfig(),
94
+ },
95
+ {
96
+ label: "Claude Code tool allowlist",
97
+ path: CLAUDE_SETTINGS,
98
+ present: () => jsonHasAllow(CLAUDE_SETTINGS),
99
+ remove: () => stripClaudeAllowlist(),
100
+ },
101
+ {
102
+ label: "Codex MCP server",
103
+ path: CODEX_CONFIG,
104
+ present: () => tomlHasServer(CODEX_CONFIG),
105
+ remove: () => stripTomlServer(CODEX_CONFIG),
106
+ },
107
+ {
108
+ label: "Gemini CLI MCP server",
109
+ path: GEMINI_CONFIG,
110
+ present: () => jsonHasServer(GEMINI_CONFIG),
111
+ remove: () => stripJsonServer(GEMINI_CONFIG),
112
+ },
113
+ ];
114
+ const found = targets.filter((t) => safe(t.present));
115
+ if (found.length === 0) {
116
+ console.log("\n ① MCP wiring — none found, skipping.");
117
+ return;
118
+ }
119
+ console.log("\n ① MCP wiring found in:");
120
+ for (const t of found)
121
+ console.log(` • ${t.label} (${t.path})`);
122
+ if (!(await gate(opts, "Remove the replen MCP entries above?"))) {
123
+ console.log(" · kept.");
124
+ return;
125
+ }
126
+ if (opts.dryRun)
127
+ return;
128
+ for (const t of found) {
129
+ try {
130
+ t.remove();
131
+ console.log(` ✓ removed: ${t.label}`);
132
+ }
133
+ catch (e) {
134
+ console.warn(` ⚠ ${t.label}: ${e.message}`);
135
+ }
136
+ }
137
+ }
138
+ // ============================================================================
139
+ // Category 2 — installed skills
140
+ // ============================================================================
141
+ async function removeSkills(opts) {
142
+ const present = SKILL_DIRS.map((n) => join(CLAUDE_SKILLS, n)).filter((p) => existsSync(p));
143
+ if (present.length === 0) {
144
+ console.log("\n ② Skills — none found, skipping.");
145
+ return;
146
+ }
147
+ console.log("\n ② Installed skills:");
148
+ for (const p of present)
149
+ console.log(` • ${p}`);
150
+ if (!(await gate(opts, "Remove these skill directories?"))) {
151
+ console.log(" · kept.");
152
+ return;
153
+ }
154
+ if (opts.dryRun)
155
+ return;
156
+ for (const p of present) {
157
+ try {
158
+ rmSync(p, { recursive: true, force: true });
159
+ console.log(` ✓ removed: ${basename(p)}`);
160
+ }
161
+ catch (e) {
162
+ console.warn(` ⚠ ${p}: ${e.message}`);
163
+ }
164
+ }
165
+ }
166
+ // ============================================================================
167
+ // Category 3 — the "## Replen integration" block in per-repo doc files
168
+ // ============================================================================
169
+ async function removeDocBlocks(opts) {
170
+ let repos = [];
171
+ try {
172
+ const { previewDiscovery } = await import("./sync-projects.js");
173
+ const result = await previewDiscovery(opts.explicitRoots);
174
+ repos = result.projects.map((p) => p.localPath);
175
+ }
176
+ catch {
177
+ repos = [];
178
+ }
179
+ // Find every doc file under those repos that carries our section.
180
+ const hits = [];
181
+ for (const repo of repos) {
182
+ for (const fileName of ["CLAUDE.md", "AGENTS.md", "GEMINI.md"]) {
183
+ const file = join(repo, fileName);
184
+ if (!existsSync(file))
185
+ continue;
186
+ let text;
187
+ try {
188
+ text = readFileSync(file, "utf8");
189
+ }
190
+ catch {
191
+ continue;
192
+ }
193
+ if (!hasReplenSection(text))
194
+ continue;
195
+ hits.push({ file, stubOnly: isStubOnly(text) });
196
+ }
197
+ }
198
+ if (hits.length === 0) {
199
+ console.log("\n ③ Per-repo doc edits — none found, skipping.");
200
+ if (repos.length === 0) {
201
+ console.log(" (no repos discovered — pass --root PATH if your code lives somewhere non-conventional)");
202
+ }
203
+ return;
204
+ }
205
+ console.log(`\n ③ "${SECTION_HEADER}" blocks found in ${hits.length} file(s):`);
206
+ for (const h of hits.slice(0, 10)) {
207
+ console.log(` • ${h.file}${h.stubOnly ? " (Replen-created — will delete whole file)" : " (strip section, keep your content)"}`);
208
+ }
209
+ if (hits.length > 10)
210
+ console.log(` … and ${hits.length - 10} more`);
211
+ console.log(" These are git-tracked, so the change shows as a normal diff you can review.");
212
+ if (!(await gate(opts, "Strip the Replen section from these files?"))) {
213
+ console.log(" · kept.");
214
+ return;
215
+ }
216
+ if (opts.dryRun)
217
+ return;
218
+ for (const h of hits) {
219
+ try {
220
+ if (h.stubOnly) {
221
+ rmSync(h.file, { force: true });
222
+ console.log(` ✓ deleted (Replen-created): ${h.file}`);
223
+ }
224
+ else {
225
+ writeFileSync(h.file, stripReplenSection(readFileSync(h.file, "utf8")));
226
+ console.log(` ✓ stripped section: ${h.file}`);
227
+ }
228
+ }
229
+ catch (e) {
230
+ console.warn(` ⚠ ${h.file}: ${e.message}`);
231
+ }
232
+ }
233
+ }
234
+ // ============================================================================
235
+ // Category 4 — ~/.replen (auth token + saved roots + Atlas vault export)
236
+ // ============================================================================
237
+ async function removeLocalConfig(opts) {
238
+ if (!existsSync(REPLEN_DIR)) {
239
+ console.log("\n ④ Local config (~/.replen) — none found, skipping.");
240
+ return;
241
+ }
242
+ const cfg = existsSync(configPath());
243
+ const atlas = existsSync(join(REPLEN_DIR, "atlas"));
244
+ console.log("\n ④ Local Replen directory:");
245
+ console.log(` • ${REPLEN_DIR}`);
246
+ if (cfg)
247
+ console.log(" - config.json (auth token + saved project roots)");
248
+ if (atlas)
249
+ console.log(" - atlas/ (your exported knowledge-graph vault)");
250
+ if (!(await gate(opts, "Remove ~/.replen entirely?"))) {
251
+ console.log(" · kept (you stay signed in).");
252
+ return;
253
+ }
254
+ if (opts.dryRun)
255
+ return;
256
+ try {
257
+ rmSync(REPLEN_DIR, { recursive: true, force: true });
258
+ console.log(" ✓ removed: ~/.replen");
259
+ }
260
+ catch (e) {
261
+ console.warn(` ⚠ ${REPLEN_DIR}: ${e.message}`);
262
+ }
263
+ }
264
+ // ============================================================================
265
+ // Host-config mutation helpers (mirror mcp-setup's read/backup/atomic-write)
266
+ // ============================================================================
267
+ function jsonHasServer(path) {
268
+ const c = readJson(path);
269
+ const servers = c.mcpServers;
270
+ return !!servers && SERVER_NAME in servers;
271
+ }
272
+ function jsonHasHook(path) {
273
+ const c = readJson(path);
274
+ const ss = c.hooks?.SessionStart;
275
+ if (!Array.isArray(ss))
276
+ return false;
277
+ return ss.some((e) => (e.hooks ?? []).some((h) => typeof h.command === "string" && h.command.includes(HOOK_MARKER)));
278
+ }
279
+ function jsonHasAllow(path) {
280
+ const c = readJson(path);
281
+ const allow = c.permissions?.allow;
282
+ return Array.isArray(allow) && allow.some((a) => typeof a === "string" && a.startsWith(REPLEN_AUTO_ALLOW_PREFIX));
283
+ }
284
+ function tomlHasServer(path) {
285
+ const c = readTomlSafe(path);
286
+ const servers = c.mcp_servers;
287
+ return !!servers && SERVER_NAME in servers;
288
+ }
289
+ // Remove mcpServers.replen AND our SessionStart hook from ~/.claude.json.
290
+ function stripClaudeConfig() {
291
+ const c = readJson(CLAUDE_CONFIG);
292
+ const servers = c.mcpServers ?? {};
293
+ delete servers[SERVER_NAME];
294
+ const hooks = c.hooks ?? {};
295
+ const ss = hooks.SessionStart ?? [];
296
+ const cleanedSS = ss.filter((e) => !(e.hooks ?? []).some((h) => typeof h.command === "string" && h.command.includes(HOOK_MARKER)));
297
+ const nextHooks = { ...hooks };
298
+ if (cleanedSS.length > 0)
299
+ nextHooks.SessionStart = cleanedSS;
300
+ else
301
+ delete nextHooks.SessionStart;
302
+ const next = { ...c, mcpServers: servers };
303
+ if (Object.keys(nextHooks).length > 0)
304
+ next.hooks = nextHooks;
305
+ else
306
+ delete next.hooks;
307
+ if (Object.keys(servers).length === 0)
308
+ delete next.mcpServers;
309
+ backupIfExists(CLAUDE_CONFIG);
310
+ writeJsonAtomic(CLAUDE_CONFIG, next);
311
+ }
312
+ function stripClaudeAllowlist() {
313
+ const c = readJson(CLAUDE_SETTINGS);
314
+ const permissions = c.permissions ?? {};
315
+ const allow = Array.isArray(permissions.allow) ? permissions.allow : [];
316
+ const kept = allow.filter((a) => !(typeof a === "string" && a.startsWith(REPLEN_AUTO_ALLOW_PREFIX)));
317
+ const nextPerms = { ...permissions };
318
+ if (kept.length > 0)
319
+ nextPerms.allow = kept;
320
+ else
321
+ delete nextPerms.allow;
322
+ const next = { ...c };
323
+ if (Object.keys(nextPerms).length > 0)
324
+ next.permissions = nextPerms;
325
+ else
326
+ delete next.permissions;
327
+ backupIfExists(CLAUDE_SETTINGS);
328
+ writeJsonAtomic(CLAUDE_SETTINGS, next);
329
+ }
330
+ function stripJsonServer(path) {
331
+ const c = readJson(path);
332
+ const servers = c.mcpServers ?? {};
333
+ delete servers[SERVER_NAME];
334
+ const next = { ...c, mcpServers: servers };
335
+ if (Object.keys(servers).length === 0)
336
+ delete next.mcpServers;
337
+ backupIfExists(path);
338
+ writeJsonAtomic(path, next);
339
+ }
340
+ function stripTomlServer(path) {
341
+ const c = readTomlSafe(path);
342
+ const servers = c.mcp_servers ?? {};
343
+ delete servers[SERVER_NAME];
344
+ const next = { ...c, mcp_servers: servers };
345
+ if (Object.keys(servers).length === 0)
346
+ delete next.mcp_servers;
347
+ backupIfExists(path);
348
+ writeTomlAtomic(path, next);
349
+ }
350
+ // ============================================================================
351
+ // Doc-section helpers (mirror inject-instruction's section anchoring)
352
+ // ============================================================================
353
+ function hasReplenSection(text) {
354
+ return text.split("\n").some((l) => l.trim() === SECTION_HEADER);
355
+ }
356
+ // A file is "stub only" if Replen created it: it carries our generated stub
357
+ // hint AND contains nothing but the header + the Replen section. Removing the
358
+ // section would leave an empty husk, so we delete the whole file instead.
359
+ function isStubOnly(text) {
360
+ if (!text.includes(STUB_HINT))
361
+ return false;
362
+ const stripped = stripReplenSection(text).trim();
363
+ // After removing the section, all that's left is the auto-generated H1 +
364
+ // stub comment. If there's no other prose, treat it as Replen-owned.
365
+ const meaningful = stripped
366
+ .split("\n")
367
+ .filter((l) => l.trim() && !l.trim().startsWith("# ") && !l.trim().startsWith("<!--"))
368
+ .join("")
369
+ .trim();
370
+ return meaningful.length === 0;
371
+ }
372
+ // Remove the `## Replen integration` block: from its H2 header to the next H2
373
+ // (not H3+) or EOF. Mirrors replaceSection() in inject-instruction.ts but
374
+ // deletes rather than replaces. Collapses any duplicate blocks too.
375
+ function stripReplenSection(text) {
376
+ let out = text;
377
+ // Loop until no section remains (defends against legacy duplicate blocks).
378
+ // Bounded to avoid any pathological non-progress.
379
+ for (let guard = 0; guard < 10; guard++) {
380
+ const lines = out.split("\n");
381
+ let start = -1;
382
+ for (let i = 0; i < lines.length; i++) {
383
+ if (lines[i].trim() === SECTION_HEADER) {
384
+ start = i;
385
+ break;
386
+ }
387
+ }
388
+ if (start === -1)
389
+ break;
390
+ let end = lines.length;
391
+ for (let j = start + 1; j < lines.length; j++) {
392
+ if (lines[j].startsWith("## ") && !lines[j].startsWith("### ")) {
393
+ end = j;
394
+ break;
395
+ }
396
+ }
397
+ const before = lines.slice(0, start).join("\n").replace(/\n+$/, "");
398
+ const after = end < lines.length ? lines.slice(end).join("\n") : "";
399
+ out = (before + (after ? "\n\n" + after : "\n")).replace(/\n{3,}/g, "\n\n");
400
+ }
401
+ void MARKER_RE; // marker presence already implied by header; kept for parity
402
+ return out.replace(/\s+$/, "") + "\n";
403
+ }
404
+ // ============================================================================
405
+ // File primitives
406
+ // ============================================================================
407
+ function readJson(path) {
408
+ if (!existsSync(path))
409
+ return {};
410
+ const raw = readFileSync(path, "utf8");
411
+ if (!raw.trim())
412
+ return {};
413
+ return JSON.parse(raw);
414
+ }
415
+ function readTomlSafe(path) {
416
+ if (!existsSync(path))
417
+ return {};
418
+ const raw = readFileSync(path, "utf8");
419
+ if (!raw.trim())
420
+ return {};
421
+ return parseToml(raw);
422
+ }
423
+ function writeJsonAtomic(path, data) {
424
+ writeAtomic(path, JSON.stringify(data, null, 2) + "\n");
425
+ }
426
+ function writeTomlAtomic(path, data) {
427
+ writeAtomic(path, stringifyToml(data) + "\n");
428
+ }
429
+ function writeAtomic(path, content) {
430
+ mkdirSync(dirname(path), { recursive: true });
431
+ const tmp = `${path}.tmp.${Date.now()}`;
432
+ writeFileSync(tmp, content, { mode: 0o600 });
433
+ renameSync(tmp, path);
434
+ try {
435
+ chmodSync(path, 0o600);
436
+ }
437
+ catch {
438
+ /* best-effort */
439
+ }
440
+ }
441
+ function backupIfExists(path) {
442
+ if (!existsSync(path))
443
+ return;
444
+ const ts = new Date().toISOString().replace(/[:.]/g, "-");
445
+ writeFileSync(`${path}.bak.${ts}`, readFileSync(path));
446
+ }
447
+ // ============================================================================
448
+ // CLI plumbing
449
+ // ============================================================================
450
+ function banner(opts) {
451
+ console.log("");
452
+ console.log(" Replen uninstall — removes Replen's LOCAL footprint from this machine.");
453
+ console.log(" You'll be asked before each category; nothing is removed without a yes.");
454
+ if (opts.dryRun)
455
+ console.log(" (--dry-run: previewing only, no changes)");
456
+ console.log("");
457
+ console.log(" In scope: MCP wiring (Claude/Codex/Gemini), the /replen skills,");
458
+ console.log(" the per-repo \"## Replen integration\" doc blocks, and ~/.replen.");
459
+ console.log(" NOT in scope: server-side profiles & match history (see note at the end).");
460
+ }
461
+ // Per-category gate. --yes bypasses (scripted). Default is NO.
462
+ async function gate(opts, question) {
463
+ if (opts.dryRun)
464
+ return true; // show what would happen, but caller no-ops
465
+ if (opts.yes)
466
+ return true;
467
+ return confirm(question, false);
468
+ }
469
+ async function confirm(question, defaultYes) {
470
+ if (!process.stdin.isTTY)
471
+ return false; // non-interactive + no --yes ⇒ refuse to delete
472
+ const hint = defaultYes ? "[Y/n]" : "[y/N]";
473
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
474
+ try {
475
+ const ans = (await rl.question(` ${question} ${hint} `)).trim().toLowerCase();
476
+ if (ans === "")
477
+ return defaultYes;
478
+ return ans === "y" || ans === "yes";
479
+ }
480
+ catch {
481
+ return false;
482
+ }
483
+ finally {
484
+ rl.close();
485
+ }
486
+ }
487
+ function collectRoots(argv) {
488
+ const out = [];
489
+ for (let i = 0; i < argv.length; i++) {
490
+ if (argv[i] === "--root" && argv[i + 1])
491
+ out.push(argv[++i]);
492
+ else if (argv[i].startsWith("--root="))
493
+ out.push(argv[i].slice("--root=".length));
494
+ }
495
+ return out;
496
+ }
497
+ function safe(fn) {
498
+ try {
499
+ return fn();
500
+ }
501
+ catch {
502
+ return false;
503
+ }
504
+ }
@@ -335,10 +335,11 @@ If several repos are one product (e.g. `aegis-web`/`aegis-api`/`aegis-cv`), grou
335
335
  them with `replen_set_product` so Replen unions the whole product's capabilities
336
336
  when the user is in any one of them.
337
337
 
338
- ## Step 4 — Warm + close out
338
+ ## Step 4 — Close out
339
339
 
340
- 1. Trigger an embedding/inventory pass so everything's ready: `replen_run` (it's
341
- async; the per-project facets you set are already live regardless).
340
+ 1. Nothing to trigger the per-project facets, capabilities, and versions you
341
+ set are live immediately, and the next scheduled run refreshes the candidate
342
+ inventory automatically.
342
343
  2. Summarise what you did, calmly:
343
344
 
344
345
  ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "replen",
3
- "version": "1.0.40",
3
+ "version": "1.2.0",
4
4
  "description": "Make your AI coding tools smarter. One command, no API keys, free. Replen watches what your projects actually do and surfaces a few things worth bringing in each month. Use one as is, port a piece of another, cherry pick an idea, or build it clean room. The match happens inside your AI tool's session. A few actionable matches a month, by design.",
5
5
  "type": "module",
6
6
  "bin": {