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 +11 -0
- package/dist/init.js +22 -8
- package/dist/inject-instruction.js +27 -5
- package/dist/mcp-setup.js +2 -2
- package/dist/select-repos.js +108 -0
- package/dist/sync-projects.js +7 -1
- package/dist/uninstall.js +504 -0
- package/extras/skills/replen/SKILL.md +16 -4
- package/package.json +1 -1
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
|
-
|
|
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
|
|
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("
|
|
168
|
-
console.log("
|
|
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 = "
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/dist/sync-projects.js
CHANGED
|
@@ -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
|
-
**
|
|
54
|
-
by `whyShortlisted` strength + stars + recency
|
|
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?
|
|
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
|
|
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": {
|