taskledger 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 (67) hide show
  1. taskledger/__init__.py +5 -0
  2. taskledger/__main__.py +6 -0
  3. taskledger/_version.py +24 -0
  4. taskledger/api/__init__.py +13 -0
  5. taskledger/api/handoff.py +247 -0
  6. taskledger/api/introductions.py +9 -0
  7. taskledger/api/locks.py +4 -0
  8. taskledger/api/plans.py +31 -0
  9. taskledger/api/project.py +185 -0
  10. taskledger/api/questions.py +19 -0
  11. taskledger/api/search.py +87 -0
  12. taskledger/api/task_runs.py +38 -0
  13. taskledger/api/tasks.py +61 -0
  14. taskledger/cli.py +600 -0
  15. taskledger/cli_actor.py +196 -0
  16. taskledger/cli_common.py +617 -0
  17. taskledger/cli_implement.py +409 -0
  18. taskledger/cli_migrate.py +328 -0
  19. taskledger/cli_misc.py +984 -0
  20. taskledger/cli_plan.py +478 -0
  21. taskledger/cli_question.py +350 -0
  22. taskledger/cli_task.py +257 -0
  23. taskledger/cli_validate.py +285 -0
  24. taskledger/command_inventory.py +125 -0
  25. taskledger/domain/__init__.py +2 -0
  26. taskledger/domain/models.py +1697 -0
  27. taskledger/domain/policies.py +542 -0
  28. taskledger/domain/states.py +320 -0
  29. taskledger/errors.py +165 -0
  30. taskledger/exchange.py +343 -0
  31. taskledger/ids.py +19 -0
  32. taskledger/py.typed +0 -0
  33. taskledger/search.py +349 -0
  34. taskledger/services/__init__.py +1 -0
  35. taskledger/services/actors.py +245 -0
  36. taskledger/services/dashboard.py +306 -0
  37. taskledger/services/doctor.py +435 -0
  38. taskledger/services/handoff.py +1029 -0
  39. taskledger/services/handoff_lifecycle.py +154 -0
  40. taskledger/services/navigation.py +930 -0
  41. taskledger/services/phase5_lock_transfer.py +96 -0
  42. taskledger/services/plan_lint.py +397 -0
  43. taskledger/services/serve_read_model.py +852 -0
  44. taskledger/services/tasks.py +4224 -0
  45. taskledger/services/validation.py +221 -0
  46. taskledger/services/web_dashboard.py +1742 -0
  47. taskledger/storage/__init__.py +39 -0
  48. taskledger/storage/atomic.py +57 -0
  49. taskledger/storage/common.py +90 -0
  50. taskledger/storage/events.py +98 -0
  51. taskledger/storage/frontmatter.py +57 -0
  52. taskledger/storage/indexes.py +42 -0
  53. taskledger/storage/init.py +187 -0
  54. taskledger/storage/locks.py +83 -0
  55. taskledger/storage/meta.py +103 -0
  56. taskledger/storage/migrations.py +207 -0
  57. taskledger/storage/paths.py +166 -0
  58. taskledger/storage/project_config.py +393 -0
  59. taskledger/storage/repos.py +256 -0
  60. taskledger/storage/task_store.py +836 -0
  61. taskledger/timeutils.py +7 -0
  62. taskledger-0.1.0.dist-info/METADATA +411 -0
  63. taskledger-0.1.0.dist-info/RECORD +67 -0
  64. taskledger-0.1.0.dist-info/WHEEL +5 -0
  65. taskledger-0.1.0.dist-info/entry_points.txt +2 -0
  66. taskledger-0.1.0.dist-info/licenses/LICENSE +201 -0
  67. taskledger-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,61 @@
