ragtime-cli 0.1.2__tar.gz → 0.2.0__tar.gz
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.
- {ragtime_cli-0.1.2/ragtime_cli.egg-info → ragtime_cli-0.2.0}/PKG-INFO +1 -1
- {ragtime_cli-0.1.2 → ragtime_cli-0.2.0}/pyproject.toml +1 -1
- {ragtime_cli-0.1.2 → ragtime_cli-0.2.0/ragtime_cli.egg-info}/PKG-INFO +1 -1
- {ragtime_cli-0.1.2 → ragtime_cli-0.2.0}/src/cli.py +344 -293
- {ragtime_cli-0.1.2 → ragtime_cli-0.2.0}/src/config.py +3 -2
- {ragtime_cli-0.1.2 → ragtime_cli-0.2.0}/src/memory.py +3 -3
- {ragtime_cli-0.1.2 → ragtime_cli-0.2.0}/LICENSE +0 -0
- {ragtime_cli-0.1.2 → ragtime_cli-0.2.0}/README.md +0 -0
- {ragtime_cli-0.1.2 → ragtime_cli-0.2.0}/ragtime_cli.egg-info/SOURCES.txt +0 -0
- {ragtime_cli-0.1.2 → ragtime_cli-0.2.0}/ragtime_cli.egg-info/dependency_links.txt +0 -0
- {ragtime_cli-0.1.2 → ragtime_cli-0.2.0}/ragtime_cli.egg-info/entry_points.txt +0 -0
- {ragtime_cli-0.1.2 → ragtime_cli-0.2.0}/ragtime_cli.egg-info/requires.txt +0 -0
- {ragtime_cli-0.1.2 → ragtime_cli-0.2.0}/ragtime_cli.egg-info/top_level.txt +0 -0
- {ragtime_cli-0.1.2 → ragtime_cli-0.2.0}/setup.cfg +0 -0
- {ragtime_cli-0.1.2 → ragtime_cli-0.2.0}/src/__init__.py +0 -0
- {ragtime_cli-0.1.2 → ragtime_cli-0.2.0}/src/commands/audit.md +0 -0
- {ragtime_cli-0.1.2 → ragtime_cli-0.2.0}/src/commands/handoff.md +0 -0
- {ragtime_cli-0.1.2 → ragtime_cli-0.2.0}/src/commands/pr-graduate.md +0 -0
- {ragtime_cli-0.1.2 → ragtime_cli-0.2.0}/src/commands/recall.md +0 -0
- {ragtime_cli-0.1.2 → ragtime_cli-0.2.0}/src/commands/remember.md +0 -0
- {ragtime_cli-0.1.2 → ragtime_cli-0.2.0}/src/commands/save.md +0 -0
- {ragtime_cli-0.1.2 → ragtime_cli-0.2.0}/src/commands/start.md +0 -0
- {ragtime_cli-0.1.2 → ragtime_cli-0.2.0}/src/db.py +0 -0
- {ragtime_cli-0.1.2 → ragtime_cli-0.2.0}/src/indexers/__init__.py +0 -0
- {ragtime_cli-0.1.2 → ragtime_cli-0.2.0}/src/indexers/docs.py +0 -0
- {ragtime_cli-0.1.2 → ragtime_cli-0.2.0}/src/mcp_server.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: ragtime-cli
|
|
3
|
-
Version: 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,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: ragtime-cli
|
|
3
|
-
Version: 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
|
|
@@ -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,
|
|
@@ -122,8 +123,50 @@ def get_current_branch(path: Path) -> str | None:
|
|
|
122
123
|
return None
|
|
123
124
|
|
|
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
|
+
|
|
125
168
|
@click.group()
|
|
126
|
-
@click.version_option(version="0.
|
|
169
|
+
@click.version_option(version="0.2.0")
|
|
127
170
|
def main():
|
|
128
171
|
"""Ragtime - semantic search over code and documentation."""
|
|
129
172
|
pass
|
|
@@ -140,19 +183,42 @@ def init(path: Path):
|
|
|
140
183
|
click.echo(f" Code paths: {config.code.paths}")
|
|
141
184
|
click.echo(f" Languages: {config.code.languages}")
|
|
142
185
|
|
|
143
|
-
# Create
|
|
144
|
-
|
|
145
|
-
(
|
|
146
|
-
(
|
|
147
|
-
(
|
|
148
|
-
|
|
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)")
|
|
149
214
|
|
|
150
215
|
# Check for ghp-cli
|
|
151
216
|
if check_ghp_installed():
|
|
152
|
-
click.echo(f"\n✓ ghp-cli detected
|
|
217
|
+
click.echo(f"\n✓ ghp-cli detected")
|
|
218
|
+
click.echo(f" Run 'ragtime setup-ghp' to enable auto-context on 'ghp start'")
|
|
153
219
|
else:
|
|
154
|
-
click.echo(f"\n• ghp-cli not found
|
|
155
|
-
click.echo(f" Install
|
|
220
|
+
click.echo(f"\n• ghp-cli not found")
|
|
221
|
+
click.echo(f" Install for enhanced workflow: npm install -g @bretwardjames/ghp-cli")
|
|
156
222
|
|
|
157
223
|
|
|
158
224
|
@main.command()
|
|
@@ -172,7 +238,6 @@ def index(path: Path, index_type: str, clear: bool):
|
|
|
172
238
|
else:
|
|
173
239
|
db.clear(type_filter=index_type)
|
|
174
240
|
|
|
175
|
-
# Index docs
|
|
176
241
|
if index_type in ("all", "docs"):
|
|
177
242
|
total_entries = []
|
|
178
243
|
for docs_path in config.docs.paths:
|
|
@@ -192,24 +257,17 @@ def index(path: Path, index_type: str, clear: bool):
|
|
|
192
257
|
ids = [e.file_path for e in total_entries]
|
|
193
258
|
documents = [e.content for e in total_entries]
|
|
194
259
|
metadatas = [e.to_metadata() for e in total_entries]
|
|
195
|
-
|
|
196
260
|
db.upsert(ids=ids, documents=documents, metadatas=metadatas)
|
|
197
261
|
click.echo(f" Indexed {len(total_entries)} documents")
|
|
198
262
|
else:
|
|
199
263
|
click.echo(" No documents found")
|
|
200
264
|
|
|
201
|
-
# Index code
|
|
202
265
|
if index_type in ("all", "code"):
|
|
203
|
-
# Build exclude list that includes docs paths
|
|
204
266
|
code_exclude = list(config.code.exclude)
|
|
205
267
|
for docs_path in config.docs.paths:
|
|
206
268
|
code_exclude.append(f"**/{docs_path}/**")
|
|
207
|
-
|
|
208
269
|
click.echo("Code indexing not yet implemented")
|
|
209
|
-
click.echo(f" Will exclude docs paths: {config.docs.paths}")
|
|
210
|
-
# TODO: Implement code indexer with code_exclude
|
|
211
270
|
|
|
212
|
-
# Show stats
|
|
213
271
|
stats = db.stats()
|
|
214
272
|
click.echo(f"\nIndex stats: {stats['total']} total ({stats['docs']} docs, {stats['code']} code)")
|
|
215
273
|
|
|
@@ -219,9 +277,11 @@ def index(path: Path, index_type: str, clear: bool):
|
|
|
219
277
|
@click.option("--path", type=click.Path(exists=True, path_type=Path), default=".")
|
|
220
278
|
@click.option("--type", "type_filter", type=click.Choice(["all", "docs", "code"]), default="all")
|
|
221
279
|
@click.option("--namespace", "-n", help="Filter by namespace")
|
|
280
|
+
@click.option("--include-archive", is_flag=True, help="Also search archived branches")
|
|
222
281
|
@click.option("--limit", "-l", default=5, help="Max results")
|
|
223
282
|
@click.option("--verbose", "-v", is_flag=True, help="Show full content")
|
|
224
|
-
def search(query: str, path: Path, type_filter: str, namespace: str,
|
|
283
|
+
def search(query: str, path: Path, type_filter: str, namespace: str,
|
|
284
|
+
include_archive: bool, limit: int, verbose: bool):
|
|
225
285
|
"""Search indexed content."""
|
|
226
286
|
path = Path(path).resolve()
|
|
227
287
|
db = get_db(path)
|
|
@@ -253,7 +313,6 @@ def search(query: str, path: Path, type_filter: str, namespace: str, limit: int,
|
|
|
253
313
|
if verbose:
|
|
254
314
|
click.echo(f"\n{result['content'][:500]}...")
|
|
255
315
|
else:
|
|
256
|
-
# Show first 150 chars
|
|
257
316
|
preview = result["content"][:150].replace("\n", " ")
|
|
258
317
|
click.echo(f" {preview}...")
|
|
259
318
|
|
|
@@ -368,11 +427,7 @@ def remember(content: str, path: Path, namespace: str, memory_type: str,
|
|
|
368
427
|
type=click.Choice(["handoff", "document", "plan", "notes"]),
|
|
369
428
|
help="Document type")
|
|
370
429
|
def store_doc(file: Path, path: Path, namespace: str, doc_type: str):
|
|
371
|
-
"""Store a document verbatim (like handoff.md).
|
|
372
|
-
|
|
373
|
-
Example:
|
|
374
|
-
ragtime store-doc .claude/handoff.md --namespace branch-feature/auth
|
|
375
|
-
"""
|
|
430
|
+
"""Store a document verbatim (like handoff.md)."""
|
|
376
431
|
path = Path(path).resolve()
|
|
377
432
|
file = Path(file).resolve()
|
|
378
433
|
store = get_memory_store(path)
|
|
@@ -389,11 +444,7 @@ def store_doc(file: Path, path: Path, namespace: str, doc_type: str):
|
|
|
389
444
|
@click.option("--path", type=click.Path(exists=True, path_type=Path), default=".")
|
|
390
445
|
@click.confirmation_option(prompt="Are you sure you want to delete this memory?")
|
|
391
446
|
def forget(memory_id: str, path: Path):
|
|
392
|
-
"""Delete a memory by ID.
|
|
393
|
-
|
|
394
|
-
Example:
|
|
395
|
-
ragtime forget abc123
|
|
396
|
-
"""
|
|
447
|
+
"""Delete a memory by ID."""
|
|
397
448
|
path = Path(path).resolve()
|
|
398
449
|
store = get_memory_store(path)
|
|
399
450
|
|
|
@@ -410,14 +461,7 @@ def forget(memory_id: str, path: Path):
|
|
|
410
461
|
type=click.Choice(["high", "medium", "low"]),
|
|
411
462
|
help="Confidence level for graduated memory")
|
|
412
463
|
def graduate(memory_id: str, path: Path, confidence: str):
|
|
413
|
-
"""Graduate a branch memory to app namespace.
|
|
414
|
-
|
|
415
|
-
Copies the memory to app namespace with high confidence
|
|
416
|
-
and marks the original as graduated.
|
|
417
|
-
|
|
418
|
-
Example:
|
|
419
|
-
ragtime graduate abc123
|
|
420
|
-
"""
|
|
464
|
+
"""Graduate a branch memory to app namespace."""
|
|
421
465
|
path = Path(path).resolve()
|
|
422
466
|
store = get_memory_store(path)
|
|
423
467
|
|
|
@@ -443,13 +487,7 @@ def graduate(memory_id: str, path: Path, confidence: str):
|
|
|
443
487
|
@click.option("--verbose", "-v", is_flag=True, help="Show full content")
|
|
444
488
|
def list_memories(path: Path, namespace: str, type_filter: str, status: str,
|
|
445
489
|
component: str, limit: int, verbose: bool):
|
|
446
|
-
"""List memories with optional filters.
|
|
447
|
-
|
|
448
|
-
Examples:
|
|
449
|
-
ragtime memories --namespace app
|
|
450
|
-
ragtime memories --namespace branch-* --status active
|
|
451
|
-
ragtime memories --type decision --component auth
|
|
452
|
-
"""
|
|
490
|
+
"""List memories with optional filters."""
|
|
453
491
|
path = Path(path).resolve()
|
|
454
492
|
store = get_memory_store(path)
|
|
455
493
|
|
|
@@ -485,10 +523,7 @@ def list_memories(path: Path, namespace: str, type_filter: str, status: str,
|
|
|
485
523
|
@main.command()
|
|
486
524
|
@click.option("--path", type=click.Path(exists=True, path_type=Path), default=".")
|
|
487
525
|
def reindex(path: Path):
|
|
488
|
-
"""Reindex all memory files.
|
|
489
|
-
|
|
490
|
-
Scans .claude/memory/ and adds any files not in the index.
|
|
491
|
-
"""
|
|
526
|
+
"""Reindex all memory files."""
|
|
492
527
|
path = Path(path).resolve()
|
|
493
528
|
store = get_memory_store(path)
|
|
494
529
|
|
|
@@ -505,22 +540,15 @@ def reindex(path: Path):
|
|
|
505
540
|
def new_branch(issue: int, path: Path, content: str, issue_json: str, branch: str):
|
|
506
541
|
"""Initialize a branch context from a GitHub issue.
|
|
507
542
|
|
|
508
|
-
Creates .
|
|
543
|
+
Creates .ragtime/branches/{branch-slug}/context.md with either:
|
|
509
544
|
- Provided content (from --content flag, e.g., LLM-generated plan)
|
|
510
545
|
- Auto-generated scaffold from issue metadata (fallback)
|
|
511
|
-
|
|
512
|
-
Examples:
|
|
513
|
-
ragtime new-branch 42 # Scaffold from issue #42
|
|
514
|
-
ragtime new-branch 42 --content "..." # Use provided content
|
|
515
|
-
ragtime new-branch 42 --issue-json '{...}' # Use JSON from ghp hook
|
|
516
|
-
ragtime new-branch 42 --branch my-feat # Specify branch name
|
|
517
546
|
"""
|
|
518
547
|
import json
|
|
519
548
|
from datetime import date
|
|
520
549
|
|
|
521
550
|
path = Path(path).resolve()
|
|
522
551
|
|
|
523
|
-
# Determine branch name
|
|
524
552
|
if not branch:
|
|
525
553
|
branch = get_current_branch(path)
|
|
526
554
|
if not branch or branch in ("main", "master"):
|
|
@@ -529,23 +557,21 @@ def new_branch(issue: int, path: Path, content: str, issue_json: str, branch: st
|
|
|
529
557
|
|
|
530
558
|
# Create branch slug for folder name
|
|
531
559
|
branch_slug = branch.replace("/", "-")
|
|
532
|
-
branch_dir = path / ".
|
|
560
|
+
branch_dir = path / ".ragtime" / "branches" / branch_slug
|
|
533
561
|
branch_dir.mkdir(parents=True, exist_ok=True)
|
|
534
562
|
|
|
535
563
|
context_file = branch_dir / "context.md"
|
|
536
564
|
|
|
537
565
|
if content:
|
|
538
|
-
# Use provided content directly
|
|
539
566
|
context_file.write_text(content)
|
|
540
567
|
click.echo(f"✓ Created context.md with provided content")
|
|
541
568
|
click.echo(f" Path: {context_file.relative_to(path)}")
|
|
542
569
|
return
|
|
543
570
|
|
|
544
|
-
# Get issue data
|
|
571
|
+
# Get issue data
|
|
545
572
|
issue_data = None
|
|
546
573
|
source = None
|
|
547
574
|
|
|
548
|
-
# Use provided JSON if available (from ghp hook)
|
|
549
575
|
if issue_json:
|
|
550
576
|
try:
|
|
551
577
|
issue_data = json.loads(issue_json)
|
|
@@ -554,30 +580,22 @@ def new_branch(issue: int, path: Path, content: str, issue_json: str, branch: st
|
|
|
554
580
|
click.echo(f"✗ Invalid JSON: {e}", err=True)
|
|
555
581
|
return
|
|
556
582
|
else:
|
|
557
|
-
# Fall back to fetching from API
|
|
558
583
|
click.echo(f"Fetching issue #{issue}...")
|
|
559
|
-
|
|
560
|
-
# Try ghp first if available
|
|
561
584
|
if check_ghp_installed():
|
|
562
585
|
issue_data = get_issue_from_ghp(issue, path)
|
|
563
586
|
source = "ghp"
|
|
564
|
-
|
|
565
|
-
# Fall back to gh
|
|
566
587
|
if not issue_data:
|
|
567
588
|
issue_data = get_issue_from_gh(issue, path)
|
|
568
589
|
source = "gh"
|
|
569
590
|
|
|
570
591
|
if not issue_data:
|
|
571
592
|
click.echo(f"✗ Could not fetch issue #{issue}", err=True)
|
|
572
|
-
click.echo(" Make sure you're in a git repo with GitHub remote")
|
|
573
593
|
return
|
|
574
594
|
|
|
575
|
-
# Extract issue fields
|
|
576
595
|
title = issue_data.get("title", f"Issue #{issue}")
|
|
577
596
|
body = issue_data.get("body", "")
|
|
578
597
|
labels = issue_data.get("labels", [])
|
|
579
598
|
|
|
580
|
-
# Format labels
|
|
581
599
|
if labels:
|
|
582
600
|
if isinstance(labels[0], dict):
|
|
583
601
|
label_names = [l.get("name", "") for l in labels]
|
|
@@ -587,7 +605,6 @@ def new_branch(issue: int, path: Path, content: str, issue_json: str, branch: st
|
|
|
587
605
|
else:
|
|
588
606
|
labels_str = ""
|
|
589
607
|
|
|
590
|
-
# Generate scaffold context.md
|
|
591
608
|
scaffold = f"""---
|
|
592
609
|
type: context
|
|
593
610
|
branch: {branch}
|
|
@@ -628,7 +645,6 @@ author: {get_author()}
|
|
|
628
645
|
click.echo(f"✓ Created context.md from issue #{issue}")
|
|
629
646
|
click.echo(f" Path: {context_file.relative_to(path)}")
|
|
630
647
|
click.echo(f" Source: {source}")
|
|
631
|
-
click.echo(f"\nNext: Fill in the Plan section or use /start to generate it")
|
|
632
648
|
|
|
633
649
|
|
|
634
650
|
# ============================================================================
|
|
@@ -657,13 +673,7 @@ def get_available_commands() -> list[str]:
|
|
|
657
673
|
@click.argument("commands", nargs=-1)
|
|
658
674
|
def install_commands(global_install: bool, workspace_install: bool, list_commands: bool,
|
|
659
675
|
force: bool, commands: tuple):
|
|
660
|
-
"""Install Claude command templates.
|
|
661
|
-
|
|
662
|
-
Examples:
|
|
663
|
-
ragtime install --list # List available commands
|
|
664
|
-
ragtime install --workspace # Install all to .claude/commands/
|
|
665
|
-
ragtime install --global remember recall # Install specific commands globally
|
|
666
|
-
"""
|
|
676
|
+
"""Install Claude command templates."""
|
|
667
677
|
available = get_available_commands()
|
|
668
678
|
|
|
669
679
|
if list_commands:
|
|
@@ -672,7 +682,6 @@ def install_commands(global_install: bool, workspace_install: bool, list_command
|
|
|
672
682
|
click.echo(f" - {cmd}")
|
|
673
683
|
return
|
|
674
684
|
|
|
675
|
-
# Determine target directory
|
|
676
685
|
if global_install and workspace_install:
|
|
677
686
|
click.echo("Error: Cannot specify both --global and --workspace", err=True)
|
|
678
687
|
return
|
|
@@ -682,12 +691,9 @@ def install_commands(global_install: bool, workspace_install: bool, list_command
|
|
|
682
691
|
elif workspace_install:
|
|
683
692
|
target_dir = Path.cwd() / ".claude" / "commands"
|
|
684
693
|
else:
|
|
685
|
-
# Default to workspace
|
|
686
694
|
target_dir = Path.cwd() / ".claude" / "commands"
|
|
687
695
|
click.echo("Installing to workspace (.claude/commands/)")
|
|
688
|
-
click.echo("Use --global for ~/.claude/commands/")
|
|
689
696
|
|
|
690
|
-
# Determine which commands to install
|
|
691
697
|
if commands:
|
|
692
698
|
to_install = [c for c in commands if c in available]
|
|
693
699
|
not_found = [c for c in commands if c not in available]
|
|
@@ -700,13 +706,9 @@ def install_commands(global_install: bool, workspace_install: bool, list_command
|
|
|
700
706
|
click.echo("No commands to install.")
|
|
701
707
|
return
|
|
702
708
|
|
|
703
|
-
# Create target directory
|
|
704
709
|
target_dir.mkdir(parents=True, exist_ok=True)
|
|
705
|
-
|
|
706
|
-
# Install each command
|
|
707
710
|
commands_dir = get_commands_dir()
|
|
708
711
|
installed = 0
|
|
709
|
-
skipped = 0
|
|
710
712
|
|
|
711
713
|
for cmd in to_install:
|
|
712
714
|
source = commands_dir / f"{cmd}.md"
|
|
@@ -715,45 +717,26 @@ def install_commands(global_install: bool, workspace_install: bool, list_command
|
|
|
715
717
|
if target.exists() and not force:
|
|
716
718
|
if click.confirm(f" {cmd}.md exists. Overwrite?", default=False):
|
|
717
719
|
target.write_text(source.read_text())
|
|
718
|
-
click.echo(f" ✓ {cmd}.md (overwritten)")
|
|
719
720
|
installed += 1
|
|
720
|
-
else:
|
|
721
|
-
click.echo(f" - {cmd}.md (skipped)")
|
|
722
|
-
skipped += 1
|
|
723
721
|
else:
|
|
724
722
|
target.write_text(source.read_text())
|
|
725
723
|
click.echo(f" ✓ {cmd}.md")
|
|
726
724
|
installed += 1
|
|
727
725
|
|
|
728
726
|
click.echo(f"\nInstalled {installed} commands to {target_dir}")
|
|
729
|
-
if skipped:
|
|
730
|
-
click.echo(f"Skipped {skipped} existing commands (use --force to overwrite)")
|
|
731
|
-
|
|
732
|
-
# Remind about MCP server setup
|
|
733
|
-
click.echo("\nTo use these commands, add ragtime MCP server to your Claude config:")
|
|
734
|
-
click.echo(' "ragtime": {"command": "ragtime-mcp", "args": ["--path", "."]}')
|
|
735
727
|
|
|
736
728
|
|
|
737
729
|
@main.command("setup-ghp")
|
|
738
730
|
@click.option("--remove", is_flag=True, help="Remove ragtime hooks from ghp")
|
|
739
731
|
def setup_ghp(remove: bool):
|
|
740
|
-
"""Register ragtime hooks with ghp-cli.
|
|
741
|
-
|
|
742
|
-
Adds event hooks so ghp automatically creates context.md when starting issues.
|
|
743
|
-
|
|
744
|
-
Examples:
|
|
745
|
-
ragtime setup-ghp # Register hooks
|
|
746
|
-
ragtime setup-ghp --remove # Remove hooks
|
|
747
|
-
"""
|
|
732
|
+
"""Register ragtime hooks with ghp-cli."""
|
|
748
733
|
if not check_ghp_installed():
|
|
749
734
|
click.echo("✗ ghp-cli not installed", err=True)
|
|
750
|
-
click.echo(" Install with: npm install -g @bretwardjames/ghp-cli")
|
|
751
735
|
return
|
|
752
736
|
|
|
753
737
|
hook_name = "ragtime-context"
|
|
754
738
|
|
|
755
739
|
if remove:
|
|
756
|
-
# Remove the hook
|
|
757
740
|
result = subprocess.run(
|
|
758
741
|
["ghp", "hooks", "remove", hook_name],
|
|
759
742
|
capture_output=True,
|
|
@@ -762,13 +745,9 @@ def setup_ghp(remove: bool):
|
|
|
762
745
|
if result.returncode == 0:
|
|
763
746
|
click.echo(f"✓ Removed hook: {hook_name}")
|
|
764
747
|
else:
|
|
765
|
-
|
|
766
|
-
click.echo(f"• Hook {hook_name} not registered")
|
|
767
|
-
else:
|
|
768
|
-
click.echo(f"✗ Failed to remove hook: {result.stderr}", err=True)
|
|
748
|
+
click.echo(f"• Hook {hook_name} not registered")
|
|
769
749
|
return
|
|
770
750
|
|
|
771
|
-
# Check if hook already exists
|
|
772
751
|
result = subprocess.run(
|
|
773
752
|
["ghp", "hooks", "show", hook_name],
|
|
774
753
|
capture_output=True,
|
|
@@ -776,11 +755,9 @@ def setup_ghp(remove: bool):
|
|
|
776
755
|
)
|
|
777
756
|
if result.returncode == 0:
|
|
778
757
|
click.echo(f"• Hook {hook_name} already registered")
|
|
779
|
-
click.echo(" Use --remove to unregister first")
|
|
780
758
|
return
|
|
781
759
|
|
|
782
|
-
#
|
|
783
|
-
# The command uses ${issue.number}, ${issue.json}, and ${branch} from ghp
|
|
760
|
+
# Updated path for .ragtime/
|
|
784
761
|
hook_command = "ragtime new-branch ${issue.number} --issue-json '${issue.json}' --branch '${branch}'"
|
|
785
762
|
|
|
786
763
|
result = subprocess.run(
|
|
@@ -798,7 +775,6 @@ def setup_ghp(remove: bool):
|
|
|
798
775
|
click.echo(f"✓ Registered hook: {hook_name}")
|
|
799
776
|
click.echo(f" Event: issue-started")
|
|
800
777
|
click.echo(f" Action: Creates context.md from issue metadata")
|
|
801
|
-
click.echo(f"\nNow when you run 'ghp start <issue>', ragtime will auto-create context.md")
|
|
802
778
|
else:
|
|
803
779
|
click.echo(f"✗ Failed to register hook: {result.stderr}", err=True)
|
|
804
780
|
|
|
@@ -808,250 +784,325 @@ def setup_ghp(remove: bool):
|
|
|
808
784
|
# ============================================================================
|
|
809
785
|
|
|
810
786
|
|
|
811
|
-
def get_branch_slug(ref: str) -> str:
|
|
812
|
-
"""Convert a git ref to a branch slug for folder naming."""
|
|
813
|
-
# Remove origin/ prefix if present
|
|
814
|
-
if ref.startswith("origin/"):
|
|
815
|
-
ref = ref[7:]
|
|
816
|
-
# Replace / with - for folder names
|
|
817
|
-
return ref.replace("/", "-")
|
|
818
|
-
|
|
819
|
-
|
|
820
787
|
@main.command()
|
|
821
|
-
@click.argument("ref")
|
|
822
788
|
@click.option("--path", type=click.Path(exists=True, path_type=Path), default=".")
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
and copies to a local (unmerged) folder for searching.
|
|
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.
|
|
828
793
|
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
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/).
|
|
832
796
|
"""
|
|
833
797
|
import shutil
|
|
834
|
-
import tempfile
|
|
835
798
|
|
|
836
799
|
path = Path(path).resolve()
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
# Get the branch slug for folder naming
|
|
840
|
-
branch_slug = get_branch_slug(ref)
|
|
841
|
-
unmerged_dir = path / ".claude" / "memory" / "branches" / f"{branch_slug}(unmerged)"
|
|
800
|
+
branches_dir = path / ".ragtime" / "branches"
|
|
842
801
|
|
|
843
|
-
|
|
802
|
+
if not quiet:
|
|
803
|
+
click.echo("Fetching remote branches...")
|
|
844
804
|
|
|
845
|
-
# Fetch
|
|
846
|
-
|
|
847
|
-
["git", "fetch", "
|
|
848
|
-
cwd=path,
|
|
849
|
-
capture_output=True,
|
|
850
|
-
text=True,
|
|
851
|
-
)
|
|
852
|
-
|
|
853
|
-
# Check if the ref exists
|
|
854
|
-
result = subprocess.run(
|
|
855
|
-
["git", "rev-parse", "--verify", ref],
|
|
856
|
-
cwd=path,
|
|
857
|
-
capture_output=True,
|
|
858
|
-
text=True,
|
|
859
|
-
)
|
|
860
|
-
if result.returncode != 0:
|
|
861
|
-
click.echo(f"✗ Ref not found: {ref}", err=True)
|
|
862
|
-
return
|
|
863
|
-
|
|
864
|
-
commit_hash = result.stdout.strip()[:8]
|
|
865
|
-
|
|
866
|
-
# Check if there are memory files in that ref
|
|
867
|
-
result = subprocess.run(
|
|
868
|
-
["git", "ls-tree", "-r", "--name-only", ref, ".claude/memory/branches/"],
|
|
805
|
+
# Fetch first
|
|
806
|
+
subprocess.run(
|
|
807
|
+
["git", "fetch", "--quiet"],
|
|
869
808
|
cwd=path,
|
|
870
809
|
capture_output=True,
|
|
871
|
-
text=True,
|
|
872
810
|
)
|
|
873
811
|
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
files = result.stdout.strip().split("\n")
|
|
879
|
-
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
|
|
880
815
|
|
|
881
|
-
#
|
|
882
|
-
|
|
883
|
-
shutil.rmtree(unmerged_dir)
|
|
816
|
+
# Find remote branches with ragtime content
|
|
817
|
+
remote_branches = get_remote_branches_with_ragtime(path)
|
|
884
818
|
|
|
885
|
-
|
|
819
|
+
if not remote_branches and not quiet:
|
|
820
|
+
click.echo("No remote branches with ragtime content found.")
|
|
886
821
|
|
|
887
|
-
# Extract each file
|
|
888
822
|
synced = 0
|
|
889
|
-
for
|
|
890
|
-
|
|
823
|
+
for ref in remote_branches:
|
|
824
|
+
branch_slug = get_branch_slug(ref)
|
|
825
|
+
|
|
826
|
+
# Skip current branch
|
|
827
|
+
if branch_slug == current_slug:
|
|
891
828
|
continue
|
|
892
829
|
|
|
893
|
-
#
|
|
830
|
+
# Synced folders are dot-prefixed
|
|
831
|
+
synced_dir = branches_dir / f".{branch_slug}"
|
|
832
|
+
|
|
833
|
+
# Get files from remote
|
|
894
834
|
result = subprocess.run(
|
|
895
|
-
["git", "
|
|
835
|
+
["git", "ls-tree", "-r", "--name-only", ref, ".ragtime/branches/"],
|
|
896
836
|
cwd=path,
|
|
897
837
|
capture_output=True,
|
|
898
838
|
text=True,
|
|
899
839
|
)
|
|
900
840
|
|
|
901
|
-
if result.returncode != 0:
|
|
841
|
+
if result.returncode != 0 or not result.stdout.strip():
|
|
902
842
|
continue
|
|
903
843
|
|
|
904
|
-
|
|
844
|
+
files = result.stdout.strip().split("\n")
|
|
905
845
|
|
|
906
|
-
#
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
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)
|
|
911
850
|
|
|
912
|
-
|
|
913
|
-
|
|
851
|
+
# Extract files
|
|
852
|
+
for file_path in files:
|
|
853
|
+
if not file_path.endswith(".md"):
|
|
854
|
+
continue
|
|
914
855
|
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
memory.namespace = f"branch-{branch_slug}(unmerged)"
|
|
921
|
-
|
|
922
|
-
store.db.upsert(
|
|
923
|
-
ids=[f"{memory.id}-unmerged"],
|
|
924
|
-
documents=[memory.content],
|
|
925
|
-
metadatas=[{
|
|
926
|
-
**memory.to_metadata(),
|
|
927
|
-
"status": "pre-merge",
|
|
928
|
-
"source_ref": ref,
|
|
929
|
-
"source_commit": commit_hash,
|
|
930
|
-
}],
|
|
856
|
+
content_result = subprocess.run(
|
|
857
|
+
["git", "show", f"{ref}:{file_path}"],
|
|
858
|
+
cwd=path,
|
|
859
|
+
capture_output=True,
|
|
860
|
+
text=True,
|
|
931
861
|
)
|
|
932
|
-
except Exception as e:
|
|
933
|
-
click.echo(f" Warning: Could not index {filename}: {e}", err=True)
|
|
934
862
|
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
863
|
+
if content_result.returncode == 0:
|
|
864
|
+
filename = Path(file_path).name
|
|
865
|
+
(synced_dir / filename).write_text(content_result.stdout)
|
|
938
866
|
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
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.")
|
|
942
900
|
|
|
943
901
|
|
|
944
902
|
@main.command()
|
|
945
903
|
@click.option("--path", type=click.Path(exists=True, path_type=Path), default=".")
|
|
946
|
-
@click.option("--dry-run", is_flag=True, help="Show what would be pruned
|
|
904
|
+
@click.option("--dry-run", is_flag=True, help="Show what would be pruned")
|
|
947
905
|
def prune(path: Path, dry_run: bool):
|
|
948
|
-
"""Remove stale
|
|
906
|
+
"""Remove stale synced branch folders.
|
|
949
907
|
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
- The source branch was merged (PR closed)
|
|
953
|
-
|
|
954
|
-
Examples:
|
|
955
|
-
ragtime prune --dry-run # See what would be pruned
|
|
956
|
-
ragtime prune # Actually prune
|
|
908
|
+
Removes dot-prefixed folders (.branch) when an undotted
|
|
909
|
+
counterpart (branch) exists (indicating the branch was merged).
|
|
957
910
|
"""
|
|
958
911
|
import shutil
|
|
959
912
|
|
|
960
913
|
path = Path(path).resolve()
|
|
961
|
-
branches_dir = path / ".
|
|
914
|
+
branches_dir = path / ".ragtime" / "branches"
|
|
962
915
|
|
|
963
916
|
if not branches_dir.exists():
|
|
964
917
|
click.echo("No branches directory found.")
|
|
965
918
|
return
|
|
966
919
|
|
|
967
|
-
# Find
|
|
968
|
-
|
|
969
|
-
|
|
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)
|
|
970
927
|
|
|
971
|
-
if not
|
|
972
|
-
click.echo("
|
|
928
|
+
if not to_prune:
|
|
929
|
+
click.echo("Nothing to prune.")
|
|
973
930
|
return
|
|
974
931
|
|
|
975
|
-
click.echo(
|
|
932
|
+
click.echo("Will prune:")
|
|
933
|
+
for folder in to_prune:
|
|
934
|
+
click.echo(f" ✗ {folder.name} → {folder.name[1:]} exists")
|
|
976
935
|
|
|
977
|
-
|
|
978
|
-
|
|
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")
|
|
979
943
|
|
|
980
|
-
for folder in unmerged_folders:
|
|
981
|
-
source_file = folder / ".source"
|
|
982
|
-
if not source_file.exists():
|
|
983
|
-
# No source tracking - mark for pruning
|
|
984
|
-
to_prune.append((folder, "no source tracking"))
|
|
985
|
-
continue
|
|
986
944
|
|
|
987
|
-
|
|
988
|
-
|
|
945
|
+
# ============================================================================
|
|
946
|
+
# Daemon Commands
|
|
947
|
+
# ============================================================================
|
|
989
948
|
|
|
990
|
-
# Check if ref still exists
|
|
991
|
-
result = subprocess.run(
|
|
992
|
-
["git", "rev-parse", "--verify", ref],
|
|
993
|
-
cwd=path,
|
|
994
|
-
capture_output=True,
|
|
995
|
-
text=True,
|
|
996
|
-
)
|
|
997
949
|
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
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())
|
|
1024
|
+
|
|
1025
|
+
import time
|
|
1026
|
+
from datetime import datetime
|
|
1027
|
+
|
|
1028
|
+
print(f"\n[{datetime.now().isoformat()}] Daemon started (interval: {interval})")
|
|
1002
1029
|
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1030
|
+
while True:
|
|
1031
|
+
try:
|
|
1032
|
+
print(f"[{datetime.now().isoformat()}] Running sync...")
|
|
1033
|
+
|
|
1034
|
+
# Fetch
|
|
1035
|
+
subprocess.run(
|
|
1036
|
+
["git", "fetch", "--quiet"],
|
|
1006
1037
|
cwd=path,
|
|
1007
1038
|
capture_output=True,
|
|
1008
|
-
text=True,
|
|
1009
1039
|
)
|
|
1010
1040
|
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1041
|
+
# Sync
|
|
1042
|
+
subprocess.run(
|
|
1043
|
+
["ragtime", "sync", "--quiet", "--auto-prune"],
|
|
1044
|
+
cwd=path,
|
|
1045
|
+
capture_output=True,
|
|
1046
|
+
)
|
|
1017
1047
|
|
|
1018
|
-
|
|
1019
|
-
if to_prune:
|
|
1020
|
-
click.echo("Will prune:")
|
|
1021
|
-
for folder, reason in to_prune:
|
|
1022
|
-
click.echo(f" ✗ {folder.name} ({reason})")
|
|
1048
|
+
print(f"[{datetime.now().isoformat()}] Sync complete")
|
|
1023
1049
|
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
for folder, ref in to_keep:
|
|
1027
|
-
click.echo(f" ✓ {folder.name}")
|
|
1050
|
+
except Exception as e:
|
|
1051
|
+
print(f"[{datetime.now().isoformat()}] Error: {e}")
|
|
1028
1052
|
|
|
1029
|
-
|
|
1030
|
-
|
|
1053
|
+
time.sleep(interval_seconds)
|
|
1054
|
+
|
|
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.")
|
|
1031
1065
|
return
|
|
1032
1066
|
|
|
1033
|
-
|
|
1034
|
-
if dry_run:
|
|
1035
|
-
click.echo(f"\n--dry-run: Would prune {len(to_prune)} folders")
|
|
1036
|
-
else:
|
|
1037
|
-
click.echo("")
|
|
1038
|
-
db = get_db(path)
|
|
1039
|
-
|
|
1040
|
-
for folder, reason in to_prune:
|
|
1041
|
-
# Remove from index
|
|
1042
|
-
branch_slug = folder.name.replace("(unmerged)", "")
|
|
1043
|
-
# Find and delete indexed memories for this folder
|
|
1044
|
-
results = db.collection.get(
|
|
1045
|
-
where={"namespace": f"branch-{folder.name}"}
|
|
1046
|
-
)
|
|
1047
|
-
if results["ids"]:
|
|
1048
|
-
db.delete(results["ids"])
|
|
1067
|
+
pid = int(pid_file.read_text().strip())
|
|
1049
1068
|
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
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).")
|
|
1053
1076
|
|
|
1054
|
-
|
|
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()
|
|
1055
1106
|
|
|
1056
1107
|
|
|
1057
1108
|
if __name__ == "__main__":
|
|
@@ -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", ".
|
|
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
|
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"""
|
|
2
2
|
Memory storage for ragtime.
|
|
3
3
|
|
|
4
|
-
Handles structured memory storage in .
|
|
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 / ".
|
|
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 .
|
|
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():
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|