replen 0.4.1 → 1.0.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.
@@ -0,0 +1,169 @@
1
+ // Layered project-root discovery. Tries cheap inference first, falls back
2
+ // to interactive prompt if nothing works. Result is one or more root dirs
3
+ // that the recursive walker in discover-projects.ts will then scan for
4
+ // git repos.
5
+ //
6
+ // Order (cheapest / most-targeted first):
7
+ // 1. Explicit --root flag(s) → user-specified
8
+ // 2. REPLEN_PROJECT_ROOTS env var → scripted / dotfiles
9
+ // 3. Saved ~/.replen/config.json roots → previously confirmed
10
+ // 4. cwd walk-up → "I'm in a repo right now"
11
+ // 5. ~/.claude.json mining → "I use Claude Code regularly"
12
+ // 6. Hardcoded ~/github, ~/code, ~/projects → conventional layout
13
+ // 7. Interactive prompt → ask the user directly
14
+ //
15
+ // The orchestration in sync-projects.ts tries strategies in order and
16
+ // keeps going until at least one git repo is found, then stops looking.
17
+ import { readFileSync, existsSync, statSync } from "node:fs";
18
+ import { homedir } from "node:os";
19
+ import { join, dirname, resolve } from "node:path";
20
+ import { createInterface } from "node:readline/promises";
21
+ import { readConfig } from "./config.js";
22
+ /** From explicit `--root <path>` CLI flag(s), or empty if not passed. */
23
+ export function rootsFromFlag(flagValues) {
24
+ return flagValues.map(expandTilde).filter(existsAndIsDir);
25
+ }
26
+ /** From `REPLEN_PROJECT_ROOTS` env var (colon-separated). */
27
+ export function rootsFromEnv() {
28
+ const env = process.env.REPLEN_PROJECT_ROOTS;
29
+ if (!env)
30
+ return [];
31
+ return env.split(":").map((s) => s.trim()).filter(Boolean).map(expandTilde).filter(existsAndIsDir);
32
+ }
33
+ /** From the user's persisted ~/.replen/config.json `projectRoots`. */
34
+ export async function rootsFromConfig() {
35
+ const cfg = await readConfig();
36
+ if (!cfg?.projectRoots?.length)
37
+ return [];
38
+ return cfg.projectRoots.map(expandTilde).filter(existsAndIsDir);
39
+ }
40
+ /**
41
+ * If `npx replen` was invoked from inside a git repo (or one of its
42
+ * subdirs), walk up to find the repo root, then return its parent dir
43
+ * as a candidate root. This catches the common "I'm sitting in one of
44
+ * my projects when I install Replen" case.
45
+ *
46
+ * Stops at `homedir()` and at `/` to avoid walking into system dirs.
47
+ */
48
+ export function rootsFromCwdWalkUp() {
49
+ const cwd = process.cwd();
50
+ const home = homedir();
51
+ let dir = cwd;
52
+ // Walk up until we find a .git/ or hit home/root.
53
+ while (dir !== "/" && dir !== home && dir.length > 1) {
54
+ if (existsSync(join(dir, ".git"))) {
55
+ const parent = dirname(dir);
56
+ // Only suggest the parent if it's still inside home — never offer
57
+ // "/" or anything above $HOME as a scan root.
58
+ if (parent.startsWith(home) && parent !== home)
59
+ return [parent];
60
+ return [];
61
+ }
62
+ dir = dirname(dir);
63
+ }
64
+ return [];
65
+ }
66
+ /**
67
+ * Read ~/.claude.json (Claude Code's per-user config) and extract the
68
+ * project cwds it tracks. Group by parent dir. Return parents that
69
+ * contain ≥2 Claude Code projects — these are very likely the user's
70
+ * actual repo roots.
71
+ *
72
+ * Why this is high-signal: Claude Code only adds an entry when the user
73
+ * opens it inside a real project, so the cwd list is curated by their
74
+ * actual usage. Far more accurate than guessing dir names.
75
+ */
76
+ export function rootsFromClaudeJson() {
77
+ const claudePath = join(homedir(), ".claude.json");
78
+ if (!existsSync(claudePath))
79
+ return [];
80
+ let data;
81
+ try {
82
+ data = JSON.parse(readFileSync(claudePath, "utf8"));
83
+ }
84
+ catch {
85
+ return [];
86
+ }
87
+ if (!data || typeof data !== "object")
88
+ return [];
89
+ const projects = data.projects;
90
+ if (!projects || typeof projects !== "object")
91
+ return [];
92
+ const parentCounts = new Map();
93
+ for (const path of Object.keys(projects)) {
94
+ if (!path.startsWith("/"))
95
+ continue;
96
+ const parent = dirname(path);
97
+ if (!parent.startsWith(homedir()))
98
+ continue;
99
+ parentCounts.set(parent, (parentCounts.get(parent) ?? 0) + 1);
100
+ }
101
+ return Array.from(parentCounts.entries())
102
+ .filter(([, n]) => n >= 2)
103
+ .sort((a, b) => b[1] - a[1])
104
+ .slice(0, 5)
105
+ .map(([parent]) => parent)
106
+ .filter(existsAndIsDir);
107
+ }
108
+ /** Conventional layout: ~/github, ~/code, ~/projects. */
109
+ export function rootsFromHardcoded() {
110
+ return ["github", "code", "projects", "Code", "Projects", "dev", "src", "work"]
111
+ .map((n) => join(homedir(), n))
112
+ .filter(existsAndIsDir);
113
+ }
114
+ /**
115
+ * Last-resort interactive prompt. Asks the user where they keep their
116
+ * code, with a sensible default. Returns null if the user is in a
117
+ * non-interactive context (stdin not a TTY) or hits Ctrl-C.
118
+ */
119
+ export async function promptForRoot() {
120
+ if (!process.stdin.isTTY)
121
+ return null;
122
+ const home = homedir();
123
+ // Suggest the most common parent of any folder we know about, falling
124
+ // back to home itself.
125
+ const suggestion = pickPromptSuggestion() ?? home;
126
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
127
+ try {
128
+ const answer = (await rl.question(`\n Where do you keep your code? [${suggestion}]: `)).trim();
129
+ const chosen = answer || suggestion;
130
+ const expanded = expandTilde(chosen);
131
+ if (!existsAndIsDir(expanded)) {
132
+ console.warn(` ✗ Not a directory: ${expanded}`);
133
+ return null;
134
+ }
135
+ return expanded;
136
+ }
137
+ catch {
138
+ return null;
139
+ }
140
+ finally {
141
+ rl.close();
142
+ }
143
+ }
144
+ function pickPromptSuggestion() {
145
+ const fromCwd = rootsFromCwdWalkUp();
146
+ if (fromCwd[0])
147
+ return fromCwd[0];
148
+ const fromClaude = rootsFromClaudeJson();
149
+ if (fromClaude[0])
150
+ return fromClaude[0];
151
+ const fromHard = rootsFromHardcoded();
152
+ if (fromHard[0])
153
+ return fromHard[0];
154
+ return null;
155
+ }
156
+ function expandTilde(p) {
157
+ if (p === "~" || p.startsWith("~/")) {
158
+ return p === "~" ? homedir() : join(homedir(), p.slice(2));
159
+ }
160
+ return resolve(p);
161
+ }
162
+ function existsAndIsDir(p) {
163
+ try {
164
+ return statSync(p).isDirectory();
165
+ }
166
+ catch {
167
+ return false;
168
+ }
169
+ }
@@ -0,0 +1,140 @@
1
+ // First-time ingest streamer used by `npx replen` after install.
2
+ //
3
+ // Triggers a pipeline run and tails progress until the discovered pool
4
+ // is ready (event signal: `fetch_done` with "Discovered pool" message,
5
+ // or candidates count crosses 0). Then exits cleanly. Stages 1+2 and
6
+ // the scouted-pool fetcher continue server-side in the background;
7
+ // the user can do `npx replen progress` later if they want to watch
8
+ // the rest, or just open Claude Code and the agent will find whatever
9
+ // landed.
10
+ //
11
+ // Why exit early instead of waiting for full pipeline completion: a
12
+ // fresh install with 30+ projects can take 5-10 min for Stage 1+2
13
+ // per-project LLM work to finish. The user shouldn't sit at a blank
14
+ // terminal — they want to know "is there something I can look at now?"
15
+ // and the answer becomes yes once the discovered pool lands (typically
16
+ // 30-60s in).
17
+ //
18
+ // Tolerant of rate-limit and in-flight responses; never fails the
19
+ // install flow on its own — worst case prints a "you can run
20
+ // `npx replen progress` later" hint and returns.
21
+ import { apiGet, apiPost } from "./api.js";
22
+ const POLL_INTERVAL_MS = 2500;
23
+ // Absolute cap on how long we sit in the streamer. Discovered pool
24
+ // usually lands inside 60s but giving generous headroom for slow
25
+ // fetcher tails. If we hit this, we exit with a hint rather than
26
+ // blocking the install flow.
27
+ const MAX_WAIT_MS = 180_000;
28
+ export async function runFirstIngest(cfg) {
29
+ console.log("");
30
+ console.log(" Pulling first batch of candidates from your sources…");
31
+ console.log(" (This takes about a minute. You can `^C` here — ingest continues server-side.)");
32
+ console.log("");
33
+ // Step 1: trigger a run. Tolerant of in-flight / rate-limit.
34
+ let triggered = null;
35
+ try {
36
+ triggered = await apiPost(cfg, "/api/mcp/run-now", {});
37
+ }
38
+ catch (e) {
39
+ // Surface but don't fail install — the cron scheduler will pick
40
+ // up the new projects on its next tick.
41
+ console.log(` · Couldn't trigger an immediate run (${e.message.slice(0, 80)}).`);
42
+ console.log(` The cron scheduler will catch up shortly. Open Claude Code in a tracked repo`);
43
+ console.log(` in a few minutes and the agent will mention any new matches.`);
44
+ return;
45
+ }
46
+ if (!triggered.ok && triggered.status !== "in_flight") {
47
+ console.log(` · Run not started: ${triggered.reason ?? triggered.status}`);
48
+ console.log(` The next scheduled run will pick up the new projects.`);
49
+ return;
50
+ }
51
+ // Step 2: poll status, print events, exit when discovered pool ready.
52
+ let since = 0;
53
+ let firstTick = true;
54
+ let lastCandidates = 0;
55
+ let startedAt = Date.now();
56
+ let discoveredReadyAnnounced = false;
57
+ while (true) {
58
+ if (Date.now() - startedAt > MAX_WAIT_MS) {
59
+ console.log("");
60
+ console.log(" · Ingest still going — exiting the streamer so you're not blocked.");
61
+ console.log(" Run `npx replen progress` to keep watching, or just open Claude Code.");
62
+ return;
63
+ }
64
+ let status;
65
+ try {
66
+ status = await apiGet(cfg, "/api/mcp/status", since ? { since } : undefined);
67
+ }
68
+ catch (e) {
69
+ console.log(` · Lost connection to status endpoint: ${e.message.slice(0, 80)}`);
70
+ console.log(` Ingest still running. Open Claude Code shortly to see matches.`);
71
+ return;
72
+ }
73
+ if (firstTick) {
74
+ const phase = status.phase ?? "starting";
75
+ console.log(` Run #${status.runId ?? "?"} · ${status.inFlight ? `running · phase=${phase}` : "idle"}`);
76
+ firstTick = false;
77
+ }
78
+ for (const ev of status.events ?? []) {
79
+ console.log(` ${marker(ev.kind)} ${ev.message}`);
80
+ if (ev.id > since)
81
+ since = ev.id;
82
+ // The Phase-1 completion signal: pipeline emits a `fetch_done`
83
+ // event whose message starts with "Discovered pool:". Once we
84
+ // see that, the discovered candidates are persisted and visible
85
+ // to replen_match.
86
+ if (ev.kind === "fetch_done" &&
87
+ ev.message.startsWith("Discovered pool:") &&
88
+ !discoveredReadyAnnounced) {
89
+ discoveredReadyAnnounced = true;
90
+ // Don't exit immediately — let the next poll catch any inline
91
+ // events so the user sees a coherent end state, then bail.
92
+ }
93
+ }
94
+ // Fallback signal: if candidates count crossed 0, the discovered
95
+ // pool is functionally ready even if the event ordering didn't
96
+ // emit the expected marker. Belt + suspenders.
97
+ if (!discoveredReadyAnnounced && (status.candidates ?? 0) > lastCandidates && (status.candidates ?? 0) > 0) {
98
+ discoveredReadyAnnounced = true;
99
+ }
100
+ lastCandidates = status.candidates ?? lastCandidates;
101
+ // If the whole pipeline already finished (e.g. existing user with
102
+ // cached vectors — runs end fast), exit cleanly.
103
+ if (!status.inFlight) {
104
+ console.log("");
105
+ console.log(` ✓ Ingest complete · ${status.candidates ?? 0} candidate(s) in inventory.`);
106
+ return;
107
+ }
108
+ // If discovered pool is ready, exit and let the rest run server-
109
+ // side. Stage 1+2 + scouted pool take much longer; no point making
110
+ // the user watch them.
111
+ if (discoveredReadyAnnounced) {
112
+ console.log("");
113
+ console.log(` ✓ First candidates ready (${status.candidates ?? "?"} so far).`);
114
+ console.log(` Per-project relevance refinement continues in the background.`);
115
+ console.log(` Run \`npx replen progress\` later to watch, or just open Claude Code.`);
116
+ return;
117
+ }
118
+ await sleep(POLL_INTERVAL_MS);
119
+ }
120
+ }
121
+ function marker(kind) {
122
+ switch (kind) {
123
+ case "fetch_start":
124
+ case "fetch_done":
125
+ return "›";
126
+ case "scan":
127
+ return "·";
128
+ case "match":
129
+ return "✓";
130
+ case "skip":
131
+ return "·";
132
+ case "error":
133
+ return "✗";
134
+ default:
135
+ return "·";
136
+ }
137
+ }
138
+ function sleep(ms) {
139
+ return new Promise((resolve) => setTimeout(resolve, ms));
140
+ }
package/dist/index.js CHANGED
@@ -12,6 +12,15 @@ Usage:
12
12
  npx replen mcp setup Re-wire MCP using saved auth
