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

src/cli.py CHANGED
@@ -3,6 +3,7 @@ Ragtime CLI - semantic search and memory storage.
3
3
  """
4
4
 
5
5
  from pathlib import Path
6
+ import json
6
7
  import subprocess
7
8
  import click
8
9
  import os
@@ -168,17 +169,166 @@ def get_remote_branches_with_ragtime(path: Path) -> list[str]:
168
169
  return []
169
170
 
170
171
 
172
+ def setup_mcp_server(project_path: Path, force: bool = False) -> bool:
173
+ """Offer to configure MCP server for Claude Code integration.
174
+
175
+ Args:
176
+ project_path: The project directory
177
+ force: If True, add MCP server without prompting
178
+
179
+ Returns True if MCP was configured, False otherwise.
180
+ """
181
+ mcp_config_path = project_path / ".mcp.json"
182
+
183
+ ragtime_config = {
184
+ "command": "ragtime-mcp",
185
+ "args": ["--path", "."]
186
+ }
187
+
188
+ if mcp_config_path.exists():
189
+ # Read file once
190
+ try:
191
+ existing = json.loads(mcp_config_path.read_text())
192
+ except (IOError, OSError) as e:
193
+ click.echo(f"\n✗ Could not read .mcp.json: {e}", err=True)
194
+ return False
195
+ except json.JSONDecodeError as e:
196
+ click.echo(f"\n! Warning: .mcp.json contains invalid JSON: {e}", err=True)
197
+ if not (force or click.confirm("? Overwrite with new config?", default=False)):
198
+ return False
199
+ existing = {}
200
+
201
+ # Check if ragtime is already configured
202
+ if "mcpServers" in existing and "ragtime" in existing.get("mcpServers", {}):
203
+ click.echo("\n✓ MCP server already configured in .mcp.json")
204
+ return True
205
+
206
+ # Add ragtime to existing config
207
+ if force or click.confirm("\n? Add ragtime MCP server to existing .mcp.json?", default=True):
208
+ try:
209
+ if "mcpServers" not in existing:
210
+ existing["mcpServers"] = {}
211
+ existing["mcpServers"]["ragtime"] = ragtime_config
212
+ mcp_config_path.write_text(json.dumps(existing, indent=2) + "\n")
213
+ click.echo("\n✓ Added ragtime to .mcp.json")
214
+ return True
215
+ except IOError as e:
216
+ click.echo(f"\n✗ Failed to update .mcp.json: {e}", err=True)
217
+ return False
218
+ else:
219
+ # Create new config
220
+ if force or click.confirm("\n? Create .mcp.json to enable Claude Code MCP integration?", default=True):
221
+ mcp_config = {
222
+ "mcpServers": {
223
+ "ragtime": ragtime_config
224
+ }
225
+ }
226
+ try:
227
+ mcp_config_path.write_text(json.dumps(mcp_config, indent=2) + "\n")
228
+ click.echo("\n✓ Created .mcp.json with ragtime server")
229
+ return True
230
+ except IOError as e:
231
+ click.echo(f"\n✗ Failed to create .mcp.json: {e}", err=True)
232
+ return False
233
+
234
+ return False
235
+
236
+
237
+ def setup_mcp_global(force: bool = False) -> bool:
238
+ """Add ragtime MCP server to global Claude settings.
239
+
240
+ Args:
241
+ force: If True, add without prompting
242
+
243
+ Returns True if configured, False otherwise.
244
+ """
245
+ claude_dir = Path.home() / ".claude"
246
+ settings_path = claude_dir / "settings.json"
247
+
248
+ ragtime_config = {
249
+ "command": "ragtime-mcp",
250
+ "args": ["--path", "."]
251
+ }
252
+
253
+ # Ensure ~/.claude exists
254
+ try:
255
+ claude_dir.mkdir(parents=True, exist_ok=True)
256
+ except OSError as e:
257
+ click.echo(f"✗ Failed to create {claude_dir}: {e}", err=True)
258
+ return False
259
+
260
+ if settings_path.exists():
261
+ # Read file once
262
+ try:
263
+ existing = json.loads(settings_path.read_text())
264
+ except (IOError, OSError) as e:
265
+ click.echo(f"✗ Could not read ~/.claude/settings.json: {e}", err=True)
266
+ return False
267
+ except json.JSONDecodeError as e:
268
+ click.echo(f"! Warning: ~/.claude/settings.json contains invalid JSON: {e}", err=True)
269
+ if not (force or click.confirm("? Overwrite with new config?", default=False)):
270
+ return False
271
+ existing = {}
272
+
273
+ # Check if ragtime is already configured
274
+ if "mcpServers" in existing and "ragtime" in existing.get("mcpServers", {}):
275
+ click.echo("✓ MCP server already configured in ~/.claude/settings.json")
276
+ return True
277
+
278
+ # Add ragtime to existing config
279
+ if force or click.confirm("? Add ragtime MCP server to ~/.claude/settings.json?", default=True):
280
+ try:
281
+ if "mcpServers" not in existing:
282
+ existing["mcpServers"] = {}
283
+ existing["mcpServers"]["ragtime"] = ragtime_config
284
+ settings_path.write_text(json.dumps(existing, indent=2) + "\n")
285
+ click.echo("✓ Added ragtime to ~/.claude/settings.json")
286
+ return True
287
+ except IOError as e:
288
+ click.echo(f"✗ Failed to update settings: {e}", err=True)
289
+ return False
290
+ else:
291
+ # Create new settings file
292
+ if force or click.confirm("? Create ~/.claude/settings.json with ragtime MCP server?", default=True):
293
+ settings = {
294
+ "mcpServers": {
295
+ "ragtime": ragtime_config
296
+ }
297
+ }
298
+ try:
299
+ settings_path.write_text(json.dumps(settings, indent=2) + "\n")
300
+ click.echo("✓ Created ~/.claude/settings.json with ragtime server")
301
+ return True
302
+ except IOError as e:
303
+ click.echo(f"✗ Failed to create settings: {e}", err=True)
304
+ return False
305
+
306
+ return False
307
+
308
+
171
309
  @click.group()
172
- @click.version_option(version="0.2.9")
173
- def main():
310
+ @click.version_option(version="0.2.18")
311
+ @click.option("-y", "--force-defaults", is_flag=True, help="Accept all defaults without prompting")
312
+ @click.pass_context
313
+ def main(ctx, force_defaults: bool):
174
314
  """Ragtime - semantic search over code and documentation."""
175
- pass
315
+ ctx.ensure_object(dict)
316
+ ctx.obj["force_defaults"] = force_defaults
176
317
 
177
318
 
178
319
  @main.command()
179
- @click.argument("path", type=click.Path(exists=True, path_type=Path), default=".")
180
- def init(path: Path):
181
- """Initialize ragtime config for a project."""
320
+ @click.argument("path", type=click.Path(exists=True, path_type=Path), default=".", required=False)
321
+ @click.option("-G", "--global", "global_install", is_flag=True, help="Add MCP server to ~/.claude/settings.json")
322
+ @click.pass_context
323
+ def init(ctx, path: Path, global_install: bool):
324
+ """Initialize ragtime config for a project, or globally with -G."""
325
+ force = (ctx.obj or {}).get("force_defaults", False)
326
+
327
+ # Global install: just add MCP to ~/.claude/settings.json
328
+ if global_install:
329
+ setup_mcp_global(force=force)
330
+ return
331
+
182
332
  path = path.resolve()
183
333
  config = init_config(path)
184
334
  click.echo(f"Created .ragtime/config.yaml with defaults:")
@@ -257,6 +407,9 @@ Add your team's conventions above. Each rule should be:
257
407
  click.echo(f"\n• ghp-cli not found")
258
408
  click.echo(f" Install for enhanced workflow: npm install -g @bretwardjames/ghp-cli")
259
409
 
410
+ # Offer MCP server setup for Claude Code integration
411
+ setup_mcp_server(path, force=(ctx.obj or {}).get("force_defaults", False))
412
+
260
413
 
261
414
  # Batch size for ChromaDB upserts (embedding computation happens here)
262
415
  INDEX_BATCH_SIZE = 100
@@ -664,16 +817,81 @@ def forget(memory_id: str, path: Path):
664
817
 
665
818
 
666
819
  @main.command()
667
- @click.argument("memory_id")
820
+ @click.argument("memory_id", required=False)
668
821
  @click.option("--path", type=click.Path(exists=True, path_type=Path), default=".")
822
+ @click.option("--list", "list_candidates", is_flag=True, help="List graduation candidates")
823
+ @click.option("--branch", "-b", help="Branch name or slug (default: current branch)")
669
824
  @click.option("--confidence", default="high",
670
825
  type=click.Choice(["high", "medium", "low"]),
671
826
  help="Confidence level for graduated memory")
672
- def graduate(memory_id: str, path: Path, confidence: str):
673
- """Graduate a branch memory to app namespace."""
827
+ def graduate(memory_id: str, path: Path, list_candidates: bool, branch: str, confidence: str):
828
+ """Graduate a branch memory to app namespace.
829
+
830
+ With --list: Show branch memories that are candidates for graduation.
831
+ With MEMORY_ID: Graduate a specific memory to app namespace.
832
+
833
+ Examples:
834
+ ragtime graduate --list # List candidates for current branch
835
+ ragtime graduate --list -b feature/auth # List candidates for specific branch
836
+ ragtime graduate abc123 # Graduate specific memory
837
+ """
674
838
  path = Path(path).resolve()
675
839
  store = get_memory_store(path)
676
840
 
841
+ # List mode: show graduation candidates
842
+ if list_candidates or not memory_id:
843
+ # Determine branch
844
+ if not branch:
845
+ branch = get_current_branch(path)
846
+ if not branch:
847
+ click.echo("✗ Not in a git repository or no branch specified", err=True)
848
+ return
849
+
850
+ # Create namespace pattern (handle both original and slugified)
851
+ branch_slug = branch.replace("/", "-")
852
+ namespace = f"branch-{branch_slug}"
853
+
854
+ # Get memories for this branch
855
+ memories = store.list_memories(namespace=namespace, status="active")
856
+
857
+ if not memories:
858
+ click.echo(f"No active memories found for branch: {branch}")
859
+ click.echo(f" (namespace: {namespace})")
860
+ return
861
+
862
+ # Filter to graduation candidates (exclude context type)
863
+ candidates = [m for m in memories if m.type != "context"]
864
+
865
+ if not candidates:
866
+ click.echo(f"No graduation candidates for branch: {branch}")
867
+ click.echo(f" (found {len(memories)} memories, but all are context type)")
868
+ return
869
+
870
+ click.echo(f"\nGraduation candidates for branch: {branch}")
871
+ click.echo(f"{'─' * 50}")
872
+
873
+ for i, mem in enumerate(candidates, 1):
874
+ type_badge = f"[{mem.type}]" if mem.type else "[unknown]"
875
+ confidence_badge = f"({mem.confidence})" if hasattr(mem, 'confidence') and mem.confidence else ""
876
+
877
+ click.echo(f"\n {i}. {type_badge} {confidence_badge}")
878
+ click.echo(f" ID: {mem.id}")
879
+
880
+ # Show preview
881
+ preview = mem.content[:150].replace("\n", " ").strip()
882
+ if len(mem.content) > 150:
883
+ preview += "..."
884
+ click.echo(f" {preview}")
885
+
886
+ if hasattr(mem, 'added') and mem.added:
887
+ click.echo(f" Added: {mem.added}")
888
+
889
+ click.echo(f"\n{'─' * 50}")
890
+ click.echo(f"{len(candidates)} candidate(s) found.")
891
+ click.echo(f"\nTo graduate: ragtime graduate <ID>")
892
+ return
893
+
894
+ # Graduate mode: graduate specific memory
677
895
  try:
678
896
  graduated = store.graduate(memory_id, confidence)
679
897
  if graduated:
@@ -740,6 +958,568 @@ def reindex(path: Path):
740
958
  click.echo(f"✓ Reindexed {count} memory files")
741
959
 
742
960
 
961
+ @main.command("check-conventions")
962
+ @click.option("--path", type=click.Path(exists=True, path_type=Path), default=".")
963
+ @click.option("--files", "-f", help="Files to check (comma-separated or JSON array)")
964
+ @click.option("--branch", "-b", help="Branch to diff against main (default: current branch)")
965
+ @click.option("--event-file", type=click.Path(exists=True, path_type=Path), help="GHP event file (JSON)")
966
+ @click.option("--include-memories", is_flag=True, default=True, help="Include convention memories")
967
+ @click.option("--all", "return_all", is_flag=True, help="Return ALL conventions (for AI workflows)")
968
+ @click.option("--json", "json_output", is_flag=True, help="Output as JSON (for MCP/hooks)")
969
+ def check_conventions(path: Path, files: str, branch: str, event_file: Path,
970
+ include_memories: bool, return_all: bool, json_output: bool):
971
+ """Show conventions applicable to changed files.
972
+
973
+ Used by pre-PR hooks to check code against team conventions.
974
+
975
+ By default, returns only conventions RELEVANT to the changed files (semantic search).
976
+ Use --all to return ALL conventions (useful for AI workflows that analyze edge cases).
977
+
978
+ Examples:
979
+ ragtime check-conventions # Relevant conventions only
980
+ ragtime check-conventions --all # ALL conventions (for AI)
981
+ ragtime check-conventions -f "src/auth.ts" # Conventions relevant to specific file
982
+ ragtime check-conventions --event-file /tmp/ghp-event.json --all # From GHP hook
983
+ """
984
+ import json as json_module
985
+
986
+ path = Path(path).resolve()
987
+ config = RagtimeConfig.load(path)
988
+
989
+ # Load from event file if provided (GHP hook pattern)
990
+ if event_file:
991
+ try:
992
+ event_data = json_module.loads(Path(event_file).read_text())
993
+ # Event file can provide changed_files and branch
994
+ if not files and "changed_files" in event_data:
995
+ files = event_data["changed_files"]
996
+ if not branch and "branch" in event_data:
997
+ branch = event_data["branch"]
998
+ except (json_module.JSONDecodeError, IOError) as e:
999
+ click.echo(f"⚠ Could not read event file: {e}", err=True)
1000
+
1001
+ # Determine files to check
1002
+ changed_files = []
1003
+ if files:
1004
+ # Handle list (from event file) or string (from CLI)
1005
+ if isinstance(files, list):
1006
+ changed_files = files
1007
+ elif files.startswith("["):
1008
+ try:
1009
+ changed_files = json_module.loads(files)
1010
+ except json_module.JSONDecodeError:
1011
+ changed_files = [f.strip() for f in files.split(",")]
1012
+ else:
1013
+ changed_files = [f.strip() for f in files.split(",")]
1014
+ else:
1015
+ # Get changed files from git diff
1016
+ if not branch:
1017
+ branch = get_current_branch(path)
1018
+ if branch and branch not in ("main", "master"):
1019
+ result = subprocess.run(
1020
+ ["git", "diff", "--name-only", "main...HEAD"],
1021
+ cwd=path,
1022
+ capture_output=True,
1023
+ text=True,
1024
+ )
1025
+ if result.returncode == 0:
1026
+ changed_files = [f for f in result.stdout.strip().split("\n") if f]
1027
+
1028
+ # Gather conventions from files
1029
+ conventions_data = {
1030
+ "files": [],
1031
+ "memories": [],
1032
+ "changed_files": changed_files,
1033
+ }
1034
+
1035
+ # Read convention files from config
1036
+ for conv_file in config.conventions.files:
1037
+ conv_path = path / conv_file
1038
+ if conv_path.exists():
1039
+ if conv_path.is_file():
1040
+ content = conv_path.read_text()
1041
+ conventions_data["files"].append({
1042
+ "path": str(conv_file),
1043
+ "content": content,
1044
+ })
1045
+ elif conv_path.is_dir():
1046
+ # If it's a directory, read all files in it
1047
+ for f in conv_path.rglob("*"):
1048
+ if f.is_file() and f.suffix in (".md", ".txt", ".yaml", ".yml"):
1049
+ content = f.read_text()
1050
+ conventions_data["files"].append({
1051
+ "path": str(f.relative_to(path)),
1052
+ "content": content,
1053
+ })
1054
+
1055
+ # Search memories for conventions
1056
+ if include_memories and config.conventions.also_search_memories:
1057
+ store = get_memory_store(path)
1058
+ db = get_db(path)
1059
+
1060
+ if return_all:
1061
+ # Return ALL convention/pattern memories (for AI workflows)
1062
+ for ns in ["team", "app"]:
1063
+ memories = store.list_memories(namespace=ns, type_filter="convention", status="active")
1064
+ for mem in memories:
1065
+ conventions_data["memories"].append({
1066
+ "id": mem.id,
1067
+ "namespace": mem.namespace,
1068
+ "content": mem.content,
1069
+ "component": mem.component,
1070
+ })
1071
+ # Also get pattern-type memories
1072
+ patterns = store.list_memories(namespace=ns, type_filter="pattern", status="active")
1073
+ for mem in patterns:
1074
+ conventions_data["memories"].append({
1075
+ "id": mem.id,
1076
+ "namespace": mem.namespace,
1077
+ "type": "pattern",
1078
+ "content": mem.content,
1079
+ "component": mem.component,
1080
+ })
1081
+ else:
1082
+ # Semantic search for RELEVANT conventions based on changed files
1083
+ if changed_files:
1084
+ # Build search query from file paths and names
1085
+ # Extract meaningful terms: paths, extensions, inferred components
1086
+ search_terms = set()
1087
+ for f in changed_files:
1088
+ parts = Path(f).parts
1089
+ search_terms.update(parts) # Add path components
1090
+ search_terms.add(Path(f).suffix.lstrip(".")) # Add extension
1091
+ search_terms.add(Path(f).stem) # Add filename without ext
1092
+
1093
+ # Remove common noise
1094
+ noise = {"src", "lib", "test", "tests", "spec", "specs", "", "js", "ts", "py", "md"}
1095
+ search_terms = search_terms - noise
1096
+
1097
+ query = f"conventions patterns rules standards for {' '.join(search_terms)}"
1098
+
1099
+ # Search both namespaces using the db directly
1100
+ seen_ids = set()
1101
+ for ns in ["team", "app"]:
1102
+ results = db.search(query, namespace=ns, limit=50)
1103
+ for result in results:
1104
+ meta = result.get("metadata", {})
1105
+ result_type = meta.get("type", "")
1106
+ result_category = meta.get("category", "")
1107
+ # Include convention/pattern types OR docs with category="convention"
1108
+ is_convention_content = (
1109
+ result_type in ("convention", "pattern") or
1110
+ result_category == "convention"
1111
+ )
1112
+ if is_convention_content:
1113
+ mem_id = meta.get("id") or meta.get("file", "")
1114
+ if mem_id not in seen_ids:
1115
+ seen_ids.add(mem_id)
1116
+ distance = result.get("distance", 1)
1117
+ score = 1 - distance if distance else 0
1118
+ conventions_data["memories"].append({
1119
+ "id": mem_id,
1120
+ "namespace": ns,
1121
+ "type": result_type,
1122
+ "content": result.get("content", ""),
1123
+ "component": meta.get("component"),
1124
+ "relevance": score,
1125
+ })
1126
+ else:
1127
+ # No changed files - fall back to listing all
1128
+ for ns in ["team", "app"]:
1129
+ memories = store.list_memories(namespace=ns, type_filter="convention", status="active")
1130
+ for mem in memories:
1131
+ conventions_data["memories"].append({
1132
+ "id": mem.id,
1133
+ "namespace": mem.namespace,
1134
+ "content": mem.content,
1135
+ "component": mem.component,
1136
+ })
1137
+
1138
+ # Output
1139
+ if json_output:
1140
+ click.echo(json_module.dumps(conventions_data, indent=2))
1141
+ return
1142
+
1143
+ # Human-friendly output
1144
+ click.echo(f"\nConventions Check")
1145
+ click.echo(f"{'═' * 50}")
1146
+
1147
+ if changed_files:
1148
+ click.echo(f"\nFiles to check ({len(changed_files)}):")
1149
+ for f in changed_files[:10]:
1150
+ click.echo(f" • {f}")
1151
+ if len(changed_files) > 10:
1152
+ click.echo(f" ... and {len(changed_files) - 10} more")
1153
+
1154
+ click.echo(f"\n{'─' * 50}")
1155
+ click.echo(f"Convention Files ({len(conventions_data['files'])}):")
1156
+
1157
+ if not conventions_data["files"]:
1158
+ click.echo(" No convention files found.")
1159
+ click.echo(f" Configure in .ragtime/config.yaml under 'conventions.files'")
1160
+ else:
1161
+ for conv in conventions_data["files"]:
1162
+ click.echo(f"\n 📄 {conv['path']}")
1163
+ # Show first few lines as preview
1164
+ lines = conv["content"].strip().split("\n")[:5]
1165
+ for line in lines:
1166
+ if line.strip():
1167
+ click.echo(f" {line[:70]}{'...' if len(line) > 70 else ''}")
1168
+
1169
+ if conventions_data["memories"]:
1170
+ click.echo(f"\n{'─' * 50}")
1171
+ mode_label = "All" if return_all else "Relevant"
1172
+ click.echo(f"Convention Memories - {mode_label} ({len(conventions_data['memories'])}):")
1173
+
1174
+ for mem in conventions_data["memories"]:
1175
+ type_str = mem.get("type", "convention")
1176
+ relevance = mem.get("relevance")
1177
+ relevance_str = f" (relevance: {relevance:.2f})" if relevance else ""
1178
+ click.echo(f"\n [{mem['id'][:8] if mem['id'] else '?'}] {mem['namespace']} / {type_str}{relevance_str}")
1179
+ if mem.get("component"):
1180
+ click.echo(f" Component: {mem['component']}")
1181
+ preview = mem["content"][:100].replace("\n", " ")
1182
+ click.echo(f" {preview}...")
1183
+
1184
+ click.echo(f"\n{'═' * 50}")
1185
+ total = len(conventions_data["files"]) + len(conventions_data["memories"])
1186
+ mode_note = " (all)" if return_all else " (filtered by relevance)"
1187
+ click.echo(f"Total: {total} convention source(s){mode_note}")
1188
+
1189
+
1190
+ @main.command("add-convention")
1191
+ @click.argument("content", required=False)
1192
+ @click.option("--path", type=click.Path(exists=True, path_type=Path), default=".")
1193
+ @click.option("--component", "-c", help="Component this convention applies to")
1194
+ @click.option("--to-file", type=click.Path(path_type=Path), help="Add to specific file")
1195
+ @click.option("--to-memory", is_flag=True, help="Store as memory only (not in git)")
1196
+ @click.option("--quiet", "-q", is_flag=True, help="Non-interactive mode")
1197
+ def add_convention(content: str | None, path: Path, component: str | None,
1198
+ to_file: Path | None, to_memory: bool, quiet: bool):
1199
+ """Add a new convention with smart storage routing.
1200
+
1201
+ Finds the best place to store the convention:
1202
+ - Existing doc with ## Conventions section (if component matches)
1203
+ - Convention folder (.ragtime/conventions/)
1204
+ - Central conventions file (.ragtime/CONVENTIONS.md)
1205
+ - Memory only (searchable but not committed)
1206
+
1207
+ Examples:
1208
+ ragtime add-convention "Always use async/await, never .then()"
1209
+ ragtime add-convention --component auth "JWT tokens expire after 15 minutes"
1210
+ ragtime add-convention --to-file docs/api.md "Use snake_case for JSON"
1211
+ ragtime add-convention --to-memory "Prefer composition over inheritance"
1212
+ """
1213
+ path = Path(path).resolve()
1214
+ config = RagtimeConfig.load(path)
1215
+
1216
+ # Get content interactively if not provided
1217
+ if not content:
1218
+ content = click.prompt("Convention to add")
1219
+
1220
+ if not content.strip():
1221
+ click.echo("✗ No content provided", err=True)
1222
+ return
1223
+
1224
+ # If explicit destination provided, use it
1225
+ if to_memory:
1226
+ _store_convention_as_memory(path, content, component)
1227
+ return
1228
+
1229
+ if to_file:
1230
+ _append_convention_to_file(path, to_file, content)
1231
+ return
1232
+
1233
+ # Auto-routing based on config
1234
+ storage_mode = config.conventions.storage
1235
+
1236
+ if storage_mode == "memory":
1237
+ _store_convention_as_memory(path, content, component)
1238
+ return
1239
+
1240
+ # Find storage options
1241
+ options = _find_convention_storage_options(path, config, component)
1242
+
1243
+ if storage_mode == "file" or (storage_mode == "auto" and options):
1244
+ # Use first available file option, or default
1245
+ if options:
1246
+ target = options[0]
1247
+ else:
1248
+ target = {
1249
+ "type": "default_file",
1250
+ "path": config.conventions.default_file,
1251
+ "description": "Central conventions file",
1252
+ }
1253
+ _store_convention_to_target(path, target, content, component)
1254
+ return
1255
+
1256
+ if storage_mode == "ask" or (storage_mode == "auto" and not quiet):
1257
+ # Present options to user
1258
+ click.echo("\nWhere should this convention be stored?\n")
1259
+
1260
+ choices = []
1261
+ for i, opt in enumerate(options, 1):
1262
+ icon = "📄" if opt["type"] == "existing_section" else "📁" if opt["type"] == "folder" else "📋"
1263
+ click.echo(f" {i}. {icon} {opt['description']}")
1264
+ click.echo(f" {opt['path']}")
1265
+ choices.append(opt)
1266
+
1267
+ # Add default options if not already present
1268
+ default_file_present = any(o["path"] == config.conventions.default_file for o in choices)
1269
+ if not default_file_present:
1270
+ i = len(choices) + 1
1271
+ click.echo(f" {i}. 📋 Central conventions file")
1272
+ click.echo(f" {config.conventions.default_file}")
1273
+ choices.append({
1274
+ "type": "default_file",
1275
+ "path": config.conventions.default_file,
1276
+ "description": "Central conventions file",
1277
+ })
1278
+
1279
+ i = len(choices) + 1
1280
+ click.echo(f" {i}. 🧠 Memory only (not committed)")
1281
+ click.echo(f" Stored in team namespace, searchable but not in git")
1282
+ choices.append({"type": "memory", "path": None, "description": "Memory only"})
1283
+
1284
+ click.echo()
1285
+ choice = click.prompt("Choice", type=int, default=1)
1286
+
1287
+ if choice < 1 or choice > len(choices):
1288
+ click.echo("✗ Invalid choice", err=True)
1289
+ return
1290
+
1291
+ target = choices[choice - 1]
1292
+ if target["type"] == "memory":
1293
+ _store_convention_as_memory(path, content, component)
1294
+ else:
1295
+ _store_convention_to_target(path, target, content, component)
1296
+ return
1297
+
1298
+ # Fallback: memory only
1299
+ _store_convention_as_memory(path, content, component)
1300
+
1301
+
1302
+ def _find_convention_storage_options(path: Path, config: RagtimeConfig, component: str | None) -> list[dict]:
1303
+ """Find potential storage locations for a convention."""
1304
+ import re
1305
+ options = []
1306
+
1307
+ # 1. Check for existing docs with ## Conventions sections
1308
+ for scan_path in config.conventions.scan_docs_for_sections:
1309
+ scan_dir = path / scan_path
1310
+ if scan_dir.exists() and scan_dir.is_dir():
1311
+ for md_file in scan_dir.rglob("*.md"):
1312
+ content = md_file.read_text()
1313
+ # Look for ## Conventions header
1314
+ if re.search(r'^##\s+(Conventions?|Rules|Standards|Guidelines)', content, re.MULTILINE | re.IGNORECASE):
1315
+ # If component specified, check if file relates to it
1316
+ file_component = md_file.stem.lower()
1317
+ rel_path = md_file.relative_to(path)
1318
+
1319
+ if component:
1320
+ if component.lower() in str(rel_path).lower():
1321
+ options.insert(0, { # Prioritize component match
1322
+ "type": "existing_section",
1323
+ "path": str(rel_path),
1324
+ "description": f"Add to existing Conventions section ({file_component})",
1325
+ })
1326
+ else:
1327
+ options.append({
1328
+ "type": "existing_section",
1329
+ "path": str(rel_path),
1330
+ "description": f"Add to existing Conventions section",
1331
+ })
1332
+
1333
+ # 2. Check if convention folder exists
1334
+ folder = path / config.conventions.folder
1335
+ if folder.exists() and folder.is_dir():
1336
+ if component:
1337
+ target_file = f"{component.lower()}.md"
1338
+ options.append({
1339
+ "type": "folder",
1340
+ "path": f"{config.conventions.folder}{target_file}",
1341
+ "description": f"Create/append to {target_file} in conventions folder",
1342
+ })
1343
+ else:
1344
+ options.append({
1345
+ "type": "folder",
1346
+ "path": f"{config.conventions.folder}general.md",
1347
+ "description": "Add to general.md in conventions folder",
1348
+ })
1349
+
1350
+ # 3. Check if default conventions file exists
1351
+ default_file = path / config.conventions.default_file
1352
+ if default_file.exists():
1353
+ options.append({
1354
+ "type": "default_file",
1355
+ "path": config.conventions.default_file,
1356
+ "description": "Append to central conventions file",
1357
+ })
1358
+
1359
+ return options
1360
+
1361
+
1362
+ def _store_convention_as_memory(path: Path, content: str, component: str | None):
1363
+ """Store convention as a memory in team namespace."""
1364
+ from datetime import date
1365
+ store = get_memory_store(path)
1366
+
1367
+ memory = Memory(
1368
+ content=content,
1369
+ namespace="team",
1370
+ type="convention",
1371
+ component=component,
1372
+ confidence="high",
1373
+ confidence_reason="user-added",
1374
+ source="add-convention",
1375
+ status="active",
1376
+ added=date.today().isoformat(),
1377
+ author=get_author(),
1378
+ )
1379
+
1380
+ file_path = store.save(memory)
1381
+ click.echo(f"✓ Convention stored as memory")
1382
+ click.echo(f" ID: {memory.id}")
1383
+ click.echo(f" File: {file_path.relative_to(path)}")
1384
+ click.echo(f" Namespace: team")
1385
+
1386
+
1387
+ def _store_convention_to_target(path: Path, target: dict, content: str, component: str | None):
1388
+ """Store convention to a file target."""
1389
+ import re
1390
+
1391
+ target_path = path / target["path"]
1392
+ target_type = target["type"]
1393
+
1394
+ if target_type == "existing_section":
1395
+ # Append to existing ## Conventions section
1396
+ if not target_path.exists():
1397
+ click.echo(f"✗ File not found: {target['path']}", err=True)
1398
+ return
1399
+
1400
+ file_content = target_path.read_text()
1401
+
1402
+ # Find the Conventions section and append
1403
+ pattern = r'(^##\s+(?:Conventions?|Rules|Standards|Guidelines).*?)(\n##|\Z)'
1404
+ match = re.search(pattern, file_content, re.MULTILINE | re.IGNORECASE | re.DOTALL)
1405
+
1406
+ if match:
1407
+ section_end = match.end(1)
1408
+ new_content = file_content[:section_end] + f"\n\n- {content}" + file_content[section_end:]
1409
+ target_path.write_text(new_content)
1410
+ click.echo(f"✓ Convention added to {target['path']}")
1411
+ click.echo(f" Added to ## Conventions section")
1412
+ else:
1413
+ click.echo(f"✗ Could not find Conventions section in {target['path']}", err=True)
1414
+
1415
+ elif target_type == "folder":
1416
+ # Create or append to file in conventions folder
1417
+ target_path.parent.mkdir(parents=True, exist_ok=True)
1418
+
1419
+ if target_path.exists():
1420
+ # Append to existing file
1421
+ existing = target_path.read_text()
1422
+ new_content = existing.rstrip() + f"\n\n- {content}\n"
1423
+ else:
1424
+ # Create new file
1425
+ title = target_path.stem.replace("-", " ").replace("_", " ").title()
1426
+ new_content = f"""---
1427
+ namespace: team
1428
+ type: convention
1429
+ component: {component or ''}
1430
+ ---
1431
+
1432
+ # {title} Conventions
1433
+
1434
+ - {content}
1435
+ """
1436
+ target_path.write_text(new_content)
1437
+ click.echo(f"✓ Convention added to {target['path']}")
1438
+
1439
+ elif target_type == "default_file":
1440
+ # Create or append to default conventions file
1441
+ target_path.parent.mkdir(parents=True, exist_ok=True)
1442
+
1443
+ if target_path.exists():
1444
+ existing = target_path.read_text()
1445
+ # Check if there's a component section
1446
+ if component:
1447
+ section_pattern = rf'^##\s+{re.escape(component)}.*?(?=\n##|\Z)'
1448
+ match = re.search(section_pattern, existing, re.MULTILINE | re.IGNORECASE | re.DOTALL)
1449
+ if match:
1450
+ # Append to component section
1451
+ section_end = match.end()
1452
+ new_content = existing[:section_end] + f"\n- {content}" + existing[section_end:]
1453
+ else:
1454
+ # Create new component section
1455
+ new_content = existing.rstrip() + f"\n\n## {component.title()}\n\n- {content}\n"
1456
+ else:
1457
+ # Append to general section or end
1458
+ new_content = existing.rstrip() + f"\n\n- {content}\n"
1459
+ else:
1460
+ # Create new file
1461
+ header = f"## {component.title()}\n\n" if component else ""
1462
+ new_content = f"""# Team Conventions
1463
+
1464
+ {header}- {content}
1465
+ """
1466
+ target_path.write_text(new_content)
1467
+ click.echo(f"✓ Convention added to {target['path']}")
1468
+
1469
+ # Reindex the file
1470
+ _reindex_convention_file(path, target_path)
1471
+
1472
+
1473
+ def _append_convention_to_file(path: Path, to_file: Path, content: str):
1474
+ """Append a convention directly to a user-specified file."""
1475
+ import re
1476
+
1477
+ target_path = path / to_file if not to_file.is_absolute() else to_file
1478
+
1479
+ if not target_path.exists():
1480
+ click.echo(f"✗ File not found: {to_file}", err=True)
1481
+ return
1482
+
1483
+ file_content = target_path.read_text()
1484
+
1485
+ # Try to find a Conventions section to append to
1486
+ pattern = r'(^##\s+(?:Conventions?|Rules|Standards|Guidelines).*?)(\n##|\Z)'
1487
+ match = re.search(pattern, file_content, re.MULTILINE | re.IGNORECASE | re.DOTALL)
1488
+
1489
+ if match:
1490
+ # Append to existing section
1491
+ section_end = match.end(1)
1492
+ new_content = file_content[:section_end] + f"\n\n- {content}" + file_content[section_end:]
1493
+ target_path.write_text(new_content)
1494
+ click.echo(f"✓ Convention added to {to_file}")
1495
+ click.echo(f" Added to ## Conventions section")
1496
+ else:
1497
+ # Append at end of file
1498
+ new_content = file_content.rstrip() + f"\n\n- {content}\n"
1499
+ target_path.write_text(new_content)
1500
+ click.echo(f"✓ Convention appended to {to_file}")
1501
+
1502
+ # Reindex the file
1503
+ _reindex_convention_file(path, target_path)
1504
+
1505
+
1506
+ def _reindex_convention_file(path: Path, target_path: Path):
1507
+ """Reindex a convention file after modification."""
1508
+ try:
1509
+ db = get_db(path)
1510
+ from .indexers.docs import index_file
1511
+ entries = index_file(target_path)
1512
+ for entry in entries:
1513
+ db.upsert(
1514
+ ids=[f"{entry.file_path}:{entry.chunk_index}"],
1515
+ documents=[entry.content],
1516
+ metadatas=[entry.to_metadata()],
1517
+ )
1518
+ click.echo(f" Indexed {len(entries)} section(s)")
1519
+ except Exception as e:
1520
+ click.echo(f" ⚠ Could not reindex: {e}")
1521
+
1522
+
743
1523
  @main.command()
744
1524
  @click.option("--path", type=click.Path(exists=True, path_type=Path), default=".")
745
1525
  @click.option("--dry-run", is_flag=True, help="Show what would be removed")
@@ -867,9 +1647,9 @@ def new_branch(issue: int, path: Path, content: str, issue_json: str, branch: st
867
1647
  click.echo(f"✗ Could not fetch issue #{issue}", err=True)
868
1648
  return
869
1649
 
870
- title = issue_data.get("title", f"Issue #{issue}")
871
- body = issue_data.get("body", "")
872
- labels = issue_data.get("labels", [])
1650
+ title = issue_data.get("title") or f"Issue #{issue}"
1651
+ body = issue_data.get("body") or "" # Handle null and empty
1652
+ labels = issue_data.get("labels") or []
873
1653
 
874
1654
  if labels:
875
1655
  if isinstance(labels[0], dict):
@@ -923,115 +1703,244 @@ author: {get_author()}
923
1703
 
924
1704
 
925
1705
  # ============================================================================
926
- # Command Installation
1706
+ # Usage Documentation
927
1707
  # ============================================================================
928
1708
 
929
1709
 
930
- def get_commands_dir() -> Path:
931
- """Get the directory containing bundled command templates."""
932
- return Path(__file__).parent / "commands"
1710
+ @main.command("usage")
1711
+ @click.option("--section", "-s", help="Show specific section (mcp, cli, workflows, conventions)")
1712
+ def usage(section: str | None):
1713
+ """Show how to use ragtime effectively with AI agents.
933
1714
 
