@antonior/claude-code-setup 1.0.1 → 1.1.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
@@ -18,13 +18,16 @@ That's it. The installer is idempotent — safe to re-run to pull updates.
18
18
 
19
19
  **macOS only.** The hooks and statusline use macOS-native tools (`afplay` for sound, BSD `date -r`, Homebrew for `jq`). On Linux/Windows the installer runs but the sound and statusline-clock features won't work.
20
20
 
21
- ## Prerequisites
21
+ ## Dependencies (auto-installed via Homebrew)
22
22
 
23
- | Tool | Required? | Notes |
24
- |------|-----------|-------|
25
- | `jq` | **Yes** | Hooks parse JSON with it. Auto-installed via Homebrew if missing. |
26
- | `git` | Optional | Commit-scan and verify hooks no-op outside a git repo. |
27
- | `eslint` / `tsc` | Optional | Auto-fix and verify hooks no-op if not in `node_modules`. |
23
+ The installer installs these for you if missing:
24
+
25
+ | Tool | For | Notes |
26
+ |------|-----|-------|
27
+ | `jq` | hooks (JSON parsing) | **Required** install aborts without it. |
28
+ | `python3` | `transcript-search` MCP + video-FPS hook | Best-effort — feature stays dormant if it can't install. |
29
+ | `ffmpeg` | `claude-video-vision` MCP | Best-effort. |
30
+ | `git`, `eslint`, `tsc` | commit-scan / lint / typecheck hooks | Optional — hooks no-op cleanly when absent. |
28
31
 
29
32
  ## What it installs (into `~/.claude/`)
30
33
 
@@ -38,24 +41,28 @@ That's it. The installer is idempotent — safe to re-run to pull updates.
38
41
  - `stop-verify.sh` — lint + typecheck changed files when a turn ends
39
42
  - `notify-sound.sh` — sound on notification/stop
40
43
  - **`commands/`** — `/check-dep`, `/debug`, `/scan-secrets` slash commands
41
- - **`skills/`** — `/mute`, `/unmute`
44
+ - **`skills/`** — `/mute`, `/unmute`, and `/caveman` (with intensity levels)
42
45
  - **`statusline.sh`** — git, model, context %, rate-limit statusline
46
+ - **`transcript-search/rag_lite.py`** — the transcript-search MCP engine
47
+ - **MCP servers** (registered via `claude mcp add-json`, user scope) — see below
48
+
49
+ ## Bundled MCP servers
43
50
 
44
- ## Optional MCP servers
51
+ Both are installed and auto-registered for you:
45
52
 