13
13
  npx replen project-init Print a prompt your AI coding tool uses to draft
14
14
  a CLAUDE.md tuned for replen
15
+ npx replen inject [-y] Append the "## Replen integration" section to
16
+ every CLAUDE.md + AGENTS.md (Claude Code +
17
+ Codex) under ~/github/, ~/code/, ~/projects/
18
+ so the agent auto-surfaces matches on session
19
+ start. Idempotent. Asks for consent unless -y.
20
+ npx replen sync-projects Re-scan local repos for new GitHub remotes
21
+ [--root PATH ...] and register them with Replen. Run after
22
+ cloning a new repo, or pass --root to point
23
+ at a non-conventional layout.
15
24
  npx replen logout Forget saved auth
16
25
  npx replen --help This help
17
26
 
@@ -39,6 +48,14 @@ Every data command accepts --json to dump raw JSON for scripting / jq.
39
48
 
40
49
  Env:
41
50
  REPLEN_BASE Override dashboard URL (default https://app.replen.dev)
51
+ REPLEN_PROJECT_ROOTS Colon-separated list of dirs to scan for git repos.
52
+ Overrides auto-detection. e.g. ~/work:~/src
53
+
54
+ Project discovery (when run without --root and REPLEN_PROJECT_ROOTS unset):
55
+ 1. Walks up from the current dir to find a git repo, suggests its parent
56
+ 2. Reads ~/.claude.json for tracked Claude Code project paths
57
+ 3. Falls back to ~/github, ~/code, ~/projects, ~/dev, ~/src, ~/work
58
+ 4. Prompts you interactively if none of the above turn up anything
42
59
 
43
60
  Learn more: https://replen.dev
44
61
  `;
@@ -92,6 +109,27 @@ async function main() {
92
109
  console.log(`Note: this only clears local auth. The token is still valid until you rotate it on /settings.`);
93
110
  return;
94
111
  }
112
+ if (cmd === "inject") {
113
+ const { injectInstructions, summariseOutcome } = await import("./inject-instruction.js");
114
+ const yes = argv.includes("--yes") || argv.includes("-y");
115
+ const explicitRoots = collectRootFlags(argv);
116
+ const outcome = await injectInstructions({ yes, explicitRoots });
117
+ const summary = summariseOutcome(outcome);
118
+ if (summary)
119
+ console.log(summary);
120
+ return;
121
+ }
122
+ if (cmd === "sync-projects" || cmd === "sync") {
123
+ const cfg = await readConfig();
124
+ if (!cfg) {
125
+ console.error("Not signed in. Run `npx replen` first.");
126
+ process.exit(1);
127
+ }
128
+ const explicitRoots = collectRootFlags(argv);
129
+ const { syncDiscoveredProjects } = await import("./sync-projects.js");
130
+ await syncDiscoveredProjects({ token: cfg.token, base: cfg.base, explicitRoots });
131
+ return;
132
+ }
95
133
  if (cmd === "run")
96
134
  return runRun(argv);
97
135
  if (cmd === "progress")
@@ -124,6 +162,24 @@ async function main() {
124
162
  console.error(HELP);
125
163
  process.exit(1);
126
164
  }
165
+ /**
166
+ * Parse one or more `--root <path>` (or `--root=<path>`) flags out of
167
+ * the argv tail. Doesn't validate the path — discover-roots.ts handles
168
+ * existence + stat checks downstream.
169
+ */
170
+ function collectRootFlags(argv) {
171
+ const out = [];
172
+ for (let i = 0; i < argv.length; i++) {
173
+ const arg = argv[i];
174
+ if (arg === "--root" && argv[i + 1]) {
175
+ out.push(argv[++i]);
176
+ }
177
+ else if (arg.startsWith("--root=")) {
178
+ out.push(arg.slice("--root=".length));
179
+ }
180
+ }
181
+ return out;
182
+ }
127
183
  main().catch((e) => {
128
184
  console.error(`\n✗ ${e?.message ?? String(e)}`);
129
185
  process.exit(1);
package/dist/init.js CHANGED
@@ -142,10 +142,30 @@ export async function runInit() {
142
142
  });
143
143
  console.log(` ✓ Saved auth to ${configPath()}`);
144
144
  await setupMcp(exchange.token, exchange.base);
145
+ // Phase A: auto-discover the user's local projects, extract tags
146
+ // from manifests, and register them in one shot. Replaces the
147
+ // legacy "paste a GitHub PAT and let us call api.github.com" flow
148
+ // for project discovery.
145
149
  console.log("");
146
- console.log(" All set. Restart Claude Code (or Codex) and try:");
147
- console.log(" /replen-triage → runs the morning triage protocol");
148
- console.log(" use replen to replen_today pulls today's matches");
150
+ console.log(" Scanning your local repos for projects…");
151
+ const { syncDiscoveredProjects } = await import("./sync-projects.js");
152
+ await syncDiscoveredProjects({ token: exchange.token, base: exchange.base });
153
+ // Phase B: trigger the first ingest and stream progress until the
154
+ // discovered pool is ready (~30-60s). Without this, a new user
155
+ // would open Claude Code and find replen_match returning nothing —
156
+ // the server-side cron hasn't run yet for the just-registered
157
+ // projects. Streaming gives them visible activity AND ensures
158
+ // there's something to surface by the time they get to Claude Code.
159
+ const { runFirstIngest } = await import("./first-ingest.js");
160
+ await runFirstIngest({ token: exchange.token, base: exchange.base, savedAt: "" });
161
+ console.log("");
162
+ console.log(" All set. Restart Claude Code and try:");
163
+ console.log(" /replen-match → 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)");
166
+ console.log("");
167
+ console.log(" Other MCP hosts (Codex / Cursor / Aider):");
168
+ console.log(" \"use replen_match\" — same tool, no slash command");
149
169
  console.log("");
150
170
  console.log(` Dashboard: ${exchange.base}`);
151
171
  console.log("");