ragtime-cli 0.1.0__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.0/ragtime_cli.egg-info → ragtime_cli-0.2.0}/PKG-INFO +1 -1
- {ragtime_cli-0.1.0 → ragtime_cli-0.2.0}/pyproject.toml +1 -1
- {ragtime_cli-0.1.0 → ragtime_cli-0.2.0/ragtime_cli.egg-info}/PKG-INFO +1 -1
- {ragtime_cli-0.1.0 → ragtime_cli-0.2.0}/src/cli.py +578 -242
- {ragtime_cli-0.1.0 → ragtime_cli-0.2.0}/src/config.py +3 -2
- {ragtime_cli-0.1.0 → ragtime_cli-0.2.0}/src/memory.py +3 -3
- {ragtime_cli-0.1.0 → ragtime_cli-0.2.0}/LICENSE +0 -0
- {ragtime_cli-0.1.0 → ragtime_cli-0.2.0}/README.md +0 -0
- {ragtime_cli-0.1.0 → ragtime_cli-0.2.0}/ragtime_cli.egg-info/SOURCES.txt +0 -0
- {ragtime_cli-0.1.0 → ragtime_cli-0.2.0}/ragtime_cli.egg-info/dependency_links.txt +0 -0
- {ragtime_cli-0.1.0 → ragtime_cli-0.2.0}/ragtime_cli.egg-info/entry_points.txt +0 -0
- {ragtime_cli-0.1.0 → ragtime_cli-0.2.0}/ragtime_cli.egg-info/requires.txt +0 -0
- {ragtime_cli-0.1.0 → ragtime_cli-0.2.0}/ragtime_cli.egg-info/top_level.txt +0 -0
- {ragtime_cli-0.1.0 → ragtime_cli-0.2.0}/setup.cfg +0 -0
- {ragtime_cli-0.1.0 → ragtime_cli-0.2.0}/src/__init__.py +0 -0
- {ragtime_cli-0.1.0 → ragtime_cli-0.2.0}/src/commands/audit.md +0 -0
- {ragtime_cli-0.1.0 → ragtime_cli-0.2.0}/src/commands/handoff.md +0 -0
- {ragtime_cli-0.1.0 → ragtime_cli-0.2.0}/src/commands/pr-graduate.md +0 -0
- {ragtime_cli-0.1.0 → ragtime_cli-0.2.0}/src/commands/recall.md +0 -0
- {ragtime_cli-0.1.0 → ragtime_cli-0.2.0}/src/commands/remember.md +0 -0
- {ragtime_cli-0.1.0 → ragtime_cli-0.2.0}/src/commands/save.md +0 -0
- {ragtime_cli-0.1.0 → ragtime_cli-0.2.0}/src/commands/start.md +0 -0
- {ragtime_cli-0.1.0 → ragtime_cli-0.2.0}/src/db.py +0 -0
- {ragtime_cli-0.1.0 → ragtime_cli-0.2.0}/src/indexers/__init__.py +0 -0
- {ragtime_cli-0.1.0 → ragtime_cli-0.2.0}/src/indexers/docs.py +0 -0
- {ragtime_cli-0.1.0 → 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,
|
|
@@ -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.
|
|
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,
|
|
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
|
-
|
|
517
|
-
|
|
518
|
-
|
|
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
|
-
|
|
539
|
-
|
|
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 .
|
|
542
|
-
|
|
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
|
-
|
|
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
|
-
|
|
802
|
+
if not quiet:
|
|
803
|
+
click.echo("Fetching remote branches...")
|
|
580
804
|
|
|
581
|
-
#
|
|
582
|
-
|
|
583
|
-
["git", "
|
|
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
|
-
|
|
590
|
-
|
|
591
|
-
|
|
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
|
-
#
|
|
597
|
-
|
|
598
|
-
shutil.rmtree(unmerged_dir)
|
|
816
|
+
# Find remote branches with ragtime content
|
|
817
|
+
remote_branches = get_remote_branches_with_ragtime(path)
|
|
599
818
|
|
|
600
|
-
|
|
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
|
|
605
|
-
|
|
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
|
-
#
|
|
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", "
|
|
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
|
-
|
|
844
|
+
files = result.stdout.strip().split("\n")
|
|
620
845
|
|
|
621
|
-
#
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
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
|
-
|
|
628
|
-
|
|
851
|
+
# Extract files
|
|
852
|
+
for file_path in files:
|
|
853
|
+
if not file_path.endswith(".md"):
|
|
854
|
+
continue
|
|
629
855
|
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
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
|
-
|
|
651
|
-
|
|
652
|
-
|
|
863
|
+
if content_result.returncode == 0:
|
|
864
|
+
filename = Path(file_path).name
|
|
865
|
+
(synced_dir / filename).write_text(content_result.stdout)
|
|
653
866
|
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
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
|
|
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
|
|
906
|
+
"""Remove stale synced branch folders.
|
|
664
907
|
|
|
665
|
-
|
|
666
|
-
|
|
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 / ".
|
|
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
|
|
683
|
-
|
|
684
|
-
|
|
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
|
|
687
|
-
click.echo("
|
|
928
|
+
if not to_prune:
|
|
929
|
+
click.echo("Nothing to prune.")
|
|
688
930
|
return
|
|
689
931
|
|
|
690
|
-
click.echo(
|
|
932
|
+
click.echo("Will prune:")
|
|
933
|
+
for folder in to_prune:
|
|
934
|
+
click.echo(f" ✗ {folder.name} → {folder.name[1:]} exists")
|
|
691
935
|
|
|
692
|
-
|
|
693
|
-
|
|
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
|
-
|
|
703
|
-
|
|
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
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
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
|
-
|
|
719
|
-
|
|
720
|
-
|
|
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
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
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
|
-
|
|
734
|
-
|
|
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
|
-
|
|
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
|
-
|
|
745
|
-
|
|
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
|
-
|
|
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
|
-
|
|
766
|
-
|
|
767
|
-
|
|
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
|
-
|
|
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__":
|
|
@@ -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
|