ragtime-cli 0.1.0__py3-none-any.whl → 0.2.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.

@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ragtime-cli
3
- Version: 0.1.0
3
+ Version: 0.2.0
4
4
  Summary: Local-first memory and RAG system for Claude Code - semantic search over code, docs, and team knowledge
5
5
  Author-email: Bret Martineau <bretwardjames@gmail.com>
6
6
  License-Expression: MIT
@@ -1,10 +1,10 @@
1
- ragtime_cli-0.1.0.dist-info/licenses/LICENSE,sha256=9A0wJs2PRDciGRH4F8JUJ-aMKYQyq_gVu2ixrXs-l5A,1070
1
+ ragtime_cli-0.2.0.dist-info/licenses/LICENSE,sha256=9A0wJs2PRDciGRH4F8JUJ-aMKYQyq_gVu2ixrXs-l5A,1070
2
2
  src/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
3
- src/cli.py,sha256=7LOyDfHsC-3glutDrasEq8dsLdUPdfWAMauGYfp9tFI,26372
4
- src/config.py,sha256=_5ev0OkhFKdVaU0T76x-lJUUccn1hB21-dqzV-UleUw,3155
3
+ src/cli.py,sha256=z7h-8E6ADXzJU8yEg0Vbh3lFXR9ZVaQh1NHUZJrw-Ek,36310
4
+ src/config.py,sha256=_zSMnGSO8uFFF8Was_Jtm2m1JDPGhT3Lh8Zz2rcQs98,3232
5
5
  src/db.py,sha256=BKrlhilXYHNaj-ZcffinSXVhdUqowmwpFPBx7aLxamU,4642
6
6
  src/mcp_server.py,sha256=Tx0i73GXO0YmcVrdO7UjRMS0auN8fBG2LOpHuf_LUC0,20374
7
- src/memory.py,sha256=XZCJMuF1jzmbKGUDZ0K1279x9pF9TXoW6wByw1_VP6w,11961
7
+ src/memory.py,sha256=POT2lYeBcEx4_MPbsIdet6ScwcjmuETz8Dxmz-Z_7IY,11939
8
8
  src/commands/audit.md,sha256=Xkucm-gfBIMalK9wf7NBbyejpsqBTUAGGlb7GxMtMPY,5137
9
9
  src/commands/handoff.md,sha256=8VxTddtW08jGTW36GW_rS77JdeSn8vHeMfklrWwVUD4,5055
10
10
  src/commands/pr-graduate.md,sha256=TdqcIwtemrvLbbbUw-mY7hvixjOSh8H_L-63_QsAtpI,6455
@@ -14,8 +14,8 @@ src/commands/save.md,sha256=7gTpW46AU9Y4l8XVZ8f4h1sEdBfVqIRA7hlidUxMAC4,251
14
14
  src/commands/start.md,sha256=qoqhkMgET74DBx8YPIT1-wqCiVBUDxlmevigsCinHSY,6506
15
15
  src/indexers/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
16
16
  src/indexers/docs.py,sha256=7FENHaKSvC1T557bRzvmrjyaG_vK94GuQG9XMZdr89w,3349
17
- ragtime_cli-0.1.0.dist-info/METADATA,sha256=IdKf5KyWm7JabJTCB4344AYbIYTLKKyQc3nYRDIiiM4,5311
18
- ragtime_cli-0.1.0.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
19
- ragtime_cli-0.1.0.dist-info/entry_points.txt,sha256=cWLbeyMxZNbew-THS3bHXTpCRXt1EaUy5QUOXGXLjl4,75
20
- ragtime_cli-0.1.0.dist-info/top_level.txt,sha256=74rtVfumQlgAPzR5_2CgYN24MB0XARCg0t-gzk6gTrM,4
21
- ragtime_cli-0.1.0.dist-info/RECORD,,
17
+ ragtime_cli-0.2.0.dist-info/METADATA,sha256=C8Di3ToJJqt1K3FEzvgfZ-Js2C5qz31T5IXk8oMWFm8,5311
18
+ ragtime_cli-0.2.0.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
19
+ ragtime_cli-0.2.0.dist-info/entry_points.txt,sha256=cWLbeyMxZNbew-THS3bHXTpCRXt1EaUy5QUOXGXLjl4,75
20
+ ragtime_cli-0.2.0.dist-info/top_level.txt,sha256=74rtVfumQlgAPzR5_2CgYN24MB0XARCg0t-gzk6gTrM,4
21
+ ragtime_cli-0.2.0.dist-info/RECORD,,
src/cli.py CHANGED
@@ -5,6 +5,9 @@ Ragtime CLI - semantic search and memory storage.
5
5
  from pathlib import Path
6
6
  import subprocess
7
7
  import click
8
+ import os
9
+ import signal
10
+ import sys
8
11
 
9
12
  from .db import RagtimeDB
10
13
  from .config import RagtimeConfig, init_config
@@ -27,7 +30,6 @@ def get_memory_store(project_path: Path) -> MemoryStore:
27
30
  def get_author() -> str:
28
31
  """Get the current developer's username."""
29
32
  try:
