dctracker 1.0.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 (87) hide show
  1. dct/__init__.py +6 -0
  2. dct/cli.py +1659 -0
  3. dct/config.py +150 -0
  4. dct/core/__init__.py +0 -0
  5. dct/core/analytics.py +382 -0
  6. dct/core/changelog.py +112 -0
  7. dct/core/checkpoints.py +205 -0
  8. dct/core/decisions.py +238 -0
  9. dct/core/engine.py +32 -0
  10. dct/core/handoff.py +151 -0
  11. dct/core/ids.py +25 -0
  12. dct/core/items.py +382 -0
  13. dct/core/plans/__init__.py +6 -0
  14. dct/core/plans/classify.py +94 -0
  15. dct/core/plans/crud.py +680 -0
  16. dct/core/plans/ingest.py +408 -0
  17. dct/core/plans/parser.py +312 -0
  18. dct/core/projects.py +298 -0
  19. dct/core/sprint.py +315 -0
  20. dct/core/sprints.py +601 -0
  21. dct/core/version.py +116 -0
  22. dct/db.py +558 -0
  23. dct/export.py +107 -0
  24. dct/hooks/check-changelog-on-stop.sh +49 -0
  25. dct/hooks/check-changelog.sh +143 -0
  26. dct/hooks/dct-plan-autoscan.sh +54 -0
  27. dct/hooks/dct-task-mirror.sh +62 -0
  28. dct/server.py +1812 -0
  29. dct/setup.py +258 -0
  30. dct/skills/clog/SKILL.md +73 -0
  31. dct/skills/commit/SKILL.md +153 -0
  32. dct/skills/dct-import/SKILL.md +329 -0
  33. dct/skills/dct-init/SKILL.md +289 -0
  34. dct/skills/handoff/SKILL.md +136 -0
  35. dct/skills/pickup/SKILL.md +115 -0
  36. dct/skills/plan-resolve-uncertain/SKILL.md +82 -0
  37. dct/skills/plan-status/SKILL.md +79 -0
  38. dct/skills/release/SKILL.md +281 -0
  39. dct/skills/track/SKILL.md +121 -0
  40. dct/skills/track-list/SKILL.md +134 -0
  41. dct/skills/track-resolve/SKILL.md +53 -0
  42. dct/skills/track-update/SKILL.md +109 -0
  43. dct/web/__init__.py +1 -0
  44. dct/web/app.py +83 -0
  45. dct/web/blueprints/__init__.py +5 -0
  46. dct/web/blueprints/analytics.py +34 -0
  47. dct/web/blueprints/changelog.py +40 -0
  48. dct/web/blueprints/decisions.py +23 -0
  49. dct/web/blueprints/events.py +69 -0
  50. dct/web/blueprints/handoffs.py +26 -0
  51. dct/web/blueprints/home.py +15 -0
  52. dct/web/blueprints/items.py +128 -0
  53. dct/web/blueprints/plans.py +53 -0
  54. dct/web/blueprints/projects.py +23 -0
  55. dct/web/blueprints/sprints.py +71 -0
  56. dct/web/changes.py +77 -0
  57. dct/web/serializers.py +109 -0
  58. dct/web/static/app.css +609 -0
  59. dct/web/static/app.js +62 -0
  60. dct/web/static/vendor/SOURCES.txt +10 -0
  61. dct/web/static/vendor/htmx.min.js +1 -0
  62. dct/web/static/vendor/uPlot.iife.min.js +2 -0
  63. dct/web/static/vendor/uPlot.min.css +1 -0
  64. dct/web/templates/analytics.html +63 -0
  65. dct/web/templates/base.html +106 -0
  66. dct/web/templates/changelog/list.html +23 -0
  67. dct/web/templates/decisions/list.html +22 -0
  68. dct/web/templates/handoffs/list.html +45 -0
  69. dct/web/templates/home.html +20 -0
  70. dct/web/templates/item_detail.html +91 -0
  71. dct/web/templates/items_list.html +44 -0
  72. dct/web/templates/partials/_bars.html +15 -0
  73. dct/web/templates/partials/_checkpoint_steps.html +22 -0
  74. dct/web/templates/partials/_items_table.html +23 -0
  75. dct/web/templates/partials/_plan_section.html +24 -0
  76. dct/web/templates/partials/_project_card.html +24 -0
  77. dct/web/templates/plans/detail.html +16 -0
  78. dct/web/templates/plans/list.html +15 -0
  79. dct/web/templates/project_home.html +51 -0
  80. dct/web/templates/sprints/detail.html +37 -0
  81. dct/web/templates/sprints/list.html +35 -0
  82. dctracker-1.0.0.dist-info/METADATA +357 -0
  83. dctracker-1.0.0.dist-info/RECORD +87 -0
  84. dctracker-1.0.0.dist-info/WHEEL +5 -0
  85. dctracker-1.0.0.dist-info/entry_points.txt +2 -0
  86. dctracker-1.0.0.dist-info/licenses/LICENSE +21 -0
  87. dctracker-1.0.0.dist-info/top_level.txt +1 -0