1715
+ Prints documentation on integrating ragtime into your AI workflow,
1716
+ whether using the MCP server or CLI directly.
1717
+ """
1718
+ sections = {
1719
+ "mcp": USAGE_MCP,
1720
+ "cli": USAGE_CLI,
1721
+ "workflows": USAGE_WORKFLOWS,
1722
+ "conventions": USAGE_CONVENTIONS,
1723
+ }
934
1724
 
935
- def get_available_commands() -> list[str]:
936
- """List available command templates."""
937
- commands_dir = get_commands_dir()
938
- if not commands_dir.exists():
939
- return []
940
- return [f.stem for f in commands_dir.glob("*.md")]
941
-
942
-
943
- @main.command("install")
944
- @click.option("--global", "global_install", is_flag=True, help="Install to ~/.claude/commands/")
945
- @click.option("--workspace", "workspace_install", is_flag=True, help="Install to .claude/commands/")
946
- @click.option("--list", "list_commands", is_flag=True, help="List available commands")
947
- @click.option("--force", is_flag=True, help="Overwrite existing commands without asking")
948
- @click.argument("commands", nargs=-1)
949
- def install_commands(global_install: bool, workspace_install: bool, list_commands: bool,
950
- force: bool, commands: tuple):
951
- """Install Claude command templates."""
952
- available = get_available_commands()
953
-
954
- if list_commands:
955
- click.echo("Available commands:")
956
- for cmd in available:
957
- click.echo(f" - {cmd}")
1725
+ if section:
1726
+ if section.lower() in sections:
1727
+ click.echo(sections[section.lower()])
1728
+ else:
1729
+ click.echo(f"Unknown section: {section}")
1730
+ click.echo(f"Available: {', '.join(sections.keys())}")
958
1731
  return
959
1732
 
960
- if global_install and workspace_install:
961
- click.echo("Error: Cannot specify both --global and --workspace", err=True)
962
- return
1733
+ # Print all sections
1734
+ click.echo(USAGE_HEADER)
1735
+ for content in sections.values():
1736
+ click.echo(content)
1737
+ click.echo()
963
1738
 
964
- if global_install:
965
- target_dir = Path.home() / ".claude" / "commands"
966
- elif workspace_install:
967
- target_dir = Path.cwd() / ".claude" / "commands"
968
- else:
969
- target_dir = Path.cwd() / ".claude" / "commands"
970
- click.echo("Installing to workspace (.claude/commands/)")
971
-
972
- if commands:
973
- to_install = [c for c in commands if c in available]
974
- not_found = [c for c in commands if c not in available]
975
- if not_found:
976
- click.echo(f"Warning: Commands not found: {', '.join(not_found)}", err=True)
977
- else:
978
- to_install = available
979
1739
 
980
- if not to_install:
981
- click.echo("No commands to install.")
982
- return
1740
+ USAGE_HEADER = """
1741
+ # Ragtime Usage Guide
983
1742
 