1
+ from __future__ import annotations
2
+
3
+ from taskledger.services.tasks import (
4
+ activate_task,
5
+ add_file_link,
6
+ add_requirement,
7
+ add_todo,
8
+ can_perform,
9
+ cancel_task,
10
+ clear_active_task,
11
+ close_task,
12
+ create_task,
13
+ deactivate_task,
14
+ edit_task,
15
+ list_file_links,
16
+ list_task_summaries,
17
+ next_action,
18
+ next_todo,
19
+ reindex,
20
+ remove_file_link,
21
+ remove_requirement,
22
+ repair_task_record,
23
+ resolve_active_task,
24
+ set_todo_done,
25
+ show_active_task,
26
+ show_task,
27
+ show_todo,
28
+ task_dossier,
29
+ todo_status,
30
+ waive_requirement,
31
+ )
32
+
33
+ __all__ = [
34
+ "create_task",
35
+ "show_active_task",
36
+ "activate_task",
37
+ "deactivate_task",
38
+ "clear_active_task",
39
+ "resolve_active_task",
40
+ "list_task_summaries",
41
+ "show_task",
42
+ "edit_task",
43
+ "cancel_task",
44
+ "close_task",
45
+ "add_requirement",
46
+ "remove_requirement",
47
+ "waive_requirement",
48
+ "add_file_link",
49
+ "remove_file_link",
50
+ "list_file_links",
51
+ "add_todo",
52
+ "set_todo_done",
53
+ "show_todo",
54
+ "todo_status",
55
+ "next_todo",
56
+ "task_dossier",
57
+ "next_action",
58
+ "can_perform",
59
+ "reindex",
60
+ "repair_task_record",
61
+ ]
taskledger/cli.py ADDED
@@ -0,0 +1,600 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Callable
4
+ from pathlib import Path
5
+ from typing import Annotated, Any, cast
6
+
7
+ import typer
8
+
9
+ from taskledger.api.handoff import render_handoff
10
+ from taskledger.api.project import (
11
+ init_project,
12
+ project_export,
13
+ project_import,
14
+ project_snapshot,
15
+ project_status,
16
+ project_status_summary,
17
+ )
18
+ from taskledger.api.search import (
19
+ dependencies_for_module,
20
+ grep_workspace,
21
+ search_workspace,
22
+ symbols_workspace,
23
+ )
24
+ from taskledger.cli_actor import app as actors_app
25
+ from taskledger.cli_actor import harness_app
26
+ from taskledger.cli_common import (
27
+ CLIState,
28
+ TaskOption,
29
+ emit_error,
30
+ emit_payload,
31
+ launch_error_exit_code,
32
+ render_json,
33
+ resolve_cli_task,
34
+ resolve_workspace_root,
35
+ )
36
+ from taskledger.cli_implement import register_implement_v2_commands
37
+ from taskledger.cli_migrate import migrate_app
38
+ from taskledger.cli_misc import (
39
+ emit_can_command,
40
+ emit_doctor_command,
41
+ emit_doctor_indexes_command,
42
+ emit_doctor_locks_command,
43
+ emit_doctor_schema_command,
44
+ emit_next_action_command,
45
+ emit_reindex_command,
46
+ register_file_v2_commands,
47
+ register_handoff_v2_commands,
48
+ register_intro_v2_commands,
49
+ register_link_v2_commands,
50
+ register_lock_v2_commands,
51
+ register_require_v2_commands,
52
+ register_todo_v2_commands,
53
+ )
54
+ from taskledger.cli_plan import register_plan_v2_commands
55
+ from taskledger.cli_question import register_question_v2_commands
56
+ from taskledger.cli_task import register_task_v2_commands
57
+ from taskledger.cli_validate import register_validate_v2_commands
58
+ from taskledger.errors import LaunchError
59
+ from taskledger.services.dashboard import dashboard, render_dashboard_text
60
+ from taskledger.services.web_dashboard import (
61
+ DashboardServerConfig,
62
+ launch_dashboard_server,
63
+ )
64
+
65
+ app = typer.Typer(add_completion=False, help="Manage staged taskledger coding work.")
66
+ task_app = typer.Typer(add_completion=False, help="Manage coding tasks.")
67
+ plan_app = typer.Typer(add_completion=False, help="Manage plan versions.")
68
+ question_app = typer.Typer(add_completion=False, help="Manage planning questions.")
69
+ implement_app = typer.Typer(add_completion=False, help="Manage implementation runs.")
70
+ validate_app = typer.Typer(add_completion=False, help="Manage validation runs.")
71
+ todo_app = typer.Typer(add_completion=False, help="Manage task todos.")
72
+ intro_app = typer.Typer(add_completion=False, help="Manage shared introductions.")
73
+ file_app = typer.Typer(add_completion=False, help="Manage task file links.")
74
+ link_app = typer.Typer(
75
+ add_completion=False,
76
+ help="Manage external and typed task links.",
77
+ )
78
+ require_app = typer.Typer(add_completion=False, help="Manage task requirements.")
79
+ lock_app = typer.Typer(add_completion=False, help="Inspect and repair locks.")
80
+ handoff_app = typer.Typer(add_completion=False, help="Render fresh-context handoffs.")
81
+ repair_app = typer.Typer(add_completion=False, help="Repair taskledger state.")
82
+ doctor_app = typer.Typer(
83
+ add_completion=False,
84
+ help="Inspect taskledger integrity.",
85
+ invoke_without_command=True,
86
+ )
87
+
88
+ app.add_typer(task_app, name="task")
89
+ app.add_typer(plan_app, name="plan")
90
+ app.add_typer(question_app, name="question")
91
+ app.add_typer(implement_app, name="implement")
92
+ app.add_typer(validate_app, name="validate")
93
+ app.add_typer(todo_app, name="todo")
94
+ app.add_typer(intro_app, name="intro")
95
+ app.add_typer(file_app, name="file")
96
+ app.add_typer(link_app, name="link")
97
+ app.add_typer(require_app, name="require")
98
+ app.add_typer(lock_app, name="lock")
99
+ app.add_typer(handoff_app, name="handoff")
100
+ app.add_typer(doctor_app, name="doctor")
101
+ app.add_typer(repair_app, name="repair")
102
+ app.add_typer(migrate_app, name="migrate")
103
+ app.add_typer(actors_app, name="actor")
104
+ app.add_typer(harness_app, name="harness")
105
+
106
+ register_task_v2_commands(task_app)
107
+ register_plan_v2_commands(plan_app)
108
+ register_question_v2_commands(question_app)
109
+ register_implement_v2_commands(implement_app)
110
+ register_validate_v2_commands(validate_app)
111
+ register_todo_v2_commands(todo_app)
112
+ register_intro_v2_commands(intro_app)
113
+ register_file_v2_commands(file_app)
114
+ register_link_v2_commands(link_app)
115
+ register_require_v2_commands(require_app)
116
+ register_lock_v2_commands(lock_app)
117
+ register_handoff_v2_commands(handoff_app)
118
+
119
+
120
+ @app.command("context")
121
+ def context_command(
122
+ ctx: typer.Context,
123
+ task_ref: TaskOption = None,
124
+ context_for: Annotated[
125
+ str,
126
+ typer.Option(
127
+ "--for",
128
+ help=(
129
+ "Context role: planner, implementer, validator, "
130
+ "spec-reviewer, code-reviewer, reviewer, full."
131
+ ),
132
+ ),
133
+ ] = "full",
134
+ scope: Annotated[
135
+ str | None,
136
+ typer.Option("--scope", help="Context scope: task, todo, or run."),
137
+ ] = None,
138
+ todo_id: Annotated[
139
+ str | None, typer.Option("--todo", help="Focus on one todo id.")
140
+ ] = None,
141
+ focus_run_id: Annotated[
142
+ str | None, typer.Option("--run", help="Focus on one run id.")
143
+ ] = None,
144
+ format_name: Annotated[str, typer.Option("--format")] = "markdown",
145
+ ) -> None:
146
+ state = ctx.obj
147
+ assert isinstance(state, CLIState)
148
+ try:
149
+ task = resolve_cli_task(state.cwd, task_ref)
150
+ payload = render_handoff(
151
+ state.cwd,
152
+ task.id,
153
+ context_for=context_for,
154
+ scope=scope,
155
+ todo_id=todo_id,
156
+ focus_run_id=focus_run_id,
157
+ format_name=format_name,
158
+ )
159
+ except LaunchError as exc:
160
+ emit_error(ctx, exc)
161
+ raise typer.Exit(code=launch_error_exit_code(exc)) from exc
162
+ human = (
163
+ payload
164
+ if isinstance(payload, str)
165
+ else render_json(payload)
166
+ if format_name == "json"
167
+ else None
168
+ )
169
+ emit_payload(ctx, payload, human=human)
170
+
171
+
172
+ @app.callback()
173
+ def main(
174
+ ctx: typer.Context,
175
+ cwd: Annotated[
176
+ Path | None,
177
+ typer.Option(
178
+ "--cwd",
179
+ help="Workspace root. Defaults to the current directory.",
180
+ ),
181
+ ] = None,
182
+ root: Annotated[
183
+ Path | None,
184
+ typer.Option(
185
+ "--root",
186
+ help="Workspace root. Preferred alias for --cwd.",
187
+ ),
188
+ ] = None,
189
+ json_output: Annotated[
190
+ bool,
191
+ typer.Option("--json", help="Render machine-readable JSON."),
192
+ ] = False,
193
+ ) -> None:
194
+ if cwd is not None and root is not None and cwd != root:
195
+ raise typer.BadParameter(
196
+ "Use either --cwd or --root, not both with different values."
197
+ )
198
+ raw_cwd = (root or cwd or Path.cwd()).expanduser().resolve()
199
+ try:
200
+ resolved_cwd = resolve_workspace_root(raw_cwd)
201
+ except LaunchError as exc:
202
+ ctx.obj = CLIState(cwd=raw_cwd, json_output=json_output)
203
+ emit_error(ctx, exc)
204
+ raise typer.Exit(code=launch_error_exit_code(exc)) from exc
205
+ ctx.obj = CLIState(cwd=resolved_cwd, json_output=json_output)
206
+
207
+
208
+ @app.command("init")
209
+ def init_command(
210
+ ctx: typer.Context,
211
+ taskledger_dir: Annotated[
212
+ Path | None,
213
+ typer.Option("--taskledger-dir", help="Durable taskledger storage root."),
214
+ ] = None,
215
+ ) -> None:
216
+ state = ctx.obj
217
+ assert isinstance(state, CLIState)
218
+ payload = init_project(state.cwd, taskledger_dir=taskledger_dir)
219
+ emit_payload(
220
+ ctx,
221
+ payload,
222
+ human="\n".join(
223
+ [
224
+ f"initialized taskledger: {payload['root']}",
225
+ *[f"- {item}" for item in cast(list[str], payload["created"])],
226
+ ]
227
+ ),
228
+ )
229
+
230
+
231
+ @app.command("status")
232
+ def status_command(
233
+ ctx: typer.Context,
234
+ full: Annotated[
235
+ bool,
236
+ typer.Option(
237
+ "--full",
238
+ help="Show the full status payload instead of the compact summary.",
239
+ ),
240
+ ] = False,
241
+ ) -> None:
242
+ state = ctx.obj
243
+ assert isinstance(state, CLIState)
244
+ try:
245
+ payload = (
246
+ project_status(state.cwd) if full else project_status_summary(state.cwd)
247
+ )
248
+ except LaunchError as exc:
249
+ emit_error(ctx, exc)
250
+ raise typer.Exit(code=launch_error_exit_code(exc)) from exc
251
+ emit_payload(ctx, payload)
252
+
253
+
254
+ @app.command("view")
255
+ def view_command(
256
+ ctx: typer.Context,
257
+ task_ref: TaskOption = None,
258
+ ) -> None:
259
+ state = ctx.obj
260
+ assert isinstance(state, CLIState)
261
+ try:
262
+ payload = dashboard(state.cwd, ref=task_ref)
263
+ except LaunchError as exc:
264
+ emit_error(ctx, exc)
265
+ raise typer.Exit(code=launch_error_exit_code(exc)) from exc
266
+ human = render_dashboard_text(payload)
267
+ emit_payload(ctx, payload, human=human)
268
+
269
+
270
+ @app.command("serve")
271
+ def serve_command(
272
+ ctx: typer.Context,
273
+ task_ref: TaskOption = None,
274
+ host: Annotated[str, typer.Option("--host")] = "127.0.0.1",
275
+ port: Annotated[int, typer.Option("--port")] = 8765,
276
+ refresh_ms: Annotated[int, typer.Option("--refresh-ms")] = 1000,
277
+ open_browser: Annotated[bool, typer.Option("--open/--no-open")] = False,
278
+ ) -> None:
279
+ state = ctx.obj
280
+ assert isinstance(state, CLIState)
281
+ try:
282
+ handle = launch_dashboard_server(
283
+ DashboardServerConfig(
284
+ workspace_root=state.cwd,
285
+ host=host,
286
+ port=port,
287
+ task_ref=task_ref,
288
+ refresh_ms=refresh_ms,
289
+ open_browser=open_browser,
290
+ )
291
+ )
292
+ except LaunchError as exc:
293
+ emit_error(ctx, exc)
294
+ raise typer.Exit(code=launch_error_exit_code(exc)) from exc
295
+ emit_payload(
296
+ ctx,
297
+ {
298
+ "kind": "serve_started",
299
+ "url": handle.url,
300
+ "host": handle.host,
301
+ "port": handle.port,
302
+ },
303
+ human=f"Serving taskledger dashboard at {handle.url}\nPress Ctrl-C to stop.",
304
+ )
305
+ try:
306
+ handle.serve_forever()
307
+ except KeyboardInterrupt:
308
+ pass
309
+ finally:
310
+ handle.close()
311
+
312
+
313
+ @doctor_app.callback()
314
+ def doctor_command(ctx: typer.Context) -> None:
315
+ if ctx.invoked_subcommand is not None:
316
+ return
317
+ emit_doctor_command(ctx)
318
+
319
+
320
+ @doctor_app.command("locks")
321
+ def doctor_locks_command(ctx: typer.Context) -> None:
322
+ emit_doctor_locks_command(ctx)
323
+
324
+
325
+ @doctor_app.command("schema")
326
+ def doctor_schema_command(ctx: typer.Context) -> None:
327
+ emit_doctor_schema_command(ctx)
328
+
329
+
330
+ @doctor_app.command("indexes")
331
+ def doctor_indexes_command(ctx: typer.Context) -> None:
332
+ emit_doctor_indexes_command(ctx)
333
+
334
+
335
+ @app.command("next-action")
336
+ def next_action_command(
337
+ ctx: typer.Context,
338
+ task_ref: TaskOption = None,
339
+ ) -> None:
340
+ emit_next_action_command(ctx, task_ref)
341
+
342
+
343
+ @app.command("can")
344
+ def can_command(
345
+ ctx: typer.Context,
346
+ action_or_task: Annotated[str, typer.Argument(..., help="Action name.")],
347
+ task_ref: TaskOption = None,
348
+ ) -> None:
349
+ emit_can_command(ctx, task_ref, action_or_task)
350
+
351
+
352
+ @app.command("reindex")
353
+ def reindex_command(ctx: typer.Context) -> None:
354
+ emit_reindex_command(ctx)
355
+
356
+
357
+ @repair_app.command("index")
358
+ def repair_index_command(ctx: typer.Context) -> None:
359
+ emit_reindex_command(ctx)
360
+
361
+
362
+ @repair_app.command("lock")
363
+ def repair_lock_command(
364
+ ctx: typer.Context,
365
+ reason: Annotated[str, typer.Option("--reason")],
366
+ task_ref: Annotated[
367
+ str | None,
368
+ typer.Option("--task", help="Task ref. Defaults to the active task."),
369
+ ] = None,
370
+ ) -> None:
371
+ from taskledger.api.locks import break_lock
372
+
373
+ state = ctx.obj
374
+ assert isinstance(state, CLIState)
375
+ try:
376
+ task = resolve_cli_task(state.cwd, task_ref)
377
+ payload = break_lock(state.cwd, task.id, reason=reason)
378
+ except LaunchError as exc:
379
+ emit_error(ctx, exc)
380
+ raise typer.Exit(code=launch_error_exit_code(exc)) from exc
381
+ emit_payload(ctx, payload, human=f"repaired lock for {payload['task_id']}")
382
+
383
+
384
+ @repair_app.command("task")
385
+ def repair_task_command(
386
+ ctx: typer.Context,
387
+ reason: Annotated[str, typer.Option("--reason")],
388
+ task_ref: Annotated[
389
+ str | None,
390
+ typer.Option("--task", help="Task ref. Defaults to the active task."),
391
+ ] = None,
392
+ ) -> None:
393
+ from taskledger.api.tasks import repair_task_record
394
+
395
+ state = ctx.obj
396
+ assert isinstance(state, CLIState)
397
+ try:
398
+ task = resolve_cli_task(state.cwd, task_ref)
399
+ payload = repair_task_record(state.cwd, task.id, reason=reason)
400
+ except LaunchError as exc:
401
+ emit_error(ctx, exc)
402
+ raise typer.Exit(code=launch_error_exit_code(exc)) from exc
403
+ emit_payload(ctx, payload, human=f"inspected repair for {payload['task_id']}")
404
+
405
+
406
+ @repair_app.command("task-dirs")
407
+ def repair_task_dirs_command(ctx: typer.Context) -> None:
408
+ from taskledger.services.doctor import cleanup_orphan_slug_dirs
409
+
410
+ state = ctx.obj
411
+ assert isinstance(state, CLIState)
412
+ payload = cleanup_orphan_slug_dirs(state.cwd)
413
+ removed = cast(list[str], payload.get("removed", []))
414
+ names = ", ".join(removed) if removed else "(none)"
415
+ emit_payload(
416
+ ctx,
417
+ payload,
418
+ human=f"removed {payload['count']} orphan slug directories: {names}",
419
+ )
420
+
421
+
422
+ @app.command("export")
423
+ def export_command(
424
+ ctx: typer.Context,
425
+ include_bodies: Annotated[
426
+ bool,
427
+ typer.Option("--include-bodies", help="Include Markdown bodies in the export."),
428
+ ] = False,
429
+ include_run_artifacts: Annotated[
430
+ bool,
431
+ typer.Option(
432
+ "--include-run-artifacts",
433
+ help="Include run artifact files in the export payload.",
434
+ ),
435
+ ] = False,
436
+ ) -> None:
437
+ state = ctx.obj
438
+ assert isinstance(state, CLIState)
439
+ payload = project_export(
440
+ state.cwd,
441
+ include_bodies=include_bodies,
442
+ include_run_artifacts=include_run_artifacts,
443
+ )
444
+ emit_payload(ctx, payload, human="exported taskledger state")
445
+
446
+
447
+ @app.command("import")
448
+ def import_command(
449
+ ctx: typer.Context,
450
+ source: Annotated[Path, typer.Argument(..., exists=True, readable=True)],
451
+ replace: Annotated[
452
+ bool,
453
+ typer.Option("--replace", help="Replace existing taskledger state."),
454
+ ] = False,
455
+ ) -> None:
456
+ state = ctx.obj
457
+ assert isinstance(state, CLIState)
458
+ text = source.read_text(encoding="utf-8")
459
+ try:
460
+ payload = project_import(state.cwd, text=text, replace=replace)
461
+ except LaunchError as exc:
462
+ emit_error(ctx, exc)
463
+ raise typer.Exit(code=launch_error_exit_code(exc)) from exc
464
+ emit_payload(ctx, payload, human="imported taskledger state")
465
+
466
+
467
+ @app.command("snapshot")
468
+ def snapshot_command(
469
+ ctx: typer.Context,
470
+ output_dir: Annotated[Path, typer.Argument(..., file_okay=False, dir_okay=True)],
471
+ include_bodies: Annotated[
472
+ bool,
473
+ typer.Option(
474
+ "--include-bodies",
475
+ help="Include Markdown bodies in the snapshot export.",
476
+ ),
477
+ ] = False,
478
+ include_run_artifacts: Annotated[
479
+ bool,
480
+ typer.Option(
481
+ "--include-run-artifacts",
482
+ help="Include run artifact files in the snapshot export.",
483
+ ),
484
+ ] = False,
485
+ ) -> None:
486
+ state = ctx.obj
487
+ assert isinstance(state, CLIState)
488
+ try:
489
+ payload = project_snapshot(
490
+ state.cwd,
491
+ output_dir=output_dir,
492
+ include_bodies=include_bodies,
493
+ include_run_artifacts=include_run_artifacts,
494
+ )
495
+ except LaunchError as exc:
496
+ emit_error(ctx, exc)
497
+ raise typer.Exit(code=launch_error_exit_code(exc)) from exc
498
+ emit_payload(ctx, payload, human=f"wrote snapshot to {payload['snapshot_dir']}")
499
+
500
+
501
+ @app.command("search")
502
+ def search_command(
503
+ ctx: typer.Context,
504
+ query: Annotated[str, typer.Argument(..., help="Search query.")],
505
+ repo_refs: Annotated[list[str] | None, typer.Option("--repo")] = None,
506
+ limit: Annotated[int, typer.Option("--limit")] = 50,
507
+ ) -> None:
508
+ _emit_search_results(
509
+ ctx,
510
+ lambda cwd: search_workspace(
511
+ cwd,
512
+ query=query,
513
+ repo_refs=tuple(repo_refs or ()),
514
+ limit=limit,
515
+ ),
516
+ title="SEARCH",
517
+ )
518
+
519
+
520
+ @app.command("grep")
521
+ def grep_command(
522
+ ctx: typer.Context,
523
+ pattern: Annotated[str, typer.Argument(..., help="Regex pattern.")],
524
+ repo_refs: Annotated[list[str] | None, typer.Option("--repo")] = None,
525
+ limit: Annotated[int, typer.Option("--limit")] = 100,
526
+ ) -> None:
527
+ _emit_search_results(
528
+ ctx,
529
+ lambda cwd: grep_workspace(
530
+ cwd,
531
+ pattern=pattern,
532
+ repo_refs=tuple(repo_refs or ()),
533
+ limit=limit,
534
+ ),
535
+ title="GREP",
536
+ )
537
+
538
+
539
+ @app.command("symbols")
540
+ def symbols_command(
541
+ ctx: typer.Context,
542
+ query: Annotated[str, typer.Argument(..., help="Symbol query.")],
543
+ repo_refs: Annotated[list[str] | None, typer.Option("--repo")] = None,
544
+ limit: Annotated[int, typer.Option("--limit")] = 50,
545
+ ) -> None:
546
+ _emit_search_results(
547
+ ctx,
548
+ lambda cwd: symbols_workspace(
549
+ cwd,
550
+ query=query,
551
+ repo_refs=tuple(repo_refs or ()),
552
+ limit=limit,
553
+ ),
554
+ title="SYMBOLS",
555
+ )
556
+
557
+
558
+ @app.command("deps")
559
+ def deps_command(
560
+ ctx: typer.Context,
561
+ repo_ref: Annotated[str, typer.Argument(..., help="Repo ref.")],
562
+ module: Annotated[str, typer.Argument(..., help="Module path.")],
563
+ ) -> None:
564
+ state = ctx.obj
565
+ assert isinstance(state, CLIState)
566
+ try:
567
+ payload = dependencies_for_module(
568
+ state.cwd,
569
+ repo_ref=repo_ref,
570
+ module=module,
571
+ )
572
+ except LaunchError as exc:
573
+ emit_error(ctx, str(exc))
574
+ raise typer.Exit(code=1) from exc
575
+ emit_payload(ctx, payload)
576
+
577
+
578
+ def _emit_search_results(
579
+ ctx: typer.Context,
580
+ factory: Callable[..., Any],
581
+ *,
582
+ title: str,
583
+ ) -> None:
584
+ state = ctx.obj
585
+ assert isinstance(state, CLIState)
586
+ try:
587
+ results = factory(state.cwd)
588
+ except LaunchError as exc:
589
+ emit_error(ctx, str(exc))
590
+ raise typer.Exit(code=1) from exc
591
+ human = (
592
+ "\n".join([title, *[item.path for item in results]])
593
+ if results
594
+ else f"{title}\n(empty)"
595
+ )
596
+ emit_payload(ctx, [item.to_dict() for item in results], human=human)
597
+
598
+
599
+ def cli_main() -> None:
600
+ app()