replen 1.1.0 → 1.2.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/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 —
@@ -159,13 +166,20 @@ export async function runInit() {
159
166
  const { runFirstIngest } = await import("./first-ingest.js");
160
167
  await runFirstIngest({ token: exchange.token, base: exchange.base, savedAt: "" });
161
168
  console.log("");
162
- console.log(" All set. Restart Claude Code and try:");
163
- console.log(" /replen → triage today's candidates against this repo,");
164
- console.log(" in-session, using your subscription tokens");
165
- console.log(" (no LLM API keys needed — the agent does the reasoning)");
169
+ console.log(" All set. Restart Claude Code, then do these two — IN THIS ORDER:");
166
170
  console.log("");
167
- console.log(" Other MCP hosts (Codex / Cursor / Aider):");
168
- console.log(" \"use replen_match\" same tool, no slash command");
171
+ console.log(" 1. /replen-onboard ONE-TIME, FIRST. Reads your repos and builds a");
172
+ console.log(" tailored profile so matches are relevant, not");
173
+ console.log(" generic. Runs in the background — kick it off,");
174
+ console.log(" then just keep working.");
175
+ console.log("");
176
+ console.log(" 2. /replen ANYTIME after that. Triages the candidates for");
177
+ console.log(" the current repo, in-session, on your own");
178
+ console.log(" subscription tokens — no LLM API key needed.");
179
+ console.log("");
180
+ console.log(" (Onboarding is once per project. After that, it's just /replen.)");
181
+ console.log("");
182
+ console.log(" Other MCP hosts (Codex / Cursor / Aider): \"use replen_match\" — same tool.");
169
183
  console.log("");
170
184
  console.log(` Dashboard: ${exchange.base}`);
171
185
  console.log("");
@@ -33,12 +33,25 @@
33
33
  import { readFileSync, writeFileSync, existsSync } from "node:fs";
34
34
  import { join, basename } from "node:path";
35
35
  import { createInterface } from "node:readline";
36
- export const INTEGRATION_VERSION = "9";
36
+ export const INTEGRATION_VERSION = "10";
37
37
  const SECTION_HEADER = "## Replen integration";
38
38
  const MARKER_RE = /<!--\s*replen-integration:\s*v(\d+)\s*-->/;
39
39
  const SECTION_BODY = `<!-- replen-integration: v${INTEGRATION_VERSION} -->
40
40
 
41
- On your **very first response** of each session:
41
+ **FIRST, on your first response in a repo: check onboarding.** Call
42
+ \`replen_onboard_state\` once. If it reports this repo (or the user's
43
+ portfolio) is NOT yet onboarded/grounded, do NOT run the footnote flow
44
+ this turn. Answer the user's message normally, then offer this VERBATIM
45
+ as a new paragraph at the end:
46
+
47
+ > Replen isn't set up for your repos yet — want me to do that now? Replen builds a profile so matches are relevant. Onboarding runs in the background, ~a few minutes.
48
+
49
+ If they accept, invoke the \`replen-onboard\` skill. If they decline, carry
50
+ on and do NOT re-offer for the rest of this session. Once \`replen_onboard_state\`
51
+ shows the repo is onboarded, SKIP this check entirely and use the footnote
52
+ flow below.
53
+
54
+ Once onboarded, on your **very first response** of each session:
42
55
 
43
56
  1. **Skip the whole flow ONLY if** the user's first message is a
44
57
  pure trivial greeting with no content ("hi", "hey", "yo",
@@ -106,12 +119,21 @@ to say "you know I use scrapling elsewhere" — knowing is your job.
106
119
  // only repos that Replen can't match against anyway — the injected
107
120
  // instruction tells the agent to call `replen_match`, which is a no-op
108
121
  // without a registered project.
109
- async function discoverRepos(explicitRoots) {
122
+ async function discoverRepos(explicitRoots, onlyRepos) {
110
123
  // Reuse sync-projects' resolveAndWalk via the previewDiscovery helper
111
124
  // so inject and sync-projects find the exact same set of repos.
112
125
  const { previewDiscovery } = await import("./sync-projects.js");
113
126
  const result = await previewDiscovery(explicitRoots);
114
- return result.projects.map((p) => p.localPath);
127
+ let paths = result.projects.map((p) => p.localPath);
128
+ // Honour an explicit onboarding scope choice: when the user picked a
129
+ // subset of repos (or "none"), restrict doc-injection to exactly that
130
+ // set so we never edit a CLAUDE.md in a repo they opted out of.
131
+ // `undefined` means "no scope choice made" → inject into all (default).
132
+ if (onlyRepos !== undefined) {
133
+ const allow = new Set(onlyRepos);
134
+ paths = paths.filter((p) => allow.has(p));
135
+ }
136
+ return paths;
115
137
  }
116
138
  function applyToClaudeMd(claudeMdPath) {
117
139
  if (!existsSync(claudeMdPath)) {
@@ -251,7 +273,7 @@ async function promptYes(question) {
251
273
  });
252
274
  }
253
275
  export async function injectInstructions(opts = {}) {
254
- const repos = await discoverRepos(opts.explicitRoots ?? []);
276
+ const repos = await discoverRepos(opts.explicitRoots ?? [], opts.onlyRepos);
255
277
  const outcome = {
256
278
  scanned: repos.length,
257
279
  created: 0,
package/dist/mcp-setup.js CHANGED
@@ -75,7 +75,7 @@ const REPLEN_AUTO_ALLOW = [
75
75
  // ============================================================================
76
76
  // Public entry point
77
77
  // ============================================================================
78
- export async function setupMcp(token, base) {
78
+ export async function setupMcp(token, base, opts = {}) {
79
79
  console.log(` Wiring replen MCP into agent configs…`);
80
80
  // Pin the exact latest MCP version (deterministic; avoids npx serving a stale
81
81
  // `@^1` build and the spurious upgrade nudge that follows).
@@ -104,7 +104,7 @@ export async function setupMcp(token, base) {
104
104
  // call replen_match at session start. Idempotent + versioned.
105
105
  console.log("");
106
106
  const { injectInstructions, summariseOutcome } = await import("./inject-instruction.js");
107
- const outcome = await injectInstructions();
107
+ const outcome = await injectInstructions({ onlyRepos: opts.onlyRepos });
108
108
  const summary = summariseOutcome(outcome);
109
109
  if (summary)
110
110
  console.log(summary);
@@ -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
+ }
@@ -40,7 +40,10 @@ Parse the JSON response. Note:
40
40
 
41
41
  - `filterMode` — `tags`, `zero-knowledge`, or `fingerprint`
42
42
  - `scopedTo` — confirms the project context the user has open
43
- - `candidates[]` — the actual list to triage
43
+ - `candidates[]` — the actual list to triage. Each carries `solid: true|false` —
44
+ the ones Replen counts as genuinely worth your time (clear domain-fit + posture +
45
+ not-already-covered, or a dependency-maintenance match). The footnote's count is
46
+ the number of `solid` ones; the rest are "worth a glance" laterals.
44
47
 
45
48
  If `candidates.length === 0`, tell the user "No new candidates today for
46
49
  `<owner/name>`. Calm-cadence working as designed — 1-3 actionable
@@ -50,8 +53,11 @@ If 1+ candidates, continue.
50
53
 
51
54
  ### Step 3 — Per-candidate analysis
52
55
 
53
- **Cap at 5 candidates.** If the inventory returned more, take the top 5
54
- by `whyShortlisted` strength + stars + recency, defer the rest.
56
+ **Triage the `solid` candidates first** (cap at 5). If there are more than 5
57
+ solid, take the top 5 by `whyShortlisted` strength + stars + recency. The
58
+ non-`solid` "worth a glance" entries are optional — skim them only if the solid
59
+ set is thin or the user asks. A low-domain-fit lateral there can still be a real
60
+ port/cherry-pick, so don't dismiss them blindly, but they're not the headline.
55
61
 
56
62
  For each candidate, do this loop:
57
63
 
@@ -210,9 +216,15 @@ For tech-news-site:
210
216
 
211
217
  (Triaged 9 candidates; surfacing the 2 worth acting on + 1 idea worth keeping.)
212
218
 
213
- What would you like to do with each? (star / hide / handoff)
219
+ What would you like to do with each? star (save it for later; won't
220
+ re-surface), hide (dismiss; never show again), or handoff (open a pull
221
+ request against your repo for it). Or skip and decide later.
214
222
  ```
215
223
 
224
+ Always present the options WITH that one-line gloss the first time in a
225
+ session — never the bare "(star / hide / handoff)". A new user doesn't know
226
+ what they mean, and the calm-cadence promise dies if the user has to ask.
227
+
216
228
  The one-line "(Triaged N…)" footer is the *only* acknowledgement that skips
217
229
  happened — honest, but it doesn't parade them.
218
230
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "replen",
3
- "version": "1.1.0",
3
+ "version": "1.2.1",
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": {