984
- target_dir.mkdir(parents=True, exist_ok=True)
985
- commands_dir = get_commands_dir()
986
- installed = 0
987
- skipped = 0
988
- namespaced = 0
989
-
990
- for cmd in to_install:
991
- source = commands_dir / f"{cmd}.md"
992
- target = target_dir / f"{cmd}.md"
993
- namespaced_target = target_dir / f"ragtime-{cmd}.md"
994
-
995
- if target.exists() and not force:
996
- # Check if it's our file (contains ragtime marker)
997
- existing_content = target.read_text()
998
- is_ragtime_file = "ragtime" in existing_content.lower() and "mcp__ragtime" in existing_content
999
-
1000
- if is_ragtime_file:
1001
- # It's our file, safe to overwrite
1002
- target.write_text(source.read_text())
1003
- click.echo(f" ✓ {cmd}.md (updated)")
1004
- installed += 1
1005
- else:
1006
- # Conflict with non-ragtime command
1007
- click.echo(f"\n⚠️ Conflict: {cmd}.md already exists (not a ragtime command)")
1008
- click.echo(f" 1. Overwrite with ragtime's version")
1009
- click.echo(f" 2. Skip (keep existing)")
1010
- click.echo(f" 3. Install as ragtime-{cmd}.md")
1011
-
1012
- choice = click.prompt(" Choice", type=click.Choice(["1", "2", "3"]), default="2")
1013
-
1014
- if choice == "1":
1015
- target.write_text(source.read_text())
1016
- click.echo(f" ✓ {cmd}.md (overwritten)")
1017
- installed += 1
1018
- elif choice == "2":
1019
- click.echo(f" {cmd}.md (skipped)")
1020
- skipped += 1
1021
- else:
1022
- namespaced_target.write_text(source.read_text())
1023
- click.echo(f" ✓ ragtime-{cmd}.md")
1024
- namespaced += 1
1025
- else:
1026
- target.write_text(source.read_text())
1027
- click.echo(f" ✓ {cmd}.md")
1028
- installed += 1
1743
+ Ragtime provides persistent memory for AI coding sessions. Use it via:
1744
+ - **MCP Server** (recommended for Claude Code / AI tools)
1745
+ - **CLI** (for scripts, hooks, and manual use)
1746
+ """
1747
+
1748
+ USAGE_MCP = """
1749
+ ## MCP Server Tools
1750
+
1751
+ After `ragtime init`, add to your `.mcp.json`:
1752
+
1753
+ ```json
1754
+ {
1755
+ "mcpServers": {
1756
+ "ragtime": {
1757
+ "command": "ragtime",
1758
+ "args": ["serve"]
1759
+ }
1760
+ }
1761
+ }
1762
+ ```
1763
+
1764
+ ### Core Tools
1765
+
1766
+ | Tool | Purpose |
1767
+ |------|---------|
1768
+ | `mcp__ragtime__search` | Find relevant memories, docs, and code |
1769
+ | `mcp__ragtime__remember` | Store new knowledge |
1770
+ | `mcp__ragtime__list_memories` | Browse stored memories |
1771
+ | `mcp__ragtime__forget` | Delete a memory |
1772
+ | `mcp__ragtime__graduate` | Promote branch memory to app namespace |
1773
+
1774
+ ### Search Examples
1775
+
1776
+ ```
1777
+ # Find architecture knowledge
1778
+ mcp__ragtime__search(query="authentication flow", namespace="app")
1779
+
1780
+ # Find team conventions
1781
+ mcp__ragtime__search(query="error handling", namespace="team", type="convention")
1782
+
1783
+ # Search with auto-qualifier detection
1784
+ mcp__ragtime__search(query="how does auth work in mobile", tiered=true)
1785
+ ```
1786
+
1787
+ ### Remember Examples
1788
+
1789
+ ```
1790
+ # Store architecture insight
1791
+ mcp__ragtime__remember(
1792
+ content="JWT tokens expire after 15 minutes, refresh tokens after 7 days",
1793
+ namespace="app",
1794
+ type="architecture",
1795
+ component="auth"
1796
+ )
1797
+
1798
+ # Store team convention
1799
+ mcp__ragtime__remember(
1800
+ content="Always use async/await, never .then() chains",
1801
+ namespace="team",
1802
+ type="convention"
1803
+ )
1804
+
1805
+ # Store branch-specific decision
1806
+ mcp__ragtime__remember(
1807
+ content="Using Redis for session storage because of horizontal scaling needs",
1808
+ namespace="branch-feature/auth",
1809
+ type="decision"
1810
+ )
1811
+ ```
1812
+ """
1813
+
1814
+ USAGE_CLI = """
1815
+ ## CLI Commands
1816
+
1817
+ ### Memory Management
1818
+
1819
+ ```bash
1820
+ # Search memories
1821
+ ragtime search "authentication patterns"
1822
+ ragtime search "conventions" --namespace team
1823
+
1824
+ # Add a convention
1825
+ ragtime add-convention "Always validate user input"
1826
+ ragtime add-convention -c auth "JWT must be validated on every request"
1827
+
1828
+ # List memories
1829
+ ragtime memories --namespace app
1830
+ ragtime memories --type convention
1831
+
1832
+ # Check conventions for changed files
1833
+ ragtime check-conventions
1834
+ ragtime check-conventions --all # For AI (comprehensive)
1835
+ ```
1836
+
1837
+ ### Branch Context
1029
1838
 
