replen 0.4.1 → 1.0.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 CHANGED
@@ -1,54 +1,76 @@
1
1
  # replen
2
2
 
3
- **Smarter AI Development workflows.** One-command setup for [replen](https://replen.dev) - the AI that asks *"can we do this better?"* on your codebase. Calm cadence: 1-3 actionable matches a month, not a daily feed.
3
+ **Make your AI coding tools aware of the wider OSS ecosystem.** One command, no API keys.
4
4
 
5
5
  ```bash
6
6
  npx replen
7
7
  ```
8
8
 
9
- While your AI coding tool waits for prompts, replen reads your code against the ecosystem and surfaces drop-in libraries, ideas to port, and patterns to learn from. A proactive layer for your AI coding workflow.
9
+ Claude Code, Codex, Cursor already know your code. Replen tells them what else is out there drop-in libraries, ideas worth porting, dead deps to swap. The match decision happens *inside your AI tool's session* on your subscription tokens. Your code stays on your laptop. Replen never sees it.
10
10
 
11
- The single command above:
12
- 1. Opens your browser to sign in / sign up at `app.replen.dev`
11
+ ## What `npx replen` does
12
+
13
+ In 60 seconds, the one-liner:
14
+
15
+ 1. Opens your browser to sign in (Google or GitHub via Firebase)
13
16
  2. Captures the auth back into the terminal (browser-callback flow, like `gh auth login`)
14
- 3. Wires the [@replen/mcp](https://www.npmjs.com/package/@replen/mcp) server into your Claude Code / Codex config
17
+ 3. Scans your local repos under `~/github/`, `~/code/`, `~/projects/` for git repos
18
+ 4. Auto-extracts tags from each (`package.json` deps, `pyproject.toml`, etc.)
19
+ 5. Registers them with Replen as your projects (no GitHub PAT needed)
20
+ 6. Wires the [@replen/mcp](https://www.npmjs.com/package/@replen/mcp) server into your Claude Code / Codex config
21
+ 7. Installs the `/replen-match` skill into `~/.claude/skills/`
22
+ 8. Injects a small "Replen integration" section into each project's `CLAUDE.md` + `AGENTS.md`
23
+
24
+ **What you do not provide:**
25
+ - ❌ OpenAI / Anthropic API key — your AI tool's subscription handles all reasoning
26
+ - ❌ GitHub PAT — optional, only if you want server-side handoff PRs later
27
+ - ❌ Per-project setup — auto-discovered from your local filesystem
15
28
 
16
- Same flow that `claude` itself uses for auth.
29
+ Open Claude Code (or Codex) in any of your tracked repos and start working normally. When Replen has matches, your AI tool mentions them after answering your first message. Silent on quiet days.
17
30
 
18
31
  ## Subcommands
19
32
 
20
33
  ```bash
21
- npx replen # sign in + setup
22
- npx replen status # show current config
23
- npx replen mcp setup # re-wire MCP using saved auth
24
- npx replen logout # forget saved auth (token stays valid; rotate on /settings to revoke)
34
+ npx replen # sign in + setup (the one-liner above)
35
+ npx replen status # show current config
36
+ npx replen sync-projects # re-scan local repos for new GitHub remotes;
37
+ # run after cloning a new repo
38
+ npx replen inject [-y] # re-inject the "Replen integration" section
39
+ # into every CLAUDE.md + AGENTS.md
40
+ npx replen mcp setup # re-wire MCP using saved auth
41
+ npx replen logout # forget saved auth (rotate on /settings to revoke)
25
42
  ```
26
43
 
27
44
  ## Plain-shell usage (no Claude Code / Codex needed)
28
45
 
29
46
  ```bash
30
- npx replen run # trigger a pipeline run
31
- npx replen progress # tail the run live; exits when done
32
- npx replen check-new # one-shot: any new actionable matches since
33
- # you last engaged? (also runs automatically
34
- # at every Claude Code session start)
47
+ npx replen check-new # one-shot: any new matches since you last
48
+ # engaged? (used by the SessionStart hook)
35
49
  npx replen feed # show recent matches (default 2 days)
36
50
  npx replen watch # keep a terminal open — rings the bell when
37
51
  # a new match lands. Default poll 5min.
38
52
  npx replen search <query> # full-text search past matches
39
53
  npx replen starred # starred matches + handoff PR status
40
54
  npx replen handoff <matchId> # open the handoff PR for a starred match
55
+ npx replen run # trigger a fresh pipeline run (hosted-tier only)
56
+ npx replen progress # tail the run live; exits when done
41
57
  ```
42
58
 
43
- `watch` is the calm-utility companion: leave it running in a `tmux` pane and forget about it. The first poll establishes a baseline; existing matches don't ring.
59
+ Every data command accepts `--json` for scripting.
44
60
 
45
- `check-new` is wired into Claude Code automatically by `npx replen` setup — it installs a SessionStart hook that runs `npx replen check-new --hook` whenever you open Claude Code. The hook is silent unless there's something new since you last engaged (across dashboard, email, or a prior session); when there is, the new matches show up in the agent's opening context without you having to ask. Calm-cadence by design.
61
+ ## How matches surface in Claude Code / Codex
46
62
 
47
- Every data command accepts `--json` for scripting.
63
+ Three layers, defence-in-depth at least one will fire:
64
+
65
+ 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.
66
+ 2. **MCP tool** `replen_check_new` is exposed to the agent at all times. The instructions in `CLAUDE.md` tell it to call this tool early in each session.
67
+ 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.
68
+
69
+ Calm-cadence by design: most days are silent. 1-3 actionable matches a month per project.
48
70
 
49
71
  ## Self-host
50
72
 
51
- Pointing at your own replen instance:
73
+ Pointing at your own Replen instance:
52
74
 
53
75
  ```bash
54
76
  REPLEN_BASE=https://replen.mydomain.dev npx replen
@@ -64,7 +86,7 @@ REPLEN_BASE=https://replen.mydomain.dev npx replen
64
86
  6. CLI validates state, saves the token to `~/.replen/config.json` (mode 0600)
65
87
  7. CLI continues and writes the MCP config into `~/.claude.json`
66
88
 
67
- The token never transits anything other than your browser ↔ localhost ↔ disk. The replen backend only sees it on subsequent MCP / API requests.
89
+ The token never transits anything other than your browser ↔ localhost ↔ disk. The Replen backend only sees it on subsequent MCP / API requests.
68
90
 
69
91
  ## Revoke
70
92
 
@@ -0,0 +1,256 @@
1
+ // Local-filesystem project discovery for day-1 onboarding.
2
+ //
3
+ // Walks the user's conventional repo roots (~/github/, ~/code/,
4
+ // ~/projects/) for git repos, then for each one extracts:
5
+ // - The repo's `owner/name` from `git remote get-url origin`
6
+ // - A slug (from the directory basename, normalised)
7
+ // - A name (from package.json's `name` field if present, else slug)
8
+ // - Auto-suggested tags from the project's manifests
9
+ // - The primary language (best-effort, from manifest type)
10
+ //
11
+ // Output is shaped for POST /api/projects/bulk on the server. No
12
+ // network or LLM calls — pure local filesystem.
13
+ //
14
+ // Why local-FS instead of asking GitHub via PAT: skill-mode's whole
15
+ // pitch is "no API keys to share with us." Auto-detect via GitHub
16
+ // API requires a PAT; auto-detect via local git remotes requires
17
+ // nothing the user doesn't already have. PAT becomes optional, only
18
+ // needed if/when the user wants server-side handoff PRs.
19
+ import { readFileSync, readdirSync, existsSync, statSync } from "node:fs";
20
+ import { execSync } from "node:child_process";
21
+ import { homedir } from "node:os";
22
+ import { join } from "node:path";
23
+ const SCAN_ROOTS = ["github", "code", "projects"]; // immediate children of ~
24
+ // Manifest-derived tag mappings. Each pattern matches a dep name (or
25
+ // a substring) and yields one or more tags. Ordered by specificity:
26
+ // more-specific patterns first so e.g. "next" doesn't accidentally
27
+ // tag a "next-auth-only" project as "next.js".
28
+ const DEP_TO_TAGS = [
29
+ // Frontend frameworks (Node)
30
+ { match: /^next$/, tags: ["next.js"] },
31
+ { match: /^react$/, tags: ["react"] },
32
+ { match: /^vue$/, tags: ["vue"] },
33
+ { match: /^svelte$/, tags: ["svelte"] },
34
+ { match: /^solid-js$/, tags: ["solid"] },
35
+ { match: /^astro$/, tags: ["astro"] },
36
+ { match: /^remix-run\//, tags: ["remix"] },
37
+ // Backend / runtime
38
+ { match: /^express$/, tags: ["express"] },
39
+ { match: /^fastify$/, tags: ["fastify"] },
40
+ { match: /^hono$/, tags: ["hono"] },
41
+ { match: /^nestjs\//, tags: ["nestjs"] },
42
+ // Database / ORM
43
+ { match: /^prisma$/, tags: ["prisma"] },
44
+ { match: /^@prisma\/client$/, tags: ["prisma"] },
45
+ { match: /^drizzle-orm/, tags: ["drizzle"] },
46
+ { match: /^kysely$/, tags: ["kysely"] },
47
+ { match: /^typeorm$/, tags: ["typeorm"] },
48
+ { match: /^mongoose$/, tags: ["mongoose"] },
49
+ { match: /postgres|pg$/, tags: ["postgres"] },
50
+ { match: /^mysql/, tags: ["mysql"] },
51
+ { match: /^redis|^ioredis$/, tags: ["redis"] },
52
+ // Queue / async
53
+ { match: /^bullmq$/, tags: ["bullmq", "queue"] },
54
+ { match: /^bee-queue$/, tags: ["bee-queue", "queue"] },
55
+ { match: /^node-cron$/, tags: ["cron"] },
56
+ // LLM / AI
57
+ { match: /^openai$/, tags: ["openai"] },
58
+ { match: /^@anthropic-ai\//, tags: ["anthropic"] },
59
+ { match: /^langchain/, tags: ["langchain"] },
60
+ // Auth
61
+ { match: /^next-auth$/, tags: ["next-auth"] },
62
+ { match: /^@auth\//, tags: ["next-auth"] },
63
+ { match: /firebase/, tags: ["firebase"] },
64
+ // Cloud / hosting
65
+ { match: /^@aws-sdk\//, tags: ["aws"] },
66
+ { match: /^@vercel\//, tags: ["vercel"] },
67
+ { match: /^@cloudflare\//, tags: ["cloudflare"] },
68
+ // Web3
69
+ { match: /^viem$/, tags: ["viem", "web3"] },
70
+ { match: /^ethers$/, tags: ["ethers", "web3"] },
71
+ { match: /^wagmi$/, tags: ["wagmi", "web3"] },
72
+ { match: /^@matterlabs\//, tags: ["zksync"] },
73
+ // Image / media
74
+ { match: /^sharp$/, tags: ["images"] },
75
+ { match: /^@napi-rs\/canvas$/, tags: ["canvas", "images"] },
76
+ { match: /ffmpeg/, tags: ["ffmpeg"] },
77
+ // Python (matches go through the same loop on requirements.txt)
78
+ { match: /^torch$/, tags: ["pytorch"] },
79
+ { match: /^tensorflow$/, tags: ["tensorflow"] },
80
+ { match: /^fastapi$/, tags: ["fastapi"] },
81
+ { match: /^flask$/, tags: ["flask"] },
82
+ { match: /^django$/, tags: ["django"] },
83
+ { match: /^segmentation-models-pytorch$/, tags: ["pytorch", "segmentation"] },
84
+ { match: /^albumentations$/, tags: ["augmentation"] },
85
+ { match: /^scikit-learn$/, tags: ["ml"] },
86
+ ];
87
+ export function discoverProjects() {
88
+ const out = [];
89
+ const home = homedir();
90
+ for (const root of SCAN_ROOTS) {
91
+ const rootPath = join(home, root);
92
+ if (!existsSync(rootPath))
93
+ continue;
94
+ let entries;
95
+ try {
96
+ entries = readdirSync(rootPath);
97
+ }
98
+ catch {
99
+ continue;
100
+ }
101
+ for (const dirName of entries) {
102
+ if (dirName.startsWith(".") || dirName === "node_modules")
103
+ continue;
104
+ const localPath = join(rootPath, dirName);
105
+ try {
106
+ if (!statSync(localPath).isDirectory())
107
+ continue;
108
+ if (!existsSync(join(localPath, ".git")))
109
+ continue;
110
+ }
111
+ catch {
112
+ continue;
113
+ }
114
+ const githubFullName = readGitRemote(localPath);
115
+ if (!githubFullName)
116
+ continue; // No GitHub remote → skip; can't register
117
+ const { name, tags, primaryLanguage } = extractMetadata(localPath, dirName);
118
+ const slug = normaliseSlug(dirName);
119
+ out.push({ localPath, slug, name, githubFullName, tags, primaryLanguage });
120
+ }
121
+ }
122
+ return out;
123
+ }
124
+ function normaliseSlug(dirName) {
125
+ return dirName
126
+ .toLowerCase()
127
+ .replace(/[^a-z0-9_-]/g, "-")
128
+ .replace(/^-+|-+$/g, "")
129
+ .slice(0, 80) || "project";
130
+ }
131
+ // Run `git remote get-url origin` in the repo. Returns owner/name on
132
+ // success, null otherwise. Tolerates non-GitHub remotes (returns null
133
+ // so the repo is skipped).
134
+ function readGitRemote(repoPath) {
135
+ let url;
136
+ try {
137
+ url = execSync("git remote get-url origin", {
138
+ cwd: repoPath,
139
+ stdio: ["ignore", "pipe", "ignore"],
140
+ encoding: "utf8",
141
+ timeout: 3000,
142
+ }).trim();
143
+ }
144
+ catch {
145
+ return null;
146
+ }
147
+ // Match both HTTPS (https://github.com/owner/name[.git]) and SSH
148
+ // (git@github.com:owner/name[.git]) formats.
149
+ const m = url.match(/github\.com[:/]([^/]+)\/([^/?#]+?)(?:\.git)?$/i);
150
+ if (!m)
151
+ return null;
152
+ return `${m[1]}/${m[2]}`;
153
+ }
154
+ function extractMetadata(repoPath, fallbackName) {
155
+ const tags = new Set();
156
+ let name = fallbackName;
157
+ let primaryLanguage = null;
158
+ // Node: package.json
159
+ const pkgPath = join(repoPath, "package.json");
160
+ if (existsSync(pkgPath)) {
161
+ primaryLanguage = "TypeScript"; // updated below if no TS detected
162
+ try {
163
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
164
+ if (typeof pkg.name === "string" && pkg.name.length > 0)
165
+ name = pkg.name;
166
+ if (Array.isArray(pkg.keywords)) {
167
+ for (const k of pkg.keywords) {
168
+ if (typeof k === "string" && k.length > 0 && k.length <= 40) {
169
+ tags.add(k.toLowerCase());
170
+ }
171
+ }
172
+ }
173
+ const deps = { ...(pkg.dependencies ?? {}), ...(pkg.devDependencies ?? {}) };
174
+ // Detect TS vs JS from presence of typescript in deps.
175
+ if (deps["typescript"] !== undefined) {
176
+ tags.add("typescript");
177
+ primaryLanguage = "TypeScript";
178
+ }
179
+ else {
180
+ // Look for *.ts files at top level to decide TS vs JS.
181
+ try {
182
+ const top = readdirSync(repoPath);
183
+ if (top.some((f) => f.endsWith(".ts") || f.endsWith(".tsx"))) {
184
+ tags.add("typescript");
185
+ primaryLanguage = "TypeScript";
186
+ }
187
+ else {
188
+ tags.add("javascript");
189
+ primaryLanguage = "JavaScript";
190
+ }
191
+ }
192
+ catch { /* ignore */ }
193
+ }
194
+ // Match deps against the tag mapping.
195
+ for (const depName of Object.keys(deps)) {
196
+ for (const { match, tags: ts } of DEP_TO_TAGS) {
197
+ if (match.test(depName)) {
198
+ for (const t of ts)
199
+ tags.add(t);
200
+ }
201
+ }
202
+ }
203
+ }
204
+ catch { /* malformed package.json — skip */ }
205
+ }
206
+ // Python: pyproject.toml / requirements.txt
207
+ const pyprojectPath = join(repoPath, "pyproject.toml");
208
+ const requirementsPath = join(repoPath, "requirements.txt");
209
+ if (existsSync(pyprojectPath) || existsSync(requirementsPath)) {
210
+ primaryLanguage = "Python";
211
+ tags.add("python");
212
+ const text = [pyprojectPath, requirementsPath]
213
+ .filter((p) => existsSync(p))
214
+ .map((p) => { try {
215
+ return readFileSync(p, "utf8");
216
+ }
217
+ catch {
218
+ return "";
219
+ } })
220
+ .join("\n");
221
+ // Pull dep names from "<name>>=<ver>" / "<name>==<ver>" / quoted strings.
222
+ const depMatches = text.matchAll(/^\s*["']?([a-zA-Z0-9][\w.-]*)["']?\s*[><=!~]/gm);
223
+ for (const dm of depMatches) {
224
+ const depName = dm[1].toLowerCase();
225
+ for (const { match, tags: ts } of DEP_TO_TAGS) {
226
+ if (match.test(depName)) {
227
+ for (const t of ts)
228
+ tags.add(t);
229
+ }
230
+ }
231
+ }
232
+ }
233
+ // Rust: Cargo.toml
234
+ if (existsSync(join(repoPath, "Cargo.toml"))) {
235
+ primaryLanguage = primaryLanguage ?? "Rust";
236
+ tags.add("rust");
237
+ }
238
+ // Go: go.mod
239
+ if (existsSync(join(repoPath, "go.mod"))) {
240
+ primaryLanguage = primaryLanguage ?? "Go";
241
+ tags.add("go");
242
+ }
243
+ // Solidity (web3 contracts under hardhat/foundry)
244
+ if (existsSync(join(repoPath, "hardhat.config.js")) ||
245
+ existsSync(join(repoPath, "hardhat.config.ts")) ||
246
+ existsSync(join(repoPath, "foundry.toml"))) {
247
+ tags.add("solidity");
248
+ tags.add("web3");
249
+ }
250
+ // Cap total tags so a kitchen-sink monorepo doesn't blow up the row.
251
+ return {
252
+ name,
253
+ tags: Array.from(tags).slice(0, 25),
254
+ primaryLanguage,
255
+ };
256
+ }
package/dist/index.js CHANGED
@@ -12,6 +12,14 @@ 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
+ and register them with Replen. Run after
22
+ cloning a new repo.
15
23
  npx replen logout Forget saved auth
16
24
  npx replen --help This help
17
25
 
@@ -92,6 +100,25 @@ async function main() {
92
100
  console.log(`Note: this only clears local auth. The token is still valid until you rotate it on /settings.`);
93
101
  return;
94
102
  }
103
+ if (cmd === "inject") {
104
+ const { injectInstructions, summariseOutcome } = await import("./inject-instruction.js");
105
+ const yes = argv.includes("--yes") || argv.includes("-y");
106
+ const outcome = await injectInstructions({ yes });
107
+ const summary = summariseOutcome(outcome);
108
+ if (summary)
109
+ console.log(summary);
110
+ return;
111
+ }
112
+ if (cmd === "sync-projects" || cmd === "sync") {
113
+ const cfg = await readConfig();
114
+ if (!cfg) {
115
+ console.error("Not signed in. Run `npx replen` first.");
116
+ process.exit(1);
117
+ }
118
+ const { syncDiscoveredProjects } = await import("./sync-projects.js");
119
+ await syncDiscoveredProjects({ token: cfg.token, base: cfg.base });
120
+ return;
121
+ }
95
122
  if (cmd === "run")
96
123
  return runRun(argv);
97
124
  if (cmd === "progress")
package/dist/init.js CHANGED
@@ -142,10 +142,22 @@ 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
+ console.log("");
154
+ console.log(" All set. Restart Claude Code and try:");
155
+ console.log(" /replen-match → triage today's candidates against this repo,");
156
+ console.log(" in-session, using your subscription tokens");
157
+ console.log(" (no LLM API keys needed — the agent does the reasoning)");
158
+ console.log("");
159
+ console.log(" Other MCP hosts (Codex / Cursor / Aider):");
160
+ console.log(" \"use replen_match\" — same tool, no slash command");
149
161
  console.log("");
150
162
  console.log(` Dashboard: ${exchange.base}`);
151
163
  console.log("");
@@ -0,0 +1,308 @@
1
+ // Auto-inject the "## Replen integration" instruction block into the
2
+ // user's local project CLAUDE.md files so Claude Code reads it at
3
+ // session start and proactively calls replen_match before responding
4
+ // to the user's first message.
5
+ //
6
+ // Why this exists: SessionStart hooks in Claude Code v2.1.141 don't
7
+ // reliably inject stdout into the agent's context. The CLAUDE.md
8
+ // instruction is the only mechanism today that consistently makes
9
+ // Claude call the tool. Without auto-injection, every user has to
10
+ // hand-edit every project's CLAUDE.md — adoption blocker.
11
+ //
12
+ // Discovery: scans the user's local filesystem under conventional
13
+ // repo roots (~/github/*, ~/code/*, ~/projects/*) for directories
14
+ // that are git repos (contain .git/) and have a CLAUDE.md OR are
15
+ // likely Claude Code projects (look like dev repos with package.json,
16
+ // pyproject.toml, etc.). We inject into both the existing-CLAUDE.md
17
+ // case (append section) and the no-CLAUDE.md case (create with stub).
18
+ //
19
+ // Idempotent contract:
20
+ // - CLAUDE.md missing → create stub with just the Replen
21
+ // section, header notes "edit freely above"
22
+ // - CLAUDE.md exists, current section present (same version) → no-op
23
+ // - CLAUDE.md exists, older-version section present → replace in place
24
+ // - CLAUDE.md exists, section absent → append at bottom
25
+ //
26
+ // Versioning: the section carries a hidden HTML comment marker
27
+ // <!-- replen-integration: v<N> -->. Bumping INTEGRATION_VERSION
28
+ // re-injects on next setup run.
29
+ //
30
+ // Consent: first-run shows the user the list of repos and asks Y/n.
31
+ // --yes skips the prompt (CI / scripted use). Non-TTY also skips
32
+ // (e.g. when invoked from a hook).
33
+ import { readFileSync, writeFileSync, existsSync, statSync, readdirSync } from "node:fs";
34
+ import { homedir } from "node:os";
35
+ import { join, basename } from "node:path";
36
+ import { createInterface } from "node:readline";
37
+ export const INTEGRATION_VERSION = "1";
38
+ const SECTION_HEADER = "## Replen integration";
39
+ const MARKER_RE = /<!--\s*replen-integration:\s*v(\d+)\s*-->/;
40
+ const SECTION_BODY = `<!-- replen-integration: v${INTEGRATION_VERSION} -->
41
+
42
+ On your **very first response** of each session, silently call the
43
+ \`replen_match\` MCP tool (or invoke the \`/replen-match\` skill). It
44
+ returns today's curated OSS candidate inventory scoped to this repo
45
+ so you can mention real matches without making the user ask.
46
+
47
+ **Response shape — ORDER MATTERS:**
48
+
49
+ 1. **Answer the user's actual ask first**, in full. The user didn't
50
+ ask about Replen; whatever they asked is the priority.
51
+ 2. **Then**, on a new line separated by a blank line, append a single-
52
+ paragraph tail like:
53
+ > _By the way — N new Replen matches landed for this repo. Top one:
54
+ > \`owner/name\` (1-line reason). Want the full triage?_
55
+ 3. Cap the tail at 3 sentences. The user's task is the headline; Replen
56
+ is the footnote.
57
+
58
+ **When to stay silent:** if \`replen_match\` returns \`hasNew: false\`,
59
+ OR the user's first message is itself a Replen command
60
+ (\`/replen-match\`, "what's new from replen?", etc.).
61
+
62
+ **Don't call \`replen_match\` again on subsequent turns** — once per
63
+ session at start only. The user explicitly types \`/replen-match\` if
64
+ they want a fresh triage mid-session.
65
+ `;
66
+ const SCAN_ROOTS = ["github", "code", "projects"]; // under ~/
67
+ // Find candidate project directories: immediate children of SCAN_ROOTS
68
+ // that are git repos. We don't recurse — most users keep one level of
69
+ // nesting under their org-or-user-named root.
70
+ function discoverRepos() {
71
+ const out = [];
72
+ const home = homedir();
73
+ for (const root of SCAN_ROOTS) {
74
+ const rootPath = join(home, root);
75
+ if (!existsSync(rootPath))
76
+ continue;
77
+ let entries;
78
+ try {
79
+ entries = readdirSync(rootPath);
80
+ }
81
+ catch {
82
+ continue;
83
+ }
84
+ for (const name of entries) {
85
+ // Skip dotfiles + obvious non-repos.
86
+ if (name.startsWith(".") || name === "node_modules")
87
+ continue;
88
+ const path = join(rootPath, name);
89
+ try {
90
+ if (!statSync(path).isDirectory())
91
+ continue;
92
+ if (!existsSync(join(path, ".git")))
93
+ continue;
94
+ out.push(path);
95
+ }
96
+ catch {
97
+ // permission / symlink errors — skip silently
98
+ }
99
+ }
100
+ }
101
+ return out;
102
+ }
103
+ function applyToClaudeMd(claudeMdPath) {
104
+ if (!existsSync(claudeMdPath)) {
105
+ const header = `# CLAUDE.md\n\n<!-- This file is read by Claude Code at session start to understand the project. Edit freely above this marker — the Replen integration section below is auto-managed. -->\n\n`;
106
+ writeFileSync(claudeMdPath, header + SECTION_HEADER + "\n\n" + SECTION_BODY);
107
+ return "created";
108
+ }
109
+ const current = readFileSync(claudeMdPath, "utf8");
110
+ const markerMatch = current.match(MARKER_RE);
111
+ if (markerMatch) {
112
+ const existingVersion = markerMatch[1];
113
+ if (existingVersion === INTEGRATION_VERSION) {
114
+ // Marker present + current version. Defence-in-depth: a previous
115
+ // version of this CLI may have already appended a duplicate
116
+ // pre-marker section above. Collapse any extra `## Replen
117
+ // integration` blocks above the marker into the canonical one.
118
+ const collapsed = collapseDuplicateSections(current);
119
+ if (collapsed !== current) {
120
+ writeFileSync(claudeMdPath, collapsed);
121
+ return "versionUpdated";
122
+ }
123
+ return "alreadyCurrent";
124
+ }
125
+ writeFileSync(claudeMdPath, replaceSection(current));
126
+ return "versionUpdated";
127
+ }
128
+ // No marker. Check if a legacy/manual Replen section is here by
129
+ // header alone — replace it in place rather than appending a dupe.
130
+ const headerIdx = findFirstSectionHeader(current);
131
+ if (headerIdx !== -1) {
132
+ writeFileSync(claudeMdPath, replaceSection(current));
133
+ return "versionUpdated";
134
+ }
135
+ // Truly absent — append at end.
136
+ const sep = current.endsWith("\n") ? "\n" : "\n\n";
137
+ writeFileSync(claudeMdPath, current + sep + SECTION_HEADER + "\n\n" + SECTION_BODY);
138
+ return "appended";
139
+ }
140
+ function findFirstSectionHeader(claudeMd) {
141
+ const lines = claudeMd.split("\n");
142
+ for (let i = 0; i < lines.length; i++) {
143
+ if (lines[i].trim() === SECTION_HEADER)
144
+ return i;
145
+ }
146
+ return -1;
147
+ }
148
+ // If a CLAUDE.md ended up with multiple `## Replen integration`
149
+ // sections (e.g. a legacy manual one + an auto-injected one), keep
150
+ // only the one carrying the canonical marker comment.
151
+ function collapseDuplicateSections(claudeMd) {
152
+ const lines = claudeMd.split("\n");
153
+ // Find every `## Replen integration` block. Each block: header line
154
+ // through to the next `## ` or EOF.
155
+ const blocks = [];
156
+ for (let i = 0; i < lines.length; i++) {
157
+ if (lines[i].trim() === SECTION_HEADER) {
158
+ let end = lines.length;
159
+ for (let j = i + 1; j < lines.length; j++) {
160
+ if (lines[j].startsWith("## ") && !lines[j].startsWith("### ")) {
161
+ end = j;
162
+ break;
163
+ }
164
+ }
165
+ const body = lines.slice(i, end).join("\n");
166
+ blocks.push({ start: i, end, hasMarker: MARKER_RE.test(body) });
167
+ i = end - 1;
168
+ }
169
+ }
170
+ if (blocks.length <= 1)
171
+ return claudeMd; // nothing to collapse
172
+ // Keep the marker-equipped block, drop the others. If none has the
173
+ // marker (legacy file), keep the FIRST and drop the rest — caller's
174
+ // replaceSection will then upgrade it.
175
+ const keepIdx = blocks.findIndex((b) => b.hasMarker);
176
+ const keep = keepIdx >= 0 ? blocks[keepIdx] : blocks[0];
177
+ // Build the output keeping `keep` in place and removing the others.
178
+ const dropRanges = blocks.filter((b) => b !== keep).map((b) => [b.start, b.end]);
179
+ // Walk lines, skipping any line inside a drop range.
180
+ const out = [];
181
+ let i = 0;
182
+ while (i < lines.length) {
183
+ const inDrop = dropRanges.find(([s, e]) => i >= s && i < e);
184
+ if (inDrop) {
185
+ i = inDrop[1];
186
+ continue;
187
+ }
188
+ out.push(lines[i]);
189
+ i++;
190
+ }
191
+ // Collapse runs of >2 blank lines to exactly one blank.
192
+ const text = out.join("\n").replace(/\n{3,}/g, "\n\n");
193
+ return text;
194
+ }
195
+ // Replace the existing Replen section in place. Anchored on the H2
196
+ // header, terminated by the next H2 (not H3+) or EOF.
197
+ function replaceSection(claudeMd) {
198
+ const lines = claudeMd.split("\n");
199
+ let startIdx = -1;
200
+ let endIdx = lines.length;
201
+ for (let i = 0; i < lines.length; i++) {
202
+ if (lines[i].trim() === SECTION_HEADER) {
203
+ startIdx = i;
204
+ break;
205
+ }
206
+ }
207
+ if (startIdx === -1)
208
+ return claudeMd; // unreachable
209
+ for (let j = startIdx + 1; j < lines.length; j++) {
210
+ if (lines[j].startsWith("## ") && !lines[j].startsWith("### ")) {
211
+ endIdx = j;
212
+ break;
213
+ }
214
+ }
215
+ const before = lines.slice(0, startIdx).join("\n").replace(/\n+$/, "");
216
+ const after = endIdx < lines.length ? "\n\n" + lines.slice(endIdx).join("\n") : "\n";
217
+ return before + "\n\n" + SECTION_HEADER + "\n\n" + SECTION_BODY + after;
218
+ }
219
+ async function promptYes(question) {
220
+ if (!process.stdin.isTTY)
221
+ return true; // non-interactive → assume yes
222
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
223
+ return new Promise((resolve) => {
224
+ rl.question(question, (answer) => {
225
+ rl.close();
226
+ const a = answer.trim().toLowerCase();
227
+ resolve(a === "" || a === "y" || a === "yes");
228
+ });
229
+ });
230
+ }
231
+ export async function injectInstructions(opts = {}) {
232
+ const repos = discoverRepos();
233
+ const outcome = {
234
+ scanned: repos.length,
235
+ created: 0,
236
+ appended: 0,
237
+ alreadyCurrent: 0,
238
+ versionUpdated: 0,
239
+ skipped: [],
240
+ declined: false,
241
+ };
242
+ if (repos.length === 0) {
243
+ console.log(" · no git repos found under ~/github/, ~/code/, or ~/projects/ — skipping CLAUDE.md inject");
244
+ return outcome;
245
+ }
246
+ // First-run consent. Shows the count + an example path so the user
247
+ // knows the blast radius. --yes (or non-TTY) bypasses.
248
+ if (!opts.yes) {
249
+ console.log(`\n Found ${repos.length} git repo(s) under ~/github/, ~/code/, ~/projects/.`);
250
+ console.log(` Append a "## Replen integration" section to each CLAUDE.md so Claude Code`);
251
+ console.log(` surfaces today's matches at session start. Idempotent; edit freely above`);
252
+ console.log(` the section. First 3:`);
253
+ for (const r of repos.slice(0, 3))
254
+ console.log(` • ${r}`);
255
+ if (repos.length > 3)
256
+ console.log(` … and ${repos.length - 3} more`);
257
+ const ok = await promptYes(` Proceed? [Y/n] `);
258
+ if (!ok) {
259
+ outcome.declined = true;
260
+ console.log(` · skipped. Run \`npx replen inject\` later to apply.`);
261
+ return outcome;
262
+ }
263
+ }
264
+ // We write to BOTH CLAUDE.md (Claude Code convention) and AGENTS.md
265
+ // (Codex convention). Same section content; each tool reads its own
266
+ // native file. Idempotent + collapsing applies to both.
267
+ for (const path of repos) {
268
+ for (const fileName of ["CLAUDE.md", "AGENTS.md"]) {
269
+ const filePath = join(path, fileName);
270
+ try {
271
+ const action = applyToClaudeMd(filePath);
272
+ outcome[action]++;
273
+ }
274
+ catch (e) {
275
+ outcome.skipped.push({ path: `${basename(path)}/${fileName}`, reason: e.message ?? String(e) });
276
+ }
277
+ }
278
+ }
279
+ return outcome;
280
+ }
281
+ export function summariseOutcome(o) {
282
+ if (o.declined)
283
+ return ""; // already printed the decline message
284
+ const lines = [];
285
+ const touched = o.created + o.appended + o.versionUpdated;
286
+ if (touched === 0 && o.alreadyCurrent === 0) {
287
+ if (o.scanned > 0)
288
+ lines.push(` · ${o.scanned} repos scanned, none needed Replen inject`);
289
+ return lines.join("\n");
290
+ }
291
+ const parts = [];
292
+ if (o.created > 0)
293
+ parts.push(`${o.created} file${o.created === 1 ? "" : "s"} created`);
294
+ if (o.appended > 0)
295
+ parts.push(`${o.appended} appended`);
296
+ if (o.versionUpdated > 0)
297
+ parts.push(`${o.versionUpdated} updated to v${INTEGRATION_VERSION}`);
298
+ if (o.alreadyCurrent > 0)
299
+ parts.push(`${o.alreadyCurrent} already current`);
300
+ if (parts.length > 0)
301
+ lines.push(` ✓ Replen integration: ${parts.join(", ")}`);
302
+ if (o.skipped.length > 0) {
303
+ lines.push(` · skipped ${o.skipped.length}:`);
304
+ for (const s of o.skipped.slice(0, 5))
305
+ lines.push(` - ${s.path}: ${s.reason}`);
306
+ }
307
+ return lines.join("\n");
308
+ }
package/dist/mcp-setup.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { readFileSync, writeFileSync, existsSync, mkdirSync, renameSync, chmodSync } from "node:fs";
2
2
  import { homedir } from "node:os";
3
3
  import { join, dirname } from "node:path";
4
+ import { installSkills } from "./skill-install.js";
4
5
  // Write the @replen/mcp server entry into Claude Code's config. Uses npx so
5
6
  // the user doesn't need a separate global install; Claude Code will fetch
6
7
  // @replen/mcp on first MCP launch and cache it.
@@ -63,6 +64,24 @@ export async function setupMcp(token, base) {
63
64
  writeJsonAtomic(CONFIG_PATH, { ...config, mcpServers, hooks });
64
65
  console.log(` ✓ ${overwrite ? "Updated" : "Added"} "${SERVER_NAME}" in ${CONFIG_PATH}`);
65
66
  console.log(` ✓ Installed SessionStart hook (surfaces new matches automatically)`);
67
+ // Skill install runs alongside the MCP+hook setup so any Claude Code
68
+ // session can `/replen-match` to trigger in-session triage. Other MCP
69
+ // hosts (Codex, Cursor) don't have a skills concept; they use the
70
+ // replen_match MCP tool description as the equivalent instruction.
71
+ installSkills();
72
+ // Auto-inject the "## Replen integration" section into each local
73
+ // project's CLAUDE.md. This is the adoption-unblock — without it
74
+ // Claude Code (v2.1.141) doesn't reliably auto-surface matches
75
+ // because the SessionStart hook stdout-injection is buggy. Per-project
76
+ // CLAUDE.md instruction is the only working surface today, and we
77
+ // can't expect every user to hand-edit every repo. Idempotent +
78
+ // versioned; safe to re-run.
79
+ console.log("");
80
+ const { injectInstructions, summariseOutcome } = await import("./inject-instruction.js");
81
+ const outcome = await injectInstructions();
82
+ const summary = summariseOutcome(outcome);
83
+ if (summary)
84
+ console.log(summary);
66
85
  }
67
86
  // SessionStart hook: on every Claude Code session, runs `replen check-new
68
87
  // --hook`, which prints a one-block summary to stdout if (and only if)
@@ -0,0 +1,58 @@
1
+ // Install the bundled `replen-match` skill into Claude Code's
2
+ // ~/.claude/skills/ tree so users can invoke it via `/replen-match`
3
+ // (or by saying "use replen / triage today / what's new from replen").
4
+ //
5
+ // Idempotent: re-running setup overwrites the skill in place. The
6
+ // skill is the source of truth in Replen's npm package; users
7
+ // shouldn't be hand-editing the installed copy because it'll be
8
+ // clobbered on next `npx replen mcp setup`.
9
+ import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
10
+ import { homedir } from "node:os";
11
+ import { join, dirname } from "node:path";
12
+ import { fileURLToPath } from "node:url";
13
+ // Resolved at runtime: the published package's bundled extras dir.
14
+ // __dirname-equivalent for ESM. dist/ lives at <package>/dist/, so the
15
+ // extras dir is one level up + "extras".
16
+ const SELF_DIR = dirname(fileURLToPath(import.meta.url));
17
+ const BUNDLED_SKILLS_ROOT = join(SELF_DIR, "..", "extras", "skills");
18
+ // Claude Code reads skills from ~/.claude/skills/<name>/SKILL.md. Other
19
+ // MCP hosts (Codex, Cursor) don't have a skills concept yet; they
20
+ // follow the MCP tool's description instead. The CLAUDE.md instruction
21
+ // shipped by `replen project-init` is the cross-host fallback.
22
+ const CLAUDE_SKILLS_ROOT = join(homedir(), ".claude", "skills");
23
+ const SKILLS = [
24
+ {
25
+ name: "replen-match",
26
+ files: ["replen-match/SKILL.md"],
27
+ },
28
+ ];
29
+ export function installSkills() {
30
+ if (!existsSync(BUNDLED_SKILLS_ROOT)) {
31
+ // Bundle missing — running from a source tree without the
32
+ // extras/ dir, or a corrupt install. Skip rather than crash; the
33
+ // skill is a nice-to-have not a hard requirement (the MCP tool
34
+ // surface covers all hosts).
35
+ console.warn(` · skills bundle not found at ${BUNDLED_SKILLS_ROOT}; skipping skill install`);
36
+ return;
37
+ }
38
+ mkdirSync(CLAUDE_SKILLS_ROOT, { recursive: true });
39
+ let installed = 0;
40
+ for (const skill of SKILLS) {
41
+ for (const rel of skill.files) {
42
+ const src = join(BUNDLED_SKILLS_ROOT, rel);
43
+ const dst = join(CLAUDE_SKILLS_ROOT, rel);
44
+ if (!existsSync(src)) {
45
+ console.warn(` · missing in bundle: ${rel}`);
46
+ continue;
47
+ }
48
+ mkdirSync(dirname(dst), { recursive: true });
49
+ const body = readFileSync(src);
50
+ writeFileSync(dst, body);
51
+ installed++;
52
+ }
53
+ console.log(` ✓ Installed skill: ${skill.name} → ~/.claude/skills/${skill.name}/`);
54
+ }
55
+ if (installed === 0) {
56
+ console.warn(` · no skill files installed (bundle present but empty)`);
57
+ }
58
+ }
@@ -0,0 +1,71 @@
1
+ // Wraps discoverProjects() (local FS walk) + POST /api/projects/bulk
2
+ // (server-side upsert) so it can be invoked both during the initial
3
+ // `npx replen` flow and as a standalone `npx replen sync-projects`
4
+ // command for ongoing use (after cloning new repos).
5
+ import { discoverProjects } from "./discover-projects.js";
6
+ export async function syncDiscoveredProjects({ token, base, }) {
7
+ const projects = discoverProjects();
8
+ if (projects.length === 0) {
9
+ console.log(" · no git repos found under ~/github/, ~/code/, ~/projects/ — skipping project registration");
10
+ return { discovered: 0, created: 0, updated: 0 };
11
+ }
12
+ // Show the user what we found before sending. Three examples is
13
+ // enough context; full list lives in the API call.
14
+ console.log(` ✓ Found ${projects.length} git repo(s) with GitHub remotes:`);
15
+ for (const p of projects.slice(0, 3)) {
16
+ const sampleTags = p.tags.slice(0, 4).join(", ") || "(no auto-tags)";
17
+ console.log(` • ${p.githubFullName} → tags: ${sampleTags}`);
18
+ }
19
+ if (projects.length > 3)
20
+ console.log(` … and ${projects.length - 3} more`);
21
+ const payload = {
22
+ projects: projects.map((p) => ({
23
+ slug: p.slug,
24
+ githubFullName: p.githubFullName,
25
+ name: p.name,
26
+ tags: p.tags,
27
+ primaryLanguage: p.primaryLanguage ?? undefined,
28
+ })),
29
+ };
30
+ let res;
31
+ try {
32
+ res = await fetch(`${base}/api/projects/bulk`, {
33
+ method: "POST",
34
+ headers: {
35
+ "x-digest-token": token,
36
+ "content-type": "application/json",
37
+ },
38
+ body: JSON.stringify(payload),
39
+ });
40
+ }
41
+ catch (e) {
42
+ console.warn(` ✗ Failed to reach ${base}/api/projects/bulk: ${e.message}`);
43
+ console.warn(` Skipping project registration. Run \`npx replen sync-projects\` later to retry.`);
44
+ return { discovered: projects.length, created: 0, updated: 0 };
45
+ }
46
+ if (!res.ok) {
47
+ const text = await res.text().catch(() => "");
48
+ console.warn(` ✗ Project registration failed (HTTP ${res.status}): ${text.slice(0, 200)}`);
49
+ return { discovered: projects.length, created: 0, updated: 0 };
50
+ }
51
+ const body = (await res.json());
52
+ const created = body.created ?? 0;
53
+ const updated = body.updated ?? 0;
54
+ if (created > 0 || updated > 0) {
55
+ const parts = [];
56
+ if (created > 0)
57
+ parts.push(`${created} new`);
58
+ if (updated > 0)
59
+ parts.push(`${updated} updated`);
60
+ console.log(` ✓ Registered with Replen: ${parts.join(", ")}`);
61
+ }
62
+ else {
63
+ console.log(` · All ${projects.length} projects already up to date with Replen`);
64
+ }
65
+ return { discovered: projects.length, created, updated };
66
+ }
67
+ // Returns just the discovered list without sending. Useful for the
68
+ // CLI's `replen list-projects` subcommand (preview mode).
69
+ export function previewDiscovery() {
70
+ return discoverProjects();
71
+ }
@@ -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.0",
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
  ],