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,195 @@
1
+ // Orchestrates project discovery + server registration. Tries layered
2
+ // inference strategies (cwd / Claude Code config / hardcoded roots /
3
+ // interactive prompt) until at least one git repo is found, then walks
4
+ // each root recursively to find every repo, then POSTs to
5
+ // /api/projects/bulk for upsert.
6
+ //
7
+ // Persists the user's chosen root(s) to ~/.replen/config.json so future
8
+ // `npx replen sync-projects` runs use the same set without re-prompting.
9
+ import { readConfig, writeConfig } from "./config.js";
10
+ import { discoverProjects } from "./discover-projects.js";
11
+ import { promptForRoot, rootsFromClaudeJson, rootsFromConfig, rootsFromCwdWalkUp, rootsFromEnv, rootsFromFlag, rootsFromHardcoded, } from "./discover-roots.js";
12
+ export async function syncDiscoveredProjects({ token, base, explicitRoots = [], }) {
13
+ const { result, source, prompted } = await resolveAndWalk(explicitRoots);
14
+ // Tell the user what we did, regardless of outcome.
15
+ reportDiscovery(result, source);
16
+ if (result.projects.length === 0) {
17
+ console.log(` · No git repos with GitHub remotes were found. Run \`npx replen sync-projects --root <path>\` to point at a specific dir, or set REPLEN_PROJECT_ROOTS=...`);
18
+ return { discovered: 0, created: 0, updated: 0 };
19
+ }
20
+ // Persist ONLY when the user came in via the interactive prompt.
21
+ // Flags and env vars are deliberate per-invocation overrides — saving
22
+ // them to config creates a confusing dual source of truth and, worse,
23
+ // can lock subsequent commands (like `inject` with no args) into the
24
+ // narrow scope the user chose for a one-off `--root` use.
25
+ if (prompted) {
26
+ await persistRoots(result.scannedRoots);
27
+ }
28
+ const payload = {
29
+ projects: result.projects.map((p) => ({
30
+ slug: p.slug,
31
+ githubFullName: p.githubFullName, // null is impossible here — we filter above
32
+ name: p.name,
33
+ tags: p.tags,
34
+ primaryLanguage: p.primaryLanguage ?? undefined,
35
+ })),
36
+ };
37
+ let res;
38
+ try {
39
+ res = await fetch(`${base}/api/projects/bulk`, {
40
+ method: "POST",
41
+ headers: {
42
+ "x-digest-token": token,
43
+ "content-type": "application/json",
44
+ },
45
+ body: JSON.stringify(payload),
46
+ });
47
+ }
48
+ catch (e) {
49
+ console.warn(` ✗ Failed to reach ${base}/api/projects/bulk: ${e.message}`);
50
+ console.warn(` Skipping project registration. Run \`npx replen sync-projects\` later to retry.`);
51
+ return { discovered: result.projects.length, created: 0, updated: 0 };
52
+ }
53
+ if (!res.ok) {
54
+ const text = await res.text().catch(() => "");
55
+ console.warn(` ✗ Project registration failed (HTTP ${res.status}): ${text.slice(0, 200)}`);
56
+ return { discovered: result.projects.length, created: 0, updated: 0 };
57
+ }
58
+ const body = (await res.json());
59
+ const created = body.created ?? 0;
60
+ const updated = body.updated ?? 0;
61
+ if (created > 0 || updated > 0) {
62
+ const parts = [];
63
+ if (created > 0)
64
+ parts.push(`${created} new`);
65
+ if (updated > 0)
66
+ parts.push(`${updated} updated`);
67
+ console.log(` ✓ Registered with Replen: ${parts.join(", ")}`);
68
+ }
69
+ else {
70
+ console.log(` · All ${result.projects.length} projects already up to date with Replen`);
71
+ }
72
+ return { discovered: result.projects.length, created, updated };
73
+ }
74
+ /**
75
+ * Try discovery strategies in priority order. The first strategy that
76
+ * yields ≥1 project's worth of repos wins; if none do, falls back to
77
+ * the interactive prompt.
78
+ *
79
+ * Returns the walked result, the strategy that supplied the roots,
80
+ * and a flag indicating whether the interactive prompt was used.
81
+ */
82
+ async function resolveAndWalk(explicitRoots) {
83
+ // Strategies that take precedence and short-circuit on hit.
84
+ const ordered = [
85
+ { source: "flag", roots: () => rootsFromFlag(explicitRoots) },
86
+ { source: "env", roots: () => rootsFromEnv() },
87
+ { source: "config", roots: () => rootsFromConfig() },
88
+ ];
89
+ for (const { source, roots } of ordered) {
90
+ const r = await roots();
91
+ if (r.length > 0) {
92
+ const result = discoverProjects(r);
93
+ return { result, source, prompted: false };
94
+ }
95
+ }
96
+ // Inference strategies combine: walk the union of cwd / Claude / hardcoded.
97
+ // Any one of them surfacing repos is enough; we union to maximise the
98
+ // chance of catching the user's repos no matter where they live.
99
+ const inferred = Array.from(new Set([
100
+ ...rootsFromCwdWalkUp(),
101
+ ...rootsFromClaudeJson(),
102
+ ...rootsFromHardcoded(),
103
+ ]));
104
+ if (inferred.length > 0) {
105
+ const result = discoverProjects(inferred);
106
+ if (result.projects.length > 0) {
107
+ return { result, source: pickInferredSource(inferred), prompted: false };
108
+ }
109
+ // Inferred roots existed but contained no GitHub repos — fall
110
+ // through to prompt rather than reporting empty.
111
+ }
112
+ // Last resort: ask.
113
+ const chosen = await promptForRoot();
114
+ if (!chosen) {
115
+ return {
116
+ result: { projects: [], nonGithubSkipped: 0, scannedRoots: inferred },
117
+ source: "prompt",
118
+ prompted: true,
119
+ };
120
+ }
121
+ const result = discoverProjects([chosen]);
122
+ return { result, source: "prompt", prompted: true };
123
+ }
124
+ /**
125
+ * If the inferred-roots union turned up projects, pick the most
126
+ * specific source for the user-facing message. Order matches priority:
127
+ * cwd is most specific, hardcoded least.
128
+ */
129
+ function pickInferredSource(roots) {
130
+ if (rootsFromCwdWalkUp().some((r) => roots.includes(r)))
131
+ return "cwd";
132
+ if (rootsFromClaudeJson().some((r) => roots.includes(r)))
133
+ return "claude-json";
134
+ return "hardcoded";
135
+ }
136
+ function reportDiscovery(result, source) {
137
+ const sourceLabel = {
138
+ flag: "from --root flag",
139
+ env: "from REPLEN_PROJECT_ROOTS env",
140
+ config: "from saved config",
141
+ cwd: "via cwd walk-up",
142
+ "claude-json": "via Claude Code's tracked projects",
143
+ hardcoded: "via conventional ~/github, ~/code, ~/projects",
144
+ prompt: "you specified",
145
+ };
146
+ console.log(` · Scanning ${result.scannedRoots.length} root(s) (${sourceLabel[source]}):`);
147
+ for (const r of result.scannedRoots.slice(0, 5)) {
148
+ console.log(` ${r}`);
149
+ }
150
+ if (result.scannedRoots.length > 5) {
151
+ console.log(` … and ${result.scannedRoots.length - 5} more`);
152
+ }
153
+ if (result.projects.length === 0)
154
+ return;
155
+ console.log(` ✓ Found ${result.projects.length} git repo(s) with GitHub remotes:`);
156
+ for (const p of result.projects.slice(0, 5)) {
157
+ const sampleTags = p.tags.slice(0, 3).join(", ") || "(no auto-tags)";
158
+ const localHint = pathHint(p);
159
+ console.log(` • ${p.githubFullName} ${localHint} → ${sampleTags}`);
160
+ }
161
+ if (result.projects.length > 5) {
162
+ console.log(` … and ${result.projects.length - 5} more`);
163
+ }
164
+ if (result.nonGithubSkipped > 0) {
165
+ console.log(` · Skipped ${result.nonGithubSkipped} local repo(s) without a GitHub remote (nothing to match against)`);
166
+ }
167
+ }
168
+ /** Render "(at ~/projects/drone)" only when the dirname differs from the
169
+ * repo name — i.e. only when there's actual signal worth showing. */
170
+ function pathHint(p) {
171
+ const repoName = (p.githubFullName ?? "").split("/")[1] ?? "";
172
+ const dirName = p.localPath.split("/").pop() ?? "";
173
+ if (!repoName || dirName.toLowerCase() === repoName.toLowerCase())
174
+ return "";
175
+ // Compress home prefix for readability.
176
+ const home = process.env.HOME ?? "";
177
+ const display = home && p.localPath.startsWith(home) ? "~" + p.localPath.slice(home.length) : p.localPath;
178
+ return ` (at ${display})`;
179
+ }
180
+ async function persistRoots(roots) {
181
+ const cfg = await readConfig();
182
+ if (!cfg)
183
+ return;
184
+ // Don't overwrite if config already had a roots list — leave the
185
+ // user's previous choice alone unless they re-prompt.
186
+ if (cfg.projectRoots && cfg.projectRoots.length > 0)
187
+ return;
188
+ await writeConfig({ ...cfg, projectRoots: roots });
189
+ }
190
+ // Returns just the discovered list without sending. Useful for the
191
+ // CLI's `replen list-projects` subcommand (preview mode).
192
+ export async function previewDiscovery(explicitRoots = []) {
193
+ const { result } = await resolveAndWalk(explicitRoots);
194
+ return result;
195
+ }
@@ -0,0 +1,230 @@
1
+ ---
2
+ name: replen-match
3
+ description: Triage today's Replen candidates against the open codebase, in-session, using subscription tokens. Pulls the day's inventory from Replen, reads the user's local source, produces per-candidate writeups with verdict + effort + concrete file-level impact, then captures star/hide/handoff actions back to Replen. Invoke with `/replen-match` or by saying "run replen on this repo" / "what's new from replen?".
4
+ ---
5
+
6
+ # Replen Match — in-session candidate triage
7
+
8
+ You are running the matching loop locally. Replen has fetched a list of
9
+ plausible OSS candidates from the wider ecosystem; you've got the user's
10
+ codebase open. Your job is to decide which (if any) are worth their
11
+ attention, write up the strong ones, and capture what they want to do
12
+ about them.
13
+
14
+ **This runs entirely on the user's subscription tokens.** No API keys
15
+ get used. Replen's hosted side did the cheap structural filtering; you
16
+ do the expensive, code-grounded reasoning.
17
+
18
+ ## Protocol
19
+
20
+ ### Step 1 — Auth + context
21
+
22
+ 1. Read `~/.replen/config.json` to get the user's DIGEST_TOKEN and base
23
+ URL. If the file doesn't exist, stop and tell the user to run
24
+ `npx replen` first.
25
+ 2. Run `git remote get-url origin` to detect the repo. Extract
26
+ `owner/name` from the URL.
27
+ 3. Run `git log --oneline -20` to get a sense of recent activity (will
28
+ inform scoring — what's the user actively working on?).
29
+
30
+ ### Step 2 — Pull today's inventory
31
+
32
+ Call:
33
+
34
+ ```bash
35
+ curl -sS -H "x-digest-token: $TOKEN" \
36
+ "$BASE/api/inventory/today?repo=<owner/name>&days=2&limit=10"
37
+ ```
38
+
39
+ Parse the JSON response. Note:
40
+
41
+ - `filterMode` — `tags`, `zero-knowledge`, or `fingerprint`
42
+ - `scopedTo` — confirms the project context the user has open
43
+ - `candidates[]` — the actual list to triage
44
+
45
+ If `candidates.length === 0`, tell the user "No new candidates today for
46
+ `<owner/name>`. Calm-cadence working as designed — 1-3 actionable
47
+ matches a month is the goal." Stop.
48
+
49
+ If 1+ candidates, continue.
50
+
51
+ ### Step 3 — Per-candidate analysis
52
+
53
+ **Cap at 5 candidates.** If the inventory returned more, take the top 5
54
+ by `whyShortlisted` strength + stars + recency, defer the rest.
55
+
56
+ For each candidate, do this loop:
57
+
58
+ #### 3a. Gather signals
59
+
60
+ - WebFetch `<candidate.url>` — the GitHub repo page. Pull description +
61
+ README.
62
+ - If the README mentions specific files (e.g. `src/index.ts`), WebFetch
63
+ the raw file too (`https://raw.githubusercontent.com/<owner>/<name>/<default-branch>/<path>`).
64
+ - Search the user's local codebase for related code:
65
+ - If the candidate is in a known package ecosystem, grep the user's
66
+ `package.json` / `pyproject.toml` / etc. for the candidate's name
67
+ or close variants (the user might already have a similar dep).
68
+ - If the candidate solves a problem area (e.g. "social card generator"),
69
+ grep the user's source for files that already do that work
70
+ (e.g. `grep -rln "Canvas\|imageRenderer\|OG"` under `lib/` and `src/`).
71
+ - If you find one, read the file to understand what the user has built.
72
+
73
+ #### 3b. Form a verdict
74
+
75
+ You're answering: **is this worth the user's attention right now?**
76
+
77
+ Verdicts:
78
+
79
+ - **adopt** — drop-in replacement / direct dep. The candidate does
80
+ something the user genuinely needs and isn't doing well. Wire up.
81
+ - **port** — the candidate has an idea / pattern / algorithm worth
82
+ copying, but the candidate's runtime / language / license is
83
+ mismatched. Worth reading + adapting; not worth depending on.
84
+ - **skip** — the candidate is a worse version of what the user has, the
85
+ candidate's runtime is incompatible, or the candidate's not actively
86
+ maintained. Honest skips are valuable signal; don't manufacture
87
+ reasons to keep something.
88
+
89
+ Score the fit on a 0-100 scale:
90
+
91
+ - 80-100 = high (strong fit, clear adopt or port path, no major blockers)
92
+ - 50-79 = medium (real value, but caveats — port path required, or
93
+ partial overlap, or active-area-mismatched)
94
+ - 0-49 = general-awareness or skip (interesting but not directly
95
+ actionable; or definitely skip)
96
+
97
+ Estimate effort:
98
+
99
+ - **quick** — <1 day. Single-file swap, drop a dep, copy a file.
100
+ - **moderate** — 1-3 days. Real API delta, multi-site update, light port.
101
+ - **deep** — 1+ week. Framework adoption, paradigm shift, full rewrite.
102
+
103
+ #### 3c. Compose the writeup
104
+
105
+ Format **exactly** like this for each candidate:
106
+
107
+ ```
108
+ ### owner/name — VERDICT (score) · effort
109
+
110
+ **One-liner.** What the candidate is, in one sentence.
111
+
112
+ **Why this could help.** 2-3 sentences. Be specific. Reference the
113
+ user's actual files when possible:
114
+ - "Could replace `lib/social/imageRenderer.ts` (180 LoC) with a 30-line
115
+ wrapper around X."
116
+ - "Their `<approach>` is what `scripts/auto-process.ts:124` tries to do
117
+ in 80 lines; copying the algorithm saves ~50 lines and the buggy
118
+ edge cases."
119
+
120
+ **Integration approach.** One of: cherry-pick / vendor /
121
+ cleanroom-rebuild / depend-on-it / n/a. Plus a one-sentence note on what
122
+ that means here.
123
+
124
+ **Caveats.** Anything to watch for — license, abandonment, runtime
125
+ mismatch, security flag. Empty list is fine; don't manufacture.
126
+ ```
127
+
128
+ No marketing voice. No hype. The user is a working engineer; talk to
129
+ them like a peer. Concrete > clever.
130
+
131
+ ### Step 4 — Present + capture actions
132
+
133
+ After all writeups, summarise:
134
+
135
+ ```
136
+ 3 candidates triaged for tech-news-site:
137
+ ✓ adopt: kribblo/node-ffmpeg-installer (high · quick) — ffmpeg-static swap
138
+ ⏭ port: tj/n (medium · moderate) — version-manager pattern
139
+ ✗ skip: vercel/turbo (medium · deep) — wrong runtime, already have Vite
140
+
141
+ For each, what would you like to do? (star / hide / handoff / skip)
142
+ ```
143
+
144
+ Then, for each candidate, capture the user's choice. For each action,
145
+ POST to `/api/state`:
146
+
147
+ ```bash
148
+ curl -sS -X POST \
149
+ -H "x-digest-token: $TOKEN" \
150
+ -H "content-type: application/json" \
151
+ -d '{"repo":"owner/name","status":"<star|hide|handed_off|surfaced>","projectId":<id-from-inventory-projectMatch-or-null>}' \
152
+ "$BASE/api/state"
153
+ ```
154
+
155
+ Status values:
156
+
157
+ - `starred` — user wants to come back to this. Won't re-surface.
158
+ - `hidden` — user dismissed it. Never re-surface unless they clear it.
159
+ - `handed_off` — user wants a handoff PR opened against their project.
160
+ Use the existing `mcp__replen__replen_handoff` MCP tool to actually
161
+ open the PR (server-side, needs write-scoped GitHub auth). Pass the
162
+ PR URL back to /api/state via `handoffPrUrl` so future sessions see
163
+ the status.
164
+ - `surfaced` — neutral "I showed this, user neither stared nor hid."
165
+ Bumps the surface counter so the inventory deprioritises it next
166
+ call without locking it out. POST this for EVERY candidate you
167
+ presented, even the skips. It's the closing bookend of this skill.
168
+
169
+ ### Step 5 — Close out
170
+
171
+ End with one line summarising what got actioned:
172
+
173
+ ```
174
+ Done. 1 starred, 0 handoff PRs opened, 1 hidden, 1 skipped.
175
+ Run /replen-match again tomorrow for the next batch (or whenever a
176
+ fresh candidate shows up at session start via the hook).
177
+ ```
178
+
179
+ ## What to do if things go wrong
180
+
181
+ **Inventory call returns 401.** User's token expired or got rotated.
182
+ Tell them to run `npx replen` to re-auth.
183
+
184
+ **Inventory returns `scopedTo: null` with a `note` about "repo not in
185
+ your project list"**. The cwd isn't a known project. Ask the user:
186
+ "This repo isn't in your Replen projects yet. Add it via /projects?"
187
+ Then either stop or re-run with `?repo=` (empty) to see the global
188
+ firehose.
189
+
190
+ **Candidate's README is unreachable (WebFetch 404)**. Note it in the
191
+ writeup (`Caveats: README unreachable; verdict based on description
192
+ only`) and proceed with a more conservative score.
193
+
194
+ **Local codebase is large / search is slow.** Don't do `grep -r` from
195
+ the root; scope to `src/`, `lib/`, `app/`. Skip `node_modules`,
196
+ `dist`, `build`, `.next`, `vendor`. If you genuinely can't find
197
+ related code in 3 grep attempts, say so in the writeup ("No directly
198
+ related code found in src/; verdict based on README + project shape
199
+ only").
200
+
201
+ **No project tags configured (inventory returns
202
+ `whyShortlisted: "no project tags configured; showing unfiltered"`)**.
203
+ The user hasn't set up filter-mode B's tag list. Mention it once: "Heads
204
+ up — you'd get sharper matches if you set project tags at /settings.
205
+ Continuing with unfiltered for now."
206
+
207
+ ## When NOT to run this skill
208
+
209
+ - The user is mid-task and just wants help with the current thing. The
210
+ match flow is interruptive; don't volunteer it. Only run when invoked
211
+ via `/replen-match` or when the user explicitly asks.
212
+ - The repo isn't a tracked Replen project. Tell them how to add it,
213
+ don't try to match against an unknown project.
214
+ - The session-start hook already surfaced matches in the opening
215
+ context and the user hasn't asked for more. Don't double-deliver.
216
+
217
+ ## Voice guide
218
+
219
+ - **Concrete.** "Replaces `lib/foo.ts:42-180`" beats "could simplify
220
+ your image pipeline."
221
+ - **Honest.** Skip is a valid verdict, often the right one. Don't pad.
222
+ - **One-block-per-candidate.** Don't sprawl. The user will read 1-3
223
+ candidates; if you write 9 paragraphs each, they bail.
224
+ - **Show your work.** When the verdict is "adopt", say WHAT in the
225
+ user's code it replaces. When "port", say WHAT idea is worth
226
+ porting. When "skip", say WHY.
227
+ - **No salesmanship.** Don't end with "would you like to learn more?"
228
+ The user will tell you what they want.
229
+ - **Match the calm-cadence positioning.** Most days, no candidates.
230
+ When there are some, they're real. Treat them that way.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "replen",
3
- "version": "0.4.1",
4
- "description": "Smarter AI Development workflows. The AI that asks 'can we do this better?' - replen reads your codebase against the live ecosystem and surfaces drop-in libraries, ideas to port, and patterns to learn from. Calm cadence: 1-3 actionable matches a month. One-command setup: opens a browser to sign in, then wires the MCP server into Claude Code / Codex.",
3
+ "version": "1.0.1",
4
+ "description": "Make your AI coding tools smarter. One command, no API keys. Replen scouts the OSS firehose against your projects and surfaces drop-in libraries, ideas to port, and dead deps to swap the match decision happens inside your AI tool's session on your subscription tokens. 1-3 actionable matches a month, by design.",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "replen": "dist/index.js"
@@ -13,6 +13,7 @@
13
13
  },
14
14
  "files": [
15
15
  "dist",
16
+ "extras",
16
17
  "README.md",
17
18
  "LICENSE"
18
19
  ],
@@ -38,5 +39,8 @@
38
39
  "@types/node": "^22.10.5",
39
40
  "tsx": "^4.19.2",
40
41
  "typescript": "^5.7.2"
42
+ },
43
+ "dependencies": {
44
+ "smol-toml": "^1.6.1"
41
45
  }
42
46
  }