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 +1 -1
- package/dist/commands.js +1 -1
- package/dist/index.js +11 -0
- package/dist/init.js +9 -2
- package/dist/inject-instruction.js +12 -8
- package/dist/mcp-setup.js +15 -13
- package/dist/project-init.js +5 -6
- package/dist/select-repos.js +108 -0
- package/dist/sync-projects.js +7 -1
- package/dist/uninstall.js +504 -0
- package/extras/skills/replen-onboard/SKILL.md +4 -3
- package/package.json +1 -1
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** `
|
|
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("
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
59
|
-
//
|
|
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
|
-
"
|
|
68
|
-
"
|
|
69
|
-
"
|
|
70
|
-
"
|
|
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
|
|
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);
|
package/dist/project-init.js
CHANGED
|
@@ -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 \`
|
|
29
|
-
available, call it once
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
+
}
|
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
|
+
}
|
|
@@ -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 —
|
|
338
|
+
## Step 4 — Close out
|
|
339
339
|
|
|
340
|
-
1.
|
|
341
|
-
|
|
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
|
|
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": {
|