46
- `CLAUDE.md` references two MCP servers that are **not bundled** (they need their own setup). Without them, the related instructions simply don't fire:
53
+ - **`transcript-search`** full-text search over your *own* past Claude Code transcripts ("we talked about…", "like last time"). Pure-Python, stdlib only. The index is built **locally on your machine** from `~/.claude/projects/` on first run — nothing about your conversations is ever shipped in this package.
54
+ - **`claude-video-vision`** — lets Claude watch/analyse videos (frames via `ffmpeg`). Published as [`claude-video-vision`](https://www.npmjs.com/package/claude-video-vision) on npm; run via `npx`.
47
55
 
48
- - **`transcript-search`** powers the "past conversations are indexed" behaviour
49
- - **`claude-video-vision`** — video analysis
56
+ ## On a new machine two one-time steps
50
57
 
51
- Install these separately if you want the full experience.
58
+ The installer reproduces **100% of the configuration and behaviour**. Two things are *identity and secrets*, not config, and can never live in a public package — you set them once:
52
59
 
53
- ## Notes on the shared config
60
+ 1. **Log into Claude Code** (your account — you'd do this on any new machine anyway).
61
+ 2. **Give `claude-video-vision` your own API key** if you use video analysis.
54
62
 
55
- Two items from my personal setup are intentionally **stripped** from the published version:
63
+ ## Intentionally not shipped
56
64
 
57
- - `skipDangerousModePermissionPrompt` — I won't disable a safety prompt on your machine.
58
- - The video-FPS `UserPromptSubmit` hook — it depends on `python3` and an unbundled MCP server.
65
+ - `skipDangerousModePermissionPrompt` — this disables a safety confirmation. I won't flip that on your machine by default; enable it yourself if you want it.
59
66
 
60
67
  ## License
61
68
 
package/bin/install.js CHANGED
@@ -44,28 +44,79 @@ function makeExecutable(filePath) {
44
44
  fs.chmodSync(filePath, '755');
45
45
  }
46
46
 
47
- function checkJq() {
48
- const r = spawnSync('which', ['jq'], { encoding: 'utf8' });
49
- return r.status === 0;
47
+ // Is a command resolvable on PATH? (uses `command -v` via a shell so it sees
48
+ // the same PATH the user's tools do, not just the bare which builtin.)
49
+ function have(cmd) {
50
+ return spawnSync('bash', ['-lc', `command -v ${cmd}`], { encoding: 'utf8' }).status === 0;
50
51
  }
51
52
 
52
- function installJq() {
53
- const hasBrew = spawnSync('which', ['brew'], { encoding: 'utf8' }).status === 0;
54
- if (!hasBrew) {
55
- fail('jq not found and Homebrew not installed.');
56
- console.log(c.yellow(' Install jq manually: https://stedolan.github.io/jq/download/'));
53
+ function hasBrew() {
54
+ return have('brew');
55
+ }
56
+
57
+ // Install a Homebrew formula. `required:true` → return false on failure so the
58
+ // caller can abort; `required:false` → best-effort, warn and continue (the
59
+ // related feature just stays dormant until the user installs it).
60
+ function brewInstall(pkg, { required = false } = {}) {
61
+ if (have(pkg)) { ok(`${pkg} present`); return true; }
62
+ if (!hasBrew()) {
63
+ const m = `${pkg} not found and Homebrew not installed.`;
64
+ if (required) { fail(m); console.log(c.yellow(` Install Homebrew (https://brew.sh) then re-run, or install ${pkg} manually.`)); return false; }
65
+ warn(`${m} Skipping — install it later for the related feature.`);
57
66
  return false;
58
67
  }
59
- console.log(c.yellow(' Installing jq via Homebrew...'));
60
- const r = spawnSync('brew', ['install', 'jq'], { stdio: 'inherit' });
68
+ console.log(c.yellow(` Installing ${pkg} via Homebrew...`));
69
+ const r = spawnSync('brew', ['install', pkg], { stdio: 'inherit' });
61
70
  if (r.status !== 0) {
62
- fail('brew install jq failed. Install manually.');
71
+ if (required) { fail(`brew install ${pkg} failed. Install manually.`); return false; }
72
+ warn(`brew install ${pkg} failed — continuing without it.`);
63
73
  return false;
64
74
  }
65
- ok('jq installed');
75
+ ok(`${pkg} installed`);
66
76
  return true;
67
77
  }
68
78
 
79
+ // Register the MCP servers via the official `claude` CLI (writes to user scope,
80
+ // the same place they already live). Idempotent: skip any server already
81
+ // registered. SECURITY: env is always {} — never serialize the user's real
82
+ // environment or read ~/.claude.json (it holds the OAuth token).
83
+ function registerMcpServers() {
84
+ if (!have('claude')) {
85
+ warn('`claude` CLI not on PATH — skipping MCP registration.');
86
+ info(' After installing Claude Code, register them manually:');
87
+ info(` claude mcp add-json transcript-search '{"type":"stdio","command":"python3","args":["${path.join(CLAUDE_DIR, 'transcript-search', 'rag_lite.py')}","serve"],"env":{}}' --scope user`);
88
+ info(" claude mcp add claude-video-vision --scope user -- npx claude-video-vision");
89
+ return;
90
+ }
91
+ const existing = spawnSync('bash', ['-lc', 'claude mcp list'], { encoding: 'utf8', timeout: 60000 }).stdout || '';
92
+ const servers = [
93
+ {
94
+ name: 'transcript-search',
95
+ json: JSON.stringify({
96
+ type: 'stdio',
97
+ command: 'python3',
98
+ args: [path.join(CLAUDE_DIR, 'transcript-search', 'rag_lite.py'), 'serve'],
99
+ env: {},
100
+ }),
101
+ },
102
+ {
103
+ name: 'claude-video-vision',
104
+ json: JSON.stringify({
105
+ type: 'stdio',
106
+ command: 'npx',
107
+ args: ['claude-video-vision'],
108
+ env: {},
109
+ }),
110
+ },
111
+ ];
112
+ for (const s of servers) {
113
+ if (existing.includes(s.name)) { info(`MCP ${s.name} already registered — skipped`); continue; }
114
+ const r = spawnSync('claude', ['mcp', 'add-json', s.name, s.json, '--scope', 'user'], { encoding: 'utf8', timeout: 60000 });
115
+ if (r.status === 0) ok(`MCP ${s.name} registered`);
116
+ else warn(`MCP ${s.name} registration failed: ${(r.stderr || '').trim() || 'unknown error'}`);
117
+ }
118
+ }
119
+
69
120
  function mergeSettings(existingPath, incomingPath) {
70
121
  const existing = JSON.parse(fs.readFileSync(existingPath, 'utf8'));
71
122
  const incoming = JSON.parse(fs.readFileSync(incomingPath, 'utf8'));
@@ -104,20 +155,22 @@ function mergeSettings(existingPath, incomingPath) {
104
155
  function main() {
105
156
  console.log(c.bold('\nclaude-code-setup — installing Antonio\'s Claude config\n'));
106
157
 
107
- // 1. Check jq
108
- if (!checkJq()) {
109
- warn('jq not found required by hooks');
110
- const ok2 = installJq();
111
- if (!ok2) process.exit(1);
112
- } else {
113
- ok('jq present');
114
- }
158
+ // 1. Dependencies
159
+ // jq is required (hooks parse JSON with it). python3 powers the
160
+ // transcript-search MCP server; ffmpeg powers claude-video-vision
161
+ // both best-effort (feature stays dormant if absent).
162
+ console.log(c.bold('Dependencies:'));
163
+ if (!brewInstall('jq', { required: true })) process.exit(1);
164
+ brewInstall('python3');
165
+ brewInstall('ffmpeg');
115
166
 
116
167
  // 2. Ensure dirs
117
168
  ensureDir(CLAUDE_DIR);
118
169
  ensureDir(path.join(CLAUDE_DIR, 'hooks'));
119
170
  ensureDir(path.join(CLAUDE_DIR, 'skills'));
171
+ ensureDir(path.join(CLAUDE_DIR, 'skills', 'caveman'));
120
172
  ensureDir(path.join(CLAUDE_DIR, 'commands'));
173
+ ensureDir(path.join(CLAUDE_DIR, 'transcript-search'));
121
174
 
122
175
  // 3. Hooks
123
176
  const hooks = ['caveman-activate.sh', 'check-dep.sh', 'eslint-fix.sh', 'notify-sound.sh', 'scan-secrets.sh', 'stop-verify.sh'];
@@ -127,16 +180,21 @@ function main() {
127
180
  makeExecutable(dest);
128
181
  }
129
182
 
130
- // 4. Skills
183
+ // 4. Skills (mute/unmute + the caveman /caveman skill with intensity levels)
131
184
  for (const s of ['mute.md', 'unmute.md']) {
132
185
  copyFile(path.join(FILES_DIR, 'skills', s), path.join(CLAUDE_DIR, 'skills', s), { skipIfExists: true });
133
186
  }
187
+ copyFile(path.join(FILES_DIR, 'skills', 'caveman', 'SKILL.md'), path.join(CLAUDE_DIR, 'skills', 'caveman', 'SKILL.md'), { skipIfExists: true });
134
188
 
135
189
  // 4b. Commands (slash commands referenced by CLAUDE.md)
136
190
  for (const cmd of ['check-dep.md', 'debug.md', 'scan-secrets.md']) {
137
191
  copyFile(path.join(FILES_DIR, 'commands', cmd), path.join(CLAUDE_DIR, 'commands', cmd), { skipIfExists: true });
138
192
  }
139
193
 
194
+ // 4c. transcript-search engine (the MCP server script — index.db is built
195
+ // per-user from their own ~/.claude/projects and is never shipped)
196
+ copyFile(path.join(FILES_DIR, 'transcript-search', 'rag_lite.py'), path.join(CLAUDE_DIR, 'transcript-search', 'rag_lite.py'));
197
+
140
198
  // 5. Statusline
141
199
  const statuslineDest = path.join(CLAUDE_DIR, 'statusline.sh');
142
200
  copyFile(path.join(FILES_DIR, 'statusline.sh'), statuslineDest);
@@ -168,7 +226,14 @@ function main() {
168
226
  copyFile(path.join(FILES_DIR, 'settings.json'), settingsDest);
169
227
  }
170
228
 
171
- console.log(c.bold(c.green('\nDone. Restart Claude Code to apply.\n')));
229
+ // 8. MCP servers (transcript-search + claude-video-vision)
230
+ console.log(c.bold('\nMCP servers:'));
231
+ registerMcpServers();
232
+
233
+ console.log(c.bold(c.green('\nDone. Restart Claude Code to apply.')));
234
+ console.log(c.dim('Two one-time steps on a new machine (identity, not config — they can\'t ship):'));
235
+ console.log(c.dim(' 1. Log into Claude Code.'));
236
+ console.log(c.dim(' 2. For video analysis, give claude-video-vision your own API key.\n'));
172
237
  }
173
238
 
174
239
  main();
@@ -32,7 +32,10 @@
32
32
  "Bash(sw_vers:*)",
33
33
  "Bash(date)",
34
34
  "Bash(npm view:*)",
35
- "Bash(npm ls:*)"
35
+ "Bash(npm ls:*)",
36
+ "Bash(claude update *)",
37
+ "Bash(python3 -c \"import sys,json; d=json.load\\(sys.stdin\\); print\\(json.dumps\\({k: d.get\\(k\\) for k in ['dependencies','devDependencies']}, indent=2\\)\\)\")",
38
+ "Bash(python3 -c \"import sys,json; d=json.load\\(sys.stdin\\); print\\(json.dumps\\(d.get\\('mcpServers', {}\\), indent=2\\)\\)\")"
36
39
  ]
37
40
  },
38
41
  "model": "sonnet",
@@ -117,6 +120,17 @@
117
120
  ]
118
121
  }
119
122
  ],
123
+ "UserPromptSubmit": [
124
+ {
125
+ "hooks": [
126
+ {
127
+ "type": "command",
128
+ "command": "python3 -c \"\nimport json, sys, re\ndata = json.load(sys.stdin)\nmsg = data.get('message', '')\nis_video = (bool(re.search(r'(?i)(analys[ei]|watch|summariz|process|review|transcript)', msg)) and bool(re.search(r'(?i)(\\\\.(mp4|mov|avi|mkv|webm)|youtube\\\\.com|youtu\\\\.be|video)', msg))) or bool(re.search(r'https?://(www\\\\.)?(youtube\\\\.com/watch|youtu\\\\.be/)', msg))\nhas_fps = bool(re.search(r'(?i)(\\\\bfps\\\\b|\\\\d+\\\\s*fps|frame.{0,2}rate|frames.{0,3}per.{0,3}second)', msg))\nif is_video and not has_fps:\n print(json.dumps({'hookSpecificOutput': {'hookEventName': 'UserPromptSubmit', 'additionalContext': 'REMINDER: User wants to analyse a video but has NOT specified FPS. Before calling any video tools, ask them: How many FPS would you like to use? (default: auto)'}}))\n\"",
129
+ "timeout": 5
130
+ }
131
+ ]
132
+ }
133
+ ],
120
134
  "Stop": [
121
135
  {
122
136
  "hooks": [
@@ -0,0 +1,62 @@
1
+ ---
2
+ name: caveman
3
+ description: >
4
+ Ultra-compressed communication mode. Cuts token usage ~75% by speaking like caveman
5
+ while keeping full technical accuracy. Supports intensity levels: lite, full (default), ultra,
6
+ wenyan-lite, wenyan-full, wenyan-ultra.
7
+ Use when user says "caveman mode", "talk like caveman", "use caveman", "less tokens",
8
+ "be brief", or invokes /caveman. Also auto-triggers when token efficiency is requested.
9
+ ---
10
+
11
+ Respond terse like smart caveman. All technical substance stay. Only fluff die.
12
+
13
+ ## Persistence
14
+
15
+ ACTIVE EVERY RESPONSE. No revert after many turns. No filler drift. Still active if unsure. Off only: "stop caveman" / "normal mode".
16
+
17
+ Default: **full**. Switch: `/caveman lite|full|ultra`.
18
+
19
+ ## Rules
20
+
21
+ Drop: articles (a/an/the), filler (just/really/basically/actually/simply), pleasantries (sure/certainly/of course/happy to), hedging. Fragments OK. Short synonyms (big not extensive, fix not "implement a solution for"). Technical terms exact. Code blocks unchanged. Errors quoted exact.
22
+
23
+ Pattern: `[thing] [action] [reason]. [next step].`
24
+
25
+ Not: "Sure! I'd be happy to help you with that. The issue you're experiencing is likely caused by..."
26
+ Yes: "Bug in auth middleware. Token expiry check use `<` not `<=`. Fix:"
27
+
28
+ ## Intensity
29
+
30
+ | Level | What change |
31
+ |-------|------------|
32
+ | **lite** | No filler/hedging. Keep articles + full sentences. Professional but tight |
33
+ | **full** | Drop articles, fragments OK, short synonyms. Classic caveman |
34
+ | **ultra** | Abbreviate prose words (DB/auth/config/req/res/fn/impl), strip conjunctions, arrows for causality (X → Y), one word when one word enough. Code symbols, function names, API names, error strings: never abbreviate |
35
+ | **wenyan-lite** | Semi-classical. Drop filler/hedging but keep grammar structure, classical register |
36
+ | **wenyan-full** | Maximum classical terseness. Fully 文言文. 80-90% character reduction. Classical sentence patterns, verbs precede objects, subjects often omitted, classical particles (之/乃/為/其) |
37
+ | **wenyan-ultra** | Extreme abbreviation while keeping classical Chinese feel. Maximum compression, ultra terse |
38
+
39
+ Example — "Why React component re-render?"
40
+ - lite: "Your component re-renders because you create a new object reference each render. Wrap it in `useMemo`."
41
+ - full: "New object ref each render. Inline object prop = new ref = re-render. Wrap in `useMemo`."
42
+ - ultra: "Inline obj prop → new ref → re-render. `useMemo`."
43
+
44
+ Example — "Explain database connection pooling."
45
+ - lite: "Connection pooling reuses open connections instead of creating new ones per request. Avoids repeated handshake overhead."
46
+ - full: "Pool reuse open DB connections. No new connection per request. Skip handshake overhead."
47
+ - ultra: "Pool = reuse DB conn. Skip handshake → fast under load."
48
+
49
+ ## Auto-Clarity
50
+
51
+ Drop caveman when:
52
+ - Security warnings
53
+ - Irreversible action confirmations
54
+ - Multi-step sequences where fragment order or omitted conjunctions risk misread
55
+ - Compression itself creates technical ambiguity
56
+ - User asks to clarify or repeats question
57
+
58
+ Resume caveman after clear part done.
59
+
60
+ ## Boundaries
61
+
62
+ Code/commits/PRs: write normal. "stop caveman" or "normal mode": revert. Level persist until changed or session end.
@@ -0,0 +1,335 @@
1
+ #!/usr/bin/env python3
2
+ """Lightweight transcript search for Claude Code.
3
+
4
+ SQLite FTS5 full-text index over past conversation transcripts in
5
+ ~/.claude/projects/**/*.jsonl. No embeddings, no daemons, no extra deps —
6
+ pure Python stdlib. Indexes human prompts + assistant text only (tool output,
7
+ thinking, and system noise are skipped to keep the signal high).
8
+
9
+ Usage:
10
+ python3 rag_lite.py index # build / refresh the index
11
+ python3 rag_lite.py search "query" [n] # search from the CLI
12
+ python3 rag_lite.py stats # row / file counts
13
+ python3 rag_lite.py serve # run as an MCP stdio server
14
+ """
15
+ import os
16
+ import sys
17
+ import json
18
+ import glob
19
+ import sqlite3
20
+ import re
21
+
22
+ HOME = os.path.expanduser("~")
23
+ PROJ_DIR = os.path.join(HOME, ".claude", "projects")
24
+ DB_DIR = os.path.join(HOME, ".claude", "transcript-search")
25
+ DB_PATH = os.path.join(DB_DIR, "index.db")
26
+
27
+
28
+ # ---------------------------------------------------------------- storage
29
+ def db():
30
+ os.makedirs(DB_DIR, exist_ok=True)
31
+ conn = sqlite3.connect(DB_PATH)
32
+ conn.execute("PRAGMA journal_mode=WAL")
33
+ conn.execute(
34
+ "CREATE VIRTUAL TABLE IF NOT EXISTS chunks USING fts5("
35
+ "text, role UNINDEXED, session UNINDEXED, project UNINDEXED, "
36
+ "branch UNINDEXED, ts UNINDEXED, file UNINDEXED, line UNINDEXED, "
37
+ "tokenize='porter unicode61')"
38
+ )
39
+ conn.execute(
40
+ "CREATE TABLE IF NOT EXISTS files("
41
+ "path TEXT PRIMARY KEY, size INTEGER, mtime REAL, lines INTEGER)"
42
+ )
43
+ return conn
44
+
45
+
46
+ # ---------------------------------------------------------------- parsing
47
+ def extract(obj):
48
+ """Return (role, text) for indexable turns, else None.
49
+
50
+ Indexes: user string prompts, assistant text blocks.
51
+ Skips: tool_use / tool_result payloads, thinking, and everything else.
52
+ """
53
+ t = obj.get("type")
54
+ if t not in ("user", "assistant"):
55
+ return None
56
+ m = obj.get("message") or {}
57
+ role = m.get("role") or t
58
+ cont = m.get("content")
59
+ parts = []
60
+ if isinstance(cont, str):
61
+ if role != "user":
62
+ return None
63
+ parts.append(cont)
64
+ elif isinstance(cont, list):
65
+ if role != "assistant":
66
+ return None # list content on a user turn = tool results -> skip
67
+ for b in cont:
68
+ if isinstance(b, dict) and b.get("type") == "text" and b.get("text"):
69
+ parts.append(b["text"])
70
+ text = "\n".join(p for p in parts if p).strip()
71
+ if not text:
72
+ return None
73
+ # Drop local-command / system-reminder wrappers — pure noise for recall.
74
+ if text.startswith("<local-command") or text.startswith("Caveat:"):
75
+ return None
76
+ return role, text[:20000]
77
+
78
+
79
+ # ---------------------------------------------------------------- indexing
80
+ def index_file(conn, path):
81
+ st = os.stat(path)
82
+ row = conn.execute(
83
+ "SELECT size, mtime, lines FROM files WHERE path=?", (path,)
84
+ ).fetchone()
85
+ start = 0
86
+ if row:
87
+ size, mtime, lines = row
88
+ if size == st.st_size and mtime == st.st_mtime:
89
+ return 0 # unchanged
90
+ if st.st_size >= size:
91
+ start = lines # append-only growth: skip already-indexed lines
92
+ else:
93
+ conn.execute("DELETE FROM chunks WHERE file=?", (path,)) # rewritten
94
+ added = 0
95
+ ln = -1
96
+ with open(path, "r", encoding="utf-8", errors="replace") as fh:
97
+ for ln, line in enumerate(fh):
98
+ if ln < start:
99
+ continue
100
+ line = line.strip()
101
+ if not line:
102
+ continue
103
+ try:
104
+ obj = json.loads(line)
105
+ except Exception:
106
+ continue
107
+ ex = extract(obj)
108
+ if not ex:
109
+ continue
110
+ role, text = ex
111
+ cwd = obj.get("cwd") or ""
112
+ project = os.path.basename(cwd) if cwd else os.path.basename(os.path.dirname(path))
113
+ conn.execute(
114
+ "INSERT INTO chunks(text,role,session,project,branch,ts,file,line) "
115
+ "VALUES(?,?,?,?,?,?,?,?)",
116
+ (text, role, obj.get("sessionId") or os.path.basename(path),
117
+ project, obj.get("gitBranch") or "", obj.get("timestamp") or "",
118
+ path, ln),
119
+ )
120
+ added += 1
121
+ conn.execute(
122
+ "INSERT INTO files(path,size,mtime,lines) VALUES(?,?,?,?) "
123
+ "ON CONFLICT(path) DO UPDATE SET size=excluded.size, mtime=excluded.mtime, lines=excluded.lines",
124
+ (path, st.st_size, st.st_mtime, ln + 1),
125
+ )
126
+ return added
127
+
128
+
129
+ def index_all(conn):
130
+ total = 0
131
+ for path in glob.glob(os.path.join(PROJ_DIR, "*", "*.jsonl")):
132
+ try:
133
+ total += index_file(conn, path)
134
+ except Exception:
135
+ pass
136
+ conn.commit()
137
+ return total
138
+
139
+
140
+ # ---------------------------------------------------------------- search
141
+ def fts_query(q):
142
+ toks = re.findall(r"[\w']+", q or "")
143
+ toks = [t for t in toks if len(t) > 1 or t.isdigit()]
144
+ if not toks:
145
+ return None
146
+ return " OR ".join('"%s"' % t.replace('"', '""') for t in toks)
147
+
148
+
149
+ def search(conn, query, limit=8, project=None):
150
+ fq = fts_query(query)
151
+ if not fq:
152
+ return []
153
+ sql = ("SELECT snippet(chunks,0,'»','«',' … ',16), role, session, project, "
154
+ "branch, ts, file, line FROM chunks WHERE chunks MATCH ?")
155
+ args = [fq]
156
+ if project:
157
+ sql += " AND project=?"
158
+ args.append(project)
159
+ sql += " ORDER BY bm25(chunks) LIMIT ?"
160
+ args.append(int(limit))
161
+ return conn.execute(sql, args).fetchall()
162
+
163
+
164
+ def get_context(path, line, before=3, after=3):
165
+ lo, hi = max(0, int(line) - before), int(line) + after
166
+ out = []
167
+ try:
168
+ with open(path, "r", encoding="utf-8", errors="replace") as fh:
169
+ for i, l in enumerate(fh):
170
+ if i < lo:
171
+ continue
172
+ if i > hi:
173
+ break
174
+ try:
175
+ o = json.loads(l)
176
+ except Exception:
177
+ continue
178
+ ex = extract(o)
179
+ if ex:
180
+ out.append((i, ex[0], ex[1][:1800]))
181
+ except OSError:
182
+ pass
183
+ return out
184
+
185
+
186
+ def fmt_results(rows):
187
+ if not rows:
188
+ return "No matches."
189
+ out = []
190
+ for snip, role, _session, project, branch, ts, path, line in rows:
191
+ date = (ts or "")[:10] or "?"
192
+ br = branch or "-"
193
+ snip = re.sub(r"\s+", " ", snip).strip()
194
+ out.append(f"[{project} · {br} · {date} · {role}] {path}:{line}\n {snip}")
195
+ out.append("\nUse get_context(file, line) for the full surrounding turns.")
196
+ return "\n\n".join(out)
197
+
198
+
199
+ def fmt_context(rows):
200
+ if not rows:
201
+ return "No context found at that location."
202
+ return "\n\n".join(f"--- line {i} ({role}) ---\n{text}" for i, role, text in rows)
203
+
204
+
205
+ # ---------------------------------------------------------------- MCP server
206
+ TOOLS = [
207
+ {
208
+ "name": "search",
209
+ "description": (
210
+ "Full-text search over past Claude Code conversation transcripts "
211
+ "(human prompts + assistant replies) for this and other projects. "
212
+ "Use when the user references past work ('we talked about', 'remember "
213
+ "when', 'like last time') or you need a prior decision/context."
214
+ ),
215
+ "inputSchema": {
216
+ "type": "object",
217
+ "properties": {
218
+ "query": {"type": "string", "description": "Search terms."},
219
+ "limit": {"type": "integer", "description": "Max results (default 8)."},
220
+ "project": {"type": "string", "description": "Optional: restrict to a project folder name."},
221
+ },
222
+ "required": ["query"],
223
+ },
224
+ },
225
+ {
226
+ "name": "get_context",
227
+ "description": "Return the surrounding conversation turns around a transcript hit (use the file+line from a search result).",
228
+ "inputSchema": {
229
+ "type": "object",
230
+ "properties": {
231
+ "file": {"type": "string"},
232
+ "line": {"type": "integer"},
233
+ "before": {"type": "integer"},
234
+ "after": {"type": "integer"},
235
+ },
236
+ "required": ["file", "line"],
237
+ },
238
+ },
239
+ ]
240
+
241
+
242
+ def _send(obj):
243
+ sys.stdout.write(json.dumps(obj) + "\n")
244
+ sys.stdout.flush()
245
+
246
+
247
+ def _reply(mid, result):
248
+ _send({"jsonrpc": "2.0", "id": mid, "result": result})
249
+
250
+
251
+ def _error(mid, code, message):
252
+ _send({"jsonrpc": "2.0", "id": mid, "error": {"code": code, "message": message}})
253
+
254
+
255
+ def serve():
256
+ conn = db()
257
+ try:
258
+ index_all(conn)
259
+ except Exception:
260
+ pass
261
+ for raw in sys.stdin:
262
+ raw = raw.strip()
263
+ if not raw:
264
+ continue
265
+ try:
266
+ msg = json.loads(raw)
267
+ except Exception:
268
+ continue
269
+ method = msg.get("method")
270
+ mid = msg.get("id")
271
+ if method == "initialize":
272
+ ver = (msg.get("params") or {}).get("protocolVersion", "2025-06-18")
273
+ _reply(mid, {
274
+ "protocolVersion": ver,
275
+ "capabilities": {"tools": {}},
276
+ "serverInfo": {"name": "transcript-search", "version": "1.0.0"},
277
+ })
278
+ elif method in ("notifications/initialized", "notifications/cancelled"):
279
+ continue # notifications: no response
280
+ elif method == "ping":
281
+ _reply(mid, {})
282
+ elif method == "tools/list":
283
+ _reply(mid, {"tools": TOOLS})
284
+ elif method == "tools/call":
285
+ p = msg.get("params") or {}
286
+ name = p.get("name")
287
+ a = p.get("arguments") or {}
288
+ try:
289
+ if name == "search":
290
+ index_all(conn)
291
+ text = fmt_results(search(conn, a.get("query", ""),
292
+ a.get("limit", 8), a.get("project")))
293
+ elif name == "get_context":
294
+ text = fmt_context(get_context(a.get("file", ""), a.get("line", 0),
295
+ a.get("before", 3), a.get("after", 3)))
296
+ else:
297
+ _error(mid, -32601, "unknown tool: %s" % name)
298
+ continue
299
+ _reply(mid, {"content": [{"type": "text", "text": text}]})
300
+ except Exception as e:
301
+ _reply(mid, {"content": [{"type": "text", "text": "error: %s" % e}], "isError": True})
302
+ elif mid is not None:
303
+ _error(mid, -32601, "method not found: %s" % method)
304
+
305
+
306
+ # ---------------------------------------------------------------- CLI
307
+ def main():
308
+ cmd = sys.argv[1] if len(sys.argv) > 1 else "serve"
309
+ if cmd == "serve":
310
+ serve()
311
+ elif cmd == "index":
312
+ conn = db()
313
+ n = index_all(conn)
314
+ print("indexed %d new turns" % n)
315
+ elif cmd == "stats":
316
+ conn = db()
317
+ index_all(conn)
318
+ rows = conn.execute("SELECT COUNT(*) FROM chunks").fetchone()[0]
319
+ files = conn.execute("SELECT COUNT(*) FROM files").fetchone()[0]
320
+ projs = conn.execute("SELECT project, COUNT(*) FROM chunks GROUP BY project ORDER BY 2 DESC").fetchall()
321
+ print("turns: %d files: %d" % (rows, files))
322
+ for pr, c in projs:
323
+ print(" %5d %s" % (c, pr))
324
+ elif cmd == "search":
325
+ conn = db()
326
+ index_all(conn)
327
+ q = sys.argv[2] if len(sys.argv) > 2 else ""
328
+ n = int(sys.argv[3]) if len(sys.argv) > 3 else 8
329
+ print(fmt_results(search(conn, q, n)))
330
+ else:
331
+ print(__doc__)
332
+
333
+
334
+ if __name__ == "__main__":
335
+ main()
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@antonior/claude-code-setup",
3
- "version": "1.0.1",
4
- "description": "Install Antonio's Claude Code config: hooks, skills, statusline, and settings",
3
+ "version": "1.1.0",
4
+ "description": "Install Antonio's full Claude Code setup: hooks, slash commands, skills, statusline, settings, and MCP servers (transcript-search + video-vision)",
5
5
  "bin": {
6
6
  "claude-code-setup": "bin/install.js"
7
7
  },
@@ -14,7 +14,10 @@
14
14
  "claude-code",
15
15
  "anthropic",
16
16
  "config",
17
- "setup"
17
+ "setup",
18
+ "mcp",
19
+ "hooks",
20
+ "dotfiles"
18
21
  ],
19
22
  "author": "Antonio Radosav <antonio.radosav@protonmail.com>",
20
23
  "license": "MIT",