30
- # Try gh CLI first
31
33
  result = subprocess.run(
32
34
  ["gh", "api", "user", "--jq", ".login"],
33
35
  capture_output=True,
@@ -40,7 +42,6 @@ def get_author() -> str:
40
42
  pass
41
43
 
42
44
  try:
43
- # Fall back to git config
44
45
  result = subprocess.run(
45
46
  ["git", "config", "user.name"],
46
47
  capture_output=True,
@@ -55,8 +56,117 @@ def get_author() -> str:
55
56
  return "unknown"
56
57
 
57
58
 
59
+ def check_ghp_installed() -> bool:
60
+ """Check if ghp-cli is installed."""
61
+ try:
62
+ result = subprocess.run(
63
+ ["ghp", "--version"],
64
+ capture_output=True,
65
+ text=True,
66
+ timeout=5,
67
+ )
68
+ return result.returncode == 0
69
+ except (subprocess.TimeoutExpired, FileNotFoundError):
70
+ return False
71
+
72
+
73
+ def get_issue_from_ghp(issue_num: int, path: Path) -> dict | None:
74
+ """Get issue details using ghp issue open."""
75
+ try:
76
+ result = subprocess.run(
77
+ ["ghp", "issue", "open", str(issue_num), "--json"],
78
+ cwd=path,
79
+ capture_output=True,
80
+ text=True,
81
+ timeout=30,
82
+ )
83
+ if result.returncode == 0:
84
+ import json
85
+ return json.loads(result.stdout)
86
+ except (subprocess.TimeoutExpired, FileNotFoundError, Exception):
87
+ pass
88
+ return None
89
+
90
+
91
+ def get_issue_from_gh(issue_num: int, path: Path) -> dict | None:
92
+ """Get issue details using gh CLI."""
93
+ try:
94
+ result = subprocess.run(
95
+ ["gh", "issue", "view", str(issue_num), "--json", "title,body,labels,number"],
96
+ cwd=path,
97
+ capture_output=True,
98
+ text=True,
99
+ timeout=30,
100
+ )
101
+ if result.returncode == 0:
102
+ import json
103
+ return json.loads(result.stdout)
104
+ except (subprocess.TimeoutExpired, FileNotFoundError, Exception):
105
+ pass
106
+ return None
107
+
108
+
109
+ def get_current_branch(path: Path) -> str | None:
110
+ """Get the current git branch name."""
111
+ try:
112
+ result = subprocess.run(
113
+ ["git", "rev-parse", "--abbrev-ref", "HEAD"],
114
+ cwd=path,
115
+ capture_output=True,
116
+ text=True,
117
+ timeout=5,
118
+ )
119
+ if result.returncode == 0:
120
+ return result.stdout.strip()
121
+ except (subprocess.TimeoutExpired, FileNotFoundError):
122
+ pass
123
+ return None
124
+
125
+
126
+ def get_branch_slug(ref: str) -> str:
127
+ """Convert a git ref to a branch slug for folder naming."""
128
+ if ref.startswith("origin/"):
129
+ ref = ref[7:]
130
+ return ref.replace("/", "-")
131
+
132
+
133
+ def get_remote_branches_with_ragtime(path: Path) -> list[str]:
134
+ """Get list of remote branches that have .ragtime/branches/ content."""
135
+ try:
136
+ # Get all remote branches
137
+ result = subprocess.run(
138
+ ["git", "branch", "-r", "--format=%(refname:short)"],
139
+ cwd=path,
140
+ capture_output=True,
141
+ text=True,
142
+ timeout=10,
143
+ )
144
+ if result.returncode != 0:
145
+ return []
146
+
147
+ branches = []
148
+ for ref in result.stdout.strip().split("\n"):
149
+ if not ref or ref.endswith("/HEAD"):
150
+ continue
151
+
152
+ # Check if this branch has ragtime content
153
+ check = subprocess.run(
154
+ ["git", "ls-tree", "-r", "--name-only", ref, ".ragtime/branches/"],
155
+ cwd=path,
156
+ capture_output=True,
157
+ text=True,
158
+ timeout=5,
159
+ )
160
+ if check.returncode == 0 and check.stdout.strip():
161
+ branches.append(ref)
162
+
163
+ return branches
164
+ except Exception:
165
+ return []
166
+
167
+
58
168
  @click.group()
59
- @click.version_option(version="0.1.0")
169
+ @click.version_option(version="0.2.0")
60
170
  def main():
61
171
  """Ragtime - semantic search over code and documentation."""
62
172
  pass
@@ -73,6 +183,43 @@ def init(path: Path):
73
183
  click.echo(f" Code paths: {config.code.paths}")
74
184
  click.echo(f" Languages: {config.code.languages}")
75
185
 
186
+ # Create directory structure
187
+ ragtime_dir = path / ".ragtime"
188
+ (ragtime_dir / "app").mkdir(parents=True, exist_ok=True)
189
+ (ragtime_dir / "team").mkdir(parents=True, exist_ok=True)
190
+ (ragtime_dir / "branches").mkdir(parents=True, exist_ok=True)
191
+ (ragtime_dir / "archive" / "branches").mkdir(parents=True, exist_ok=True)
192
+
193
+ # Create .gitkeep files
194
+ for subdir in ["app", "team", "archive/branches"]:
195
+ gitkeep = ragtime_dir / subdir / ".gitkeep"
196
+ if not gitkeep.exists():
197
+ gitkeep.touch()
198
+
199
+ # Create .gitignore for synced branches (dot-prefixed)
200
+ gitignore_path = ragtime_dir / ".gitignore"
201
+ gitignore_content = """# Synced branches from teammates (dot-prefixed)
202
+ branches/.*
203
+
204
+ # Index database
205
+ index/
206
+ """
207
+ gitignore_path.write_text(gitignore_content)
208
+
209
+ click.echo(f"\nCreated .ragtime/ structure:")
210
+ click.echo(f" app/ - Graduated knowledge (tracked)")
211
+ click.echo(f" team/ - Team conventions (tracked)")
212
+ click.echo(f" branches/ - Active branches (yours tracked, synced gitignored)")
213
+ click.echo(f" archive/ - Completed branches (tracked)")
214
+
215
+ # Check for ghp-cli
216
+ if check_ghp_installed():
217
+ click.echo(f"\n✓ ghp-cli detected")
218
+ click.echo(f" Run 'ragtime setup-ghp' to enable auto-context on 'ghp start'")
219
+ else:
220
+ click.echo(f"\n• ghp-cli not found")
221
+ click.echo(f" Install for enhanced workflow: npm install -g @bretwardjames/ghp-cli")
222
+
76
223
 
77
224
  @main.command()
78
225
  @click.argument("path", type=click.Path(exists=True, path_type=Path), default=".")
@@ -91,7 +238,6 @@ def index(path: Path, index_type: str, clear: bool):
91
238
  else:
92
239
  db.clear(type_filter=index_type)
93
240
 
94
- # Index docs
95
241
  if index_type in ("all", "docs"):
96
242
  total_entries = []
97
243
  for docs_path in config.docs.paths:
@@ -111,24 +257,17 @@ def index(path: Path, index_type: str, clear: bool):
111
257
  ids = [e.file_path for e in total_entries]
112
258
  documents = [e.content for e in total_entries]
113
259
  metadatas = [e.to_metadata() for e in total_entries]
114
-
115
260
  db.upsert(ids=ids, documents=documents, metadatas=metadatas)
116
261
  click.echo(f" Indexed {len(total_entries)} documents")
117
262
  else:
118
263
  click.echo(" No documents found")
119
264
 
120
- # Index code
121
265
  if index_type in ("all", "code"):
122
- # Build exclude list that includes docs paths
123
266
  code_exclude = list(config.code.exclude)
124
267
  for docs_path in config.docs.paths:
125
268
  code_exclude.append(f"**/{docs_path}/**")
126
-
127
269
  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
270
 
131
- # Show stats
132
271
  stats = db.stats()
133
272
  click.echo(f"\nIndex stats: {stats['total']} total ({stats['docs']} docs, {stats['code']} code)")
134
273
 
@@ -138,9 +277,11 @@ def index(path: Path, index_type: str, clear: bool):
138
277
  @click.option("--path", type=click.Path(exists=True, path_type=Path), default=".")
139
278
  @click.option("--type", "type_filter", type=click.Choice(["all", "docs", "code"]), default="all")
140
279
  @click.option("--namespace", "-n", help="Filter by namespace")
280
+ @click.option("--include-archive", is_flag=True, help="Also search archived branches")
141
281
  @click.option("--limit", "-l", default=5, help="Max results")
142
282
  @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):
283
+ def search(query: str, path: Path, type_filter: str, namespace: str,
284
+ include_archive: bool, limit: int, verbose: bool):
144
285
  """Search indexed content."""
145
286
  path = Path(path).resolve()
146
287
  db = get_db(path)
@@ -172,7 +313,6 @@ def search(query: str, path: Path, type_filter: str, namespace: str, limit: int,
172
313
  if verbose:
173
314
  click.echo(f"\n{result['content'][:500]}...")
174
315
  else:
175
- # Show first 150 chars
176
316
  preview = result["content"][:150].replace("\n", " ")
177
317
  click.echo(f" {preview}...")
178
318
 
@@ -287,11 +427,7 @@ def remember(content: str, path: Path, namespace: str, memory_type: str,
287
427
  type=click.Choice(["handoff", "document", "plan", "notes"]),
288
428
  help="Document type")
289
429
  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
- """
430
+ """Store a document verbatim (like handoff.md)."""
295
431
  path = Path(path).resolve()
296
432
  file = Path(file).resolve()
297
433
  store = get_memory_store(path)
@@ -308,11 +444,7 @@ def store_doc(file: Path, path: Path, namespace: str, doc_type: str):
308
444
  @click.option("--path", type=click.Path(exists=True, path_type=Path), default=".")
309
445
  @click.confirmation_option(prompt="Are you sure you want to delete this memory?")
310
446
  def forget(memory_id: str, path: Path):
311
- """Delete a memory by ID.
312
-
313
- Example:
314
- ragtime forget abc123
315
- """
447
+ """Delete a memory by ID."""
316
448
  path = Path(path).resolve()
317
449
  store = get_memory_store(path)
318
450
 
@@ -329,14 +461,7 @@ def forget(memory_id: str, path: Path):
329
461
  type=click.Choice(["high", "medium", "low"]),
330
462
  help="Confidence level for graduated memory")
331
463
  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
- """
464
+ """Graduate a branch memory to app namespace."""
340
465
  path = Path(path).resolve()
341
466
  store = get_memory_store(path)
342
467
 
@@ -362,13 +487,7 @@ def graduate(memory_id: str, path: Path, confidence: str):
362
487
  @click.option("--verbose", "-v", is_flag=True, help="Show full content")
363
488
  def list_memories(path: Path, namespace: str, type_filter: str, status: str,
364
489
  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
- """
490
+ """List memories with optional filters."""
372
491
  path = Path(path).resolve()
373
492
  store = get_memory_store(path)
374
493
 
@@ -404,10 +523,7 @@ def list_memories(path: Path, namespace: str, type_filter: str, status: str,
404
523
  @main.command()
405
524
  @click.option("--path", type=click.Path(exists=True, path_type=Path), default=".")
406
525
  def reindex(path: Path):
407
- """Reindex all memory files.
408
-
409
- Scans .claude/memory/ and adds any files not in the index.
410
- """
526
+ """Reindex all memory files."""
411
527
  path = Path(path).resolve()
412
528
  store = get_memory_store(path)
413
529
 
@@ -415,6 +531,122 @@ def reindex(path: Path):
415
531
  click.echo(f"✓ Reindexed {count} memory files")
416
532
 
417
533
 
534
+ @main.command("new-branch")
535
+ @click.argument("issue", type=int)
536
+ @click.option("--path", type=click.Path(exists=True, path_type=Path), default=".")
537
+ @click.option("--content", help="Context document content (overrides auto-generated scaffold)")
538
+ @click.option("--issue-json", "issue_json", help="Issue data as JSON (from ghp hook, skips fetch)")
539
+ @click.option("--branch", "-b", help="Branch name (auto-detected from git if not provided)")
540
+ def new_branch(issue: int, path: Path, content: str, issue_json: str, branch: str):
541
+ """Initialize a branch context from a GitHub issue.
542
+
543
+ Creates .ragtime/branches/{branch-slug}/context.md with either:
544
+ - Provided content (from --content flag, e.g., LLM-generated plan)
545
+ - Auto-generated scaffold from issue metadata (fallback)
546
+ """
547
+ import json
548
+ from datetime import date
549
+
550
+ path = Path(path).resolve()
551
+
552
+ if not branch:
553
+ branch = get_current_branch(path)
554
+ if not branch or branch in ("main", "master"):
555
+ click.echo("✗ Not on a feature branch. Use --branch to specify.", err=True)
556
+ return
557
+
558
+ # Create branch slug for folder name
559
+ branch_slug = branch.replace("/", "-")
560
+ branch_dir = path / ".ragtime" / "branches" / branch_slug
561
+ branch_dir.mkdir(parents=True, exist_ok=True)
562
+
563
+ context_file = branch_dir / "context.md"
564
+
565
+ if content:
566
+ context_file.write_text(content)
567
+ click.echo(f"✓ Created context.md with provided content")
568
+ click.echo(f" Path: {context_file.relative_to(path)}")
569
+ return
570
+
571
+ # Get issue data
572
+ issue_data = None
573
+ source = None
574
+
575
+ if issue_json:
576
+ try:
577
+ issue_data = json.loads(issue_json)
578
+ source = "ghp-hook"
579
+ except json.JSONDecodeError as e:
580
+ click.echo(f"✗ Invalid JSON: {e}", err=True)
581
+ return
582
+ else:
583
+ click.echo(f"Fetching issue #{issue}...")
584
+ if check_ghp_installed():
585
+ issue_data = get_issue_from_ghp(issue, path)
586
+ source = "ghp"
587
+ if not issue_data:
588
+ issue_data = get_issue_from_gh(issue, path)
589
+ source = "gh"
590
+
591
+ if not issue_data:
592
+ click.echo(f"✗ Could not fetch issue #{issue}", err=True)
593
+ return
594
+
595
+ title = issue_data.get("title", f"Issue #{issue}")
596
+ body = issue_data.get("body", "")
597
+ labels = issue_data.get("labels", [])
598
+
599
+ if labels:
600
+ if isinstance(labels[0], dict):
601
+ label_names = [l.get("name", "") for l in labels]
602
+ else:
603
+ label_names = labels
604
+ labels_str = ", ".join(label_names)
605
+ else:
606
+ labels_str = ""
607
+
608
+ scaffold = f"""---
609
+ type: context
610
+ branch: {branch}
611
+ issue: {issue}
612
+ status: active
613
+ created: '{date.today().isoformat()}'
614
+ author: {get_author()}
615
+ ---
616
+
617
+ ## Issue
618
+
619
+ **#{issue}**: {title}
620
+
621
+ {f"**Labels**: {labels_str}" if labels_str else ""}
622
+
623
+ ## Description
624
+
625
+ {body if body else "_No description provided_"}
626
+
627
+ ## Plan
628
+
629
+ <!-- Implementation steps - fill in or let Claude generate -->
630
+
631
+ - [ ] TODO: Define implementation steps
632
+
633
+ ## Acceptance Criteria
634
+
635
+ <!-- What needs to be true for this to be complete? -->
636
+
637
+ ## Notes
638
+
639
+ <!-- Additional context, decisions, blockers -->
640
+
641
+ """
642
+
643
+ context_file.write_text(scaffold)
644
+
645
+ click.echo(f"✓ Created context.md from issue #{issue}")
646
+ click.echo(f" Path: {context_file.relative_to(path)}")
647
+ click.echo(f" Source: {source}")
648
+
649
+
418
650
  # ============================================================================
419
651
  # Command Installation
420
652
  # ============================================================================
@@ -441,13 +673,7 @@ def get_available_commands() -> list[str]:
441
673
  @click.argument("commands", nargs=-1)
442
674
  def install_commands(global_install: bool, workspace_install: bool, list_commands: bool,
443
675
  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
- """
676
+ """Install Claude command templates."""
451
677
  available = get_available_commands()
452
678
 
453
679
  if list_commands:
@@ -456,7 +682,6 @@ def install_commands(global_install: bool, workspace_install: bool, list_command
456
682
  click.echo(f" - {cmd}")
457
683
  return
458
684
 
459
- # Determine target directory
460
685
  if global_install and workspace_install:
461
686
  click.echo("Error: Cannot specify both --global and --workspace", err=True)
462
687
  return
@@ -466,12 +691,9 @@ def install_commands(global_install: bool, workspace_install: bool, list_command
466
691
  elif workspace_install:
467
692
  target_dir = Path.cwd() / ".claude" / "commands"
468
693
  else:
469
- # Default to workspace
470
694
  target_dir = Path.cwd() / ".claude" / "commands"
471
695
  click.echo("Installing to workspace (.claude/commands/)")
472
- click.echo("Use --global for ~/.claude/commands/")
473
696
 
474
- # Determine which commands to install
475
697
  if commands:
476
698
  to_install = [c for c in commands if c in available]
477
699
  not_found = [c for c in commands if c not in available]
@@ -484,13 +706,9 @@ def install_commands(global_install: bool, workspace_install: bool, list_command
484
706
  click.echo("No commands to install.")
485
707
  return
486
708
 
487
- # Create target directory
488
709
  target_dir.mkdir(parents=True, exist_ok=True)
489
-
490
- # Install each command
491
710
  commands_dir = get_commands_dir()
492
711
  installed = 0
493
- skipped = 0
494
712
 
495
713
  for cmd in to_install:
496
714
  source = commands_dir / f"{cmd}.md"
@@ -499,23 +717,66 @@ def install_commands(global_install: bool, workspace_install: bool, list_command
499
717
  if target.exists() and not force:
500
718
  if click.confirm(f" {cmd}.md exists. Overwrite?", default=False):
501
719
  target.write_text(source.read_text())
502
- click.echo(f" ✓ {cmd}.md (overwritten)")
503
720
  installed += 1
504
- else:
505
- click.echo(f" - {cmd}.md (skipped)")
506
- skipped += 1
507
721
  else:
508
722
  target.write_text(source.read_text())
509
723
  click.echo(f" ✓ {cmd}.md")
510
724
  installed += 1
511
725
 
512
726
  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
727
 
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", "."]}')
728
+
729
+ @main.command("setup-ghp")
730
+ @click.option("--remove", is_flag=True, help="Remove ragtime hooks from ghp")
731
+ def setup_ghp(remove: bool):
732
+ """Register ragtime hooks with ghp-cli."""
733
+ if not check_ghp_installed():
734
+ click.echo("✗ ghp-cli not installed", err=True)
735
+ return
736
+
737
+ hook_name = "ragtime-context"
738
+
739
+ if remove:
740
+ result = subprocess.run(
741
+ ["ghp", "hooks", "remove", hook_name],
742
+ capture_output=True,
743
+ text=True,
744
+ )
745
+ if result.returncode == 0:
746
+ click.echo(f"✓ Removed hook: {hook_name}")
747
+ else:
748
+ click.echo(f"• Hook {hook_name} not registered")
749
+ return
750
+
751
+ result = subprocess.run(
752
+ ["ghp", "hooks", "show", hook_name],
753
+ capture_output=True,
754
+ text=True,
755
+ )
756
+ if result.returncode == 0:
757
+ click.echo(f"• Hook {hook_name} already registered")
758
+ return
759
+
760
+ # Updated path for .ragtime/
761
+ hook_command = "ragtime new-branch ${issue.number} --issue-json '${issue.json}' --branch '${branch}'"
762
+
763
+ result = subprocess.run(
764
+ [
765
+ "ghp", "hooks", "add", hook_name,
766
+ "--event", "issue-started",
767
+ "--command", hook_command,
768
+ "--display-name", "Ragtime Context",
769
+ ],
770
+ capture_output=True,
771
+ text=True,
772
+ )
773
+
774
+ if result.returncode == 0:
775
+ click.echo(f"✓ Registered hook: {hook_name}")
776
+ click.echo(f" Event: issue-started")
777
+ click.echo(f" Action: Creates context.md from issue metadata")
778
+ else:
779
+ click.echo(f"✗ Failed to register hook: {result.stderr}", err=True)
519
780
 
520
781
 
521
782
  # ============================================================================
@@ -523,250 +784,325 @@ def install_commands(global_install: bool, workspace_install: bool, list_command
523
784
  # ============================================================================
524
785
 
525
786
 
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
787
  @main.command()
536
- @click.argument("ref")
537
788
  @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.
789
+ @click.option("--quiet", "-q", is_flag=True, help="Suppress output (for automated runs)")
790
+ @click.option("--auto-prune", is_flag=True, help="Automatically prune stale synced branches")
791
+ def sync(path: Path, quiet: bool, auto_prune: bool):
792
+ """Sync memories from all remote branches.
540
793
 
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
794
+ Fetches .ragtime/branches/* from remote branches and copies to
795
+ local dot-prefixed folders (e.g., .feature-branch/).
547
796
  """
548
797
  import shutil
549
- import tempfile
550
798
 
551
799
  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
800
+ branches_dir = path / ".ragtime" / "branches"
578
801
 
579
- commit_hash = result.stdout.strip()[:8]
802
+ if not quiet:
803
+ click.echo("Fetching remote branches...")
580
804
 
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/"],
805
+ # Fetch first
806
+ subprocess.run(
807
+ ["git", "fetch", "--quiet"],
584
808
  cwd=path,
585
809
  capture_output=True,
586
- text=True,
587
810
  )
588
811
 
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")
812
+ # Get current branch to exclude
813
+ current = get_current_branch(path)
814
+ current_slug = get_branch_slug(current) if current else None
595
815
 
596
- # Clear existing unmerged folder if it exists
597
- if unmerged_dir.exists():
598
- shutil.rmtree(unmerged_dir)
816
+ # Find remote branches with ragtime content
817
+ remote_branches = get_remote_branches_with_ragtime(path)
599
818
 
600
- unmerged_dir.mkdir(parents=True, exist_ok=True)
819
+ if not remote_branches and not quiet:
820
+ click.echo("No remote branches with ragtime content found.")
601
821
 
602
- # Extract each file
603
822
  synced = 0
604
- for file_path in files:
605
- if not file_path.endswith(".md"):
823
+ for ref in remote_branches:
824
+ branch_slug = get_branch_slug(ref)
825
+
826
+ # Skip current branch
827
+ if branch_slug == current_slug:
606
828
  continue
607
829
 
608
- # Get file content from git
830
+ # Synced folders are dot-prefixed
831
+ synced_dir = branches_dir / f".{branch_slug}"
832
+
833
+ # Get files from remote
609
834
  result = subprocess.run(
610
- ["git", "show", f"{ref}:{file_path}"],
835
+ ["git", "ls-tree", "-r", "--name-only", ref, ".ragtime/branches/"],
611
836
  cwd=path,
612
837
  capture_output=True,
613
838
  text=True,
614
839
  )
615
840
 
616
- if result.returncode != 0:
841
+ if result.returncode != 0 or not result.stdout.strip():
617
842
  continue
618
843
 
619
- content = result.stdout
844
+ files = result.stdout.strip().split("\n")
620
845
 
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
846
+ # Clear and recreate synced folder
847
+ if synced_dir.exists():
848
+ shutil.rmtree(synced_dir)
849
+ synced_dir.mkdir(parents=True, exist_ok=True)
626
850
 
627
- target_path.write_text(content)
628
- synced += 1
851
+ # Extract files
852
+ for file_path in files:
853
+ if not file_path.endswith(".md"):
854
+ continue
629
855
 
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
- }],
856
+ content_result = subprocess.run(
857
+ ["git", "show", f"{ref}:{file_path}"],
858
+ cwd=path,
859
+ capture_output=True,
860
+ text=True,
646
861
  )
647
- except Exception as e:
648
- click.echo(f" Warning: Could not index {filename}: {e}", err=True)
649
862
 
650
- # Write source tracking file
651
- source_file = unmerged_dir / ".source"
652
- source_file.write_text(f"{ref} @ {commit_hash}\n")
863
+ if content_result.returncode == 0:
864
+ filename = Path(file_path).name
865
+ (synced_dir / filename).write_text(content_result.stdout)
653
866
 
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)'")
867
+ synced += 1
868
+ if not quiet:
869
+ click.echo(f" Synced .{branch_slug}")
870
+
871
+ # Check for stale synced branches (dot-prefixed with undotted counterpart)
872
+ stale = []
873
+ if branches_dir.exists():
874
+ for folder in branches_dir.iterdir():
875
+ if folder.is_dir() and folder.name.startswith("."):
876
+ undotted = folder.name[1:]
877
+ undotted_path = branches_dir / undotted
878
+ if undotted_path.exists():
879
+ stale.append(folder)
880
+
881
+ if stale:
882
+ if not quiet:
883
+ click.echo(f"\nStale synced branches detected:")
884
+ for folder in stale:
885
+ click.echo(f" • {folder.name} → {folder.name[1:]} exists (merged)")
886
+
887
+ if auto_prune:
888
+ for folder in stale:
889
+ shutil.rmtree(folder)
890
+ if not quiet:
891
+ click.echo(f"\n✓ Pruned {len(stale)} stale branches")
892
+ elif not quiet:
893
+ if click.confirm("\nPrune stale branches?", default=True):
894
+ for folder in stale:
895
+ shutil.rmtree(folder)
896
+ click.echo(f"✓ Pruned {len(stale)} stale branches")
897
+
898
+ if not quiet:
899
+ click.echo(f"\nDone. Synced {synced} branches.")
657
900
 
658
901
 
659
902
  @main.command()
660
903
  @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")
904
+ @click.option("--dry-run", is_flag=True, help="Show what would be pruned")
662
905
  def prune(path: Path, dry_run: bool):
663
- """Remove stale (unmerged) memory folders.
906
+ """Remove stale synced branch folders.
664
907
 
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
908
+ Removes dot-prefixed folders (.branch) when an undotted
909
+ counterpart (branch) exists (indicating the branch was merged).
672
910
  """
673
911
  import shutil
674
912
 
675
913
  path = Path(path).resolve()
676
- branches_dir = path / ".claude" / "memory" / "branches"
914
+ branches_dir = path / ".ragtime" / "branches"
677
915
 
678
916
  if not branches_dir.exists():
679
917
  click.echo("No branches directory found.")
680
918
  return
681
919
 
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)")]
920
+ # Find dot-prefixed folders with undotted counterparts
921
+ to_prune = []
922
+ for folder in branches_dir.iterdir():
923
+ if folder.is_dir() and folder.name.startswith("."):
924
+ undotted = folder.name[1:]
925
+ if (branches_dir / undotted).exists():
926
+ to_prune.append(folder)
685
927
 
686
- if not unmerged_folders:
687
- click.echo("No (unmerged) folders to prune.")
928
+ if not to_prune:
929
+ click.echo("Nothing to prune.")
688
930
  return
689
931
 
690
- click.echo(f"Checking {len(unmerged_folders)} (unmerged) folders...\n")
932
+ click.echo("Will prune:")
933
+ for folder in to_prune:
934
+ click.echo(f" ✗ {folder.name} → {folder.name[1:]} exists")
691
935
 
692
- to_prune = []
693
- to_keep = []
936
+ if dry_run:
937
+ click.echo(f"\n--dry-run: Would prune {len(to_prune)} folders")
938
+ else:
939
+ for folder in to_prune:
940
+ shutil.rmtree(folder)
941
+ click.echo(f" Pruned: {folder.name}")
942
+ click.echo(f"\n✓ Pruned {len(to_prune)} folders")
694
943
 
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
944
 
702
- source_info = source_file.read_text().strip()
703
- ref = source_info.split(" @ ")[0] if " @ " in source_info else source_info
945
+ # ============================================================================
946
+ # Daemon Commands
947
+ # ============================================================================
704
948
 
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
949
 
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
950
+ def get_pid_file(path: Path) -> Path:
951
+ """Get path to daemon PID file."""
952
+ return path / ".ragtime" / "daemon.pid"
953
+
954
+
955
+ def get_log_file(path: Path) -> Path:
956
+ """Get path to daemon log file."""
957
+ return path / ".ragtime" / "daemon.log"
958
+
959
+
960
+ @main.group()
961
+ def daemon():
962
+ """Manage the ragtime sync daemon."""
963
+ pass
964
+
965
+
966
+ @daemon.command("start")
967
+ @click.option("--path", type=click.Path(exists=True, path_type=Path), default=".")
968
+ @click.option("--interval", default="5m", help="Sync interval (e.g., 5m, 1h)")
969
+ def daemon_start(path: Path, interval: str):
970
+ """Start the sync daemon.
971
+
972
+ Runs git fetch && ragtime sync on an interval to keep
973
+ remote branches synced automatically.
974
+ """
975
+ path = Path(path).resolve()
976
+ pid_file = get_pid_file(path)
977
+ log_file = get_log_file(path)
978
+
979
+ # Check if already running
980
+ if pid_file.exists():
981
+ pid = int(pid_file.read_text().strip())
982
+ try:
983
+ os.kill(pid, 0)
984
+ click.echo(f"Daemon already running (PID: {pid})")
985
+ return
986
+ except OSError:
987
+ pid_file.unlink()
988
+
989
+ # Parse interval
990
+ interval_seconds = 300 # default 5m
991
+ if interval.endswith("m"):
992
+ interval_seconds = int(interval[:-1]) * 60
993
+ elif interval.endswith("h"):
994
+ interval_seconds = int(interval[:-1]) * 3600
995
+ elif interval.endswith("s"):
996
+ interval_seconds = int(interval[:-1])
997
+ else:
998
+ try:
999
+ interval_seconds = int(interval)
1000
+ except ValueError:
1001
+ click.echo(f"Invalid interval: {interval}", err=True)
1002
+ return
1003
+
1004
+ # Fork daemon process
1005
+ pid = os.fork()
1006
+ if pid > 0:
1007
+ # Parent process
1008
+ click.echo(f"✓ Daemon started (PID: {pid})")
1009
+ click.echo(f" Interval: {interval}")
1010
+ click.echo(f" Log: {log_file.relative_to(path)}")
1011
+ click.echo(f"\nStop with: ragtime daemon stop")
1012
+ return
1013
+
1014
+ # Child process - become daemon
1015
+ os.setsid()
1016
+
1017
+ # Write PID file
1018
+ pid_file.write_text(str(os.getpid()))
1019
+
1020
+ # Redirect output to log file
1021
+ log_fd = open(log_file, "a")
1022
+ os.dup2(log_fd.fileno(), sys.stdout.fileno())
1023
+ os.dup2(log_fd.fileno(), sys.stderr.fileno())
717
1024
 
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"],
1025
+ import time
1026
+ from datetime import datetime
1027
+
1028
+ print(f"\n[{datetime.now().isoformat()}] Daemon started (interval: {interval})")
1029
+
1030
+ while True:
1031
+ try:
1032
+ print(f"[{datetime.now().isoformat()}] Running sync...")
1033
+
1034
+ # Fetch
1035
+ subprocess.run(
1036
+ ["git", "fetch", "--quiet"],
721
1037
  cwd=path,
722
1038
  capture_output=True,
723
- text=True,
724
1039
  )
725
1040
 
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))
1041
+ # Sync
1042
+ subprocess.run(
1043
+ ["ragtime", "sync", "--quiet", "--auto-prune"],
1044
+ cwd=path,
1045
+ capture_output=True,
1046
+ )
1047
+
1048
+ print(f"[{datetime.now().isoformat()}] Sync complete")
732
1049
 
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})")
1050
+ except Exception as e:
1051
+ print(f"[{datetime.now().isoformat()}] Error: {e}")
738
1052
 
739
- if to_keep:
740
- click.echo("\nKeeping (branch still active):")
741
- for folder, ref in to_keep:
742
- click.echo(f" ✓ {folder.name}")
1053
+ time.sleep(interval_seconds)
743
1054
 
744
- if not to_prune:
745
- click.echo("\nNothing to prune.")
1055
+
1056
+ @daemon.command("stop")
1057
+ @click.option("--path", type=click.Path(exists=True, path_type=Path), default=".")
1058
+ def daemon_stop(path: Path):
1059
+ """Stop the sync daemon."""
1060
+ path = Path(path).resolve()
1061
+ pid_file = get_pid_file(path)
1062
+
1063
+ if not pid_file.exists():
1064
+ click.echo("Daemon not running.")
746
1065
  return
747
1066
 
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"])
1067
+ pid = int(pid_file.read_text().strip())
764
1068
 
765
- # Remove folder
766
- shutil.rmtree(folder)
767
- click.echo(f" Pruned: {folder.name}")
1069
+ try:
1070
+ os.kill(pid, signal.SIGTERM)
1071
+ pid_file.unlink()
1072
+ click.echo(f"✓ Daemon stopped (PID: {pid})")
1073
+ except OSError:
1074
+ pid_file.unlink()
1075
+ click.echo("Daemon was not running (stale PID file removed).")
768
1076
 
769
- click.echo(f"\n✓ Pruned {len(to_prune)} folders")
1077
+
1078
+ @daemon.command("status")
1079
+ @click.option("--path", type=click.Path(exists=True, path_type=Path), default=".")
1080
+ def daemon_status(path: Path):
1081
+ """Check daemon status."""
1082
+ path = Path(path).resolve()
1083
+ pid_file = get_pid_file(path)
1084
+ log_file = get_log_file(path)
1085
+
1086
+ if not pid_file.exists():
1087
+ click.echo("Daemon: not running")
1088
+ return
1089
+
1090
+ pid = int(pid_file.read_text().strip())
1091
+
1092
+ try:
1093
+ os.kill(pid, 0)
1094
+ click.echo(f"Daemon: running (PID: {pid})")
1095
+
1096
+ # Show last few log lines
1097
+ if log_file.exists():
1098
+ lines = log_file.read_text().strip().split("\n")
1099
+ if lines:
1100
+ click.echo(f"\nRecent log:")
1101
+ for line in lines[-5:]:
1102
+ click.echo(f" {line}")
1103
+ except OSError:
1104
+ click.echo("Daemon: not running (stale PID file)")
1105
+ pid_file.unlink()
770
1106
 
771
1107
 
772
1108
  if __name__ == "__main__":
src/config.py CHANGED
@@ -12,12 +12,13 @@ import yaml
12
12
  @dataclass
13
13
  class DocsConfig:
14
14
  """Configuration for docs indexing."""
15
- paths: list[str] = field(default_factory=lambda: ["docs", ".claude/memory"])
15
+ paths: list[str] = field(default_factory=lambda: ["docs", ".ragtime"])
16
16
  patterns: list[str] = field(default_factory=lambda: ["**/*.md"])
17
17
  exclude: list[str] = field(default_factory=lambda: [
18
18
  "**/node_modules/**",
19
19
  "**/.git/**",
20
- "**/.ragtime/**",
20
+ "**/.ragtime/index/**",
21
+ "**/.ragtime/branches/.*", # Exclude synced (dot-prefixed) branches
21
22
  ])
22
23
 
23
24
 
src/memory.py CHANGED
@@ -1,7 +1,7 @@
1
1
  """
2
2
  Memory storage for ragtime.
3
3
 
4
- Handles structured memory storage in .claude/memory/ directory.
4
+ Handles structured memory storage in .ragtime/ directory.
5
5
  Each memory is a markdown file with YAML frontmatter.
6
6
  """
7
7
 
@@ -153,7 +153,7 @@ class MemoryStore:
153
153
  db: RagtimeDB instance for vector search
154
154
  """
155
155
  self.project_path = project_path
156
- self.memory_dir = project_path / ".claude" / "memory"
156
+ self.memory_dir = project_path / ".ragtime"
157
157
  self.db = db
158
158
 
159
159
  def save(self, memory: Memory) -> Path:
@@ -348,7 +348,7 @@ class MemoryStore:
348
348
  """
349
349
  Reindex all memory files.
350
350
 
351
- Scans .claude/memory/ and indexes any files not in ChromaDB.
351
+ Scans .ragtime/ and indexes any files not in ChromaDB.
352
352
  Returns count of files indexed.
353
353
  """
354
354
  if not self.memory_dir.exists():