ravmem-cli 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
ravmem/cli/main.py ADDED
@@ -0,0 +1,96 @@
1
+ """CLI entry point — registers all subcommands."""
2
+
3
+ import logging
4
+ from typing import Optional
5
+
6
+ import typer
7
+
8
+
9
+ def setup_logging(verbose: bool = False) -> None:
10
+ """Configure root logger based on verbosity or RAVMEM_LOG_LEVEL env var."""
11
+ import os
12
+ env_level = os.environ.get("RAVMEM_LOG_LEVEL", "").upper()
13
+ if verbose:
14
+ level = logging.DEBUG
15
+ elif env_level in ("DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"):
16
+ level = getattr(logging, env_level)
17
+ else:
18
+ level = logging.INFO
19
+ logging.basicConfig(
20
+ level=level,
21
+ format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
22
+ datefmt="%H:%M:%S",
23
+ )
24
+
25
+
26
+ def version_callback(value: bool):
27
+ if value:
28
+ typer.echo("RavMem CLI v0.2.0")
29
+ raise typer.Exit()
30
+
31
+
32
+ BANNER = r"""
33
+ [bold cyan]
34
+ _____ __ __
35
+ | __ \ | \/ |
36
+ | |__) | __ ___ ___ __ ___ | \ / | ___ _ __ ___
37
+ | _ / / _` \ \ / / '_ ` _ \| |\/| |/ _ \ '_ ` _ \
38
+ | | \ \ (_| |\ V /| | | | | | | | | __/ | | | | |
39
+ |_| \_\__,_| \_/ |_| |_| |_|_| |_|\___|_| |_| |_|
40
+ [/bold cyan]
41
+ [bold]Persistent Memory & Context Engine for AI Agent Teams[/bold]
42
+ """
43
+
44
+ app = typer.Typer(
45
+ name="ravmem",
46
+ help="RavMem — code knowledge graph engine",
47
+ rich_markup_mode="rich",
48
+ )
49
+
50
+
51
+ @app.callback(invoke_without_command=True)
52
+ def main(
53
+ ctx: typer.Context,
54
+ verbose: bool = typer.Option(False, "--verbose", "-v", help="Enable debug logging"),
55
+ version: Optional[bool] = typer.Option(
56
+ None, "--version", "-V", help="Show version and exit", callback=version_callback, is_eager=True
57
+ ),
58
+ ):
59
+ """
60
+ RavMem — The Code Knowledge Graph Engine.
61
+ """
62
+ setup_logging(verbose)
63
+ if ctx.invoked_subcommand is None and not version:
64
+ from rich.console import Console
65
+ Console().print(BANNER)
66
+ typer.echo(ctx.get_help())
67
+
68
+ from ravmem.cli.analyze import analyze_cmd
69
+ from ravmem.cli.auth import app as auth_app
70
+ from ravmem.cli.config_cmd import app as config_app
71
+ from ravmem.cli.impact import impact_cmd
72
+ from ravmem.cli.kg import app as kg_app
73
+ from ravmem.cli.remote import app as remote_app
74
+ from ravmem.cli.search import search_cmd
75
+ from ravmem.cli.serve import serve_cmd
76
+ from ravmem.cli.info import health_cmd, metrics_cmd
77
+ from ravmem.cli.diff import diff_impact_cmd
78
+ from ravmem.cli.browse import clusters_cmd, symbols_cmd
79
+
80
+ app.command("analyze")(analyze_cmd)
81
+ app.command("search")(search_cmd)
82
+ app.command("impact")(impact_cmd)
83
+ app.command("serve")(serve_cmd)
84
+ app.command("health")(health_cmd)
85
+ app.command("metrics")(metrics_cmd)
86
+ app.command("diff-impact")(diff_impact_cmd)
87
+ app.command("clusters")(clusters_cmd)
88
+ app.command("symbols")(symbols_cmd)
89
+ app.add_typer(auth_app, name="auth")
90
+ app.add_typer(config_app, name="config")
91
+ app.add_typer(remote_app, name="remote")
92
+ app.add_typer(kg_app, name="kg")
93
+
94
+
95
+ if __name__ == "__main__":
96
+ app()
ravmem/cli/remote.py ADDED
@@ -0,0 +1,549 @@
1
+ """ravmem remote — manage a remote RavMem server."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Optional
6
+
7
+ import typer
8
+ from rich.table import Table
9
+
10
+ app = typer.Typer(
11
+ name="remote",
12
+ help="Manage remote RavMem server",
13
+ no_args_is_help=True,
14
+ )
15
+
16
+
17
+ def _console():
18
+ """Lazy import of rich console."""
19
+ from rich.console import Console
20
+
21
+ return Console()
22
+
23
+
24
+ def _get_client():
25
+ """Build a client from saved config or exit with an error."""
26
+ from ravmem.client.config import get_client_from_config
27
+
28
+ client = get_client_from_config()
29
+ if client is None:
30
+ typer.echo(
31
+ "Remote server not configured. Run 'ravmem remote config --url <URL>' first.",
32
+ err=True,
33
+ )
34
+ raise typer.Exit(1)
35
+ return client
36
+
37
+
38
+ def _resolve_project(explicit: str | None) -> str:
39
+ """Return *explicit* if given, else fall back to config default_project."""
40
+ if explicit:
41
+ return explicit
42
+ from ravmem.client.config import load_config
43
+
44
+ cfg = load_config()
45
+ proj = cfg.get("default_project", "")
46
+ if not proj:
47
+ typer.echo(
48
+ "No --project given and no default_project configured. "
49
+ "Run 'ravmem remote use <project_id>' to set a default.",
50
+ err=True,
51
+ )
52
+ raise typer.Exit(1)
53
+ return proj
54
+
55
+
56
+ def _resolve_branch(explicit: str | None) -> str:
57
+ """Return *explicit* if given, else fall back to config default_branch."""
58
+ if explicit:
59
+ return explicit
60
+ from ravmem.client.config import load_config
61
+
62
+ cfg = load_config()
63
+ return cfg.get("default_branch", "") or "main"
64
+
65
+
66
+ # ------------------------------------------------------------------
67
+ # Commands
68
+ # ------------------------------------------------------------------
69
+
70
+
71
+ @app.command("config")
72
+ def config_cmd(
73
+ url: Optional[str] = typer.Option(None, "--url", help="Server URL"),
74
+ api_key: Optional[str] = typer.Option(None, "--api-key", help="API key"),
75
+ ) -> None:
76
+ """Configure server connection. Saved to ~/.ravmem/client.json"""
77
+ from ravmem.client.config import load_config, save_config
78
+
79
+ cfg = load_config()
80
+ if url is not None:
81
+ cfg["server_url"] = url.rstrip("/")
82
+ if api_key is not None:
83
+ cfg["api_key"] = api_key
84
+ save_config(cfg)
85
+ typer.echo("Configuration saved.")
86
+ typer.echo(f" server_url: {cfg['server_url']}")
87
+ typer.echo(f" api_key: {'***' if cfg['api_key'] else '(none)'}")
88
+
89
+
90
+ @app.command("projects")
91
+ def projects_cmd(
92
+ output_json: bool = typer.Option(False, "--json", help="Output raw JSON"),
93
+ ) -> None:
94
+ """List all projects on the server."""
95
+ client = _get_client()
96
+ try:
97
+ projects = client.list_projects()
98
+ finally:
99
+ client.close()
100
+
101
+ if output_json:
102
+ import json
103
+ typer.echo(json.dumps(projects, indent=2, default=str))
104
+ return
105
+
106
+ if not projects:
107
+ typer.echo("No projects found.")
108
+ return
109
+
110
+ table = Table(title="Projects")
111
+ table.add_column("ID", style="cyan")
112
+ table.add_column("Name")
113
+ table.add_column("Default Branch")
114
+ table.add_column("Repo")
115
+ for p in projects:
116
+ table.add_row(
117
+ p.get("id", ""),
118
+ p.get("name", ""),
119
+ p.get("default_branch", ""),
120
+ p.get("repo_url", "") or p.get("repo_path", ""),
121
+ )
122
+ _console().print(table)
123
+
124
+
125
+ @app.command("add")
126
+ def add_cmd(
127
+ path: Optional[str] = typer.Argument(
128
+ None,
129
+ help="Local path to the repo (upload mode) or server path (mount mode). "
130
+ "Omit when using --git-url.",
131
+ ),
132
+ name: Optional[str] = typer.Option(None, "--name", help="Project display name"),
133
+ default_branch: str = typer.Option(
134
+ "main", "--default-branch",
135
+ help="Primary branch (used as base for new-branch copy-from-base optimisation)",
136
+ ),
137
+ # Clone mode
138
+ git_url: Optional[str] = typer.Option(
139
+ None, "--git-url",
140
+ help="Git remote URL — server clones this. Selects clone mode.",
141
+ ),
142
+ git_username: Optional[str] = typer.Option(
143
+ None, "--git-username",
144
+ help="Git username for private repos (clone mode only).",
145
+ ),
146
+ git_token: Optional[str] = typer.Option(
147
+ None, "--git-token",
148
+ help="Git token/password for private repos (clone mode only). "
149
+ "Stored encrypted on the server — never logged.",
150
+ hide_input=True,
151
+ ),
152
+ # Mount mode
153
+ mount: bool = typer.Option(
154
+ False, "--mount",
155
+ help="Mount mode: treat PATH as a volume already mounted on the server. "
156
+ "No file upload; admin keeps the mount current.",
157
+ ),
158
+ ) -> None:
159
+ """Register a project on the remote server.
160
+
161
+ \b
162
+ Three modes — pick exactly one:
163
+
164
+ Upload (default when PATH is given):
165
+ ravmem remote add /local/repo --name myproject
166
+
167
+ Clone from public GitHub/GitLab:
168
+ ravmem remote add --git-url https://github.com/org/repo --name myproject
169
+
170
+ Clone from private repo:
171
+ ravmem remote add --git-url https://github.com/org/private \\
172
+ --git-username myuser --git-token ghp_xxx --name myproject
173
+
174
+ Mount (server-side volume / symlink):
175
+ ravmem remote add /mounted/repo --name myproject --mount
176
+ """
177
+ if not git_url and not path:
178
+ typer.echo(
179
+ "✗ Specify a source:\n"
180
+ " Upload: ravmem remote add <PATH>\n"
181
+ " Clone: ravmem remote add --git-url <URL>\n"
182
+ " Mount: ravmem remote add <PATH> --mount",
183
+ err=True,
184
+ )
185
+ raise typer.Exit(1)
186
+
187
+ client = _get_client()
188
+ try:
189
+ result = client.create_project(
190
+ git_url=git_url,
191
+ git_username=git_username,
192
+ git_token=git_token,
193
+ repo_path=path,
194
+ mount=mount,
195
+ name=name,
196
+ default_branch=default_branch,
197
+ )
198
+ finally:
199
+ client.close()
200
+
201
+ mode = result.get("index_mode", "upload")
202
+ typer.echo(f"✓ Project created: {result.get('id', '')}")
203
+ typer.echo(f" name: {result.get('name', '')}")
204
+ typer.echo(f" mode: {mode}")
205
+ typer.echo(f" default branch: {result.get('default_branch', '')}")
206
+ if mode == "upload":
207
+ typer.echo(
208
+ "\n Next: run 'ravmem analyze <PATH> --project "
209
+ + result.get("id", "<id>")
210
+ + "' to upload and index."
211
+ )
212
+ elif mode == "clone":
213
+ typer.echo(
214
+ "\n Next: run 'ravmem analyze --project "
215
+ + result.get("id", "<id>")
216
+ + "' to trigger the first index."
217
+ )
218
+
219
+
220
+ @app.command("use")
221
+ def use_cmd(
222
+ project_id: str = typer.Argument(..., help="Project ID to set as default"),
223
+ ) -> None:
224
+ """Set the default project for remote commands."""
225
+ from ravmem.client.config import load_config, save_config
226
+
227
+ cfg = load_config()
228
+ cfg["default_project"] = project_id
229
+ save_config(cfg)
230
+ typer.echo(f"Default project set to '{project_id}'.")
231
+
232
+
233
+ @app.command("branches")
234
+ def branches_cmd(
235
+ project: Optional[str] = typer.Option(None, "--project", "-p", help="Project ID"),
236
+ output_json: bool = typer.Option(False, "--json", help="Output raw JSON"),
237
+ ) -> None:
238
+ """List branches and their index status."""
239
+ project_id = _resolve_project(project)
240
+ client = _get_client()
241
+ try:
242
+ branches = client.list_branches(project_id)
243
+ finally:
244
+ client.close()
245
+
246
+ if output_json:
247
+ import json
248
+ typer.echo(json.dumps(branches, indent=2, default=str))
249
+ return
250
+
251
+ if not branches:
252
+ typer.echo("No branches indexed.")
253
+ return
254
+
255
+ table = Table(title=f"Branches for {project_id}")
256
+ table.add_column("Branch", style="cyan")
257
+ table.add_column("Status")
258
+ table.add_column("Symbols")
259
+ table.add_column("Last Indexed")
260
+ for b in branches:
261
+ table.add_row(
262
+ b.get("branch", ""),
263
+ b.get("status", ""),
264
+ str(b.get("symbol_count", "")),
265
+ b.get("last_indexed", ""),
266
+ )
267
+ _console().print(table)
268
+
269
+
270
+ @app.command("index")
271
+ def index_cmd(
272
+ project: Optional[str] = typer.Option(None, "--project", "-p", help="Project ID"),
273
+ branch: Optional[str] = typer.Option(None, "--branch", "-b", help="Branch to index"),
274
+ mode: str = typer.Option("auto", "--mode", help="Index mode: full, incremental, auto"),
275
+ wait: bool = typer.Option(False, "--wait", "-w", help="Wait for indexing to complete"),
276
+ ) -> None:
277
+ """Trigger indexing on the remote server."""
278
+ project_id = _resolve_project(project)
279
+ branch_name = _resolve_branch(branch)
280
+ client = _get_client()
281
+ try:
282
+ result = client.index_branch(project_id, branch_name, mode=mode)
283
+ job_id = result.get("job_id")
284
+ typer.echo(f"Indexing started: job_id={job_id}")
285
+
286
+ if wait and job_id is not None:
287
+ typer.echo("Waiting for completion...")
288
+ job = client.poll_job(job_id)
289
+ status = job.get("status", "unknown")
290
+ typer.echo(f"Job {job_id} finished: {status}")
291
+ if status == "failed":
292
+ typer.echo(f" reason: {job.get('failure_reason', 'unknown')}", err=True)
293
+ finally:
294
+ client.close()
295
+
296
+
297
+ @app.command("delete")
298
+ def delete_cmd(
299
+ project: Optional[str] = typer.Option(None, "--project", "-p", help="Project ID"),
300
+ branch: Optional[str] = typer.Option(None, "--branch", "-b",
301
+ help="Branch name — if given, deletes only that branch index (not the whole project)"),
302
+ yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation prompt"),
303
+ ) -> None:
304
+ """Delete a project or a single branch index from the server."""
305
+ client = _get_client()
306
+ project_id = _resolve_project(project)
307
+ try:
308
+ if branch:
309
+ if not yes:
310
+ typer.confirm(
311
+ f"Delete branch index '{branch}' in project '{project_id}'?", abort=True
312
+ )
313
+ result = client.delete_branch_index(project_id, branch)
314
+ typer.echo(f"Branch index '{branch}' deleted from project '{project_id}'.")
315
+ else:
316
+ if not yes:
317
+ typer.confirm(
318
+ f"Delete project '{project_id}' and ALL its indexes? This cannot be undone.",
319
+ abort=True,
320
+ )
321
+ result = client.delete_project(project_id)
322
+ typer.echo(f"Project '{project_id}' deleted.")
323
+ finally:
324
+ client.close()
325
+
326
+
327
+ @app.command("status")
328
+ def status_cmd(
329
+ job_id: Optional[int] = typer.Option(None, "--job-id", "-j", help="Job ID to check"),
330
+ project: Optional[str] = typer.Option(None, "--project", "-p", help="Project ID"),
331
+ branch: Optional[str] = typer.Option(None, "--branch", "-b", help="Branch name"),
332
+ output_json: bool = typer.Option(False, "--json", help="Output raw JSON"),
333
+ ) -> None:
334
+ """Check indexing job or branch index status."""
335
+ client = _get_client()
336
+ try:
337
+ if job_id is not None:
338
+ job = client.get_job(job_id)
339
+ if output_json:
340
+ import json
341
+ typer.echo(json.dumps(job, indent=2, default=str))
342
+ return
343
+ typer.echo(f"Job {job_id}:")
344
+ typer.echo(f" status: {job.get('status', '')}")
345
+ typer.echo(f" started: {job.get('started_at', '')}")
346
+ typer.echo(f" finished: {job.get('ended_at', '')}")
347
+ if job.get("failure_reason"):
348
+ typer.echo(f" reason: {job['failure_reason']}")
349
+ return
350
+
351
+ project_id = _resolve_project(project)
352
+ branch_name = _resolve_branch(branch)
353
+ info = client.get_branch_status(project_id, branch_name)
354
+
355
+ if output_json:
356
+ import json
357
+ typer.echo(json.dumps(info, indent=2, default=str))
358
+ return
359
+
360
+ status = info.get("status", "unknown")
361
+ index_mode = info.get("index_mode", "mount")
362
+ last_commit = info.get("last_indexed_commit") or ""
363
+ commit_msg = info.get("last_indexed_commit_message") or ""
364
+ indexed_at = info.get("last_indexed_at") or ""
365
+ symbol_count = info.get("symbol_count", 0)
366
+ edge_count = info.get("edge_count", 0)
367
+ parent_branch = info.get("parent_branch") or ""
368
+ parent_commit = info.get("parent_last_indexed_commit") or ""
369
+ active_job = info.get("active_job")
370
+
371
+ commit_display = f"{last_commit[:7]} \"{commit_msg}\"" if last_commit else "(none)"
372
+ parent_display = (
373
+ f"{parent_branch} (indexed at {parent_commit[:7]})"
374
+ if parent_branch and parent_commit
375
+ else parent_branch or "(none)"
376
+ )
377
+
378
+ col = 24
379
+ typer.echo(f"{'Branch:':<{col}}{branch_name}")
380
+ typer.echo(f"{'Status:':<{col}}{status}")
381
+ typer.echo(f"{'Index mode:':<{col}}{index_mode}")
382
+ typer.echo(f"{'Last indexed commit:':<{col}}{commit_display}")
383
+ typer.echo(f"{'Last indexed at:':<{col}}{indexed_at or '(never)'}")
384
+ typer.echo(f"{'Symbols:':<{col}}{symbol_count:,}")
385
+ typer.echo(f"{'Edges:':<{col}}{edge_count:,}")
386
+ typer.echo(f"{'Parent branch:':<{col}}{parent_display}")
387
+
388
+ if active_job:
389
+ jid = active_job.get("id", "?")
390
+ jstatus = active_job.get("status", "")
391
+ phase = active_job.get("current_phase", "")
392
+ pct = active_job.get("progress_pct", 0)
393
+ typer.echo(f"\nActive job #{jid}: {jstatus} phase={phase} {pct}%")
394
+
395
+ if status == "failed":
396
+ typer.echo("\nWarning: last indexing run failed.", err=True)
397
+ elif status == "stale":
398
+ typer.echo("\nNote: index may be out of date — run `ravmem analyze` to refresh.")
399
+ finally:
400
+ client.close()
401
+
402
+
403
+ @app.command("sync")
404
+ def sync_cmd(
405
+ project: Optional[str] = typer.Option(None, "--project", "-p", help="Project ID"),
406
+ ) -> None:
407
+ """Fetch the latest remote commits into the server-side workspace (clone-mode only).
408
+
409
+ This pulls new commits from the remote without re-indexing.
410
+ Run `ravmem analyze --project <id>` afterwards to index the new changes.
411
+ """
412
+ client = _get_client()
413
+ try:
414
+ project_id = _resolve_project(project)
415
+ typer.echo(f"Syncing workspace for project '{project_id}'…")
416
+ result = client.sync_workspace(project_id)
417
+
418
+ duration_s = result.get("duration_ms", 0) / 1000
419
+ fetched_branches = result.get("fetched_branches", [])
420
+ fetched_commits = result.get("fetched_commits", 0)
421
+ head = result.get("workspace_head", "")
422
+
423
+ head_display = f" (workspace @ {head})" if head else ""
424
+ commits_display = f"{fetched_commits} commit{'s' if fetched_commits != 1 else ''} fetched"
425
+ branches_display = (
426
+ f", {len(fetched_branches)} new branch{'es' if len(fetched_branches) != 1 else ''}"
427
+ f" ({', '.join(fetched_branches)})"
428
+ if fetched_branches else ""
429
+ )
430
+
431
+ typer.echo(
432
+ f"✓ Synced in {duration_s:.1f}s — {commits_display}"
433
+ f"{branches_display}{head_display}"
434
+ )
435
+ if fetched_commits > 0 or fetched_branches:
436
+ typer.echo(
437
+ " Run `ravmem analyze --project <id>` to index the new changes."
438
+ )
439
+ else:
440
+ typer.echo(" Already up to date.")
441
+ except Exception as exc:
442
+ typer.echo(f"✗ Sync failed: {exc}", err=True)
443
+ raise typer.Exit(1)
444
+ finally:
445
+ client.close()
446
+
447
+
448
+ @app.command("reset-workspace")
449
+ def reset_workspace_cmd(
450
+ project: Optional[str] = typer.Option(None, "--project", "-p", help="Project ID"),
451
+ yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation prompt"),
452
+ ) -> None:
453
+ """Delete the server-side workspace and reset all branch index records.
454
+
455
+ Valid for clone and upload mode projects. The workspace directory is
456
+ removed and all branch statuses are set to 'empty', so the next
457
+ `ravmem analyze` or clone will start from scratch.
458
+
459
+ Mount-mode projects cannot be reset — their workspace is managed externally.
460
+ """
461
+ client = _get_client()
462
+ try:
463
+ project_id = _resolve_project(project)
464
+ if not yes:
465
+ typer.confirm(
466
+ f"Reset workspace for project '{project_id}'? "
467
+ "This removes the server-side checkout and clears all branch indexes.",
468
+ abort=True,
469
+ )
470
+ result = client.reset_workspace(project_id)
471
+
472
+ branches_cleared = result.get("branches_cleared", 0)
473
+ removed = result.get("removed_workspace", False)
474
+ typer.echo(f"✓ Workspace reset for project '{project_id}'.")
475
+ if removed:
476
+ typer.echo(" Workspace directory removed.")
477
+ typer.echo(f" {branches_cleared} branch record{'s' if branches_cleared != 1 else ''} cleared.")
478
+ typer.echo(
479
+ " Next: run `ravmem analyze --project <id>` (upload) or "
480
+ "`ravmem analyze --project <id>` (clone) to re-index."
481
+ )
482
+ except typer.Abort:
483
+ typer.echo("Aborted.")
484
+ except Exception as exc:
485
+ typer.echo(f"✗ Reset failed: {exc}", err=True)
486
+ raise typer.Exit(1)
487
+ finally:
488
+ client.close()
489
+
490
+
491
+ @app.command("set-credentials")
492
+ def set_credentials_cmd(
493
+ project: Optional[str] = typer.Option(None, "--project", "-p", help="Project ID"),
494
+ git_username: Optional[str] = typer.Option(None, "--git-username", help="Git username"),
495
+ git_token: Optional[str] = typer.Option(
496
+ None, "--git-token", help="Git token/password (stored encrypted on server)",
497
+ hide_input=True,
498
+ ),
499
+ ) -> None:
500
+ """Store or replace encrypted git credentials for a clone-mode project.
501
+
502
+ Credentials are encrypted server-side (requires RAVMEM_SECRET_KEY on the server).
503
+ Use this to update credentials after rotating a token without re-registering the project.
504
+ """
505
+ project_id = _resolve_project(project)
506
+ if not git_username:
507
+ git_username = typer.prompt("Git username")
508
+ if not git_token:
509
+ git_token = typer.prompt("Git token/password", hide_input=True)
510
+ client = _get_client()
511
+ try:
512
+ client.set_credentials(project_id, git_username, git_token)
513
+ typer.echo(f"✓ Credentials stored for project '{project_id}'.")
514
+ typer.echo(" Token is encrypted at rest. Run `ravmem analyze` to use the updated credentials.")
515
+ except Exception as exc:
516
+ typer.echo(f"✗ Failed to store credentials: {exc}", err=True)
517
+ raise typer.Exit(1)
518
+ finally:
519
+ client.close()
520
+
521
+
522
+ @app.command("delete-credentials")
523
+ def delete_credentials_cmd(
524
+ project: Optional[str] = typer.Option(None, "--project", "-p", help="Project ID"),
525
+ yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation prompt"),
526
+ ) -> None:
527
+ """Remove stored git credentials from a clone-mode project.
528
+
529
+ The project will revert to unauthenticated cloning (public repos only).
530
+ """
531
+ project_id = _resolve_project(project)
532
+ if not yes:
533
+ typer.confirm(
534
+ f"Remove stored credentials for project '{project_id}'?", abort=True
535
+ )
536
+ client = _get_client()
537
+ try:
538
+ result = client.delete_credentials(project_id)
539
+ if result.get("deleted"):
540
+ typer.echo(f"✓ Credentials deleted for project '{project_id}'.")
541
+ else:
542
+ typer.echo(f" No credentials were stored for project '{project_id}'.")
543
+ except typer.Abort:
544
+ typer.echo("Aborted.")
545
+ except Exception as exc:
546
+ typer.echo(f"✗ Failed to delete credentials: {exc}", err=True)
547
+ raise typer.Exit(1)
548
+ finally:
549
+ client.close()