1030
- click.echo(f"\nInstalled {installed} commands to {target_dir}")
1031
- if namespaced:
1032
- click.echo(f" ({namespaced} installed with ragtime- prefix)")
1033
- if skipped:
1034
- click.echo(f" ({skipped} skipped due to conflicts)")
1839
+ ```bash
1840
+ # Create branch context from GitHub issue
1841
+ ragtime new-branch 123 --branch feature/auth
1842
+
1843
+ # Graduate branch memories after PR merge
1844
+ ragtime graduate --branch feature/auth
1845
+ ```
1846
+
1847
+ ### Indexing
1848
+
1849
+ ```bash
1850
+ # Index docs and code
1851
+ ragtime index
1852
+ ragtime reindex # Full reindex
1853
+ ```
1854
+ """
1855
+
1856
+ USAGE_WORKFLOWS = """
1857
+ ## Recommended Workflows
1858
+
1859
+ ### Starting Work on an Issue
1860
+
1861
+ 1. AI reads the issue and existing context:
1862
+ ```
1863
+ mcp__ragtime__search(query="auth implementation", namespace="app")
1864
+ ```
1865
+
1866
+ 2. Check for branch context:
1867
+ ```bash
1868
+ # Look for .ragtime/branches/{branch}/context.md
1869
+ ```
1870
+
1871
+ 3. As you make decisions, store them:
1872
+ ```
1873
+ mcp__ragtime__remember(
1874
+ content="Chose PKCE flow for mobile OAuth",
1875
+ namespace="branch-feature/oauth",
1876
+ type="decision"
1877
+ )
1878
+ ```
1879
+
1880
+ ### Before Creating a PR
1881
+
1882
+ 1. Check conventions:
1883
+ ```bash
1884
+ ragtime check-conventions
1885
+ ```
1886
+
1887
+ 2. Review branch memories for graduation candidates
1888
+
1889
+ ### After PR Merge
1890
+
1891
+ 1. Graduate valuable branch memories to app namespace:
1892
+ ```bash
1893
+ ragtime graduate --branch feature/auth
1894
+ ```
1895
+
1896
+ ### Session Handoff
1897
+
1898
+ Store context in `.ragtime/branches/{branch}/context.md`:
1899
+ - Current state
1900
+ - What's left to do
1901
+ - Key decisions made
1902
+ - Blockers or notes for next session
1903
+ """
1904
+
1905
+ USAGE_CONVENTIONS = """
1906
+ ## Convention System
1907
+
1908
+ ### Storing Conventions
1909
+
1910
+ Conventions can live in:
1911
+ - **Files**: `.ragtime/CONVENTIONS.md` or `docs/conventions/`
1912
+ - **Doc sections**: Any `## Conventions` section in markdown
1913
+ - **Memories**: `team` namespace with type `convention`
1914
+
1915
+ ```bash
1916
+ # Add via CLI (routes automatically)
1917
+ ragtime add-convention "Use snake_case for JSON fields"
1918
+
1919
+ # Add to specific file
1920
+ ragtime add-convention --to-file docs/api.md "Return 404 for missing resources"
1921
+
1922
+ # Add as memory only
1923
+ ragtime add-convention --to-memory "Prefer composition over inheritance"
1924
+ ```
1925
+
1926
+ ### Convention Detection
1927
+
1928
+ The indexer automatically detects conventions from:
1929
+ - Files named `*conventions*`, `*rules*`, `*standards*`
1930
+ - Sections with headers like `## Conventions`, `## Rules`
1931
+ - Content between `<!-- convention -->` markers
1932
+ - Frontmatter: `has_conventions: true` or `convention_sections: [...]`
1933
+
1934
+ ### Checking Conventions
1935
+
1936
+ ```bash
1937
+ # Human use (filtered to relevant)
1938
+ ragtime check-conventions
1939
+
1940
+ # AI use (comprehensive)
1941
+ ragtime check-conventions --all --json
1942
+ ```
1943
+ """
1035
1944
 
1036
1945
 
1037
1946
  @main.command("setup-ghp")
@@ -1066,7 +1975,8 @@ def setup_ghp(remove: bool):
1066
1975
  return
1067
1976
 
1068
1977
  # Updated path for .ragtime/
1069
- hook_command = "ragtime new-branch ${issue.number} --issue-json '${issue.json}' --branch '${branch}'"
1978
+ # NOTE: No quotes around template vars - GHP's shellEscape() handles escaping
1979
+ hook_command = "ragtime new-branch ${issue.number} --issue-json ${issue.json} --branch ${branch}"
1070
1980
 
1071
1981
  result = subprocess.run(
1072
1982
  [