ragtime-cli 0.1.0__py3-none-any.whl
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.
Potentially problematic release.
This version of ragtime-cli might be problematic. Click here for more details.
- ragtime_cli-0.1.0.dist-info/METADATA +220 -0
- ragtime_cli-0.1.0.dist-info/RECORD +21 -0
- ragtime_cli-0.1.0.dist-info/WHEEL +5 -0
- ragtime_cli-0.1.0.dist-info/entry_points.txt +3 -0
- ragtime_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
- ragtime_cli-0.1.0.dist-info/top_level.txt +1 -0
- src/__init__.py +0 -0
- src/cli.py +773 -0
- src/commands/audit.md +151 -0
- src/commands/handoff.md +176 -0
- src/commands/pr-graduate.md +187 -0
- src/commands/recall.md +175 -0
- src/commands/remember.md +168 -0
- src/commands/save.md +10 -0
- src/commands/start.md +206 -0
- src/config.py +101 -0
- src/db.py +167 -0
- src/indexers/__init__.py +0 -0
- src/indexers/docs.py +129 -0
- src/mcp_server.py +590 -0
- src/memory.py +379 -0
src/cli.py
ADDED
|
@@ -0,0 +1,773 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Ragtime CLI - semantic search and memory storage.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
import subprocess
|
|
7
|
+
import click
|
|
8
|
+
|
|
9
|
+
from .db import RagtimeDB
|
|
10
|
+
from .config import RagtimeConfig, init_config
|
|
11
|
+
from .indexers.docs import index_directory as index_docs
|
|
12
|
+
from .memory import Memory, MemoryStore
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def get_db(project_path: Path) -> RagtimeDB:
|
|
16
|
+
"""Get or create database for a project."""
|
|
17
|
+
db_path = project_path / ".ragtime" / "index"
|
|
18
|
+
return RagtimeDB(db_path)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def get_memory_store(project_path: Path) -> MemoryStore:
|
|
22
|
+
"""Get memory store for a project."""
|
|
23
|
+
db = get_db(project_path)
|
|
24
|
+
return MemoryStore(project_path, db)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def get_author() -> str:
|
|
28
|
+
"""Get the current developer's username."""
|
|
29
|
+
try:
|
|
30
|
+
# Try gh CLI first
|
|
31
|
+
result = subprocess.run(
|
|
32
|
+
["gh", "api", "user", "--jq", ".login"],
|
|
33
|
+
capture_output=True,
|
|
34
|
+
text=True,
|
|
35
|
+
timeout=5,
|
|
36
|
+
)
|
|
37
|
+
if result.returncode == 0 and result.stdout.strip():
|
|
38
|
+
return result.stdout.strip()
|
|
39
|
+
except (subprocess.TimeoutExpired, FileNotFoundError):
|
|
40
|
+
pass
|
|
41
|
+
|
|
42
|
+
try:
|
|
43
|
+
# Fall back to git config
|
|
44
|
+
result = subprocess.run(
|
|
45
|
+
["git", "config", "user.name"],
|
|
46
|
+
capture_output=True,
|
|
47
|
+
text=True,
|
|
48
|
+
timeout=5,
|
|
49
|
+
)
|
|
50
|
+
if result.returncode == 0 and result.stdout.strip():
|
|
51
|
+
return result.stdout.strip().lower().replace(" ", "-")
|
|
52
|
+
except (subprocess.TimeoutExpired, FileNotFoundError):
|
|
53
|
+
pass
|
|
54
|
+
|
|
55
|
+
return "unknown"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@click.group()
|
|
59
|
+
@click.version_option(version="0.1.0")
|
|
60
|
+
def main():
|
|
61
|
+
"""Ragtime - semantic search over code and documentation."""
|
|
62
|
+
pass
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@main.command()
|
|
66
|
+
@click.argument("path", type=click.Path(exists=True, path_type=Path), default=".")
|
|
67
|
+
def init(path: Path):
|
|
68
|
+
"""Initialize ragtime config for a project."""
|
|
69
|
+
path = path.resolve()
|
|
70
|
+
config = init_config(path)
|
|
71
|
+
click.echo(f"Created .ragtime/config.yaml with defaults:")
|
|
72
|
+
click.echo(f" Docs paths: {config.docs.paths}")
|
|
73
|
+
click.echo(f" Code paths: {config.code.paths}")
|
|
74
|
+
click.echo(f" Languages: {config.code.languages}")
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@main.command()
|
|
78
|
+
@click.argument("path", type=click.Path(exists=True, path_type=Path), default=".")
|
|
79
|
+
@click.option("--type", "index_type", type=click.Choice(["all", "docs", "code"]), default="all")
|
|
80
|
+
@click.option("--clear", is_flag=True, help="Clear existing index before indexing")
|
|
81
|
+
def index(path: Path, index_type: str, clear: bool):
|
|
82
|
+
"""Index a project directory."""
|
|
83
|
+
path = path.resolve()
|
|
84
|
+
db = get_db(path)
|
|
85
|
+
config = RagtimeConfig.load(path)
|
|
86
|
+
|
|
87
|
+
if clear:
|
|
88
|
+
click.echo("Clearing existing index...")
|
|
89
|
+
if index_type == "all":
|
|
90
|
+
db.clear()
|
|
91
|
+
else:
|
|
92
|
+
db.clear(type_filter=index_type)
|
|
93
|
+
|
|
94
|
+
# Index docs
|
|
95
|
+
if index_type in ("all", "docs"):
|
|
96
|
+
total_entries = []
|
|
97
|
+
for docs_path in config.docs.paths:
|
|
98
|
+
docs_root = path / docs_path
|
|
99
|
+
if not docs_root.exists():
|
|
100
|
+
click.echo(f" Docs path {docs_root} not found, skipping...")
|
|
101
|
+
continue
|
|
102
|
+
click.echo(f"Indexing docs in {docs_root}...")
|
|
103
|
+
entries = index_docs(
|
|
104
|
+
docs_root,
|
|
105
|
+
patterns=config.docs.patterns,
|
|
106
|
+
exclude=config.docs.exclude,
|
|
107
|
+
)
|
|
108
|
+
total_entries.extend(entries)
|
|
109
|
+
|
|
110
|
+
if total_entries:
|
|
111
|
+
ids = [e.file_path for e in total_entries]
|
|
112
|
+
documents = [e.content for e in total_entries]
|
|
113
|
+
metadatas = [e.to_metadata() for e in total_entries]
|
|
114
|
+
|
|
115
|
+
db.upsert(ids=ids, documents=documents, metadatas=metadatas)
|
|
116
|
+
click.echo(f" Indexed {len(total_entries)} documents")
|
|
117
|
+
else:
|
|
118
|
+
click.echo(" No documents found")
|
|
119
|
+
|
|
120
|
+
# Index code
|
|
121
|
+
if index_type in ("all", "code"):
|
|
122
|
+
# Build exclude list that includes docs paths
|
|
123
|
+
code_exclude = list(config.code.exclude)
|
|
124
|
+
for docs_path in config.docs.paths:
|
|
125
|
+
code_exclude.append(f"**/{docs_path}/**")
|
|
126
|
+
|
|
127
|
+
click.echo("Code indexing not yet implemented")
|
|
128
|
+
click.echo(f" Will exclude docs paths: {config.docs.paths}")
|
|
129
|
+
# TODO: Implement code indexer with code_exclude
|
|
130
|
+
|
|
131
|
+
# Show stats
|
|
132
|
+
stats = db.stats()
|
|
133
|
+
click.echo(f"\nIndex stats: {stats['total']} total ({stats['docs']} docs, {stats['code']} code)")
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
@main.command()
|
|
137
|
+
@click.argument("query")
|
|
138
|
+
@click.option("--path", type=click.Path(exists=True, path_type=Path), default=".")
|
|
139
|
+
@click.option("--type", "type_filter", type=click.Choice(["all", "docs", "code"]), default="all")
|
|
140
|
+
@click.option("--namespace", "-n", help="Filter by namespace")
|
|
141
|
+
@click.option("--limit", "-l", default=5, help="Max results")
|
|
142
|
+
@click.option("--verbose", "-v", is_flag=True, help="Show full content")
|
|
143
|
+
def search(query: str, path: Path, type_filter: str, namespace: str, limit: int, verbose: bool):
|
|
144
|
+
"""Search indexed content."""
|
|
145
|
+
path = Path(path).resolve()
|
|
146
|
+
db = get_db(path)
|
|
147
|
+
|
|
148
|
+
type_arg = None if type_filter == "all" else type_filter
|
|
149
|
+
|
|
150
|
+
results = db.search(
|
|
151
|
+
query=query,
|
|
152
|
+
limit=limit,
|
|
153
|
+
type_filter=type_arg,
|
|
154
|
+
namespace=namespace,
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
if not results:
|
|
158
|
+
click.echo("No results found.")
|
|
159
|
+
return
|
|
160
|
+
|
|
161
|
+
for i, result in enumerate(results, 1):
|
|
162
|
+
meta = result["metadata"]
|
|
163
|
+
distance = result["distance"]
|
|
164
|
+
score = 1 - distance if distance else None
|
|
165
|
+
|
|
166
|
+
click.echo(f"\n{'─' * 60}")
|
|
167
|
+
click.echo(f"[{i}] {meta.get('file', 'unknown')}")
|
|
168
|
+
click.echo(f" Type: {meta.get('type')} | Namespace: {meta.get('namespace', '-')}")
|
|
169
|
+
if score:
|
|
170
|
+
click.echo(f" Score: {score:.3f}")
|
|
171
|
+
|
|
172
|
+
if verbose:
|
|
173
|
+
click.echo(f"\n{result['content'][:500]}...")
|
|
174
|
+
else:
|
|
175
|
+
# Show first 150 chars
|
|
176
|
+
preview = result["content"][:150].replace("\n", " ")
|
|
177
|
+
click.echo(f" {preview}...")
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
@main.command()
|
|
181
|
+
@click.option("--path", type=click.Path(exists=True, path_type=Path), default=".")
|
|
182
|
+
def stats(path: Path):
|
|
183
|
+
"""Show index statistics."""
|
|
184
|
+
path = Path(path).resolve()
|
|
185
|
+
db = get_db(path)
|
|
186
|
+
|
|
187
|
+
s = db.stats()
|
|
188
|
+
click.echo(f"Total indexed: {s['total']}")
|
|
189
|
+
click.echo(f" Docs: {s['docs']}")
|
|
190
|
+
click.echo(f" Code: {s['code']}")
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
@main.command()
|
|
194
|
+
@click.option("--path", type=click.Path(exists=True, path_type=Path), default=".")
|
|
195
|
+
@click.option("--type", "type_filter", type=click.Choice(["all", "docs", "code"]), default="all")
|
|
196
|
+
@click.confirmation_option(prompt="Are you sure you want to clear the index?")
|
|
197
|
+
def clear(path: Path, type_filter: str):
|
|
198
|
+
"""Clear the index."""
|
|
199
|
+
path = Path(path).resolve()
|
|
200
|
+
db = get_db(path)
|
|
201
|
+
|
|
202
|
+
if type_filter == "all":
|
|
203
|
+
db.clear()
|
|
204
|
+
click.echo("Index cleared.")
|
|
205
|
+
else:
|
|
206
|
+
db.clear(type_filter=type_filter)
|
|
207
|
+
click.echo(f"Cleared {type_filter} from index.")
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
@main.command()
|
|
211
|
+
@click.option("--path", type=click.Path(exists=True, path_type=Path), default=".")
|
|
212
|
+
def config(path: Path):
|
|
213
|
+
"""Show current configuration."""
|
|
214
|
+
path = Path(path).resolve()
|
|
215
|
+
cfg = RagtimeConfig.load(path)
|
|
216
|
+
|
|
217
|
+
click.echo("Docs:")
|
|
218
|
+
click.echo(f" Paths: {cfg.docs.paths}")
|
|
219
|
+
click.echo(f" Patterns: {cfg.docs.patterns}")
|
|
220
|
+
click.echo(f" Exclude: {cfg.docs.exclude}")
|
|
221
|
+
click.echo("\nCode:")
|
|
222
|
+
click.echo(f" Paths: {cfg.code.paths}")
|
|
223
|
+
click.echo(f" Languages: {cfg.code.languages}")
|
|
224
|
+
click.echo(f" Exclude: {cfg.code.exclude}")
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
# ============================================================================
|
|
228
|
+
# Memory Storage Commands
|
|
229
|
+
# ============================================================================
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
@main.command()
|
|
233
|
+
@click.argument("content")
|
|
234
|
+
@click.option("--path", type=click.Path(exists=True, path_type=Path), default=".")
|
|
235
|
+
@click.option("--namespace", "-n", required=True, help="Namespace: app, team, user-{name}, branch-{name}")
|
|
236
|
+
@click.option("--type", "-t", "memory_type", required=True,
|
|
237
|
+
type=click.Choice(["architecture", "feature", "integration", "convention",
|
|
238
|
+
"preference", "decision", "pattern", "task-state", "handoff"]),
|
|
239
|
+
help="Memory type")
|
|
240
|
+
@click.option("--component", "-c", help="Component area (e.g., auth, claims, shifts)")
|
|
241
|
+
@click.option("--confidence", default="medium",
|
|
242
|
+
type=click.Choice(["high", "medium", "low"]),
|
|
243
|
+
help="Confidence level")
|
|
244
|
+
@click.option("--confidence-reason", help="Why this confidence level")
|
|
245
|
+
@click.option("--source", "-s", default="remember", help="Source of this memory")
|
|
246
|
+
@click.option("--issue", help="Related GitHub issue (e.g., #301)")
|
|
247
|
+
@click.option("--epic", help="Parent epic (e.g., #286)")
|
|
248
|
+
@click.option("--branch", help="Related branch name")
|
|
249
|
+
def remember(content: str, path: Path, namespace: str, memory_type: str,
|
|
250
|
+
component: str, confidence: str, confidence_reason: str,
|
|
251
|
+
source: str, issue: str, epic: str, branch: str):
|
|
252
|
+
"""Store a memory with structured metadata.
|
|
253
|
+
|
|
254
|
+
Example:
|
|
255
|
+
ragtime remember "Auth uses JWT with 15-min expiry" \\
|
|
256
|
+
--namespace app --type architecture --component auth
|
|
257
|
+
"""
|
|
258
|
+
path = Path(path).resolve()
|
|
259
|
+
store = get_memory_store(path)
|
|
260
|
+
|
|
261
|
+
memory = Memory(
|
|
262
|
+
content=content,
|
|
263
|
+
namespace=namespace,
|
|
264
|
+
type=memory_type,
|
|
265
|
+
component=component,
|
|
266
|
+
confidence=confidence,
|
|
267
|
+
confidence_reason=confidence_reason,
|
|
268
|
+
source=source,
|
|
269
|
+
author=get_author(),
|
|
270
|
+
issue=issue,
|
|
271
|
+
epic=epic,
|
|
272
|
+
branch=branch,
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
file_path = store.save(memory)
|
|
276
|
+
click.echo(f"✓ Memory saved: {memory.id}")
|
|
277
|
+
click.echo(f" File: {file_path.relative_to(path)}")
|
|
278
|
+
click.echo(f" Namespace: {namespace}")
|
|
279
|
+
click.echo(f" Type: {memory_type}")
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
@main.command("store-doc")
|
|
283
|
+
@click.argument("file", type=click.Path(exists=True, path_type=Path))
|
|
284
|
+
@click.option("--path", type=click.Path(exists=True, path_type=Path), default=".")
|
|
285
|
+
@click.option("--namespace", "-n", required=True, help="Namespace for the document")
|
|
286
|
+
@click.option("--type", "-t", "doc_type", default="handoff",
|
|
287
|
+
type=click.Choice(["handoff", "document", "plan", "notes"]),
|
|
288
|
+
help="Document type")
|
|
289
|
+
def store_doc(file: Path, path: Path, namespace: str, doc_type: str):
|
|
290
|
+
"""Store a document verbatim (like handoff.md).
|
|
291
|
+
|
|
292
|
+
Example:
|
|
293
|
+
ragtime store-doc .claude/handoff.md --namespace branch-feature/auth
|
|
294
|
+
"""
|
|
295
|
+
path = Path(path).resolve()
|
|
296
|
+
file = Path(file).resolve()
|
|
297
|
+
store = get_memory_store(path)
|
|
298
|
+
|
|
299
|
+
memory = store.store_document(file, namespace, doc_type)
|
|
300
|
+
click.echo(f"✓ Document stored: {memory.id}")
|
|
301
|
+
click.echo(f" Source: {file.name}")
|
|
302
|
+
click.echo(f" Namespace: {namespace}")
|
|
303
|
+
click.echo(f" Type: {doc_type}")
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
@main.command()
|
|
307
|
+
@click.argument("memory_id")
|
|
308
|
+
@click.option("--path", type=click.Path(exists=True, path_type=Path), default=".")
|
|
309
|
+
@click.confirmation_option(prompt="Are you sure you want to delete this memory?")
|
|
310
|
+
def forget(memory_id: str, path: Path):
|
|
311
|
+
"""Delete a memory by ID.
|
|
312
|
+
|
|
313
|
+
Example:
|
|
314
|
+
ragtime forget abc123
|
|
315
|
+
"""
|
|
316
|
+
path = Path(path).resolve()
|
|
317
|
+
store = get_memory_store(path)
|
|
318
|
+
|
|
319
|
+
if store.delete(memory_id):
|
|
320
|
+
click.echo(f"✓ Memory {memory_id} deleted")
|
|
321
|
+
else:
|
|
322
|
+
click.echo(f"✗ Memory {memory_id} not found", err=True)
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
@main.command()
|
|
326
|
+
@click.argument("memory_id")
|
|
327
|
+
@click.option("--path", type=click.Path(exists=True, path_type=Path), default=".")
|
|
328
|
+
@click.option("--confidence", default="high",
|
|
329
|
+
type=click.Choice(["high", "medium", "low"]),
|
|
330
|
+
help="Confidence level for graduated memory")
|
|
331
|
+
def graduate(memory_id: str, path: Path, confidence: str):
|
|
332
|
+
"""Graduate a branch memory to app namespace.
|
|
333
|
+
|
|
334
|
+
Copies the memory to app namespace with high confidence
|
|
335
|
+
and marks the original as graduated.
|
|
336
|
+
|
|
337
|
+
Example:
|
|
338
|
+
ragtime graduate abc123
|
|
339
|
+
"""
|
|
340
|
+
path = Path(path).resolve()
|
|
341
|
+
store = get_memory_store(path)
|
|
342
|
+
|
|
343
|
+
try:
|
|
344
|
+
graduated = store.graduate(memory_id, confidence)
|
|
345
|
+
if graduated:
|
|
346
|
+
click.echo(f"✓ Memory graduated to app namespace")
|
|
347
|
+
click.echo(f" New ID: {graduated.id}")
|
|
348
|
+
click.echo(f" Original marked as: graduated")
|
|
349
|
+
else:
|
|
350
|
+
click.echo(f"✗ Memory {memory_id} not found", err=True)
|
|
351
|
+
except ValueError as e:
|
|
352
|
+
click.echo(f"✗ {e}", err=True)
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
@main.command("memories")
|
|
356
|
+
@click.option("--path", type=click.Path(exists=True, path_type=Path), default=".")
|
|
357
|
+
@click.option("--namespace", "-n", help="Filter by namespace (use * suffix for prefix match)")
|
|
358
|
+
@click.option("--type", "-t", "type_filter", help="Filter by type")
|
|
359
|
+
@click.option("--status", "-s", help="Filter by status (active, graduated, abandoned)")
|
|
360
|
+
@click.option("--component", "-c", help="Filter by component")
|
|
361
|
+
@click.option("--limit", "-l", default=20, help="Max results")
|
|
362
|
+
@click.option("--verbose", "-v", is_flag=True, help="Show full content")
|
|
363
|
+
def list_memories(path: Path, namespace: str, type_filter: str, status: str,
|
|
364
|
+
component: str, limit: int, verbose: bool):
|
|
365
|
+
"""List memories with optional filters.
|
|
366
|
+
|
|
367
|
+
Examples:
|
|
368
|
+
ragtime memories --namespace app
|
|
369
|
+
ragtime memories --namespace branch-* --status active
|
|
370
|
+
ragtime memories --type decision --component auth
|
|
371
|
+
"""
|
|
372
|
+
path = Path(path).resolve()
|
|
373
|
+
store = get_memory_store(path)
|
|
374
|
+
|
|
375
|
+
memories = store.list_memories(
|
|
376
|
+
namespace=namespace,
|
|
377
|
+
type_filter=type_filter,
|
|
378
|
+
status=status,
|
|
379
|
+
component=component,
|
|
380
|
+
limit=limit,
|
|
381
|
+
)
|
|
382
|
+
|
|
383
|
+
if not memories:
|
|
384
|
+
click.echo("No memories found.")
|
|
385
|
+
return
|
|
386
|
+
|
|
387
|
+
click.echo(f"Found {len(memories)} memories:\n")
|
|
388
|
+
|
|
389
|
+
for mem in memories:
|
|
390
|
+
click.echo(f"{'─' * 60}")
|
|
391
|
+
click.echo(f"[{mem.id}] {mem.namespace} / {mem.type}")
|
|
392
|
+
if mem.component:
|
|
393
|
+
click.echo(f" Component: {mem.component}")
|
|
394
|
+
click.echo(f" Status: {mem.status} | Confidence: {mem.confidence}")
|
|
395
|
+
click.echo(f" Added: {mem.added} | Source: {mem.source}")
|
|
396
|
+
|
|
397
|
+
if verbose:
|
|
398
|
+
click.echo(f"\n{mem.content[:500]}...")
|
|
399
|
+
else:
|
|
400
|
+
preview = mem.content[:100].replace("\n", " ")
|
|
401
|
+
click.echo(f" {preview}...")
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
@main.command()
|
|
405
|
+
@click.option("--path", type=click.Path(exists=True, path_type=Path), default=".")
|
|
406
|
+
def reindex(path: Path):
|
|
407
|
+
"""Reindex all memory files.
|
|
408
|
+
|
|
409
|
+
Scans .claude/memory/ and adds any files not in the index.
|
|
410
|
+
"""
|
|
411
|
+
path = Path(path).resolve()
|
|
412
|
+
store = get_memory_store(path)
|
|
413
|
+
|
|
414
|
+
count = store.reindex()
|
|
415
|
+
click.echo(f"✓ Reindexed {count} memory files")
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
# ============================================================================
|
|
419
|
+
# Command Installation
|
|
420
|
+
# ============================================================================
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
def get_commands_dir() -> Path:
|
|
424
|
+
"""Get the directory containing bundled command templates."""
|
|
425
|
+
return Path(__file__).parent / "commands"
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
def get_available_commands() -> list[str]:
|
|
429
|
+
"""List available command templates."""
|
|
430
|
+
commands_dir = get_commands_dir()
|
|
431
|
+
if not commands_dir.exists():
|
|
432
|
+
return []
|
|
433
|
+
return [f.stem for f in commands_dir.glob("*.md")]
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
@main.command("install")
|
|
437
|
+
@click.option("--global", "global_install", is_flag=True, help="Install to ~/.claude/commands/")
|
|
438
|
+
@click.option("--workspace", "workspace_install", is_flag=True, help="Install to .claude/commands/")
|
|
439
|
+
@click.option("--list", "list_commands", is_flag=True, help="List available commands")
|
|
440
|
+
@click.option("--force", is_flag=True, help="Overwrite existing commands without asking")
|
|
441
|
+
@click.argument("commands", nargs=-1)
|
|
442
|
+
def install_commands(global_install: bool, workspace_install: bool, list_commands: bool,
|
|
443
|
+
force: bool, commands: tuple):
|
|
444
|
+
"""Install Claude command templates.
|
|
445
|
+
|
|
446
|
+
Examples:
|
|
447
|
+
ragtime install --list # List available commands
|
|
448
|
+
ragtime install --workspace # Install all to .claude/commands/
|
|
449
|
+
ragtime install --global remember recall # Install specific commands globally
|
|
450
|
+
"""
|
|
451
|
+
available = get_available_commands()
|
|
452
|
+
|
|
453
|
+
if list_commands:
|
|
454
|
+
click.echo("Available commands:")
|
|
455
|
+
for cmd in available:
|
|
456
|
+
click.echo(f" - {cmd}")
|
|
457
|
+
return
|
|
458
|
+
|
|
459
|
+
# Determine target directory
|
|
460
|
+
if global_install and workspace_install:
|
|
461
|
+
click.echo("Error: Cannot specify both --global and --workspace", err=True)
|
|
462
|
+
return
|
|
463
|
+
|
|
464
|
+
if global_install:
|
|
465
|
+
target_dir = Path.home() / ".claude" / "commands"
|
|
466
|
+
elif workspace_install:
|
|
467
|
+
target_dir = Path.cwd() / ".claude" / "commands"
|
|
468
|
+
else:
|
|
469
|
+
# Default to workspace
|
|
470
|
+
target_dir = Path.cwd() / ".claude" / "commands"
|
|
471
|
+
click.echo("Installing to workspace (.claude/commands/)")
|
|
472
|
+
click.echo("Use --global for ~/.claude/commands/")
|
|
473
|
+
|
|
474
|
+
# Determine which commands to install
|
|
475
|
+
if commands:
|
|
476
|
+
to_install = [c for c in commands if c in available]
|
|
477
|
+
not_found = [c for c in commands if c not in available]
|
|
478
|
+
if not_found:
|
|
479
|
+
click.echo(f"Warning: Commands not found: {', '.join(not_found)}", err=True)
|
|
480
|
+
else:
|
|
481
|
+
to_install = available
|
|
482
|
+
|
|
483
|
+
if not to_install:
|
|
484
|
+
click.echo("No commands to install.")
|
|
485
|
+
return
|
|
486
|
+
|
|
487
|
+
# Create target directory
|
|
488
|
+
target_dir.mkdir(parents=True, exist_ok=True)
|
|
489
|
+
|
|
490
|
+
# Install each command
|
|
491
|
+
commands_dir = get_commands_dir()
|
|
492
|
+
installed = 0
|
|
493
|
+
skipped = 0
|
|
494
|
+
|
|
495
|
+
for cmd in to_install:
|
|
496
|
+
source = commands_dir / f"{cmd}.md"
|
|
497
|
+
target = target_dir / f"{cmd}.md"
|
|
498
|
+
|
|
499
|
+
if target.exists() and not force:
|
|
500
|
+
if click.confirm(f" {cmd}.md exists. Overwrite?", default=False):
|
|
501
|
+
target.write_text(source.read_text())
|
|
502
|
+
click.echo(f" ✓ {cmd}.md (overwritten)")
|
|
503
|
+
installed += 1
|
|
504
|
+
else:
|
|
505
|
+
click.echo(f" - {cmd}.md (skipped)")
|
|
506
|
+
skipped += 1
|
|
507
|
+
else:
|
|
508
|
+
target.write_text(source.read_text())
|
|
509
|
+
click.echo(f" ✓ {cmd}.md")
|
|
510
|
+
installed += 1
|
|
511
|
+
|
|
512
|
+
click.echo(f"\nInstalled {installed} commands to {target_dir}")
|
|
513
|
+
if skipped:
|
|
514
|
+
click.echo(f"Skipped {skipped} existing commands (use --force to overwrite)")
|
|
515
|
+
|
|
516
|
+
# Remind about MCP server setup
|
|
517
|
+
click.echo("\nTo use these commands, add ragtime MCP server to your Claude config:")
|
|
518
|
+
click.echo(' "ragtime": {"command": "ragtime-mcp", "args": ["--path", "."]}')
|
|
519
|
+
|
|
520
|
+
|
|
521
|
+
# ============================================================================
|
|
522
|
+
# Cross-Branch Sync Commands
|
|
523
|
+
# ============================================================================
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
def get_branch_slug(ref: str) -> str:
|
|
527
|
+
"""Convert a git ref to a branch slug for folder naming."""
|
|
528
|
+
# Remove origin/ prefix if present
|
|
529
|
+
if ref.startswith("origin/"):
|
|
530
|
+
ref = ref[7:]
|
|
531
|
+
# Replace / with - for folder names
|
|
532
|
+
return ref.replace("/", "-")
|
|
533
|
+
|
|
534
|
+
|
|
535
|
+
@main.command()
|
|
536
|
+
@click.argument("ref")
|
|
537
|
+
@click.option("--path", type=click.Path(exists=True, path_type=Path), default=".")
|
|
538
|
+
def sync(ref: str, path: Path):
|
|
539
|
+
"""Sync memories from a remote branch.
|
|
540
|
+
|
|
541
|
+
Fetches .claude/memory/branches/* from the specified git ref
|
|
542
|
+
and copies to a local (unmerged) folder for searching.
|
|
543
|
+
|
|
544
|
+
Examples:
|
|
545
|
+
ragtime sync origin/jm/feature-auth
|
|
546
|
+
ragtime sync origin/sm/fix-bug
|
|
547
|
+
"""
|
|
548
|
+
import shutil
|
|
549
|
+
import tempfile
|
|
550
|
+
|
|
551
|
+
path = Path(path).resolve()
|
|
552
|
+
store = get_memory_store(path)
|
|
553
|
+
|
|
554
|
+
# Get the branch slug for folder naming
|
|
555
|
+
branch_slug = get_branch_slug(ref)
|
|
556
|
+
unmerged_dir = path / ".claude" / "memory" / "branches" / f"{branch_slug}(unmerged)"
|
|
557
|
+
|
|
558
|
+
click.echo(f"Syncing memories from {ref}...")
|
|
559
|
+
|
|
560
|
+
# Fetch the ref first
|
|
561
|
+
result = subprocess.run(
|
|
562
|
+
["git", "fetch", "origin"],
|
|
563
|
+
cwd=path,
|
|
564
|
+
capture_output=True,
|
|
565
|
+
text=True,
|
|
566
|
+
)
|
|
567
|
+
|
|
568
|
+
# Check if the ref exists
|
|
569
|
+
result = subprocess.run(
|
|
570
|
+
["git", "rev-parse", "--verify", ref],
|
|
571
|
+
cwd=path,
|
|
572
|
+
capture_output=True,
|
|
573
|
+
text=True,
|
|
574
|
+
)
|
|
575
|
+
if result.returncode != 0:
|
|
576
|
+
click.echo(f"✗ Ref not found: {ref}", err=True)
|
|
577
|
+
return
|
|
578
|
+
|
|
579
|
+
commit_hash = result.stdout.strip()[:8]
|
|
580
|
+
|
|
581
|
+
# Check if there are memory files in that ref
|
|
582
|
+
result = subprocess.run(
|
|
583
|
+
["git", "ls-tree", "-r", "--name-only", ref, ".claude/memory/branches/"],
|
|
584
|
+
cwd=path,
|
|
585
|
+
capture_output=True,
|
|
586
|
+
text=True,
|
|
587
|
+
)
|
|
588
|
+
|
|
589
|
+
if result.returncode != 0 or not result.stdout.strip():
|
|
590
|
+
click.echo(f"✗ No memories found in {ref}", err=True)
|
|
591
|
+
return
|
|
592
|
+
|
|
593
|
+
files = result.stdout.strip().split("\n")
|
|
594
|
+
click.echo(f" Found {len(files)} memory files")
|
|
595
|
+
|
|
596
|
+
# Clear existing unmerged folder if it exists
|
|
597
|
+
if unmerged_dir.exists():
|
|
598
|
+
shutil.rmtree(unmerged_dir)
|
|
599
|
+
|
|
600
|
+
unmerged_dir.mkdir(parents=True, exist_ok=True)
|
|
601
|
+
|
|
602
|
+
# Extract each file
|
|
603
|
+
synced = 0
|
|
604
|
+
for file_path in files:
|
|
605
|
+
if not file_path.endswith(".md"):
|
|
606
|
+
continue
|
|
607
|
+
|
|
608
|
+
# Get file content from git
|
|
609
|
+
result = subprocess.run(
|
|
610
|
+
["git", "show", f"{ref}:{file_path}"],
|
|
611
|
+
cwd=path,
|
|
612
|
+
capture_output=True,
|
|
613
|
+
text=True,
|
|
614
|
+
)
|
|
615
|
+
|
|
616
|
+
if result.returncode != 0:
|
|
617
|
+
continue
|
|
618
|
+
|
|
619
|
+
content = result.stdout
|
|
620
|
+
|
|
621
|
+
# Determine target path (flatten to unmerged folder)
|
|
622
|
+
# Original: .claude/memory/branches/sm-feature/abc.md
|
|
623
|
+
# Target: .claude/memory/branches/sm-feature(unmerged)/abc.md
|
|
624
|
+
filename = Path(file_path).name
|
|
625
|
+
target_path = unmerged_dir / filename
|
|
626
|
+
|
|
627
|
+
target_path.write_text(content)
|
|
628
|
+
synced += 1
|
|
629
|
+
|
|
630
|
+
# Index with pre-merge status
|
|
631
|
+
try:
|
|
632
|
+
memory = Memory.from_file(target_path)
|
|
633
|
+
memory.status = "pre-merge"
|
|
634
|
+
# Update the namespace to include (unmerged) marker
|
|
635
|
+
memory.namespace = f"branch-{branch_slug}(unmerged)"
|
|
636
|
+
|
|
637
|
+
store.db.upsert(
|
|
638
|
+
ids=[f"{memory.id}-unmerged"],
|
|
639
|
+
documents=[memory.content],
|
|
640
|
+
metadatas=[{
|
|
641
|
+
**memory.to_metadata(),
|
|
642
|
+
"status": "pre-merge",
|
|
643
|
+
"source_ref": ref,
|
|
644
|
+
"source_commit": commit_hash,
|
|
645
|
+
}],
|
|
646
|
+
)
|
|
647
|
+
except Exception as e:
|
|
648
|
+
click.echo(f" Warning: Could not index {filename}: {e}", err=True)
|
|
649
|
+
|
|
650
|
+
# Write source tracking file
|
|
651
|
+
source_file = unmerged_dir / ".source"
|
|
652
|
+
source_file.write_text(f"{ref} @ {commit_hash}\n")
|
|
653
|
+
|
|
654
|
+
click.echo(f"✓ Synced {synced} memories to {unmerged_dir.relative_to(path)}")
|
|
655
|
+
click.echo(f" Source: {ref} @ {commit_hash}")
|
|
656
|
+
click.echo(f"\nSearch with: ragtime search 'query' --namespace 'branch-{branch_slug}(unmerged)'")
|
|
657
|
+
|
|
658
|
+
|
|
659
|
+
@main.command()
|
|
660
|
+
@click.option("--path", type=click.Path(exists=True, path_type=Path), default=".")
|
|
661
|
+
@click.option("--dry-run", is_flag=True, help="Show what would be pruned without deleting")
|
|
662
|
+
def prune(path: Path, dry_run: bool):
|
|
663
|
+
"""Remove stale (unmerged) memory folders.
|
|
664
|
+
|
|
665
|
+
Checks each (unmerged) folder and removes it if:
|
|
666
|
+
- The source branch was deleted
|
|
667
|
+
- The source branch was merged (PR closed)
|
|
668
|
+
|
|
669
|
+
Examples:
|
|
670
|
+
ragtime prune --dry-run # See what would be pruned
|
|
671
|
+
ragtime prune # Actually prune
|
|
672
|
+
"""
|
|
673
|
+
import shutil
|
|
674
|
+
|
|
675
|
+
path = Path(path).resolve()
|
|
676
|
+
branches_dir = path / ".claude" / "memory" / "branches"
|
|
677
|
+
|
|
678
|
+
if not branches_dir.exists():
|
|
679
|
+
click.echo("No branches directory found.")
|
|
680
|
+
return
|
|
681
|
+
|
|
682
|
+
# Find all (unmerged) folders
|
|
683
|
+
unmerged_folders = [d for d in branches_dir.iterdir()
|
|
684
|
+
if d.is_dir() and d.name.endswith("(unmerged)")]
|
|
685
|
+
|
|
686
|
+
if not unmerged_folders:
|
|
687
|
+
click.echo("No (unmerged) folders to prune.")
|
|
688
|
+
return
|
|
689
|
+
|
|
690
|
+
click.echo(f"Checking {len(unmerged_folders)} (unmerged) folders...\n")
|
|
691
|
+
|
|
692
|
+
to_prune = []
|
|
693
|
+
to_keep = []
|
|
694
|
+
|
|
695
|
+
for folder in unmerged_folders:
|
|
696
|
+
source_file = folder / ".source"
|
|
697
|
+
if not source_file.exists():
|
|
698
|
+
# No source tracking - mark for pruning
|
|
699
|
+
to_prune.append((folder, "no source tracking"))
|
|
700
|
+
continue
|
|
701
|
+
|
|
702
|
+
source_info = source_file.read_text().strip()
|
|
703
|
+
ref = source_info.split(" @ ")[0] if " @ " in source_info else source_info
|
|
704
|
+
|
|
705
|
+
# Check if ref still exists
|
|
706
|
+
result = subprocess.run(
|
|
707
|
+
["git", "rev-parse", "--verify", ref],
|
|
708
|
+
cwd=path,
|
|
709
|
+
capture_output=True,
|
|
710
|
+
text=True,
|
|
711
|
+
)
|
|
712
|
+
|
|
713
|
+
if result.returncode != 0:
|
|
714
|
+
# Ref doesn't exist - check if it was merged
|
|
715
|
+
# Extract branch name from ref (e.g., origin/jm/feature -> jm/feature)
|
|
716
|
+
branch_name = ref.replace("origin/", "") if ref.startswith("origin/") else ref
|
|
717
|
+
|
|
718
|
+
# Check if a PR was merged for this branch
|
|
719
|
+
pr_result = subprocess.run(
|
|
720
|
+
["gh", "pr", "list", "--head", branch_name, "--state", "merged", "--json", "number"],
|
|
721
|
+
cwd=path,
|
|
722
|
+
capture_output=True,
|
|
723
|
+
text=True,
|
|
724
|
+
)
|
|
725
|
+
|
|
726
|
+
if pr_result.returncode == 0 and pr_result.stdout.strip() != "[]":
|
|
727
|
+
to_prune.append((folder, f"PR merged for {branch_name}"))
|
|
728
|
+
else:
|
|
729
|
+
to_prune.append((folder, f"branch deleted: {ref}"))
|
|
730
|
+
else:
|
|
731
|
+
to_keep.append((folder, ref))
|
|
732
|
+
|
|
733
|
+
# Report findings
|
|
734
|
+
if to_prune:
|
|
735
|
+
click.echo("Will prune:")
|
|
736
|
+
for folder, reason in to_prune:
|
|
737
|
+
click.echo(f" ✗ {folder.name} ({reason})")
|
|
738
|
+
|
|
739
|
+
if to_keep:
|
|
740
|
+
click.echo("\nKeeping (branch still active):")
|
|
741
|
+
for folder, ref in to_keep:
|
|
742
|
+
click.echo(f" ✓ {folder.name}")
|
|
743
|
+
|
|
744
|
+
if not to_prune:
|
|
745
|
+
click.echo("\nNothing to prune.")
|
|
746
|
+
return
|
|
747
|
+
|
|
748
|
+
# Actually prune if not dry-run
|
|
749
|
+
if dry_run:
|
|
750
|
+
click.echo(f"\n--dry-run: Would prune {len(to_prune)} folders")
|
|
751
|
+
else:
|
|
752
|
+
click.echo("")
|
|
753
|
+
db = get_db(path)
|
|
754
|
+
|
|
755
|
+
for folder, reason in to_prune:
|
|
756
|
+
# Remove from index
|
|
757
|
+
branch_slug = folder.name.replace("(unmerged)", "")
|
|
758
|
+
# Find and delete indexed memories for this folder
|
|
759
|
+
results = db.collection.get(
|
|
760
|
+
where={"namespace": f"branch-{folder.name}"}
|
|
761
|
+
)
|
|
762
|
+
if results["ids"]:
|
|
763
|
+
db.delete(results["ids"])
|
|
764
|
+
|
|
765
|
+
# Remove folder
|
|
766
|
+
shutil.rmtree(folder)
|
|
767
|
+
click.echo(f" Pruned: {folder.name}")
|
|
768
|
+
|
|
769
|
+
click.echo(f"\n✓ Pruned {len(to_prune)} folders")
|
|
770
|
+
|
|
771
|
+
|
|
772
|
+
if __name__ == "__main__":
|
|
773
|
+
main()
|