context-forge-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.
Files changed (37) hide show
  1. context_forge_cli-0.1.0.dist-info/METADATA +340 -0
  2. context_forge_cli-0.1.0.dist-info/RECORD +37 -0
  3. context_forge_cli-0.1.0.dist-info/WHEEL +4 -0
  4. context_forge_cli-0.1.0.dist-info/entry_points.txt +2 -0
  5. contextforge/__init__.py +3 -0
  6. contextforge/adapters/__init__.py +3 -0
  7. contextforge/adapters/altimate_code.py +179 -0
  8. contextforge/adapters/base.py +44 -0
  9. contextforge/adapters/claude_code.py +393 -0
  10. contextforge/adapters/claude_desktop.py +306 -0
  11. contextforge/adapters/codex.py +220 -0
  12. contextforge/adapters/registry.py +31 -0
  13. contextforge/cli.py +523 -0
  14. contextforge/core/__init__.py +0 -0
  15. contextforge/core/analytics.py +226 -0
  16. contextforge/core/compactor.py +182 -0
  17. contextforge/core/db.py +181 -0
  18. contextforge/core/injector.py +94 -0
  19. contextforge/core/scanner.py +72 -0
  20. contextforge/core/summarizer.py +155 -0
  21. contextforge/core/token_analyzer.py +91 -0
  22. contextforge/models/__init__.py +4 -0
  23. contextforge/models/config.py +34 -0
  24. contextforge/models/session.py +37 -0
  25. contextforge/tui/__init__.py +0 -0
  26. contextforge/tui/app.py +204 -0
  27. contextforge/tui/styles.tcss +50 -0
  28. contextforge/tui/widgets/__init__.py +0 -0
  29. contextforge/tui/widgets/session_detail.py +191 -0
  30. contextforge/tui/widgets/session_table.py +277 -0
  31. contextforge/tui/widgets/stats_panel.py +340 -0
  32. contextforge/tui/widgets/status_bar.py +102 -0
  33. contextforge/tui/widgets/tokens_panel.py +134 -0
  34. contextforge/tui/widgets/transfer_panel.py +119 -0
  35. contextforge/utils/__init__.py +0 -0
  36. contextforge/utils/display.py +105 -0
  37. contextforge/utils/tokens.py +17 -0
