claude-code-recap 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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 noluyorAbi
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,279 @@
1
+ # recap
2
+
3
+ **See every recent Claude Code session across every project on your machine, and get the exact command to jump back into any of them.**
4
+
5
+ Claude Code's built-in `/resume` only lists sessions for the directory you are standing in. After a reboot, a crash, or a week of jumping between five repos, there is no way to answer "what was I working on, and where did I leave off". `recap` answers that in one command, and can re-open a whole working set in terminal tabs.
6
+
7
+ [![ci](https://img.shields.io/github/actions/workflow/status/noluyorAbi/claude-code-recap/ci.yml?branch=main&style=flat-square&label=ci&color=555)](https://github.com/noluyorAbi/claude-code-recap/actions/workflows/ci.yml)
8
+ [![npm](https://img.shields.io/npm/v/claude-code-recap?style=flat-square&color=555&label=npm)](https://www.npmjs.com/package/claude-code-recap)
9
+ [![license](https://img.shields.io/github/license/noluyorAbi/claude-code-recap?style=flat-square&color=555)](LICENSE)
10
+ [![Claude Code](https://img.shields.io/badge/Claude%20Code-skill%20%2B%20plugin-555?style=flat-square)](https://code.claude.com/docs/en/skills)
11
+
12
+ <a href="assets/demo.mp4"><img src="assets/demo.gif" alt="recap listing recent Claude Code sessions across several projects, then re-opening them in terminal tabs" width="100%"></a>
13
+
14
+ The GIF above is a downsampled loop. The full-quality recording is [`assets/demo.mp4`](assets/demo.mp4).
15
+
16
+ ---
17
+
18
+ ## <img src="assets/icons/clock.svg" width="16" align="center"> What it does
19
+
20
+ `recap` reads the session logs Claude Code already writes to disk and prints a day-grouped timeline of your recent sessions. Per session: last activity, the absolute project path, a short summary, an approximate turn count, the git branch, the model, the session id, and a ready-to-paste `cd <path> && claude -r <id>` resume command.
21
+
22
+ - Every project on the machine, not just the current directory.
23
+ - `--pick` jumps straight into one session.
24
+ - `--open` re-opens all of them, each in its own terminal tab, already resumed.
25
+ - Pure Python 3 standard library. No dependencies, no build step.
26
+ - Offline and read-only by default. The only network path is the opt-in `--smart` flag.
27
+
28
+ ## <img src="assets/icons/download.svg" width="16" align="center"> Install
29
+
30
+ > **npm note.** The plugin marketplace and `curl | sh` paths are live. The `npx claude-code-recap` path goes live once the package is published to npm; until then, use the marketplace, the `curl | sh` one-liner, or a local checkout (`git clone` then `sh install.sh`).
31
+
32
+ ### 1. Plugin marketplace (recommended)
33
+
34
+ The repo is its own Claude Code marketplace. Two lines inside any Claude Code session:
35
+
36
+ ```
37
+ /plugin marketplace add noluyorAbi/claude-code-recap
38
+ /plugin install claude-code-recap@noluyorabi-plugins
39
+ ```
40
+
41
+ Then `/reload-plugins`, and invoke it as:
42
+
43
+ ```
44
+ /claude-code-recap:recap
45
+ ```
46
+
47
+ Plugin skills are namespaced `plugin-name:skill-name`, so this cannot collide with anything else you have installed. Add the marketplace by repo (`owner/repo`), not by a direct URL to `marketplace.json`: a URL-added marketplace downloads only that one file, and the entry's relative source (`./`) would not resolve.
48
+
49
+ ### 2. npx
50
+
51
+ Installs the skill into your Claude Code skills directory (`~/.claude/skills/recap`, or `$CLAUDE_CONFIG_DIR/skills/recap`):
52
+
53
+ ```bash
54
+ npx claude-code-recap
55
+ ```
56
+
57
+ Then invoke it as `/recap`. The package has no postinstall hook; it writes only when you run the command. It records a sha256 manifest of the files it wrote and refuses to clobber a directory it did not install or files you have edited, unless you pass `--force`.
58
+
59
+ ```bash
60
+ npx claude-code-recap --force # overwrite local edits
61
+ npx claude-code-recap --uninstall # remove what it installed
62
+ ```
63
+
64
+ ### 3. curl
65
+
66
+ Same install, no Node:
67
+
68
+ ```bash
69
+ curl -fsSL https://raw.githubusercontent.com/noluyorAbi/claude-code-recap/main/install.sh | sh
70
+ ```
71
+
72
+ Same guarantees as the npx path: sha256 manifest, no silent overwrite, `--force` and `--uninstall` supported.
73
+
74
+ ```bash
75
+ curl -fsSL https://raw.githubusercontent.com/noluyorAbi/claude-code-recap/main/install.sh | sh -s -- --uninstall
76
+ ```
77
+
78
+ Claude Code picks up `~/.claude/skills` without a restart. Restart only if that directory did not exist when the session started.
79
+
80
+ Pick one path. Installing the skill *and* the plugin gives you `/recap` and `/claude-code-recap:recap` side by side; they will not conflict, but you carry two copies of the description in the skill listing.
81
+
82
+ ## <img src="assets/icons/terminal.svg" width="16" align="center"> What you see
83
+
84
+ ```
85
+ ◆ recap 6 sessions · 5 projects 14d ▂▁▄▃▅▂▆█▃▅▇▄▆█
86
+
87
+ ── Today ─────────────────────────────────────────────────────────────────────────────────
88
+
89
+ 1 ● 14:22 12m ago Fix the flaky auth test in the checkout flow a3f91c02
90
+ ~/repos/shop-web · ⎇ fix/flaky-auth · opus-4.8 · 46 turns
91
+ cd ~/repos/shop-web && claude -r a3f91c02-7b4e-4d19-9c2a-5f83e6d1b704
92
+
93
+ 2 ● 11:05 3h ago Rewrite the ingest worker to stream instead of buffer 6d20be14
94
+ ~/repos/pipeline · ⎇ main · sonnet-4.5 · 18 turns
95
+ cd ~/repos/pipeline && claude -r 6d20be14-0a8f-49c7-8b31-1e4d90c7aa52
96
+
97
+ 3 ● 09:41 5h ago Draft the migration plan for the billing schema c17ff5a9
98
+ ~/repos/billing-svc · ⎇ chore/migrate · opus-4.8 · 7 turns
99
+ cd ~/repos/billing-svc && claude -r c17ff5a9-2e6b-4f10-9d55-b0c3e7182f44
100
+
101
+ ── Yesterday ─────────────────────────────────────────────────────────────────────────────
102
+
103
+ 4 ● 22:58 1d ago Debug the flexbox overflow in the sidebar 9b3c0d67
104
+ ~/repos/shop-web · ⎇ fix/flaky-auth · sonnet-4.5 · 31 turns
105
+ cd ~/repos/shop-web && claude -r 9b3c0d67-58a1-4c02-91ff-6ea27d3b8c19
106
+
107
+ 5 ● 17:12 1d ago Set up the release workflow and the version check 40ea8b25
108
+ ~/.claude · opus-4.8 · 12 turns
109
+ cd ~/.claude && claude -r 40ea8b25-c934-4d7e-a06b-72f1958e3dd0
110
+
111
+ ── Fri 10.07 ─────────────────────────────────────────────────────────────────────────────
112
+
113
+ 6 ● 16:30 3d ago Port the CLI to the new argparse layout e58a1c93
114
+ ~/work/internal-tools/cli · ⎇ main · haiku-4.5 · 4 turns
115
+ cd ~/work/internal-tools/cli && claude -r e58a1c93-6f22-4bb8-83e0-9c17d4a52b61
116
+
117
+ turns ≈ user + assistant messages · --pick jump in · --open all in tabs · --smart 1-line summaries
118
+ ```
119
+
120
+ In a real terminal this is colored: a day-grouped timeline (Today green, Yesterday amber), a 14-day activity sparkline in the header, and a stable per-project colored dot so the same repo keeps the same color across runs. Color is disabled automatically when the output is piped, and by `NO_COLOR`. The resume line is indented with spaces only, so a triple-click copies a command that actually runs.
121
+
122
+ ## Flags
123
+
124
+ | Flag | Default | What it does |
125
+ |------|---------|--------------|
126
+ | `--since SPEC` | off | only sessions active within the window: `30m`, `24h`, `7d`, `2w` |
127
+ | `--project SUBSTR` | off | only sessions whose absolute project path contains `SUBSTR` |
128
+ | `--limit N` | `15` | maximum number of sessions listed |
129
+ | `--json` | off | machine-readable output with full session ids and resume commands |
130
+ | `--smart` | off | replace summaries with real one-sentence descriptions, via ONE `claude -p` call (network) |
131
+ | `--pick` | off | pick a row interactively, then `cd` and `claude -r` straight into it |
132
+ | `--open` | off | open every listed session in its own new terminal tab and resume it there (macOS) |
133
+ | `--claude-flags "FLAGS"` | `""` | extra flags for each resumed `claude`, passed through verbatim |
134
+ | `--terminal auto\|iTerm2\|Terminal` | `auto` | which app `--open` drives; `auto` picks iTerm2 when it is installed |
135
+ | `--yes`, `-y` | off | skip the `--open` confirmation prompt; required when stdin is not a TTY |
136
+ | `--dry-run` | off | with `--open`: print the tabs and commands, open nothing |
137
+ | `--color` | auto | force ANSI colors even when the output is piped |
138
+ | `--plain` | off | disable all ANSI colors |
139
+
140
+ Environment: `CLAUDE_CONFIG_DIR` overrides `~/.claude`. `NO_COLOR`, `FORCE_COLOR` and `CLICOLOR_FORCE` are honored. `CLAUDE_CODE_SESSION_ID` is read by `--open` so it never re-opens the session it is running in.
141
+
142
+ Run it directly, without the skill layer:
143
+
144
+ ```bash
145
+ python3 ~/.claude/skills/recap/recap.py --since 7d --project shop-web
146
+ python3 ~/.claude/skills/recap/recap.py --json --limit 50 | jq '.[].projectPath'
147
+ ```
148
+
149
+ An alias, if you want it outside Claude Code:
150
+
151
+ ```bash
152
+ alias recap='python3 ~/.claude/skills/recap/recap.py'
153
+ ```
154
+
155
+ ## <img src="assets/icons/git-branch.svg" width="16" align="center"> Restoring a whole working set with `--open`
156
+
157
+ `--open` takes the sessions currently listed and opens one new terminal tab per session, typing the resume command into each. It is the fastest way back to a five-repo working set after a reboot.
158
+
159
+ ```bash
160
+ # preview first: prints the tabs, opens nothing
161
+ python3 ~/.claude/skills/recap/recap.py --since 24h --limit 8 --open --dry-run
162
+
163
+ # then actually do it
164
+ python3 ~/.claude/skills/recap/recap.py --since 24h --limit 8 --open
165
+ ```
166
+
167
+ Rules it follows:
168
+
169
+ - Scope comes from the normal filters. `--limit`, `--since` and `--project` decide exactly which tabs appear. Preview with `--dry-run` before you commit.
170
+ - The session `recap` itself runs in is skipped automatically, matched on `CLAUDE_CODE_SESSION_ID`. It never re-opens itself.
171
+ - Sessions whose project directory no longer exists are skipped and reported, never opened.
172
+ - Without `--yes` it asks for confirmation. When stdin is not a TTY (which is the case for Claude Code's Bash tool) it refuses to open anything unless `--yes` is passed explicitly.
173
+ - Tab opening drives iTerm2 or Terminal through `osascript`, so it is macOS-only. On other platforms `--open` exits with a clear message, and `--open --dry-run` still prints the commands so you can paste them anywhere.
174
+
175
+ ### The security note on `--claude-flags`
176
+
177
+ `--claude-flags` is passed through verbatim to every resumed `claude`. That includes `--dangerously-skip-permissions`:
178
+
179
+ ```bash
180
+ python3 ~/.claude/skills/recap/recap.py --since 24h --limit 8 \
181
+ --open --yes --claude-flags "--chrome --dangerously-skip-permissions"
182
+ ```
183
+
184
+ Read that command for what it is. It launches N Claude Code sessions, in N different repos, each with **every permission check disabled**, each resuming a conversation you may not remember the contents of. A resumed session carries its old context with it, so whatever it was in the middle of, it can now finish without asking you. That is a real blast radius, multiplied by the number of tabs.
185
+
186
+ `recap` does not stop you. It prints an explicit warning before opening the tabs:
187
+
188
+ ```
189
+ Opening 3 tab(s):
190
+ Fix the flaky auth test in the checkout flow cd /Users/you/repos/shop-web && claude --dangerously-skip-permissions -r a3f91c02-7b4e-4d19-9c2a-5f83e6d1b704
191
+ Rewrite the ingest worker to stream instead o… cd /Users/you/repos/pipeline && claude --dangerously-skip-permissions -r 6d20be14-0a8f-49c7-8b31-1e4d90c7aa52
192
+ Draft the migration plan for the billing sche… cd /Users/you/repos/billing-svc && claude --dangerously-skip-permissions -r c17ff5a9-2e6b-4f10-9d55-b0c3e7182f44
193
+ skipped (path gone): /Users/you/repos/old-spike
194
+
195
+ WARNING: --dangerously-skip-permissions disables all permission checks in every tab opened.
196
+ ```
197
+
198
+ Use it only on repos you fully control, and only when you have looked at the `--dry-run` list first. If you are not sure what a session was doing, resume it without the flag and let it ask.
199
+
200
+ ## <img src="assets/icons/folder.svg" width="16" align="center"> How it works
201
+
202
+ Claude Code already logs your sessions to disk. `recap` is a reader for those logs, nothing more.
203
+
204
+ **Data sources.** Exactly two, both local:
205
+
206
+ | Path | What is read from it |
207
+ |------|----------------------|
208
+ | `~/.claude/history.jsonl` | fast global index: prompt text, timestamp, project path, session id |
209
+ | `~/.claude/projects/<encoded>/<session>.jsonl` | the transcript, parsed only for the sessions about to be displayed: title, git branch, model, turn count, `cwd` |
210
+
211
+ `CLAUDE_CONFIG_DIR` is honored, so a non-standard config directory works.
212
+
213
+ **Ordering.** Candidate transcripts are ranked by file mtime, then re-sorted by the true last timestamp inside the file. Claude Code touches transcripts on compaction and title writes, so mtime alone is only an approximation.
214
+
215
+ **Summaries.** Preference order: Claude Code's own `ai-title` line, then the first real user prompt of the session, then `(no prompt)`. Slash commands, system reminders and tool results are not treated as prompts. `--smart` replaces this with a real one-sentence summary.
216
+
217
+ **Project paths** always come from the `cwd` and `project` fields in the logs, never decoded back from the directory-name encoding, which is lossy.
218
+
219
+ **Turn count** is user prompts plus distinct assistant messages, grouped by message id so streaming chunks are not counted twice. It is labeled approximate because it is.
220
+
221
+ **Robustness.** Broken or partial JSONL lines are skipped, never fatal. `recap` sees only sessions still on disk; Claude Code prunes old ones on its own schedule.
222
+
223
+ ## <img src="assets/icons/shield.svg" width="16" align="center"> Privacy
224
+
225
+ This tool reads your conversation transcripts, so here is the precise claim.
226
+
227
+ - **Reads.** Only `$CLAUDE_CONFIG_DIR/history.jsonl` and `$CLAUDE_CONFIG_DIR/projects/*/*.jsonl` (default `~/.claude`). Nothing else on your filesystem is opened.
228
+ - **Writes.** Nothing. A `recap` run creates, modifies and deletes zero files. It never touches your session data. (The installers are the exception, and they only write into `~/.claude/skills/recap`, tracked by a sha256 manifest.)
229
+ - **Network.** None by default. The default run is fully offline.
230
+ - **The one exception is `--smart`,** which is opt-in and off by default. It shells out to your local `claude` CLI once and sends, for the listed sessions only: the 8-character session id prefix, the session title (first 150 characters), and the first user prompt (first 300 characters). No file contents, no transcript bodies, no other session. If the `claude` CLI is not on your PATH, `--smart` is skipped with a warning and the offline summaries are used.
231
+ - **Telemetry.** None. There is no analytics, no phone-home, no counter, no crash reporter.
232
+ - **Side effects.** `--open` and `--pick` are the only paths that do anything outside stdout. `--open` drives iTerm2 or Terminal via `osascript`; `--pick` `exec`s `claude -r` in the chosen directory. Neither writes to session data.
233
+
234
+ The whole tool is one auditable file: [`skills/recap/recap.py`](skills/recap/recap.py), Python standard library only.
235
+
236
+ ## Requirements
237
+
238
+ - **Claude Code**, with a config directory on disk. `recap` exits with a clear message if `~/.claude/projects` does not exist.
239
+ - **Python 3**, standard library only. No pip packages. CI exercises Python 3.11.
240
+ - **Node 18.17+**, only if you install via `npx`. Not needed at runtime.
241
+ - **macOS**, only for `--open` tab opening (iTerm2 or Terminal, driven by `osascript`). Everything else, including `--open --dry-run`, works on Linux and Windows.
242
+
243
+ ## Uninstall
244
+
245
+ ```bash
246
+ # npx install
247
+ npx claude-code-recap --uninstall
248
+
249
+ # curl install
250
+ curl -fsSL https://raw.githubusercontent.com/noluyorAbi/claude-code-recap/main/install.sh | sh -s -- --uninstall
251
+ ```
252
+
253
+ Both refuse to delete files you have edited since installing, unless you pass `--force`.
254
+
255
+ If you installed it as a plugin instead, open the plugin manager with `/plugin` inside Claude Code and disable or remove `claude-code-recap@noluyorabi-plugins` there.
256
+
257
+ ## Contributing
258
+
259
+ Issues and pull requests are welcome. The local loop:
260
+
261
+ ```bash
262
+ git clone https://github.com/noluyorAbi/claude-code-recap
263
+ cd claude-code-recap
264
+
265
+ sh tests/smoke.sh # end-to-end test against a synthetic config dir, no network
266
+ node scripts/check-version.mjs # package.json, plugin.json and SKILL.md must agree
267
+ claude plugin validate . --strict
268
+ claude --plugin-dir . # load this checkout as a plugin for one session
269
+ ```
270
+
271
+ `tests/smoke.sh` builds a fake `CLAUDE_CONFIG_DIR` and asserts the `--json` output, so it never reads your real sessions. CI runs it on every push, together with a Python syntax check, `shellcheck`, `node --check`, and a full install-update-uninstall cycle for both installers in a scratch directory.
272
+
273
+ Version numbers live in three places (`package.json`, `.claude-plugin/plugin.json`, `skills/recap/SKILL.md` under `metadata.version`) and CI fails if they disagree. Bump them together.
274
+
275
+ Two house rules for anything you submit: no emoji, and no em dashes or en dashes as punctuation.
276
+
277
+ ## License
278
+
279
+ MIT. See [LICENSE](LICENSE).
package/bin/cli.mjs ADDED
@@ -0,0 +1,286 @@
1
+ #!/usr/bin/env node
2
+ // claude-code-recap: install the recap skill into the Claude Code skills directory.
3
+ //
4
+ // npx claude-code-recap install or update
5
+ // npx claude-code-recap --force overwrite local edits
6
+ // npx claude-code-recap --uninstall remove what this tool installed
7
+ //
8
+ // This package never writes anything on `npm install`. There is no postinstall
9
+ // hook. It touches the Claude Code config directory only when you run it.
10
+
11
+ import { createHash } from "node:crypto";
12
+ import fs from "node:fs/promises";
13
+ import os from "node:os";
14
+ import path from "node:path";
15
+ import process from "node:process";
16
+ import { fileURLToPath } from "node:url";
17
+
18
+ const HERE = path.dirname(fileURLToPath(import.meta.url));
19
+ const PKG_ROOT = path.resolve(HERE, "..");
20
+ const SKILL_NAME = "recap";
21
+ const SRC = path.join(PKG_ROOT, "skills", SKILL_NAME);
22
+ const MANIFEST_NAME = ".claude-code-recap-manifest.json";
23
+
24
+ const CONFIG_DIR =
25
+ process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), ".claude");
26
+ const DEST = path.join(CONFIG_DIR, "skills", SKILL_NAME);
27
+ const MANIFEST = path.join(DEST, MANIFEST_NAME);
28
+
29
+ const args = process.argv.slice(2);
30
+ const has = (...names) => names.some((n) => args.includes(n));
31
+
32
+ const FORCE = has("--force", "-f");
33
+ const UNINSTALL = has("--uninstall");
34
+
35
+ const HELP = `claude-code-recap: install the recap skill for Claude Code.
36
+
37
+ Usage:
38
+ npx claude-code-recap [--force]
39
+ npx claude-code-recap --uninstall [--force]
40
+
41
+ Options:
42
+ --force, -f overwrite an existing install, even if it has local edits
43
+ --uninstall remove the files this tool installed
44
+ --version, -v print the package version
45
+ --help, -h print this help
46
+
47
+ Environment:
48
+ CLAUDE_CONFIG_DIR Claude Code config dir (default: ~/.claude)
49
+
50
+ Installs to: ${DEST}
51
+ `;
52
+
53
+ function fail(...lines) {
54
+ for (const line of lines) console.error(line);
55
+ process.exit(1);
56
+ }
57
+
58
+ const sha256 = (buf) => createHash("sha256").update(buf).digest("hex");
59
+
60
+ async function readJson(file) {
61
+ try {
62
+ return JSON.parse(await fs.readFile(file, "utf8"));
63
+ } catch {
64
+ return null;
65
+ }
66
+ }
67
+
68
+ async function exists(p) {
69
+ return fs.access(p).then(
70
+ () => true,
71
+ () => false,
72
+ );
73
+ }
74
+
75
+ async function pkgVersion() {
76
+ const pkg = await readJson(path.join(PKG_ROOT, "package.json"));
77
+ return pkg?.version ?? "unknown";
78
+ }
79
+
80
+ // The Agent Skills spec requires SKILL.md `name` to equal its directory name,
81
+ // so derive the install directory from the frontmatter instead of trusting a
82
+ // second hardcoded copy of the name.
83
+ async function skillFrontmatter() {
84
+ const md = await fs.readFile(path.join(SRC, "SKILL.md"), "utf8");
85
+ const fm = md.match(/^---\r?\n([\s\S]*?)\r?\n---/);
86
+ if (!fm) fail("skills/recap/SKILL.md has no YAML frontmatter.");
87
+ const name = fm[1].match(/^name:\s*(\S+)\s*$/m)?.[1];
88
+ const version = fm[1]
89
+ .match(/^metadata:\r?\n(?:[ \t]+.*\r?\n?)*/m)?.[0]
90
+ ?.match(/^[ \t]+version:\s*["']?([^"'\s]+)["']?/m)?.[1];
91
+ if (!name) fail("skills/recap/SKILL.md is missing a `name` field.");
92
+ if (name !== SKILL_NAME) {
93
+ fail(`skills/recap/SKILL.md declares name '${name}', expected '${SKILL_NAME}'.`);
94
+ }
95
+ return { name, version: version ?? "unknown" };
96
+ }
97
+
98
+ // Build noise that must never reach the user's skills directory.
99
+ const IGNORED_DIRS = new Set(["__pycache__", ".git", "node_modules"]);
100
+ const isIgnoredFile = (name) =>
101
+ name === ".DS_Store" || name.endsWith(".pyc") || name.endsWith(".pyo");
102
+
103
+ // Every shippable file under SRC, as paths relative to SRC.
104
+ async function sourceFiles(dir = SRC, prefix = "") {
105
+ const out = [];
106
+ for (const entry of await fs.readdir(dir, { withFileTypes: true })) {
107
+ const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
108
+ if (entry.isDirectory()) {
109
+ if (IGNORED_DIRS.has(entry.name)) continue;
110
+ out.push(...(await sourceFiles(path.join(dir, entry.name), rel)));
111
+ } else if (entry.isFile() && !isIgnoredFile(entry.name)) {
112
+ out.push(rel);
113
+ }
114
+ }
115
+ return out.sort();
116
+ }
117
+
118
+ async function findEdits(manifest) {
119
+ const edited = [];
120
+ for (const [rel, recorded] of Object.entries(manifest.files ?? {})) {
121
+ try {
122
+ const buf = await fs.readFile(path.join(DEST, rel));
123
+ if (sha256(buf) !== recorded) edited.push(rel);
124
+ } catch {
125
+ // The user deleted it. Not an edit worth protecting.
126
+ }
127
+ }
128
+ return edited;
129
+ }
130
+
131
+ async function removeTracked(manifest) {
132
+ for (const rel of Object.keys(manifest.files ?? {})) {
133
+ await fs.rm(path.join(DEST, rel), { force: true });
134
+ }
135
+ await fs.rm(MANIFEST, { force: true });
136
+ }
137
+
138
+ // Removing tracked files leaves empty subdirectories behind. Left alone they
139
+ // look like user content, so the skill directory would never be cleaned up and
140
+ // the next install would refuse. Prune bottom-up.
141
+ async function pruneEmptyDirs(root) {
142
+ let entries;
143
+ try {
144
+ entries = await fs.readdir(root, { withFileTypes: true });
145
+ } catch {
146
+ return;
147
+ }
148
+ for (const entry of entries) {
149
+ if (entry.isDirectory()) await pruneEmptyDirs(path.join(root, entry.name));
150
+ }
151
+ const left = await fs.readdir(root).catch(() => ["keep"]);
152
+ if (left.length === 0) await fs.rmdir(root).catch(() => {});
153
+ }
154
+
155
+ async function uninstall() {
156
+ if (!(await exists(DEST))) {
157
+ console.log(`recap is not installed (${DEST} does not exist).`);
158
+ return;
159
+ }
160
+ const manifest = await readJson(MANIFEST);
161
+
162
+ if (!manifest) {
163
+ if (!FORCE) {
164
+ fail(
165
+ `${DEST} exists but was not installed by this tool (no manifest).`,
166
+ "Refusing to remove it. Re-run with --force to delete it anyway.",
167
+ );
168
+ }
169
+ await fs.rm(DEST, { recursive: true, force: true });
170
+ console.log(`Removed ${DEST} (forced, no manifest).`);
171
+ return;
172
+ }
173
+
174
+ const edited = await findEdits(manifest);
175
+ if (edited.length && !FORCE) {
176
+ fail(
177
+ "These installed files have local edits:",
178
+ ...edited.map((f) => ` ${f}`),
179
+ "Refusing to delete them. Back them up, then re-run with --force.",
180
+ );
181
+ }
182
+
183
+ await removeTracked(manifest);
184
+ await pruneEmptyDirs(DEST);
185
+
186
+ if (await exists(DEST)) {
187
+ console.log(`Removed the recap skill files from ${DEST}.`);
188
+ console.log("Kept the directory: it still contains files this tool did not install.");
189
+ } else {
190
+ console.log(`Removed ${DEST}.`);
191
+ }
192
+ }
193
+
194
+ async function install() {
195
+ const { version } = await skillFrontmatter();
196
+ const files = await sourceFiles();
197
+ if (!files.includes("SKILL.md") || !files.includes("recap.py")) {
198
+ fail("Package is incomplete: skills/recap must contain SKILL.md and recap.py.");
199
+ }
200
+
201
+ const destExists = await exists(DEST);
202
+ const manifest = await readJson(MANIFEST);
203
+
204
+ // Someone else's skill lives here. Never touch it.
205
+ if (destExists && !manifest && !FORCE) {
206
+ fail(
207
+ `${DEST} already exists and was not installed by this tool.`,
208
+ "Refusing to overwrite it. Move it aside, or re-run with --force.",
209
+ );
210
+ }
211
+
212
+ // Our skill, but the user customized it.
213
+ if (manifest && !FORCE) {
214
+ const edited = await findEdits(manifest);
215
+ if (edited.length) {
216
+ fail(
217
+ "You have local edits to the installed skill:",
218
+ ...edited.map((f) => ` ${f}`),
219
+ "Refusing to clobber them. Back them up, then re-run with --force.",
220
+ );
221
+ }
222
+ }
223
+
224
+ // Drop only what we installed last time, so an update removes files that no
225
+ // longer ship without deleting anything the user added.
226
+ if (manifest) await removeTracked(manifest);
227
+
228
+ const recorded = {};
229
+ for (const rel of files) {
230
+ const from = path.join(SRC, rel);
231
+ const to = path.join(DEST, rel);
232
+ await fs.mkdir(path.dirname(to), { recursive: true });
233
+ const buf = await fs.readFile(from);
234
+ await fs.writeFile(to, buf);
235
+ recorded[rel] = sha256(buf);
236
+ }
237
+ await fs.chmod(path.join(DEST, "recap.py"), 0o755).catch(() => {});
238
+
239
+ await fs.writeFile(
240
+ MANIFEST,
241
+ `${JSON.stringify(
242
+ {
243
+ tool: "claude-code-recap",
244
+ version,
245
+ installedAt: new Date().toISOString(),
246
+ files: recorded,
247
+ },
248
+ null,
249
+ 2,
250
+ )}\n`,
251
+ );
252
+
253
+ console.log(`\nInstalled the recap skill (v${version}).`);
254
+ for (const rel of files) console.log(` ${path.join(DEST, rel)}`);
255
+ console.log(`\nClaude Code picks up ${CONFIG_DIR}/skills without a restart.`);
256
+ console.log("Restart it only if that directory did not exist when the session started.");
257
+ console.log("\nRun it with: /recap");
258
+ console.log(`Or directly: python3 ${path.join(DEST, "recap.py")}`);
259
+ console.log("Uninstall: npx claude-code-recap --uninstall");
260
+ }
261
+
262
+ async function main() {
263
+ if (has("--help", "-h")) {
264
+ console.log(HELP);
265
+ return;
266
+ }
267
+ if (has("--version", "-v")) {
268
+ console.log(await pkgVersion());
269
+ return;
270
+ }
271
+ const known = new Set(["--force", "-f", "--uninstall", "--version", "-v", "--help", "-h"]);
272
+ const unknown = args.filter((a) => !known.has(a));
273
+ if (unknown.length) {
274
+ console.error(`claude-code-recap: unknown option: ${unknown[0]}\n`);
275
+ console.error(HELP);
276
+ process.exit(2);
277
+ }
278
+
279
+ if (UNINSTALL) await uninstall();
280
+ else await install();
281
+ }
282
+
283
+ main().catch((err) => {
284
+ console.error(`claude-code-recap: ${err?.message ?? err}`);
285
+ process.exit(1);
286
+ });