dct/cli.py ADDED
@@ -0,0 +1,1659 @@
1
+ """CLI entry point for dct (Deterministic Context Tracker)."""
2
+
3
+ import argparse
4
+ import os
5
+ import signal
6
+ import socket
7
+ import sys
8
+ from pathlib import Path
9
+
10
+ from dct import __version__
11
+ from dct.config import load_config, save_config, Config, DEFAULT_DB_PATH
12
+ from dct.db import get_engine, create_tables
13
+ from dct.core.engine import bootstrap_engine
14
+
15
+ # ── web daemon constants ─────────────────────────────────────────────────────
16
+ WEB_PIDFILE = Path.home() / ".claude" / "dct-web.pid"
17
+ WEB_LOGFILE = Path.home() / ".claude" / "dct-web.log"
18
+
19
+
20
+ def _get_engine():
21
+ return bootstrap_engine(create=True)
22
+
23
+
24
+ def _detect_project(engine) -> str:
25
+ """Auto-detect project slug from CWD. Exits if not found."""
26
+ import os
27
+ from dct.core.projects import get_project_by_path
28
+
29
+ cwd = os.getcwd()
30
+ project = get_project_by_path(engine, cwd)
31
+ if not project:
32
+ print(f"✗ Cannot detect project from {cwd}. Use: dct clog <cmd> <project>", file=sys.stderr)
33
+ sys.exit(1)
34
+ return project["slug"]
35
+
36
+
37
+ def _fmt_date(dt) -> str:
38
+ if dt is None:
39
+ return ""
40
+ if isinstance(dt, str):
41
+ return dt[:10]
42
+ return dt.strftime("%Y-%m-%d")
43
+
44
+
45
+ def _prompt_version(default: str = "0.1.0") -> str | None:
46
+ """Ask the user for a starting version when auto-detect failed. Empty input → `default`.
47
+
48
+ Returns None when stdin has no tty (non-interactive) — caller must handle.
49
+ """
50
+ if not sys.stdin.isatty():
51
+ return None
52
+ print(" Pick a starting version (empty = default):")
53
+ print(" 1) 0.0.1 (pre-alpha)")
54
+ print(" 2) 0.1.0 (alpha — recommended)")
55
+ print(" 3) 1.0.0 (stable)")
56
+ print(" or type your own (e.g. 2.3.4)")
57
+ choice = input(f" Version [{default}]: ").strip()
58
+ if not choice:
59
+ return default
60
+ shortcut = {"1": "0.0.1", "2": "0.1.0", "3": "1.0.0"}
61
+ return shortcut.get(choice, choice)
62
+
63
+
64
+ def _print_table(headers: list[str], rows: list[list[str]], max_widths: dict[int, int] | None = None) -> None:
65
+ """Print a simple aligned table."""
66
+ if not rows:
67
+ print("(no results)")
68
+ return
69
+
70
+ widths = [len(h) for h in headers]
71
+ for row in rows:
72
+ for i, cell in enumerate(row):
73
+ widths[i] = max(widths[i], len(str(cell)))
74
+
75
+ if max_widths:
76
+ for i, mw in max_widths.items():
77
+ widths[i] = min(widths[i], mw)
78
+
79
+ fmt = " ".join(f"{{:<{w}}}" for w in widths)
80
+ print(fmt.format(*headers))
81
+ print(fmt.format(*["─" * w for w in widths]))
82
+ for row in rows:
83
+ truncated = [str(c)[:widths[i]] for i, c in enumerate(row)]
84
+ print(fmt.format(*truncated))
85
+
86
+
87
+ # ── Init ──────────────────────────────────────────────────────────────────────
88
+
89
+ def cmd_init(args):
90
+ print("=== dct — Deterministic Context Tracker — setup ===\n")
91
+
92
+ # Database
93
+ db_type = input("Database backend [sqlite/postgresql] (default: sqlite): ").strip().lower() or "sqlite"
94
+ if db_type == "postgresql":
95
+ default_url = "postgresql://localhost:5432/dct"
96
+ url = input(f"PostgreSQL URL [{default_url}]: ").strip() or default_url
97
+ else:
98
+ url = f"sqlite:///{DEFAULT_DB_PATH}"
99
+ print(f"Using SQLite at {DEFAULT_DB_PATH}")
100
+
101
+ # Test connection
102
+ try:
103
+ engine = get_engine(url)
104
+ with engine.connect() as conn:
105
+ import sqlalchemy as sa_mod
106
+ conn.execute(sa_mod.text("SELECT 1"))
107
+ print("Connection OK")
108
+ except Exception as e:
109
+ print(f"Connection failed: {e}")
110
+ sys.exit(1)
111
+
112
+ # Save config
113
+ config = Config(database_url=url)
114
+ path = save_config(config)
115
+ print(f"Config saved to {path}")
116
+
117
+ # Create tables
118
+ create_tables(engine)
119
+ print("Tables created.\n")
120
+
121
+ # Register projects
122
+ from dct.core.projects import create_project, set_version
123
+
124
+ while True:
125
+ reg = input("Register a project? [Y/n]: ").strip().lower()
126
+ if reg == "n":
127
+ break
128
+
129
+ slug = input("Project slug: ").strip()
130
+ if not slug:
131
+ continue
132
+ proj_path = input("Project path [.]: ").strip() or "."
133
+ import os
134
+ proj_path = os.path.abspath(proj_path)
135
+ name = input(f"Project name [{slug}]: ").strip() or slug
136
+
137
+ try:
138
+ project = create_project(engine, slug=slug, name=name, path=proj_path)
139
+ print(f" ✓ Project '{slug}' registered")
140
+ version = project.get("current_version")
141
+ source = project.get("_detected_source")
142
+ if version and source:
143
+ print(f" ↪ Detected version {version} from {source}")
144
+ elif not version:
145
+ picked = _prompt_version()
146
+ if picked:
147
+ set_version(engine, slug, picked)
148
+ print(f" ✓ Version set to {picked}")
149
+ else:
150
+ print(f" ⚠ No version — run `dct project set-version {slug} <v>` later")
151
+ except Exception as e:
152
+ print(f" ✗ Error: {e}")
153
+
154
+ another = input("Register another? [Y/n]: ").strip().lower()
155
+ if another == "n":
156
+ break
157
+
158
+ # Install hook, skills, and register MCP server (user scope)
159
+ from dct.setup import install_hook, install_skills, register_mcp
160
+
161
+ ok, msg = register_mcp(config)
162
+ if ok:
163
+ print(f"\n ✓ MCP server — {msg}")
164
+ if install_hook(config):
165
+ print(" ✓ Global hook installed")
166
+ if install_skills(config):
167
+ print(" ✓ Skills installed")
168
+
169
+ # Wire hooks into ~/.claude/settings.json (idempotent; consent-gated).
170
+ # args.wire_hooks: True (--wire-hooks) / False (--no-wire-hooks) / None (ask
171
+ # on a tty, skip when non-interactive — hooks rule 1).
172
+ wire = getattr(args, "wire_hooks", None)
173
+ if wire is None:
174
+ if sys.stdin.isatty():
175
+ answer = input(
176
+ "Wire hooks into ~/.claude/settings.json? [Y/n]: "
177
+ ).strip().lower()
178
+ wire = answer != "n"
179
+ else:
180
+ wire = False
181
+ if wire:
182
+ from dct.setup import wire_hooks_into_settings
183
+ try:
184
+ result = wire_hooks_into_settings()
185
+ except ValueError as e:
186
+ print(f" ⚠ Hook wiring skipped: {e}")
187
+ else:
188
+ if result["added"]:
189
+ print(f" ✓ Hooks wired into settings.json: {', '.join(result['added'])}")
190
+ if result["backup"]:
191
+ print(f" (backup: {result['backup']})")
192
+ else:
193
+ print(" ✓ Hooks already wired into settings.json")
194
+ else:
195
+ print(" ⚠ Hooks not wired — run `dct hooks wire` later (see docs/hooks.md).")
196
+
197
+ print("\nTo register more projects: dct project add <slug> <path>")
198
+ print("To import existing data: use /dct-import in a project session")
199
+ print("\nSetup complete!")
200
+
201
+
202
+ # ── Hooks commands ────────────────────────────────────────────────────────────
203
+
204
+ def cmd_hooks_wire(args):
205
+ from dct.setup import wire_hooks_into_settings
206
+
207
+ try:
208
+ result = wire_hooks_into_settings(settings_path=args.settings)
209
+ except ValueError as e:
210
+ print(f"✗ {e}")
211
+ sys.exit(1)
212
+
213
+ if result["added"]:
214
+ print(f"✓ Wired {len(result['added'])} hook(s) into {result['settings_path']}:")
215
+ for name in result["added"]:
216
+ print(f" + {name}")
217
+ if result["backup"]:
218
+ print(f" backup: {result['backup']}")
219
+ if result["skipped"]:
220
+ print(f" already wired: {', '.join(result['skipped'])}")
221
+ if not result["added"] and not result["skipped"]:
222
+ print("✓ Nothing to wire.")
223
+
224
+
225
+ # ── Project commands ──────────────────────────────────────────────────────────
226
+
227
+ def cmd_project_add(args):
228
+ import os
229
+ from dct.core.projects import create_project, set_version
230
+
231
+ # #227: realpath (not abspath) so a symlinked path is stored canonically and
232
+ # matches os.getcwd() (physical) at detect time. os.getcwd() is already physical.
233
+ path = os.path.realpath(args.path) if args.path else os.getcwd()
234
+ name = args.name or args.slug
235
+ engine = _get_engine()
236
+
237
+ try:
238
+ project = create_project(engine, slug=args.slug, name=name, path=path, current_version=args.version)
239
+ except Exception as e:
240
+ print(f"✗ Error: {e}", file=sys.stderr)
241
+ sys.exit(1)
242
+
243
+ print(f"✓ Project '{args.slug}' registered at {path}")
244
+
245
+ version = project.get("current_version")
246
+ source = project.get("_detected_source")
247
+ if version:
248
+ if source and not args.version:
249
+ print(f" ↪ Detected version {version} from {source}")
250
+ else:
251
+ print(f" Version: {version}")
252
+ return
253
+
254
+ # No version on the row — try an interactive prompt, else warn & bail out.
255
+ picked = _prompt_version() if sys.stdin.isatty() else None
256
+ if picked:
257
+ set_version(engine, args.slug, picked)
258
+ print(f" ✓ Version set to {picked}")
259
+ else:
260
+ print(
261
+ f" ⚠ No version detected. Run `dct project set-version {args.slug} <version>` "
262
+ f"before using `/release`.",
263
+ file=sys.stderr,
264
+ )
265
+
266
+
267
+ def cmd_project_set_version(args):
268
+ from dct.core.projects import set_version
269
+
270
+ engine = _get_engine()
271
+ project = set_version(engine, args.slug, args.version)
272
+ if project:
273
+ print(f"✓ {args.slug} → {args.version}")
274
+ else:
275
+ print(f"✗ Project '{args.slug}' not found", file=sys.stderr)
276
+ sys.exit(1)
277
+
278
+
279
+ def cmd_project_rename(args):
280
+ from dct.core.projects import update_project
281
+
282
+ engine = _get_engine()
283
+ new_slug = getattr(args, "new_slug", None)
284
+ new_name = getattr(args, "name", None)
285
+ new_description = getattr(args, "description", None)
286
+ new_path = getattr(args, "path", None)
287
+
288
+ if all(v is None for v in (new_slug, new_name, new_description, new_path)):
289
+ print(
290
+ "✗ Nothing to rename — pass at least one of --slug, --name, --description, --path",
291
+ file=sys.stderr,
292
+ )
293
+ sys.exit(1)
294
+
295
+ try:
296
+ project = update_project(
297
+ engine,
298
+ args.slug,
299
+ new_slug=new_slug,
300
+ new_name=new_name,
301
+ new_description=new_description,
302
+ new_path=new_path,
303
+ )
304
+ except ValueError as e:
305
+ print(f"✗ {e}", file=sys.stderr)
306
+ sys.exit(1)
307
+
308
+ if project is None:
309
+ print(f"✗ Project '{args.slug}' not found", file=sys.stderr)
310
+ sys.exit(1)
311
+
312
+ print(f"✓ Project '{args.slug}' updated")
313
+ print(f" slug: {project['slug']}")
314
+ print(f" name: {project['name']}")
315
+ if project.get("description"):
316
+ print(f" description: {project['description']}")
317
+ if project.get("path"):
318
+ print(f" path: {project['path']}")
319
+
320
+
321
+ def cmd_project_changelog(args):
322
+ from dct.core.projects import set_manages_changelog
323
+
324
+ engine = _get_engine()
325
+ project = set_manages_changelog(engine, args.slug, args.state == "on")
326
+ if project:
327
+ state = "ON" if project["manages_changelog"] else "OFF"
328
+ print(f"✓ {args.slug}: dct changelog enforcement {state}")
329
+ else:
330
+ print(f"✗ Project '{args.slug}' not found", file=sys.stderr)
331
+ sys.exit(1)
332
+
333
+
334
+ def cmd_project_manages_changelog(args):
335
+ """Hook query — exit 0 if the project opts into dct changelog enforcement,
336
+ else exit 1. Silent by design (the changelog hook reads only the exit code).
337
+ """
338
+ from dct.core.projects import project_manages_changelog
339
+
340
+ engine = _get_engine()
341
+ sys.exit(0 if project_manages_changelog(engine, args.slug) else 1)
342
+
343
+
344
+ def cmd_project_changelog_path(args):
345
+ """Get or set a project's changelog file location, relative to its root (#225).
346
+
347
+ With a relpath → set it (validated to stay within the project root). Without
348
+ → print the current relpath and its resolved absolute path.
349
+ """
350
+ from dct.core.projects import get_project, resolve_changelog_path, set_changelog_path
351
+
352
+ engine = _get_engine()
353
+
354
+ if args.relpath is None:
355
+ project = get_project(engine, args.slug)
356
+ if not project:
357
+ print(f"✗ Project '{args.slug}' not found", file=sys.stderr)
358
+ sys.exit(1)
359
+ rel = project.get("changelog_path") or "changelog.md"
360
+ print(f"{args.slug}: {rel}")
361
+ resolved = resolve_changelog_path(project)
362
+ if resolved:
363
+ print(f" → {resolved}")
364
+ return
365
+
366
+ try:
367
+ project = set_changelog_path(engine, args.slug, args.relpath)
368
+ except ValueError as e:
369
+ print(f"✗ {e}", file=sys.stderr)
370
+ sys.exit(1)
371
+ if not project:
372
+ print(f"✗ Project '{args.slug}' not found", file=sys.stderr)
373
+ sys.exit(1)
374
+ print(f"✓ {args.slug}: changelog-path → {project['changelog_path']}")
375
+ resolved = resolve_changelog_path(project)
376
+ if resolved:
377
+ print(f" → {resolved}")
378
+
379
+
380
+ # ── Plan rescan (v0.2.0) ─────────────────────────────────────────────────────
381
+
382
+ def cmd_plan_rescan(args):
383
+ """Re-scan a plan markdown file — hook entrypoint and manual CLI.
384
+
385
+ Idempotent: hash short-circuits unchanged files. Brand-new files (no
386
+ plans row yet) get the full first-import treatment including ULID
387
+ injection + [x] state reading (§9.2).
388
+ """
389
+ import os
390
+ from dct.core.plans.ingest import ingest_file
391
+ from dct.core.projects import get_project_by_path
392
+
393
+ engine = _get_engine()
394
+ path = os.path.abspath(args.file)
395
+ project = get_project_by_path(engine, path)
396
+ if project is None:
397
+ if not args.quiet:
398
+ print(f"✗ No registered project owns {path}", file=sys.stderr)
399
+ sys.exit(0) # graceful — hook must not block user action
400
+
401
+ try:
402
+ report = ingest_file(
403
+ engine, path,
404
+ project_slug=project["slug"],
405
+ auto_resolve_uncertain=args.resolve_uncertain,
406
+ )
407
+ except Exception as e:
408
+ if not args.quiet:
409
+ print(f"✗ rescan failed: {e}", file=sys.stderr)
410
+ sys.exit(0) # graceful — never block the user
411
+
412
+ if not args.quiet:
413
+ print(
414
+ f"✓ {path} — plan #{report['plan_id']}: "
415
+ f"{report['sections_created']} new sections, "
416
+ f"{report['checkpoints_created']} new checkpoints, "
417
+ f"{report['uncertain_remaining']} uncertain"
418
+ )
419
+
420
+
421
+ def cmd_project_list(args):
422
+ from dct.core.projects import list_projects
423
+
424
+ engine = _get_engine()
425
+ projects = list_projects(engine)
426
+ headers = ["Slug", "Version", "Name", "Path"]
427
+ rows = [[p["slug"], p.get("current_version") or "—", p["name"], p.get("path") or ""] for p in projects]
428
+ _print_table(headers, rows)
429
+
430
+
431
+ def cmd_project_remove(args):
432
+ from dct.core.projects import delete_project
433
+
434
+ engine = _get_engine()
435
+ if delete_project(engine, args.slug):
436
+ print(f"✓ Project '{args.slug}' removed (soft delete)")
437
+ else:
438
+ print(f"✗ Project '{args.slug}' not found", file=sys.stderr)
439
+ sys.exit(1)
440
+
441
+
442
+ # ── Item commands ─────────────────────────────────────────────────────────────
443
+
444
+ def cmd_add(args):
445
+ from dct.core.items import create_item
446
+
447
+ engine = _get_engine()
448
+ kwargs = {}
449
+ if args.priority:
450
+ kwargs["priority"] = args.priority
451
+ if args.component:
452
+ kwargs["component"] = args.component
453
+ if args.tags:
454
+ # Shell passes `--tags "a,b,c"` as a string; core expects list[str].
455
+ # Normalize at the CLI boundary so core stays pure (see #31).
456
+ kwargs["tags"] = [t.strip() for t in args.tags.split(",") if t.strip()]
457
+ if args.description:
458
+ kwargs["description"] = args.description
459
+ if args.source_file:
460
+ kwargs["source_file"] = args.source_file
461
+
462
+ try:
463
+ item = create_item(engine, args.project, args.type, args.title, **kwargs)
464
+ print(f"✓ #{item['id']} {item['item_type']}/{item['priority']} — {item['title']}")
465
+ except Exception as e:
466
+ print(f"✗ Error: {e}", file=sys.stderr)
467
+ sys.exit(1)
468
+
469
+
470
+ def cmd_list(args):
471
+ from dct.core.items import list_items
472
+
473
+ engine = _get_engine()
474
+ kwargs = {}
475
+ if args.project:
476
+ kwargs["project_slug"] = args.project
477
+ if args.status:
478
+ kwargs["status"] = args.status
479
+ if args.type:
480
+ kwargs["item_type"] = args.type
481
+ if args.priority:
482
+ kwargs["priority"] = args.priority
483
+ if args.search:
484
+ kwargs["search"] = args.search
485
+
486
+ items = list_items(engine, **kwargs)
487
+ headers = ["#", "Project", "Type", "Pri", "Status", "Title"]
488
+
489
+ # Build project_id → slug cache
490
+ from dct.core.projects import list_projects
491
+ slug_map = {p["id"]: p["slug"] for p in list_projects(engine)}
492
+
493
+ rows = []
494
+ for i in items:
495
+ slug = slug_map.get(i.get("project_id"), "?")
496
+ rows.append([str(i["id"]), slug, i["item_type"], i["priority"], i["status"], i["title"][:60]])
497
+
498
+ _print_table(headers, rows, max_widths={5: 60})
499
+
500
+
501
+ def cmd_show(args):
502
+ from dct.core.items import get_item
503
+
504
+ engine = _get_engine()
505
+ item = get_item(engine, args.id)
506
+ if not item:
507
+ print(f"✗ Item #{args.id} not found", file=sys.stderr)
508
+ sys.exit(1)
509
+
510
+ print(f"#{item['id']} {item['item_type']}/{item['priority']} [{item['status']}]")
511
+ print(f"Title: {item['title']}")
512
+ if item.get("description"):
513
+ print(f"Description: {item['description']}")
514
+ if item.get("component"):
515
+ print(f"Component: {item['component']}")
516
+ if item.get("source_file"):
517
+ loc = item["source_file"]
518
+ if item.get("source_lines"):
519
+ loc += f":{item['source_lines']}"
520
+ print(f"Location: {loc}")
521
+ if item.get("tags"):
522
+ print(f"Tags: {', '.join(item['tags'])}")
523
+ if item.get("resolution"):
524
+ print(f"Resolution: {item['resolution']}")
525
+ print(f"Created: {_fmt_date(item['created_at'])}")
526
+ if item.get("resolved_at"):
527
+ print(f"Resolved: {_fmt_date(item['resolved_at'])}")
528
+
529
+ if item.get("notes"):
530
+ print(f"\n── Notes ({len(item['notes'])}) ──")
531
+ for n in item["notes"]:
532
+ print(f" [{_fmt_date(n['created_at'])}] {n['note']}")
533
+
534
+
535
+ def cmd_update(args):
536
+ from dct.core.items import update_item
537
+
538
+ engine = _get_engine()
539
+ kwargs = {}
540
+ if args.title:
541
+ kwargs["title"] = args.title
542
+ if args.status:
543
+ kwargs["status"] = args.status
544
+ if args.priority:
545
+ kwargs["priority"] = args.priority
546
+ if args.component:
547
+ kwargs["component"] = args.component
548
+ if args.resolution:
549
+ kwargs["resolution"] = args.resolution
550
+
551
+ try:
552
+ item = update_item(engine, args.id, **kwargs)
553
+ if item:
554
+ print(f"✓ #{item['id']} updated — {item['title']}")
555
+ else:
556
+ print(f"✗ Item #{args.id} not found", file=sys.stderr)
557
+ sys.exit(1)
558
+ except Exception as e:
559
+ print(f"✗ Error: {e}", file=sys.stderr)
560
+ sys.exit(1)
561
+
562
+
563
+ def cmd_resolve(args):
564
+ from dct.core.items import resolve_item
565
+
566
+ engine = _get_engine()
567
+ try:
568
+ item = resolve_item(engine, args.id, status=args.status, resolution=args.resolution)
569
+ if item:
570
+ print(f"✓ #{item['id']} → {item['status']}")
571
+ else:
572
+ print(f"✗ Item #{args.id} not found", file=sys.stderr)
573
+ sys.exit(1)
574
+ except Exception as e:
575
+ print(f"✗ Error: {e}", file=sys.stderr)
576
+ sys.exit(1)
577
+
578
+
579
+ def cmd_note(args):
580
+ from dct.core.items import add_note
581
+
582
+ engine = _get_engine()
583
+ try:
584
+ add_note(engine, args.id, args.note)
585
+ print(f"✓ Note added to #{args.id}")
586
+ except Exception as e:
587
+ print(f"✗ Error: {e}", file=sys.stderr)
588
+ sys.exit(1)
589
+
590
+
591
+ def cmd_delete(args):
592
+ from dct.core.items import delete_item
593
+
594
+ engine = _get_engine()
595
+ if delete_item(engine, args.id):
596
+ print(f"✓ #{args.id} soft-deleted")
597
+ else:
598
+ print(f"✗ Item #{args.id} not found", file=sys.stderr)
599
+ sys.exit(1)
600
+
601
+
602
+ # ── Handoff commands ──────────────────────────────────────────────────────────
603
+
604
+ def cmd_handoff_update(args):
605
+ from dct.core.handoff import update_handoff
606
+
607
+ engine = _get_engine()
608
+
609
+ def _read(text, path):
610
+ if path:
611
+ return sys.stdin.read() if path == "-" else Path(path).read_text()
612
+ return text
613
+
614
+ prompt = _read(args.prompt, args.prompt_file)
615
+
616
+ try:
617
+ result = update_handoff(
618
+ engine, args.id,
619
+ prompt=prompt or None,
620
+ scope=args.scope,
621
+ context_summary=args.context_summary,
622
+ )
623
+ if result is None:
624
+ print(f"✗ Handoff #{args.id} not found, consumed, or deleted", file=sys.stderr)
625
+ sys.exit(1)
626
+ print(f"✓ Handoff #{args.id} updated")
627
+ except Exception as e:
628
+ print(f"✗ Error: {e}", file=sys.stderr)
629
+ sys.exit(1)
630
+
631
+
632
+ # ── Changelog commands ────────────────────────────────────────────────────────
633
+
634
+ def cmd_clog_add(args):
635
+ from dct.core.changelog import add_entry
636
+
637
+ engine = _get_engine()
638
+ kwargs = {}
639
+ if args.component:
640
+ kwargs["component"] = args.component
641
+ if args.source_file:
642
+ kwargs["source_file"] = args.source_file
643
+ if args.item_id:
644
+ kwargs["related_item_id"] = args.item_id
645
+
646
+ try:
647
+ project_slug = args.project or _detect_project(engine)
648
+ e = add_entry(engine, project_slug, args.category, args.entry, **kwargs)
649
+ print(f"✓ Changelog #{e['id']} [{e['category']}] — {e['entry'][:60]}")
650
+ except Exception as e:
651
+ print(f"✗ Error: {e}", file=sys.stderr)
652
+ sys.exit(1)
653
+
654
+
655
+ def cmd_clog_list(args):
656
+ from dct.core.changelog import list_entries
657
+
658
+ engine = _get_engine()
659
+ project_slug = args.project or _detect_project(engine)
660
+ entries = list_entries(engine, project_slug, unreleased_only=args.unreleased)
661
+ headers = ["#", "Category", "Entry", "Version", "Created"]
662
+ rows = []
663
+ for e in entries:
664
+ rows.append([
665
+ str(e["id"]),
666
+ e["category"],
667
+ e["entry"][:50],
668
+ e["version"] or "(unreleased)",
669
+ _fmt_date(e["created_at"]),
670
+ ])
671
+ _print_table(headers, rows, max_widths={2: 50})
672
+
673
+
674
+ def cmd_clog_count(args):
675
+ from dct.core.changelog import count_unreleased
676
+
677
+ engine = _get_engine()
678
+ project_slug = args.project or _detect_project(engine)
679
+ count = count_unreleased(engine, project_slug)
680
+ print(count)
681
+
682
+
683
+ def cmd_clog_release(args):
684
+ import subprocess
685
+ from dct.core.changelog import release_entries, count_unreleased
686
+ from dct.core.projects import get_project
687
+ from dct.core.version import bump
688
+
689
+ engine = _get_engine()
690
+ project_slug = args.project or _detect_project(engine)
691
+
692
+ version = args.version
693
+ if not version:
694
+ bump_type = args.bump
695
+ if not bump_type:
696
+ print("✗ Provide a version or --patch/--minor/--major", file=sys.stderr)
697
+ sys.exit(1)
698
+ project = get_project(engine, project_slug)
699
+ if not project:
700
+ print(f"✗ Project '{project_slug}' not found", file=sys.stderr)
701
+ sys.exit(1)
702
+ current = project.get("current_version")
703
+ if not current:
704
+ print(f"✗ No current version for '{project_slug}'. Use explicit version: dct clog release {project_slug} 0.1.0", file=sys.stderr)
705
+ sys.exit(1)
706
+ version = bump(current, bump_type)
707
+ print(f" {current} → {version} ({bump_type})")
708
+
709
+ unreleased = count_unreleased(engine, project_slug)
710
+ if unreleased == 0:
711
+ print(f"✗ No unreleased entries for '{project_slug}'", file=sys.stderr)
712
+ sys.exit(1)
713
+
714
+ count = release_entries(engine, project_slug, version)
715
+ print(f"✓ Released {count} entries as {version}")
716
+
717
+ project = get_project(engine, project_slug)
718
+ if project and project.get("path"):
719
+ import os
720
+ for sync_path in ["scripts/dct-version-sync.sh", "scripts/version-sync.sh"]:
721
+ full_path = os.path.join(project["path"], sync_path)
722
+ if os.path.isfile(full_path):
723
+ print(f" Running {sync_path}...")
724
+ subprocess.run(["bash", full_path, version], check=False)
725
+ break
726
+
727
+
728
+ def cmd_clog_export(args):
729
+ from dct.export import export_changelog_md
730
+
731
+ engine = _get_engine()
732
+ project_slug = args.project or _detect_project(engine)
733
+ md = export_changelog_md(engine, project_slug)
734
+ if args.output:
735
+ with open(args.output, "w") as f:
736
+ f.write(md)
737
+ print(f"✓ Exported to {args.output}")
738
+ else:
739
+ print(md)
740
+
741
+
742
+ # ── Checkpoint commands ──────────────────────────────────────────────────────
743
+
744
+ def cmd_internal_task_mirror(args):
745
+ """Internal: worker for hooks/dct-task-mirror.sh.
746
+
747
+ Reads a Claude Code hook JSON payload on stdin, extracts TaskCreate /
748
+ TodoWrite todos, find-or-creates a session-scoped item in dct, and
749
+ appends each todo as a `kind='cc-task'` checkpoint. Non-blocking —
750
+ safe to fail silently (hook is fire-and-forget).
751
+
752
+ Intentionally named with an underscore prefix — not user-facing, just
753
+ the target of the task-mirror hook. Re-expose via the `dct` entry point
754
+ so the venv-installed Python is used (hooks cannot reliably find it).
755
+
756
+ Args:
757
+ session-id: Claude Code session_id (from hook payload .session_id).
758
+ project: dct project slug. Empty → autodetect from CWD.
759
+ """
760
+ import json
761
+
762
+ try:
763
+ payload = json.load(sys.stdin)
764
+ except json.JSONDecodeError:
765
+ return # fire-and-forget: swallow, exit clean
766
+
767
+ tool_input = payload.get("tool_input") or {}
768
+ todos: list[str] = []
769
+ if isinstance(tool_input.get("todos"), list):
770
+ todos = [
771
+ (t.get("content") or t.get("subject") or "")
772
+ for t in tool_input["todos"]
773
+ if isinstance(t, dict)
774
+ ]
775
+ else:
776
+ single = (
777
+ tool_input.get("subject")
778
+ or tool_input.get("description")
779
+ or tool_input.get("prompt")
780
+ or ""
781
+ )
782
+ if single:
783
+ todos = [single]
784
+
785
+ todos = [t.strip() for t in todos if t and t.strip()]
786
+ if not todos:
787
+ return
788
+
789
+ from dct.core.items import list_items, create_item
790
+ from dct.core.checkpoints import add_checkpoint
791
+
792
+ engine = _get_engine()
793
+
794
+ project_slug = args.project or _detect_project(engine)
795
+ session_id = args.session_id or "unknown"
796
+ title = f"CC task list — session {session_id}"
797
+
798
+ existing = [
799
+ row
800
+ for row in list_items(
801
+ engine, project_slug=project_slug, search="CC task list",
802
+ status=None, limit=100,
803
+ )
804
+ if row.get("title") == title
805
+ ]
806
+ if existing:
807
+ item_id = existing[0]["id"]
808
+ else:
809
+ item = create_item(
810
+ engine, project_slug, "todo", title,
811
+ tags=["cc-task-list", "auto"],
812
+ )
813
+ item_id = item["id"]
814
+
815
+ for t in todos:
816
+ add_checkpoint(engine, item_id, f"[cc] {t}", kind="cc-task")
817
+
818
+ print(f"mirror ok: item #{item_id}, {len(todos)} checkpoint(s) added")
819
+
820
+
821
+ def cmd_config_get(args):
822
+ """Read a dotted config key — e.g. `dct config get mirror.task_to_dct`.
823
+
824
+ Supports: database.url, dct_root, plans.<field>, mirror.<field>.
825
+ Prints the value (or empty string for unset). Exit 1 on unknown key.
826
+ """
827
+ from dct.config import load_config
828
+
829
+ cfg = load_config()
830
+ key = args.key.strip()
831
+
832
+ # Flat keys first
833
+ if key == "database.url":
834
+ print(cfg.database_url)
835
+ return
836
+ if key == "dct_root":
837
+ print(cfg.dct_root)
838
+ return
839
+
840
+ # Nested dataclass access
841
+ section, _, field_name = key.partition(".")
842
+ if not field_name:
843
+ print(f"✗ Unknown config key: '{key}'", file=sys.stderr)
844
+ sys.exit(1)
845
+
846
+ obj = getattr(cfg, section, None)
847
+ if obj is None or not hasattr(obj, field_name):
848
+ print(f"✗ Unknown config key: '{key}'", file=sys.stderr)
849
+ sys.exit(1)
850
+
851
+ value = getattr(obj, field_name)
852
+ if isinstance(value, bool):
853
+ print(str(value).lower())
854
+ else:
855
+ print(value)
856
+
857
+
858
+ def cmd_checkpoint_add(args):
859
+ from dct.core.checkpoints import add_checkpoint
860
+
861
+ engine = _get_engine()
862
+ try:
863
+ cp = add_checkpoint(engine, args.item_id, args.text, kind=args.kind)
864
+ kind_tag = "" if cp.get("kind", "task") == "task" else f" [{cp['kind']}]"
865
+ print(f"✓ Checkpoint #{cp['id']}{kind_tag} added to item #{args.item_id}")
866
+ except Exception as e:
867
+ print(f"✗ Error: {e}", file=sys.stderr)
868
+ sys.exit(1)
869
+
870
+
871
+ def cmd_checkpoint_list(args):
872
+ from dct.core.checkpoints import list_checkpoints, get_progress
873
+
874
+ engine = _get_engine()
875
+ cps = list_checkpoints(engine, args.item_id)
876
+ progress = get_progress(engine, args.item_id)
877
+
878
+ headers = ["#", "Status", "Text"]
879
+ icon = {"done": "✓", "pending": "·", "rejected": "✗"}
880
+ rows = [[str(c["id"]), f"{icon.get(c['status'], '?')} {c['status']}", c["text"][:80]] for c in cps]
881
+ _print_table(headers, rows, max_widths={2: 80})
882
+ print(f"\nProgress: {progress['done']}/{progress['active']} done ({progress['percent']}%), {progress['rejected']} rejected")
883
+
884
+
885
+ def cmd_checkpoint_done(args):
886
+ from dct.core.checkpoints import set_status
887
+
888
+ engine = _get_engine()
889
+ result = set_status(engine, args.checkpoint_id, "done")
890
+ if result:
891
+ print(f"✓ #{args.checkpoint_id} → done")
892
+ else:
893
+ print(f"✗ Checkpoint #{args.checkpoint_id} not found", file=sys.stderr)
894
+ sys.exit(1)
895
+
896
+
897
+ def cmd_checkpoint_reject(args):
898
+ from dct.core.checkpoints import set_status
899
+
900
+ engine = _get_engine()
901
+ result = set_status(engine, args.checkpoint_id, "rejected")
902
+ if result:
903
+ print(f"✗ #{args.checkpoint_id} → rejected")
904
+ else:
905
+ print(f"✗ Checkpoint #{args.checkpoint_id} not found", file=sys.stderr)
906
+ sys.exit(1)
907
+
908
+
909
+ def cmd_checkpoint_reopen(args):
910
+ from dct.core.checkpoints import set_status
911
+
912
+ engine = _get_engine()
913
+ result = set_status(engine, args.checkpoint_id, "pending")
914
+ if result:
915
+ print(f"↺ #{args.checkpoint_id} → pending")
916
+ else:
917
+ print(f"✗ Checkpoint #{args.checkpoint_id} not found", file=sys.stderr)
918
+ sys.exit(1)
919
+
920
+
921
+ def cmd_checkpoint_delete(args):
922
+ from dct.core.checkpoints import delete_checkpoint
923
+
924
+ engine = _get_engine()
925
+ if delete_checkpoint(engine, args.checkpoint_id):
926
+ print(f"✓ Checkpoint #{args.checkpoint_id} soft-deleted")
927
+ else:
928
+ print(f"✗ Checkpoint #{args.checkpoint_id} not found", file=sys.stderr)
929
+ sys.exit(1)
930
+
931
+
932
+ # ── Sprints ──────────────────────────────────────────────────────────────────
933
+
934
+ def cmd_sprint_list(args):
935
+ from dct.core.sprints import list_sprints
936
+
937
+ engine = _get_engine()
938
+ project = args.project or _detect_project(engine)
939
+ sprints = list_sprints(
940
+ engine, project_slug=project,
941
+ status=args.status or None, kind=args.kind or None,
942
+ )
943
+ headers = ["#", "Code", "Kind", "Status", "Started", "Ended", "Name"]
944
+ rows = [
945
+ [
946
+ str(s["id"]), s.get("code") or "", s["kind"], s["status"],
947
+ _fmt_date(s.get("started_at")), _fmt_date(s.get("ended_at")), s["name"],
948
+ ]
949
+ for s in sprints
950
+ ]
951
+ _print_table(headers, rows, max_widths={6: 50})
952
+
953
+
954
+ def cmd_sprint_show(args):
955
+ from dct.core.sprints import get_sprint
956
+
957
+ engine = _get_engine()
958
+ s = get_sprint(engine, args.id)
959
+ if not s:
960
+ print(f"✗ Sprint #{args.id} not found", file=sys.stderr)
961
+ sys.exit(1)
962
+
963
+ print(f"#{s['id']} {s['kind']}/{s['status']}")
964
+ print(f"Name: {s['name']}")
965
+ if s.get("code"):
966
+ print(f"Code: {s['code']}")
967
+ if s.get("goal"):
968
+ print(f"Goal: {s['goal']}")
969
+ print(f"Started: {_fmt_date(s.get('started_at'))}")
970
+ print(f"Ended: {_fmt_date(s.get('ended_at'))}")
971
+ print(f"Created: {_fmt_date(s['created_at'])}")
972
+
973
+
974
+ def cmd_sprint_create(args):
975
+ from dct.core.sprints import create_sprint
976
+
977
+ engine = _get_engine()
978
+ project = args.project or _detect_project(engine)
979
+ kwargs = {}
980
+ if args.code:
981
+ kwargs["code"] = args.code
982
+ if args.kind:
983
+ kwargs["kind"] = args.kind
984
+ if args.status:
985
+ kwargs["status"] = args.status
986
+ if args.goal:
987
+ kwargs["goal"] = args.goal
988
+ try:
989
+ s = create_sprint(engine, project, args.name, **kwargs)
990
+ print(f"✓ Sprint #{s['id']} [{s['status']}] — {s['name']}")
991
+ except Exception as e:
992
+ print(f"✗ Error: {e}", file=sys.stderr)
993
+ sys.exit(1)
994
+
995
+
996
+ def cmd_sprint_update(args):
997
+ """Handles `sprint update` and the defer/activate/complete verb aliases.
998
+
999
+ The aliases set `_forced_status` via argparse defaults; the plain update
1000
+ subcommand carries the explicit --status/--name/... flags. getattr keeps
1001
+ both namespaces working through one code path."""
1002
+ from dct.core.sprints import update_sprint
1003
+
1004
+ engine = _get_engine()
1005
+ kwargs = {}
1006
+ status = getattr(args, "_forced_status", None) or getattr(args, "status", None)
1007
+ if status:
1008
+ kwargs["status"] = status
1009
+ if getattr(args, "name", None):
1010
+ kwargs["name"] = args.name
1011
+ if getattr(args, "goal", None):
1012
+ kwargs["goal"] = args.goal
1013
+ if getattr(args, "code", None):
1014
+ kwargs["code"] = args.code
1015
+ if getattr(args, "kind", None):
1016
+ kwargs["kind"] = args.kind
1017
+ try:
1018
+ s = update_sprint(engine, args.id, **kwargs)
1019
+ if s is None:
1020
+ print(f"✗ Sprint #{args.id} not found", file=sys.stderr)
1021
+ sys.exit(1)
1022
+ print(f"✓ Sprint #{s['id']} → {s['status']} — {s['name']}")
1023
+ except Exception as e:
1024
+ print(f"✗ Error: {e}", file=sys.stderr)
1025
+ sys.exit(1)
1026
+
1027
+
1028
+ # ── MCP server (stdio) ───────────────────────────────────────────────────────
1029
+
1030
+ def cmd_server(args):
1031
+ try:
1032
+ from dct.server import mcp
1033
+ except ImportError as e:
1034
+ print(
1035
+ f"MCP server requires extras. Install with: pip install 'dct[mcp]'\n (or reinstall: uv tool install --editable . --with 'mcp[cli]')\nOriginal error: {e}",
1036
+ file=sys.stderr,
1037
+ )
1038
+ sys.exit(1)
1039
+ mcp.run()
1040
+
1041
+
1042
+ # ── Project detect (for hooks) ───────────────────────────────────────────────
1043
+
1044
+ def cmd_project_detect(args):
1045
+ import os
1046
+ from dct.core.projects import get_project_by_path
1047
+
1048
+ engine = _get_engine()
1049
+ cwd = os.getcwd()
1050
+ project = get_project_by_path(engine, cwd)
1051
+ if project:
1052
+ print(project["slug"])
1053
+ else:
1054
+ sys.exit(1)
1055
+
1056
+
1057
+ # ── web daemon helpers ───────────────────────────────────────────────────────
1058
+
1059
+ def _port_free(port: int, host: str = "127.0.0.1") -> bool:
1060
+ """True if `host:port` can be bound right now (no SO_REUSEADDR — we want a
1061
+ truthful 'is anyone holding this port' probe, not a tolerant one)."""
1062
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
1063
+ try:
1064
+ s.bind((host, port))
1065
+ return True
1066
+ except OSError:
1067
+ return False
1068
+
1069
+
1070
+ def _find_free_port(preferred: int, host: str = "127.0.0.1") -> int:
1071
+ """Return `preferred` if free, else the first free port scanning upward."""
1072
+ port = preferred
1073
+ while port < 65536:
1074
+ if _port_free(port, host):
1075
+ return port
1076
+ port += 1
1077
+ raise RuntimeError(f"no free port available at or above {preferred}")
1078
+
1079
+
1080
+ def _write_pidfile(pid: int, url: str, path: Path = WEB_PIDFILE) -> None:
1081
+ path.parent.mkdir(parents=True, exist_ok=True)
1082
+ path.write_text(f"{pid}\n{url}\n")
1083
+
1084
+
1085
+ def _detach_stdio(logfile: Path = WEB_LOGFILE) -> None:
1086
+ """Detach a daemonized child from inherited stdio.
1087
+
1088
+ Without this the serving child keeps the parent's stdout open, so a caller
1089
+ doing ``$(dct web start)`` (or any pipe / CI capture) blocks forever waiting
1090
+ for EOF. Point stdin at /dev/null and stdout+stderr at a log file, then drop
1091
+ the inherited descriptors.
1092
+ """
1093
+ sys.stdout.flush()
1094
+ sys.stderr.flush()
1095
+ null_fd = os.open(os.devnull, os.O_RDWR)
1096
+ out_fd = null_fd
1097
+ try:
1098
+ logfile.parent.mkdir(parents=True, exist_ok=True)
1099
+ out_fd = os.open(str(logfile), os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o644)
1100
+ except OSError:
1101
+ out_fd = null_fd # log path unwritable → discard output but still detach
1102
+ os.dup2(null_fd, 0)
1103
+ os.dup2(out_fd, 1)
1104
+ os.dup2(out_fd, 2)
1105
+ for fd in {null_fd, out_fd}:
1106
+ if fd > 2:
1107
+ os.close(fd)
1108
+
1109
+
1110
+ def _read_pidfile(path: Path = WEB_PIDFILE) -> "tuple[int, str] | None":
1111
+ if not path.exists():
1112
+ return None
1113
+ lines = path.read_text().splitlines()
1114
+ if not lines:
1115
+ return None
1116
+ try:
1117
+ pid = int(lines[0].strip())
1118
+ except ValueError:
1119
+ return None
1120
+ url = lines[1].strip() if len(lines) > 1 else ""
1121
+ return pid, url
1122
+
1123
+
1124
+ def _clear_pidfile(path: Path = WEB_PIDFILE) -> None:
1125
+ try:
1126
+ path.unlink()
1127
+ except FileNotFoundError:
1128
+ pass
1129
+
1130
+
1131
+ def _pid_alive(pid: int) -> bool:
1132
+ """True if a process with `pid` exists (signal 0 is a no-op probe)."""
1133
+ try:
1134
+ os.kill(pid, 0)
1135
+ except ProcessLookupError:
1136
+ return False
1137
+ except PermissionError:
1138
+ return True # exists but owned by another user
1139
+ return True
1140
+
1141
+
1142
+ # ── web daemon commands ──────────────────────────────────────────────────────
1143
+
1144
+ def cmd_web_start(args):
1145
+ from dct.core.engine import bootstrap_engine
1146
+ from dct.web.app import create_app
1147
+
1148
+ cfg = load_config()
1149
+ web = cfg.web
1150
+ host = getattr(args, "host", None) or web.host
1151
+ preferred = getattr(args, "port", None) or web.port
1152
+ no_fallback = getattr(args, "no_fallback", False)
1153
+ open_browser = getattr(args, "open", False) or web.open_browser
1154
+ fallback = web.auto_fallback and not no_fallback
1155
+
1156
+ existing = _read_pidfile()
1157
+ if existing and _pid_alive(existing[0]):
1158
+ print(f"✗ dct web already running (pid {existing[0]}) → {existing[1]}", file=sys.stderr)
1159
+ sys.exit(1)
1160
+ if existing:
1161
+ _clear_pidfile() # stale pidfile from a dead process
1162
+
1163
+ if fallback:
1164
+ port = _find_free_port(preferred, host)
1165
+ else:
1166
+ if not _port_free(preferred, host):
1167
+ print(f"✗ Port {preferred} is busy and --no-fallback is set", file=sys.stderr)
1168
+ sys.exit(1)
1169
+ port = preferred
1170
+
1171
+ url = f"http://localhost:{port}"
1172
+
1173
+ # Daemonize: parent records the child pid + URL and returns; child detaches
1174
+ # from the controlling terminal and serves. No tty needed — safe under CC/CI.
1175
+ pid = os.fork()
1176
+ if pid > 0: # parent
1177
+ _write_pidfile(pid, url)
1178
+ print(f"✓ dct web started (pid {pid}) → {url}")
1179
+ if open_browser:
1180
+ import webbrowser
1181
+ webbrowser.open(url)
1182
+ return
1183
+
1184
+ # child: detach from the controlling terminal AND from inherited stdio so a
1185
+ # captured `$(dct web start)` does not block on the pipe the server keeps open.
1186
+ os.setsid()
1187
+ _detach_stdio()
1188
+ try:
1189
+ engine = bootstrap_engine(create=False) # READ-ONLY: never create_tables()
1190
+ app = create_app(engine, read_only=True)
1191
+ app.run(host=host, port=port, threaded=True, use_reloader=False)
1192
+ finally:
1193
+ os._exit(0)
1194
+
1195
+
1196
+ def cmd_web_stop(args):
1197
+ info = _read_pidfile()
1198
+ if not info:
1199
+ print("dct web: not running (no pidfile)")
1200
+ return
1201
+ pid, _url = info
1202
+ try:
1203
+ os.kill(pid, signal.SIGTERM)
1204
+ print(f"✓ Sent SIGTERM to dct web (pid {pid})")
1205
+ except ProcessLookupError:
1206
+ print(f" (pid {pid} not alive — clearing stale pidfile)")
1207
+ except PermissionError:
1208
+ print(f"✗ No permission to signal pid {pid}", file=sys.stderr)
1209
+ sys.exit(1)
1210
+ _clear_pidfile()
1211
+
1212
+
1213
+ def cmd_web_status(args):
1214
+ info = _read_pidfile()
1215
+ if not info:
1216
+ print("dct web: stopped")
1217
+ return
1218
+ pid, url = info
1219
+ if _pid_alive(pid):
1220
+ print(f"dct web: running (pid {pid}) → {url}")
1221
+ else:
1222
+ print(f"dct web: stale pidfile (pid {pid} not alive). Run `dct web stop` to clear.")
1223
+
1224
+
1225
+ # ── Argument parser ──────────────────────────────────────────────────────────
1226
+
1227
+ def build_parser() -> argparse.ArgumentParser:
1228
+ parser = argparse.ArgumentParser(prog="dct", description="Deterministic Context Tracker — cross-project tracking")
1229
+ parser.add_argument("--version", action="version", version=f"dct {__version__}")
1230
+
1231
+ sub = parser.add_subparsers(dest="command")
1232
+
1233
+ # init
1234
+ p_init = sub.add_parser("init", help="Interactive setup")
1235
+ p_init.add_argument(
1236
+ "--wire-hooks", dest="wire_hooks", action="store_true", default=None,
1237
+ help="Wire dct hooks into ~/.claude/settings.json without prompting",
1238
+ )
1239
+ p_init.add_argument(
1240
+ "--no-wire-hooks", dest="wire_hooks", action="store_false",
1241
+ help="Skip settings.json hook wiring",
1242
+ )
1243
+
1244
+ # hooks
1245
+ p_hooks = sub.add_parser("hooks", help="Manage Claude Code hook wiring")
1246
+ p_hooks_sub = p_hooks.add_subparsers(dest="hooks_command")
1247
+ p_hooks_wire = p_hooks_sub.add_parser(
1248
+ "wire", help="Wire dct hooks into ~/.claude/settings.json (idempotent merge)"
1249
+ )
1250
+ p_hooks_wire.add_argument(
1251
+ "--settings", help="Path to settings.json (default: ~/.claude/settings.json)"
1252
+ )
1253
+
1254
+ # server (MCP stdio entry point)
1255
+ sub.add_parser("server", help="Start the MCP server (stdio transport)")
1256
+
1257
+ # project
1258
+ p_project = sub.add_parser("project", help="Manage projects")
1259
+ p_proj_sub = p_project.add_subparsers(dest="project_command")
1260
+
1261
+ p_proj_add = p_proj_sub.add_parser("add", help="Register a project")
1262
+ p_proj_add.add_argument("slug")
1263
+ p_proj_add.add_argument("path", nargs="?")
1264
+ p_proj_add.add_argument("--name")
1265
+ p_proj_add.add_argument("--version", "-v")
1266
+
1267
+ p_proj_sub.add_parser("list", help="List projects")
1268
+
1269
+ p_proj_rm = p_proj_sub.add_parser("remove", help="Remove a project (soft delete)")
1270
+ p_proj_rm.add_argument("slug")
1271
+
1272
+ p_proj_sub.add_parser("detect", help="Detect project from CWD")
1273
+
1274
+ p_proj_setv = p_proj_sub.add_parser("set-version", help="Set current_version for a project")
1275
+ p_proj_setv.add_argument("slug")
1276
+ p_proj_setv.add_argument("version")
1277
+
1278
+ p_proj_rename = p_proj_sub.add_parser(
1279
+ "rename",
1280
+ help="Rename a project (any of slug/name/description/path) — project_id stable, FKs preserved",
1281
+ )
1282
+ p_proj_rename.add_argument("slug", help="Current slug of the project to rename")
1283
+ p_proj_rename.add_argument("--slug", dest="new_slug", help="New slug (must be unique among live projects)")
1284
+ p_proj_rename.add_argument("--name", help="New display name")
1285
+ p_proj_rename.add_argument("--description", help="New description")
1286
+ p_proj_rename.add_argument("--path", help="New filesystem path (does not move files on disk)")
1287
+
1288
+ p_proj_clog = p_proj_sub.add_parser(
1289
+ "changelog", help="Opt a project in/out of dct changelog-hook enforcement (#222)",
1290
+ )
1291
+ p_proj_clog.add_argument("slug")
1292
+ p_proj_clog.add_argument("state", choices=["on", "off"])
1293
+
1294
+ p_proj_mc = p_proj_sub.add_parser(
1295
+ "manages-changelog",
1296
+ help="Exit 0 if the project opts into dct changelog enforcement, else 1 (hook query)",
1297
+ )
1298
+ p_proj_mc.add_argument("slug")
1299
+
1300
+ p_proj_clogpath = p_proj_sub.add_parser(
1301
+ "changelog-path",
1302
+ help="Get/set a project's changelog file location relative to its root (#225)",
1303
+ )
1304
+ p_proj_clogpath.add_argument("slug")
1305
+ p_proj_clogpath.add_argument(
1306
+ "relpath", nargs="?",
1307
+ help="Relative changelog path (e.g. pkg/changelog.md). Omit to query the current value.",
1308
+ )
1309
+
1310
+ # plan
1311
+ p_plan = sub.add_parser("plan", help="Plan operations (v0.2.0)")
1312
+ p_plan_sub = p_plan.add_subparsers(dest="plan_command")
1313
+
1314
+ p_plan_rescan = p_plan_sub.add_parser(
1315
+ "rescan",
1316
+ help="Re-scan a plan markdown file (hook entrypoint). Idempotent + quiet-safe.",
1317
+ )
1318
+ p_plan_rescan.add_argument("file")
1319
+ p_plan_rescan.add_argument("--quiet", "-q", action="store_true",
1320
+ help="Suppress all output on stdout/stderr")
1321
+ p_plan_rescan.add_argument("--resolve-uncertain", action="store_true",
1322
+ help="Call claude --print for any uncertain sections")
1323
+
1324
+ # add (item)
1325
+ p_add = sub.add_parser("add", help="Create a tracked item")
1326
+ p_add.add_argument("project")
1327
+ p_add.add_argument("type", choices=["issue", "todo", "feature", "idea", "improvement"])
1328
+ p_add.add_argument("title")
1329
+ p_add.add_argument("--priority", "-p", default="P2")
1330
+ p_add.add_argument("--component", "-c")
1331
+ p_add.add_argument("--tags", "-t")
1332
+ p_add.add_argument("--description", "-d")
1333
+ p_add.add_argument("--source-file", "-f")
1334
+
1335
+ # list (items)
1336
+ p_list = sub.add_parser("list", help="List items")
1337
+ p_list.add_argument("project", nargs="?")
1338
+ p_list.add_argument("--status", "-s")
1339
+ p_list.add_argument("--type", "-t")
1340
+ p_list.add_argument("--priority", "-p")
1341
+ p_list.add_argument("--search", "-q")
1342
+
1343
+ # show
1344
+ p_show = sub.add_parser("show", help="Show item details")
1345
+ p_show.add_argument("id", type=int)
1346
+
1347
+ # update
1348
+ p_update = sub.add_parser("update", help="Update an item")
1349
+ p_update.add_argument("id", type=int)
1350
+ p_update.add_argument("--title")
1351
+ p_update.add_argument("--status", "-s")
1352
+ p_update.add_argument("--priority", "-p")
1353
+ p_update.add_argument("--component", "-c")
1354
+ p_update.add_argument("--resolution")
1355
+
1356
+ # resolve
1357
+ p_resolve = sub.add_parser("resolve", help="Resolve/close an item")
1358
+ p_resolve.add_argument("id", type=int)
1359
+ p_resolve.add_argument("--status", "-s", default="resolved", choices=["resolved", "wont_fix"])
1360
+ p_resolve.add_argument("--resolution", "-r")
1361
+
1362
+ # note
1363
+ p_note = sub.add_parser("note", help="Add a note to an item")
1364
+ p_note.add_argument("id", type=int)
1365
+ p_note.add_argument("note")
1366
+
1367
+ # delete
1368
+ p_delete = sub.add_parser("delete", help="Soft-delete an item")
1369
+ p_delete.add_argument("id", type=int)
1370
+
1371
+ # clog (changelog)
1372
+ p_clog = sub.add_parser("clog", help="Changelog management")
1373
+ p_clog_sub = p_clog.add_subparsers(dest="clog_command")
1374
+
1375
+ p_clog_add = p_clog_sub.add_parser("add", help="Add changelog entry")
1376
+ p_clog_add.add_argument("category", choices=["added", "changed", "fixed", "removed"])
1377
+ p_clog_add.add_argument("entry")
1378
+ p_clog_add.add_argument("--project", "-P")
1379
+ p_clog_add.add_argument("--component", "-c")
1380
+ p_clog_add.add_argument("--source-file", "-f")
1381
+ p_clog_add.add_argument("--item-id", type=int)
1382
+
1383
+ p_clog_list = p_clog_sub.add_parser("list", help="List changelog entries")
1384
+ p_clog_list.add_argument("--project", "-P")
1385
+ p_clog_list.add_argument("--unreleased", "-u", action="store_true")
1386
+
1387
+ p_clog_count = p_clog_sub.add_parser("count", help="Count unreleased entries")
1388
+ p_clog_count.add_argument("--project", "-P")
1389
+
1390
+ p_clog_release = p_clog_sub.add_parser("release", help="Release: bump version and stamp changelog")
1391
+ p_clog_release.add_argument("version", nargs="?")
1392
+ p_clog_release.add_argument("--project", "-P")
1393
+ p_clog_bump = p_clog_release.add_mutually_exclusive_group()
1394
+ p_clog_bump.add_argument("--patch", dest="bump", action="store_const", const="patch")
1395
+ p_clog_bump.add_argument("--minor", dest="bump", action="store_const", const="minor")
1396
+ p_clog_bump.add_argument("--major", dest="bump", action="store_const", const="major")
1397
+
1398
+ p_clog_export = p_clog_sub.add_parser("export", help="Export to Keep a Changelog markdown")
1399
+ p_clog_export.add_argument("--project", "-P")
1400
+ p_clog_export.add_argument("--output", "-o")
1401
+
1402
+ # _task-mirror (internal: worker for hooks/dct-task-mirror.sh)
1403
+ p_tm = sub.add_parser(
1404
+ "_task-mirror",
1405
+ help="Internal — worker for dct-task-mirror.sh hook. Reads hook JSON on stdin.",
1406
+ )
1407
+ p_tm.add_argument("--session-id", default="")
1408
+ p_tm.add_argument("--project", default="")
1409
+
1410
+ # config
1411
+ p_config = sub.add_parser("config", help="Inspect dct configuration")
1412
+ p_config_sub = p_config.add_subparsers(dest="config_command")
1413
+ p_config_get = p_config_sub.add_parser("get", help="Read a dotted config key")
1414
+ p_config_get.add_argument("key", help="e.g. mirror.task_to_dct, plans.auto_resolve_uncertain")
1415
+
1416
+ # checkpoint
1417
+ p_cp = sub.add_parser("checkpoint", aliases=["cp"], help="Manage item checkpoints (acceptance criteria)")
1418
+ p_cp_sub = p_cp.add_subparsers(dest="checkpoint_command")
1419
+
1420
+ p_cp_add = p_cp_sub.add_parser("add", help="Add a checkpoint to an item")
1421
+ p_cp_add.add_argument("item_id", type=int)
1422
+ p_cp_add.add_argument("text")
1423
+ p_cp_add.add_argument(
1424
+ "--kind",
1425
+ default="task",
1426
+ help="Checkpoint kind: task (default, non-blocking) or a gate kind "
1427
+ "(dogfood/security/approval/review). Gates block resolve_item.",
1428
+ )
1429
+
1430
+ p_cp_list = p_cp_sub.add_parser("list", help="List checkpoints for an item with progress")
1431
+ p_cp_list.add_argument("item_id", type=int)
1432
+
1433
+ p_cp_done = p_cp_sub.add_parser("done", help="Mark checkpoint as done")
1434
+ p_cp_done.add_argument("checkpoint_id", type=int)
1435
+
1436
+ p_cp_reject = p_cp_sub.add_parser("reject", help="Mark checkpoint as rejected (scope change, N/A)")
1437
+ p_cp_reject.add_argument("checkpoint_id", type=int)
1438
+
1439
+ p_cp_reopen = p_cp_sub.add_parser("reopen", help="Revert checkpoint to pending")
1440
+ p_cp_reopen.add_argument("checkpoint_id", type=int)
1441
+
1442
+ p_cp_delete = p_cp_sub.add_parser("delete", help="Soft-delete a checkpoint (prefer reject for ledger)")
1443
+ p_cp_delete.add_argument("checkpoint_id", type=int)
1444
+
1445
+ # sprint (lifecycle: planned → active → [deferred] → completed/abandoned)
1446
+ p_sprint = sub.add_parser("sprint", help="Manage sprints")
1447
+ p_sprint_sub = p_sprint.add_subparsers(dest="sprint_command")
1448
+
1449
+ p_sp_list = p_sprint_sub.add_parser("list", help="List sprints")
1450
+ p_sp_list.add_argument("--project", help="Project slug (default: auto-detect from CWD)")
1451
+ p_sp_list.add_argument("--status", help="Filter by status "
1452
+ "(planned/active/deferred/completed/abandoned)")
1453
+ p_sp_list.add_argument("--kind", help="Filter by kind (sprint/phase/milestone/block)")
1454
+
1455
+ p_sp_show = p_sprint_sub.add_parser("show", help="Show sprint details")
1456
+ p_sp_show.add_argument("id", type=int)
1457
+
1458
+ p_sp_create = p_sprint_sub.add_parser("create", help="Create a sprint")
1459
+ p_sp_create.add_argument("name")
1460
+ p_sp_create.add_argument("--project", help="Project slug (default: auto-detect from CWD)")
1461
+ p_sp_create.add_argument("--code", help="Short handle, unique per project")
1462
+ p_sp_create.add_argument("--kind", help="sprint (default) / phase / milestone / block")
1463
+ p_sp_create.add_argument("--status", help="planned (default) / active / completed / abandoned "
1464
+ "('deferred' is not a valid creation status)")
1465
+ p_sp_create.add_argument("--goal", help="Free-text objective")
1466
+
1467
+ p_sp_update = p_sprint_sub.add_parser(
1468
+ "update", help="Update a sprint (status/name/goal/code/kind)"
1469
+ )
1470
+ p_sp_update.add_argument("id", type=int)
1471
+ p_sp_update.add_argument("--status", help="New status (transition whitelist enforced)")
1472
+ p_sp_update.add_argument("--name", help="New name")
1473
+ p_sp_update.add_argument("--goal", help="New goal")
1474
+ p_sp_update.add_argument("--code", help="New short handle (unique per project)")
1475
+ p_sp_update.add_argument("--kind", help="New kind")
1476
+
1477
+ _VERB_STATUS = {"defer": "deferred", "activate": "active", "complete": "completed"}
1478
+ for _verb, _st in _VERB_STATUS.items():
1479
+ p_verb = p_sprint_sub.add_parser(
1480
+ _verb, help=f"Shortcut for `sprint update <id> --status {_st}`"
1481
+ )
1482
+ p_verb.add_argument("id", type=int)
1483
+ p_verb.set_defaults(_forced_status=_st)
1484
+
1485
+ # web (read-only browser dashboard daemon — v0.4.1)
1486
+ p_handoff = sub.add_parser("handoff", help="Manage session handoffs")
1487
+ p_ho_sub = p_handoff.add_subparsers(dest="handoff_command")
1488
+
1489
+ p_ho_update = p_ho_sub.add_parser("update", help="Amend an unconsumed handoff in place")
1490
+ p_ho_update.add_argument("id", type=int, help="Handoff id")
1491
+ p_ho_update.add_argument("--prompt", default=None, help="New prompt text")
1492
+ p_ho_update.add_argument("--prompt-file", default=None, help="Read new prompt from file ('-' = stdin)")
1493
+ p_ho_update.add_argument("--scope", default=None, help="New scope tag")
1494
+ p_ho_update.add_argument("--context-summary", default=None, help="New context summary snapshot")
1495
+
1496
+ p_web = sub.add_parser("web", help="Read-only browser dashboard daemon")
1497
+ p_web_sub = p_web.add_subparsers(dest="web_command")
1498
+
1499
+ p_web_start = p_web_sub.add_parser("start", help="Start the dashboard daemon")
1500
+ p_web_start.add_argument("--port", type=int, help="Preferred port (overrides config)")
1501
+ p_web_start.add_argument("--host", help="Bind address (default 127.0.0.1)")
1502
+ p_web_start.add_argument("--open", action="store_true", help="Open the browser after start")
1503
+ p_web_start.add_argument("--no-fallback", action="store_true",
1504
+ help="Fail if the preferred port is busy instead of scanning upward")
1505
+
1506
+ p_web_sub.add_parser("stop", help="Stop the daemon (SIGTERM via pidfile)")
1507
+ p_web_sub.add_parser("status", help="Show daemon pid + URL")
1508
+
1509
+ return parser
1510
+
1511
+
1512
+ COMMAND_MAP = {
1513
+ "init": cmd_init,
1514
+ "server": cmd_server,
1515
+ "add": cmd_add,
1516
+ "list": cmd_list,
1517
+ "show": cmd_show,
1518
+ "update": cmd_update,
1519
+ "resolve": cmd_resolve,
1520
+ "note": cmd_note,
1521
+ "delete": cmd_delete,
1522
+ "_task-mirror": cmd_internal_task_mirror,
1523
+ }
1524
+
1525
+ HOOKS_COMMAND_MAP = {
1526
+ "wire": cmd_hooks_wire,
1527
+ }
1528
+
1529
+ PROJECT_COMMAND_MAP = {
1530
+ "add": cmd_project_add,
1531
+ "list": cmd_project_list,
1532
+ "remove": cmd_project_remove,
1533
+ "detect": cmd_project_detect,
1534
+ "set-version": cmd_project_set_version,
1535
+ "rename": cmd_project_rename,
1536
+ "changelog": cmd_project_changelog,
1537
+ "manages-changelog": cmd_project_manages_changelog,
1538
+ "changelog-path": cmd_project_changelog_path,
1539
+ }
1540
+
1541
+ PLAN_COMMAND_MAP = {
1542
+ "rescan": cmd_plan_rescan,
1543
+ }
1544
+
1545
+ CLOG_COMMAND_MAP = {
1546
+ "add": cmd_clog_add,
1547
+ "list": cmd_clog_list,
1548
+ "count": cmd_clog_count,
1549
+ "release": cmd_clog_release,
1550
+ "export": cmd_clog_export,
1551
+ }
1552
+
1553
+ CONFIG_COMMAND_MAP = {
1554
+ "get": cmd_config_get,
1555
+ }
1556
+
1557
+ WEB_COMMAND_MAP = {
1558
+ "start": cmd_web_start,
1559
+ "stop": cmd_web_stop,
1560
+ "status": cmd_web_status,
1561
+ }
1562
+
1563
+ CHECKPOINT_COMMAND_MAP = {
1564
+ "add": cmd_checkpoint_add,
1565
+ "list": cmd_checkpoint_list,
1566
+ "done": cmd_checkpoint_done,
1567
+ "reject": cmd_checkpoint_reject,
1568
+ "reopen": cmd_checkpoint_reopen,
1569
+ "delete": cmd_checkpoint_delete,
1570
+ }
1571
+
1572
+ HANDOFF_COMMAND_MAP = {
1573
+ "update": cmd_handoff_update,
1574
+ }
1575
+
1576
+ SPRINT_COMMAND_MAP = {
1577
+ "list": cmd_sprint_list,
1578
+ "show": cmd_sprint_show,
1579
+ "create": cmd_sprint_create,
1580
+ "update": cmd_sprint_update,
1581
+ # verb-sugar aliases route onto the same update path
1582
+ "defer": cmd_sprint_update,
1583
+ "activate": cmd_sprint_update,
1584
+ "complete": cmd_sprint_update,
1585
+ }
1586
+
1587
+
1588
+ def main():
1589
+ parser = build_parser()
1590
+ args = parser.parse_args()
1591
+
1592
+ if not args.command:
1593
+ parser.print_help()
1594
+ sys.exit(0)
1595
+
1596
+ if args.command == "project":
1597
+ handler = PROJECT_COMMAND_MAP.get(args.project_command)
1598
+ if not handler:
1599
+ parser.parse_args(["project", "--help"])
1600
+ return
1601
+ handler(args)
1602
+ elif args.command == "clog":
1603
+ handler = CLOG_COMMAND_MAP.get(args.clog_command)
1604
+ if not handler:
1605
+ parser.parse_args(["clog", "--help"])
1606
+ return
1607
+ handler(args)
1608
+ elif args.command in ("checkpoint", "cp"):
1609
+ handler = CHECKPOINT_COMMAND_MAP.get(args.checkpoint_command)
1610
+ if not handler:
1611
+ parser.parse_args(["checkpoint", "--help"])
1612
+ return
1613
+ handler(args)
1614
+ elif args.command == "handoff":
1615
+ handler = HANDOFF_COMMAND_MAP.get(args.handoff_command)
1616
+ if not handler:
1617
+ parser.parse_args(["handoff", "--help"])
1618
+ return
1619
+ handler(args)
1620
+ elif args.command == "sprint":
1621
+ handler = SPRINT_COMMAND_MAP.get(args.sprint_command)
1622
+ if not handler:
1623
+ parser.parse_args(["sprint", "--help"])
1624
+ return
1625
+ handler(args)
1626
+ elif args.command == "plan":
1627
+ handler = PLAN_COMMAND_MAP.get(args.plan_command)
1628
+ if not handler:
1629
+ parser.parse_args(["plan", "--help"])
1630
+ return
1631
+ handler(args)
1632
+ elif args.command == "config":
1633
+ handler = CONFIG_COMMAND_MAP.get(args.config_command)
1634
+ if not handler:
1635
+ parser.parse_args(["config", "--help"])
1636
+ return
1637
+ handler(args)
1638
+ elif args.command == "web":
1639
+ handler = WEB_COMMAND_MAP.get(args.web_command or "start")
1640
+ if not handler:
1641
+ parser.parse_args(["web", "--help"])
1642
+ return
1643
+ handler(args)
1644
+ elif args.command == "hooks":
1645
+ handler = HOOKS_COMMAND_MAP.get(args.hooks_command)
1646
+ if not handler:
1647
+ parser.parse_args(["hooks", "--help"])
1648
+ return
1649
+ handler(args)
1650
+ else:
1651
+ handler = COMMAND_MAP.get(args.command)
1652
+ if handler:
1653
+ handler(args)
1654
+ else:
1655
+ parser.print_help()
1656
+
1657
+
1658
+ if __name__ == "__main__":
1659
+ main()