contextforge/cli.py ADDED
@@ -0,0 +1,523 @@
1
+ """ContextForge CLI — cf command."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import sys
6
+ from pathlib import Path
7
+ from typing import Annotated, Optional
8
+
9
+ import typer
10
+ from rich.console import Console
11
+ from rich.panel import Panel
12
+
13
+ from contextforge.core import db as db_module
14
+ from contextforge.core.compactor import compact
15
+ from contextforge.core.injector import build_inject_command, execute_transfer
16
+ from contextforge.core.scanner import scan
17
+ from contextforge.core.summarizer import batch_summarize, summarize_session
18
+ from contextforge.models.config import ForgeConfig
19
+ from contextforge.utils.display import console, err_console, sessions_table
20
+
21
+ app = typer.Typer(
22
+ name="cf",
23
+ help="ContextForge — session manager and context bridge for agentic CLI tools.",
24
+ add_completion=False,
25
+ no_args_is_help=True,
26
+ )
27
+
28
+ config_app = typer.Typer(name="config", help="Manage ContextForge configuration.")
29
+ app.add_typer(config_app, name="config")
30
+
31
+
32
+ def _get_config() -> ForgeConfig:
33
+ return ForgeConfig()
34
+
35
+
36
+ def _get_db(cfg: ForgeConfig):
37
+ return db_module.get_db(cfg.db_path)
38
+
39
+
40
+ # ---------------------------------------------------------------------------
41
+ # cf scan
42
+ # ---------------------------------------------------------------------------
43
+
44
+ @app.command(name="scan")
45
+ def scan_cmd(
46
+ quiet: Annotated[bool, typer.Option("--quiet", "-q")] = False,
47
+ summarize: Annotated[bool, typer.Option("--summarize", "-s")] = False,
48
+ ):
49
+ """Discover and index sessions from all installed agentic tools.
50
+
51
+ Use --summarize to also generate summaries for new sessions after scanning.
52
+ Requires ANTHROPIC_API_KEY (or api_key in config) for LLM summaries;
53
+ falls back to first-message preview if no key is set.
54
+ """
55
+ cfg = _get_config()
56
+ database = _get_db(cfg)
57
+ result = scan(database, quiet=quiet)
58
+
59
+ if not quiet:
60
+ console.print(
61
+ f"[green]Scan complete:[/green] "
62
+ f"{result.new} new, {result.updated} updated, {result.unchanged} unchanged"
63
+ )
64
+ for err in result.errors:
65
+ err_console.print(f"[yellow]Warning:[/yellow] {err}")
66
+
67
+ if summarize and result.sessions:
68
+ new_ids = [s.id for s in result.sessions]
69
+ if not quiet:
70
+ console.print(f"[dim]Summarizing {len(new_ids)} sessions...[/dim]")
71
+
72
+ from rich.progress import Progress, SpinnerColumn, TextColumn
73
+ with Progress(
74
+ SpinnerColumn(),
75
+ TextColumn("[progress.description]{task.description}"),
76
+ disable=quiet,
77
+ ) as progress:
78
+ task = progress.add_task("Summarizing...", total=len(new_ids))
79
+
80
+ def on_prog(sid: str, summary: str | None) -> None:
81
+ status = "[green]ok[/green]" if summary else "[dim]skipped[/dim]"
82
+ progress.advance(task)
83
+ progress.update(task, description=f"Summarized {sid[:12]} {status}")
84
+
85
+ summary_result = batch_summarize(
86
+ db=database,
87
+ config=cfg,
88
+ session_ids=new_ids,
89
+ on_progress=on_prog,
90
+ )
91
+
92
+ if not quiet:
93
+ console.print(
94
+ f"[green]Summaries:[/green] "
95
+ f"{summary_result.summarized} generated, {summary_result.skipped} skipped"
96
+ )
97
+
98
+ sys.exit(0 if not result.errors or result.total > 0 else 1)
99
+
100
+
101
+ # ---------------------------------------------------------------------------
102
+ # cf ls
103
+ # ---------------------------------------------------------------------------
104
+
105
+ @app.command(name="ls")
106
+ def list_sessions(
107
+ tool: Annotated[Optional[str], typer.Option("--tool", "-t")] = None,
108
+ limit: Annotated[int, typer.Option("--limit", "-n")] = 50,
109
+ fmt: Annotated[str, typer.Option("--format", "-f")] = "table",
110
+ ):
111
+ """List sessions."""
112
+ cfg = _get_config()
113
+ database = _get_db(cfg)
114
+ rows = db_module.get_sessions(database, tool=tool, limit=limit)
115
+
116
+ if fmt == "json":
117
+ console.print_json(json.dumps(rows))
118
+ return
119
+
120
+ if not rows:
121
+ console.print("[dim]No sessions found. Run [bold]cf scan[/bold] first.[/dim]")
122
+ return
123
+
124
+ table = sessions_table(rows)
125
+ console.print(table)
126
+ console.print(f"[dim]{len(rows)} sessions[/dim]")
127
+
128
+
129
+ # ---------------------------------------------------------------------------
130
+ # cf show
131
+ # ---------------------------------------------------------------------------
132
+
133
+ @app.command()
134
+ def show(
135
+ session_id: str,
136
+ fmt: Annotated[str, typer.Option("--format", "-f")] = "rich",
137
+ ):
138
+ """Show detail and summary for a session."""
139
+ cfg = _get_config()
140
+ database = _get_db(cfg)
141
+ row = db_module.get_session(database, session_id)
142
+
143
+ if row is None:
144
+ err_console.print(f"[red]Session not found:[/red] {session_id}")
145
+ raise typer.Exit(1)
146
+
147
+ if fmt == "json":
148
+ console.print_json(json.dumps(row))
149
+ return
150
+
151
+ summary = row.get("summary") or "[dim]No summary yet — run [bold]cf summarize[/bold][/dim]"
152
+ panel = Panel(
153
+ f"[bold]{row.get('title') or '(no title)'}[/bold]\n\n"
154
+ f"Tool: {row.get('tool')}\n"
155
+ f"CWD: {row.get('cwd') or '?'}\n"
156
+ f"Tokens: {row.get('token_count') or '?'}\n"
157
+ f"Status: {row.get('status')}\n\n"
158
+ f"[bold]Summary:[/bold]\n{summary}",
159
+ title=f"Session {session_id[:16]}",
160
+ )
161
+ console.print(panel)
162
+
163
+
164
+ # ---------------------------------------------------------------------------
165
+ # cf summarize
166
+ # ---------------------------------------------------------------------------
167
+
168
+ @app.command()
169
+ def summarize(
170
+ session_id: Annotated[Optional[str], typer.Argument()] = None,
171
+ all_sessions: Annotated[bool, typer.Option("--all")] = False,
172
+ force: Annotated[bool, typer.Option("--force")] = False,
173
+ ):
174
+ """Generate or refresh a summary for one or all sessions."""
175
+ cfg = _get_config()
176
+ database = _get_db(cfg)
177
+
178
+ if all_sessions:
179
+ from rich.progress import Progress, SpinnerColumn, TextColumn
180
+
181
+ rows = db_module.get_sessions(database, limit=10_000)
182
+ pending = [r for r in rows if not r.get("summary") or force]
183
+ console.print(f"Summarizing {len(pending)} sessions...")
184
+
185
+ with Progress(
186
+ SpinnerColumn(),
187
+ TextColumn("[progress.description]{task.description}"),
188
+ ) as progress:
189
+ task = progress.add_task("Summarizing...", total=len(pending))
190
+
191
+ def on_prog(sid: str, summary: str | None) -> None:
192
+ status = "[green]ok[/green]" if summary else "[dim]skipped[/dim]"
193
+ progress.advance(task)
194
+ progress.update(task, description=f"{sid[:16]} {status}")
195
+
196
+ result = batch_summarize(
197
+ db=database,
198
+ config=cfg,
199
+ session_ids=[r["id"] for r in pending],
200
+ force=force,
201
+ on_progress=on_prog,
202
+ )
203
+
204
+ console.print(
205
+ f"[green]Done:[/green] {result.summarized} generated, "
206
+ f"{result.skipped} skipped"
207
+ + (f", {len(result.errors)} errors" if result.errors else "")
208
+ )
209
+ return
210
+
211
+ if session_id is None:
212
+ err_console.print("[red]Provide a session ID or use --all[/red]")
213
+ raise typer.Exit(1)
214
+
215
+ summary = summarize_session(database, session_id, cfg, force=force)
216
+ if summary:
217
+ console.print(Panel(summary, title="Summary"))
218
+ else:
219
+ err_console.print("[yellow]Could not generate summary (no messages or no API key)[/yellow]")
220
+
221
+
222
+ # ---------------------------------------------------------------------------
223
+ # cf compact
224
+ # ---------------------------------------------------------------------------
225
+
226
+ @app.command(name="compact")
227
+ def compact_cmd(
228
+ session_ids: Annotated[list[str], typer.Argument()],
229
+ strategy: Annotated[str, typer.Option("--strategy", "-s")] = "summary_only",
230
+ budget: Annotated[int, typer.Option("--budget", "-b")] = 4096,
231
+ name: Annotated[Optional[str], typer.Option("--name")] = None,
232
+ target_tool: Annotated[Optional[str], typer.Option("--to")] = None,
233
+ fmt: Annotated[str, typer.Option("--format", "-f")] = "rich",
234
+ save: Annotated[bool, typer.Option("--save")] = False,
235
+ ):
236
+ """Create a ContextBundle from one or more sessions."""
237
+ cfg = _get_config()
238
+ database = _get_db(cfg)
239
+
240
+ bundle = compact(
241
+ db=database,
242
+ session_ids=session_ids,
243
+ strategy=strategy,
244
+ token_budget=budget,
245
+ name=name,
246
+ target_tool=target_tool,
247
+ )
248
+
249
+ if fmt == "json":
250
+ console.print_json(bundle.model_dump_json())
251
+ return
252
+
253
+ console.print(Panel(
254
+ f"Strategy: {bundle.strategy}\n"
255
+ f"Sessions: {', '.join(bundle.source_sessions)}\n"
256
+ f"Tokens: {bundle.token_count} / {budget}\n\n"
257
+ f"[bold]Context:[/bold]\n{bundle.compacted_text[:1000]}"
258
+ + ("[dim]…[/dim]" if len(bundle.compacted_text) > 1000 else ""),
259
+ title=f"Bundle: {bundle.name}",
260
+ ))
261
+
262
+ if save:
263
+ bundle_id = db_module.save_bundle(database, bundle)
264
+ console.print(f"[green]Saved bundle ID:[/green] {bundle_id}")
265
+
266
+
267
+ # ---------------------------------------------------------------------------
268
+ # cf transfer
269
+ # ---------------------------------------------------------------------------
270
+
271
+ @app.command()
272
+ def transfer(
273
+ session_ids: Annotated[list[str], typer.Argument()],
274
+ target_tool: Annotated[str, typer.Option("--to")] = ...,
275
+ strategy: Annotated[str, typer.Option("--strategy", "-s")] = "summary_only",
276
+ budget: Annotated[int, typer.Option("--budget", "-b")] = 4096,
277
+ target_session: Annotated[Optional[str], typer.Option("--session")] = None,
278
+ cwd: Annotated[Optional[str], typer.Option("--cwd")] = None,
279
+ method: Annotated[Optional[str], typer.Option("--method")] = None,
280
+ execute: Annotated[bool, typer.Option("--execute")] = False,
281
+ fmt: Annotated[str, typer.Option("--format", "-f")] = "rich",
282
+ ):
283
+ """Compact context from sessions and inject into a new/existing session.
284
+
285
+ By default this is a dry run — prints the command without executing.
286
+ Use --execute to actually launch the target tool.
287
+ """
288
+ cfg = _get_config()
289
+ database = _get_db(cfg)
290
+
291
+ bundle = compact(
292
+ db=database,
293
+ session_ids=session_ids,
294
+ strategy=strategy,
295
+ token_budget=budget,
296
+ target_tool=target_tool,
297
+ )
298
+
299
+ cmd, actual_method = build_inject_command(
300
+ bundle=bundle,
301
+ target_tool=target_tool,
302
+ target_session_id=target_session,
303
+ cwd=cwd,
304
+ method=method,
305
+ )
306
+
307
+ if fmt == "json":
308
+ output = {
309
+ "target_tool": target_tool,
310
+ "method": actual_method,
311
+ "bundle_tokens": bundle.token_count,
312
+ "bundle_strategy": bundle.strategy,
313
+ "source_sessions": bundle.source_sessions,
314
+ "command": cmd,
315
+ "dry_run": not execute,
316
+ "context_file_needed": actual_method == "file",
317
+ }
318
+ console.print_json(json.dumps(output))
319
+ else:
320
+ file_note = (
321
+ "\n[dim]Note: CONTEXT.md will be written to the target directory.[/dim]"
322
+ if actual_method == "file"
323
+ else ""
324
+ )
325
+ console.print(Panel(
326
+ f"Target tool: {target_tool}\n"
327
+ f"Method: {actual_method}\n"
328
+ f"Bundle tokens: {bundle.token_count} / {budget}\n"
329
+ f"Strategy: {bundle.strategy}\n\n"
330
+ f"[bold]Command:[/bold]\n[cyan]{cmd}[/cyan]{file_note}",
331
+ title="Transfer Preview",
332
+ ))
333
+
334
+ if execute:
335
+ bundle_id = db_module.save_bundle(database, bundle)
336
+ if fmt != "json":
337
+ console.print("[yellow]Executing...[/yellow]")
338
+ execute_transfer(
339
+ db=database,
340
+ bundle=bundle,
341
+ bundle_id=bundle_id,
342
+ target_tool=target_tool,
343
+ target_session_id=target_session,
344
+ cwd=cwd,
345
+ method=method,
346
+ )
347
+ elif fmt != "json":
348
+ console.print("[dim]Dry run. Pass [bold]--execute[/bold] to launch.[/dim]")
349
+
350
+
351
+ # ---------------------------------------------------------------------------
352
+ # cf tokens
353
+ # ---------------------------------------------------------------------------
354
+
355
+ @app.command()
356
+ def tokens(
357
+ session_id: str,
358
+ top: Annotated[int, typer.Option("--top", "-n", help="Show only the N heaviest turns")] = 0,
359
+ fmt: Annotated[str, typer.Option("--format", "-f")] = "rich",
360
+ ):
361
+ """Show per-turn token breakdown for a session.
362
+
363
+ Counts tokens in every message, shows role totals, averages, and
364
+ a bar chart of consumption per turn.
365
+ """
366
+ from contextforge.core.token_analyzer import analyze_tokens
367
+ from rich.table import Table
368
+ from rich.text import Text
369
+
370
+ cfg = _get_config()
371
+ database = _get_db(cfg)
372
+
373
+ report = analyze_tokens(database, session_id)
374
+ if report is None:
375
+ err_console.print(f"[red]Session not found:[/red] {session_id}")
376
+ raise typer.Exit(1)
377
+
378
+ if not report.turns:
379
+ err_console.print("[yellow]No messages found for this session.[/yellow]")
380
+ raise typer.Exit(0)
381
+
382
+ if fmt == "json":
383
+ out = {
384
+ "session_id": report.session_id,
385
+ "tool": report.tool,
386
+ "title": report.title,
387
+ "total_tokens": report.total,
388
+ "user_tokens": report.user_total,
389
+ "assistant_tokens": report.assistant_total,
390
+ "turn_count": report.turn_count,
391
+ "avg_user_tokens": round(report.avg_user, 1),
392
+ "avg_assistant_tokens": round(report.avg_assistant, 1),
393
+ "turns": [
394
+ {
395
+ "turn": t.turn,
396
+ "role": t.role,
397
+ "tokens": t.tokens,
398
+ "cumulative": t.cumulative,
399
+ "preview": t.content_preview,
400
+ }
401
+ for t in report.turns
402
+ ],
403
+ }
404
+ console.print_json(json.dumps(out))
405
+ return
406
+
407
+ # ── Summary panel ──────────────────────────────────────────────────────
408
+ max_t = report.max_turn
409
+ console.print(Panel(
410
+ f"[bold]{report.title}[/bold] [{report.tool}]\n\n"
411
+ f"Total tokens: [bold]{report.total:,}[/bold]\n"
412
+ f"Turns: {report.turn_count} "
413
+ f"([cyan]user {report.turn_count // 2 or len([t for t in report.turns if t.role == 'user'])}[/cyan] / "
414
+ f"[green]asst {len([t for t in report.turns if t.role == 'assistant'])}[/green])\n"
415
+ f"User total: [cyan]{report.user_total:,}[/cyan] "
416
+ f"(avg {report.avg_user:,.0f}/turn)\n"
417
+ f"Assistant total:[green]{report.assistant_total:,}[/green] "
418
+ f"(avg {report.avg_assistant:,.0f}/turn)\n"
419
+ + (f"Heaviest turn: #{max_t.turn} [{max_t.role}] — "
420
+ f"[bold]{max_t.tokens:,}[/bold] tokens" if max_t else ""),
421
+ title=f"Token Analysis · {session_id[:16]}",
422
+ ))
423
+
424
+ # ── Per-turn table ──────────────────────────────────────────────────────
425
+ BAR_MAX = 30
426
+ turns_to_show = report.turns
427
+ if top:
428
+ turns_to_show = sorted(report.turns, key=lambda t: t.tokens, reverse=True)[:top]
429
+ turns_to_show = sorted(turns_to_show, key=lambda t: t.turn)
430
+
431
+ max_tokens = max((t.tokens for t in report.turns), default=1)
432
+
433
+ table = Table(show_header=True, header_style="bold", show_lines=False, expand=True)
434
+ table.add_column("#", justify="right", width=4, no_wrap=True)
435
+ table.add_column("Role", width=8, no_wrap=True)
436
+ table.add_column("Tokens", justify="right", width=8, no_wrap=True)
437
+ table.add_column("Cumul.", justify="right", width=9, no_wrap=True)
438
+ table.add_column("Bar", min_width=BAR_MAX, no_wrap=True)
439
+ table.add_column("Preview", no_wrap=True)
440
+
441
+ for t in turns_to_show:
442
+ bar_len = max(1, int(t.tokens / max_tokens * BAR_MAX))
443
+ if t.role == "user":
444
+ color = "cyan"
445
+ elif t.role == "assistant":
446
+ color = "green"
447
+ else:
448
+ color = "dim"
449
+
450
+ bar = Text("█" * bar_len, style=color)
451
+ table.add_row(
452
+ str(t.turn),
453
+ f"[{color}]{t.role}[/{color}]",
454
+ f"{t.tokens:,}",
455
+ f"{t.cumulative:,}",
456
+ bar,
457
+ Text(t.content_preview, overflow="ellipsis"),
458
+ )
459
+
460
+ console.print(table)
461
+ if top:
462
+ console.print(f"[dim]Showing top {top} heaviest turns. Omit --top to see all.[/dim]")
463
+
464
+
465
+ # ---------------------------------------------------------------------------
466
+ # cf tag
467
+ # ---------------------------------------------------------------------------
468
+
469
+ @app.command()
470
+ def tag(
471
+ session_id: str,
472
+ tag_name: str,
473
+ ):
474
+ """Add a tag to a session."""
475
+ cfg = _get_config()
476
+ database = _get_db(cfg)
477
+ row = db_module.get_session(database, session_id)
478
+ if row is None:
479
+ err_console.print(f"[red]Session not found:[/red] {session_id}")
480
+ raise typer.Exit(1)
481
+ existing = json.loads(row.get("tags") or "[]")
482
+ if tag_name not in existing:
483
+ existing.append(tag_name)
484
+ database["sessions"].update(session_id, {"tags": json.dumps(existing)})
485
+ console.print(f"[green]Tagged[/green] {session_id[:16]} with [bold]{tag_name}[/bold]")
486
+
487
+
488
+ # ---------------------------------------------------------------------------
489
+ # cf config
490
+ # ---------------------------------------------------------------------------
491
+
492
+ @config_app.command(name="show")
493
+ def config_show():
494
+ """Print active configuration."""
495
+ cfg = _get_config()
496
+ console.print_json(cfg.model_dump_json(indent=2))
497
+
498
+
499
+ @config_app.command(name="set")
500
+ def config_set(key: str, value: str):
501
+ """Set a configuration value (not yet persisted — edit ~/.contextforge/config.toml)."""
502
+ err_console.print(
503
+ "[yellow]Tip:[/yellow] Edit [bold]~/.contextforge/config.toml[/bold] directly to persist settings."
504
+ )
505
+ console.print(f"Would set [bold]{key}[/bold] = [bold]{value}[/bold]")
506
+
507
+
508
+ # ---------------------------------------------------------------------------
509
+ # cf dashboard
510
+ # ---------------------------------------------------------------------------
511
+
512
+ @app.command()
513
+ def dashboard():
514
+ """Launch the Textual TUI dashboard.
515
+
516
+ Key bindings:
517
+ r — rescan sessions s — summarize selected
518
+ t — transfer (modal) c — compact selected
519
+ x — token analysis q — quit
520
+ """
521
+ cfg = _get_config()
522
+ from contextforge.tui.app import ContextForgeApp
523
+ ContextForgeApp(db_path=cfg.db_path).run()
File without changes