code-coordinator 0.5.46__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.
- code_coordinator-0.5.46.dist-info/METADATA +625 -0
- code_coordinator-0.5.46.dist-info/RECORD +295 -0
- code_coordinator-0.5.46.dist-info/WHEEL +5 -0
- code_coordinator-0.5.46.dist-info/entry_points.txt +2 -0
- code_coordinator-0.5.46.dist-info/licenses/LICENSE +110 -0
- code_coordinator-0.5.46.dist-info/top_level.txt +1 -0
- coord/__init__.py +176 -0
- coord/_board_mapping.py +229 -0
- coord/acceptance.py +468 -0
- coord/acceptance_drivers.py +632 -0
- coord/agent.py +7517 -0
- coord/agent_app.py +1555 -0
- coord/agent_update.py +417 -0
- coord/agents/opencode/.gitignore +13 -0
- coord/agents/opencode/agents/work.md +129 -0
- coord/agents/opencode/routing.jsonc +49 -0
- coord/audit.py +301 -0
- coord/auto_loop.py +1440 -0
- coord/board_bool_guard.py +72 -0
- coord/board_service.py +141 -0
- coord/board_wire.py +309 -0
- coord/brain.py +581 -0
- coord/branch_model.py +214 -0
- coord/cargo_cache.py +258 -0
- coord/ci_github.py +386 -0
- coord/ci_store.py +560 -0
- coord/claim.py +353 -0
- coord/cli.py +454 -0
- coord/client.py +610 -0
- coord/commands/__init__.py +1 -0
- coord/commands/_common.py +329 -0
- coord/commands/acceptance.py +916 -0
- coord/commands/agent_ops.py +1339 -0
- coord/commands/audit.py +131 -0
- coord/commands/chat.py +320 -0
- coord/commands/dispatch.py +1780 -0
- coord/commands/dispatch_workers.py +4894 -0
- coord/commands/drive.py +616 -0
- coord/commands/drive_queue.py +1203 -0
- coord/commands/gate_a.py +217 -0
- coord/commands/gates.py +89 -0
- coord/commands/issues.py +681 -0
- coord/commands/lifecycle.py +513 -0
- coord/commands/merge.py +1900 -0
- coord/commands/milestone.py +2081 -0
- coord/commands/plan_followup.py +1243 -0
- coord/commands/plans.py +156 -0
- coord/commands/release.py +2232 -0
- coord/commands/report.py +341 -0
- coord/commands/review.py +1523 -0
- coord/commands/scorecard.py +252 -0
- coord/commands/sessions.py +1930 -0
- coord/commands/setup.py +576 -0
- coord/commands/status.py +2089 -0
- coord/commands/terminal.py +385 -0
- coord/commands/test_gate.py +775 -0
- coord/commands/tui.py +288 -0
- coord/comments.py +718 -0
- coord/config.py +3032 -0
- coord/conflict_fix.py +633 -0
- coord/dao.py +483 -0
- coord/dashboard/__init__.py +0 -0
- coord/dashboard/fixture.py +376 -0
- coord/dashboard/index.html +658 -0
- coord/dashboard/server.py +1894 -0
- coord/dashboard/terminal.py +382 -0
- coord/dashboard/webapp/.gitignore +9 -0
- coord/dashboard/webapp/components.json +17 -0
- coord/dashboard/webapp/dist/assets/Gallery-da3qNiIw.js +71 -0
- coord/dashboard/webapp/dist/assets/Terminal-9CEnUXvW.css +32 -0
- coord/dashboard/webapp/dist/assets/Terminal-skVFCxPU.js +63 -0
- coord/dashboard/webapp/dist/assets/index-DltfZR5f.js +184 -0
- coord/dashboard/webapp/dist/assets/index-Dq4kwTdw.css +1 -0
- coord/dashboard/webapp/dist/assets/workbox-window.prod.es5-BqEJf4Xk.js +2 -0
- coord/dashboard/webapp/dist/icons/icon-192.png +0 -0
- coord/dashboard/webapp/dist/icons/icon-512.png +0 -0
- coord/dashboard/webapp/dist/icons/icon.svg +5 -0
- coord/dashboard/webapp/dist/index.html +38 -0
- coord/dashboard/webapp/dist/manifest.webmanifest +1 -0
- coord/dashboard/webapp/dist/sw.js +1 -0
- coord/dashboard/webapp/dist/workbox-e4022e15.js +1 -0
- coord/dashboard/webapp/e2e/available-gates-terminal.spec.ts +75 -0
- coord/dashboard/webapp/e2e/deep-link.spec.ts +172 -0
- coord/dashboard/webapp/e2e/fixtureServer.ts +155 -0
- coord/dashboard/webapp/e2e/live-update-fixture.spec.ts +113 -0
- coord/dashboard/webapp/e2e/realtime.spec.ts +238 -0
- coord/dashboard/webapp/e2e/shell.spec.ts +309 -0
- coord/dashboard/webapp/e2e/smoke.spec.ts +191 -0
- coord/dashboard/webapp/e2e/terminal.spec.ts +420 -0
- coord/dashboard/webapp/e2e/theme.spec.ts +138 -0
- coord/dashboard/webapp/eslint.config.js +20 -0
- coord/dashboard/webapp/index.html +37 -0
- coord/dashboard/webapp/node_modules/flatted/python/flatted.py +144 -0
- coord/dashboard/webapp/package-lock.json +10584 -0
- coord/dashboard/webapp/package.json +63 -0
- coord/dashboard/webapp/playwright.acceptance.config.ts +166 -0
- coord/dashboard/webapp/playwright.config.ts +93 -0
- coord/dashboard/webapp/postcss.config.js +6 -0
- coord/dashboard/webapp/public/icons/icon-192.png +0 -0
- coord/dashboard/webapp/public/icons/icon-512.png +0 -0
- coord/dashboard/webapp/public/icons/icon.svg +5 -0
- coord/dashboard/webapp/src/App.tsx +140 -0
- coord/dashboard/webapp/src/api/client.ts +199 -0
- coord/dashboard/webapp/src/api/generated.ts +176 -0
- coord/dashboard/webapp/src/components/ConnectionBadge.tsx +52 -0
- coord/dashboard/webapp/src/components/Detail.tsx +800 -0
- coord/dashboard/webapp/src/components/Gallery.tsx +341 -0
- coord/dashboard/webapp/src/components/Home.tsx +435 -0
- coord/dashboard/webapp/src/components/MobileKeyBar.tsx +280 -0
- coord/dashboard/webapp/src/components/PanelHeader.tsx +59 -0
- coord/dashboard/webapp/src/components/PipelineCard.tsx +168 -0
- coord/dashboard/webapp/src/components/SessionCard.tsx +99 -0
- coord/dashboard/webapp/src/components/SessionDetail.tsx +140 -0
- coord/dashboard/webapp/src/components/SessionsList.tsx +81 -0
- coord/dashboard/webapp/src/components/Terminal.tsx +376 -0
- coord/dashboard/webapp/src/components/__tests__/ConnectionBadge.test.tsx +81 -0
- coord/dashboard/webapp/src/components/__tests__/Detail.test.tsx +680 -0
- coord/dashboard/webapp/src/components/__tests__/Gallery.test.tsx +83 -0
- coord/dashboard/webapp/src/components/__tests__/Home.test.tsx +271 -0
- coord/dashboard/webapp/src/components/__tests__/MobileKeyBar.test.tsx +197 -0
- coord/dashboard/webapp/src/components/__tests__/PipelineCard.test.tsx +143 -0
- coord/dashboard/webapp/src/components/__tests__/SessionCard.test.tsx +106 -0
- coord/dashboard/webapp/src/components/__tests__/Terminal.test.tsx +504 -0
- coord/dashboard/webapp/src/components/ui/badge.tsx +41 -0
- coord/dashboard/webapp/src/components/ui/button.tsx +54 -0
- coord/dashboard/webapp/src/components/ui/card.tsx +55 -0
- coord/dashboard/webapp/src/components/ui/dialog.tsx +99 -0
- coord/dashboard/webapp/src/components/ui/dropdown-menu.tsx +189 -0
- coord/dashboard/webapp/src/components/ui/empty-state.tsx +35 -0
- coord/dashboard/webapp/src/components/ui/sheet.tsx +123 -0
- coord/dashboard/webapp/src/components/ui/skeleton.tsx +9 -0
- coord/dashboard/webapp/src/components/ui/tabs.tsx +55 -0
- coord/dashboard/webapp/src/components/ui/theme-provider.tsx +78 -0
- coord/dashboard/webapp/src/components/ui/theme-toggle.tsx +20 -0
- coord/dashboard/webapp/src/components/ui/toast.tsx +123 -0
- coord/dashboard/webapp/src/components/ui/toaster.tsx +30 -0
- coord/dashboard/webapp/src/components/ui/tooltip.tsx +26 -0
- coord/dashboard/webapp/src/components/ui/use-toast.ts +134 -0
- coord/dashboard/webapp/src/index.css +210 -0
- coord/dashboard/webapp/src/lib/pipeline.ts +29 -0
- coord/dashboard/webapp/src/lib/utils.ts +6 -0
- coord/dashboard/webapp/src/main.tsx +46 -0
- coord/dashboard/webapp/src/realtime/RealtimeProvider.tsx +112 -0
- coord/dashboard/webapp/src/realtime/__tests__/RealtimeProvider.test.tsx +189 -0
- coord/dashboard/webapp/src/realtime/__tests__/connection.test.ts +255 -0
- coord/dashboard/webapp/src/realtime/connection.ts +227 -0
- coord/dashboard/webapp/src/realtime/events.ts +100 -0
- coord/dashboard/webapp/src/routes/__tests__/paths.test.ts +92 -0
- coord/dashboard/webapp/src/routes/paths.ts +92 -0
- coord/dashboard/webapp/src/shell/ActivityRail.tsx +335 -0
- coord/dashboard/webapp/src/shell/AppShell.tsx +276 -0
- coord/dashboard/webapp/src/shell/ComingSoon.tsx +33 -0
- coord/dashboard/webapp/src/shell/EmptyDetail.tsx +26 -0
- coord/dashboard/webapp/src/shell/RouteNotFound.tsx +33 -0
- coord/dashboard/webapp/src/shell/ShellLayout.tsx +147 -0
- coord/dashboard/webapp/src/shell/StatusBar.tsx +46 -0
- coord/dashboard/webapp/src/shell/__tests__/ShellLayout.test.tsx +520 -0
- coord/dashboard/webapp/src/shell/__tests__/shellState.test.ts +95 -0
- coord/dashboard/webapp/src/shell/__tests__/stubViewport.ts +40 -0
- coord/dashboard/webapp/src/shell/breakpoints.ts +87 -0
- coord/dashboard/webapp/src/shell/railItems.ts +105 -0
- coord/dashboard/webapp/src/shell/shellState.ts +174 -0
- coord/dashboard/webapp/src/shell/useRegionFocus.ts +95 -0
- coord/dashboard/webapp/src/test-setup.ts +41 -0
- coord/dashboard/webapp/src/vite-env.d.ts +2 -0
- coord/dashboard/webapp/tailwind.config.js +140 -0
- coord/dashboard/webapp/tsconfig.json +25 -0
- coord/dashboard/webapp/tsconfig.node.json +11 -0
- coord/dashboard/webapp/vite.config.ts +71 -0
- coord/db.py +1076 -0
- coord/dead_end.py +332 -0
- coord/deploy/README.md +33 -0
- coord/deploy/coord-agent.service +89 -0
- coord/deploy/coord-db-backup.service +60 -0
- coord/deploy/coord-db-backup.sh +74 -0
- coord/deploy/coord-db-backup.timer +18 -0
- coord/deploy/coord-drive-queue.service +117 -0
- coord/deploy/coord-drive-queue.timer +39 -0
- coord/deploy/coord-notify.service +48 -0
- coord/deploy/coord-notify.timer +24 -0
- coord/deploy/coord-release-propagate.service +83 -0
- coord/deploy/coord-release-propagate.timer +38 -0
- coord/deploy/coord-release-window.service +119 -0
- coord/deploy/coord-release-window.timer +36 -0
- coord/deploy/coord-serve.service +82 -0
- coord/deploy/coord-web-dist-build.service +43 -0
- coord/deploy/coord-web-dist-build.timer +36 -0
- coord/deploy/coord-web.service +125 -0
- coord/deploy_manifest.py +80 -0
- coord/deploy_units.py +384 -0
- coord/deps.py +115 -0
- coord/diagnose.py +1623 -0
- coord/dispatch.py +1009 -0
- coord/dist_name.py +123 -0
- coord/drive.py +3101 -0
- coord/drive_queue.py +2298 -0
- coord/drive_state.py +870 -0
- coord/events.py +381 -0
- coord/failure_class.py +914 -0
- coord/filelock.py +168 -0
- coord/fleet_config_health.py +300 -0
- coord/freshness.py +206 -0
- coord/gate_a.py +469 -0
- coord/gate_b.py +411 -0
- coord/gate_snapshot.py +385 -0
- coord/gates.py +582 -0
- coord/github_ops.py +1954 -0
- coord/goal.py +125 -0
- coord/graph_health.py +348 -0
- coord/health/__init__.py +69 -0
- coord/health/aggregate.py +129 -0
- coord/health/checks/__init__.py +13 -0
- coord/health/checks/agent_install.py +280 -0
- coord/health/checks/cargo_targets.py +171 -0
- coord/health/checks/claude_binary.py +65 -0
- coord/health/checks/deploy_lane_facts.py +458 -0
- coord/health/checks/disk.py +99 -0
- coord/health/checks/fleet_board.py +89 -0
- coord/health/checks/fleet_deploy_lanes.py +469 -0
- coord/health/checks/fleet_phantom.py +69 -0
- coord/health/checks/fleet_unit_drift.py +151 -0
- coord/health/checks/graph.py +192 -0
- coord/health/checks/plan_usage.py +88 -0
- coord/health/checks/repo_state.py +161 -0
- coord/health/checks/spawned_coord.py +465 -0
- coord/health/checks/timer_active.py +254 -0
- coord/health/checks/toolchain.py +547 -0
- coord/health/checks/unit_drift.py +648 -0
- coord/health/checks/unit_enablement.py +171 -0
- coord/health/checks/worktrees.py +96 -0
- coord/health/cli.py +121 -0
- coord/health/context.py +106 -0
- coord/health/fleet_snapshot.py +477 -0
- coord/health/models.py +250 -0
- coord/health/pypi.py +231 -0
- coord/health/registry.py +240 -0
- coord/health/render.py +82 -0
- coord/health/units.py +60 -0
- coord/hooks.py +106 -0
- coord/housekeeping.py +204 -0
- coord/interactive.py +4286 -0
- coord/issue_store.py +1496 -0
- coord/liveness_auditor.py +293 -0
- coord/machine_pause.py +755 -0
- coord/merge_queue.py +4681 -0
- coord/milestone_chat.py +600 -0
- coord/milestone_dispatch.py +943 -0
- coord/milestone_gate.py +709 -0
- coord/milestone_order.py +840 -0
- coord/mock_author.py +334 -0
- coord/models.py +891 -0
- coord/network.py +269 -0
- coord/new_issue_chat.py +229 -0
- coord/notify.py +3226 -0
- coord/openapi.py +404 -0
- coord/overlap_fence.py +133 -0
- coord/parentage.py +200 -0
- coord/parentage_github.py +58 -0
- coord/pipeline.py +481 -0
- coord/plan_parser.py +266 -0
- coord/plans.py +543 -0
- coord/platform_paths.py +43 -0
- coord/pr_body_lint.py +67 -0
- coord/prereqs.py +533 -0
- coord/progress.py +425 -0
- coord/providers/__init__.py +683 -0
- coord/providers/base.py +218 -0
- coord/providers/claude.py +284 -0
- coord/providers/claude_pty.py +610 -0
- coord/providers/opencode.py +896 -0
- coord/reconcile.py +2233 -0
- coord/refine_chat.py +485 -0
- coord/release_cordon.py +525 -0
- coord/release_propagate.py +1176 -0
- coord/release_verify.py +777 -0
- coord/release_window.py +322 -0
- coord/reports.py +1643 -0
- coord/revalidate.py +1101 -0
- coord/review.py +3317 -0
- coord/scorecard.py +484 -0
- coord/serve_app.py +7192 -0
- coord/skills/update-issue/SKILL.md +93 -0
- coord/smoke.py +1030 -0
- coord/split_work.py +210 -0
- coord/stage_projection.py +650 -0
- coord/state.py +5720 -0
- coord/test_author.py +1064 -0
- coord/test_chat.py +352 -0
- coord/test_orchestrator.py +494 -0
- coord/test_report.py +178 -0
- coord/tui_release.py +271 -0
- coord/usage.py +753 -0
- coord/usage_limits.py +358 -0
- coord/usage_rollup.py +709 -0
- coord/worker_events.py +954 -0
coord/commands/setup.py
ADDED
|
@@ -0,0 +1,576 @@
|
|
|
1
|
+
"""`coord init`/`config`/`version`/`install-skills` — one-time and
|
|
2
|
+
diagnostic setup commands. Extracted from coord/cli.py (#747)."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import os
|
|
7
|
+
import shutil
|
|
8
|
+
import socket
|
|
9
|
+
import subprocess
|
|
10
|
+
import sys
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
import click
|
|
14
|
+
import httpx
|
|
15
|
+
|
|
16
|
+
from coord import __version__
|
|
17
|
+
|
|
18
|
+
from coord.commands._common import _CONFIG_OPTION, _load_config
|
|
19
|
+
import json
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@click.command(help="Print the coord version.")
|
|
23
|
+
def version() -> None:
|
|
24
|
+
click.echo(f"coord {__version__}")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@click.command("config", help="Load coordinator.yml and pretty-print the parsed config.")
|
|
28
|
+
@_CONFIG_OPTION
|
|
29
|
+
def config_cmd(config_path: Path) -> None:
|
|
30
|
+
cfg = _load_config(config_path)
|
|
31
|
+
click.echo(f"# {cfg.path}")
|
|
32
|
+
click.echo("")
|
|
33
|
+
click.echo("Repos:")
|
|
34
|
+
for r in cfg.repos:
|
|
35
|
+
deps = f" depends_on: {', '.join(r.depends_on)}" if r.depends_on else " depends_on: (none)"
|
|
36
|
+
click.echo(f" - {r.name} ({r.github}) [branch: {r.default_branch}]")
|
|
37
|
+
click.echo(f" {deps}")
|
|
38
|
+
click.echo("")
|
|
39
|
+
click.echo("Machines:")
|
|
40
|
+
for m in cfg.machines:
|
|
41
|
+
caps = ", ".join(m.capabilities) if m.capabilities else "(none)"
|
|
42
|
+
repos = ", ".join(m.repos) if m.repos else "(none)"
|
|
43
|
+
click.echo(f" - {m.name} @ {m.host}")
|
|
44
|
+
click.echo(f" capabilities: {caps}")
|
|
45
|
+
click.echo(f" repos: {repos}")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _ensure_coord_permissions(cwd: Path) -> None:
|
|
49
|
+
"""Check .claude/settings.local.json for Bash(coord *) / Bash(coord) entries.
|
|
50
|
+
|
|
51
|
+
If either is absent, prompt the user and add both. Skips silently when
|
|
52
|
+
both entries are already present.
|
|
53
|
+
"""
|
|
54
|
+
settings_dir = cwd / ".claude"
|
|
55
|
+
settings_path = settings_dir / "settings.local.json"
|
|
56
|
+
|
|
57
|
+
COORD_PERMS = ["Bash(coord *)", "Bash(coord)"]
|
|
58
|
+
|
|
59
|
+
# Read existing settings or start fresh.
|
|
60
|
+
data: dict = {}
|
|
61
|
+
if settings_path.exists():
|
|
62
|
+
try:
|
|
63
|
+
data = json.loads(settings_path.read_text())
|
|
64
|
+
except (json.JSONDecodeError, OSError):
|
|
65
|
+
data = {}
|
|
66
|
+
|
|
67
|
+
existing_allow: list = data.get("permissions", {}).get("allow", [])
|
|
68
|
+
missing = [p for p in COORD_PERMS if p not in existing_allow]
|
|
69
|
+
|
|
70
|
+
if not missing:
|
|
71
|
+
return # Already configured — nothing to do.
|
|
72
|
+
|
|
73
|
+
click.echo("\n── Claude Code permissions ──")
|
|
74
|
+
click.echo(
|
|
75
|
+
" .claude/settings.local.json is missing: "
|
|
76
|
+
+ ", ".join(missing)
|
|
77
|
+
)
|
|
78
|
+
if click.confirm(
|
|
79
|
+
" Add Bash(coord *) and Bash(coord) to allow list?",
|
|
80
|
+
default=True,
|
|
81
|
+
):
|
|
82
|
+
if "permissions" not in data:
|
|
83
|
+
data["permissions"] = {}
|
|
84
|
+
if "allow" not in data["permissions"]:
|
|
85
|
+
data["permissions"]["allow"] = []
|
|
86
|
+
for perm in missing:
|
|
87
|
+
if perm not in data["permissions"]["allow"]:
|
|
88
|
+
data["permissions"]["allow"].append(perm)
|
|
89
|
+
|
|
90
|
+
settings_dir.mkdir(parents=True, exist_ok=True)
|
|
91
|
+
settings_path.write_text(json.dumps(data, indent=2) + "\n")
|
|
92
|
+
click.echo(f" Updated: {settings_path}")
|
|
93
|
+
else:
|
|
94
|
+
click.echo(
|
|
95
|
+
" Skipped. Add them manually to .claude/settings.local.json."
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@click.command(help="Interactive setup; generates coordinator.yml.")
|
|
100
|
+
def init() -> None:
|
|
101
|
+
cwd = Path(os.getcwd())
|
|
102
|
+
config_file = cwd / "coordinator.yml"
|
|
103
|
+
|
|
104
|
+
# ── Step 1: Check for existing config ───────────────────────────────
|
|
105
|
+
if config_file.exists():
|
|
106
|
+
if not click.confirm(
|
|
107
|
+
"coordinator.yml already exists. Overwrite?", default=False
|
|
108
|
+
):
|
|
109
|
+
click.echo("Aborted.")
|
|
110
|
+
return
|
|
111
|
+
|
|
112
|
+
# ── Step 2: Detect current machine ──────────────────────────────────
|
|
113
|
+
click.echo("\n── Machine setup ──")
|
|
114
|
+
hostname = socket.gethostname()
|
|
115
|
+
short_hostname = hostname.split(".")[0]
|
|
116
|
+
machine_name = click.prompt("Machine name", default=short_hostname)
|
|
117
|
+
|
|
118
|
+
detected_caps: list[str] = []
|
|
119
|
+
# gtk: check via pkg-config
|
|
120
|
+
try:
|
|
121
|
+
subprocess.run(
|
|
122
|
+
["pkg-config", "--exists", "gtk4"],
|
|
123
|
+
capture_output=True,
|
|
124
|
+
check=True,
|
|
125
|
+
)
|
|
126
|
+
detected_caps.append("gtk")
|
|
127
|
+
except (subprocess.CalledProcessError, FileNotFoundError):
|
|
128
|
+
pass
|
|
129
|
+
# rust
|
|
130
|
+
if shutil.which("cargo"):
|
|
131
|
+
detected_caps.append("rust")
|
|
132
|
+
# python
|
|
133
|
+
if shutil.which("python3"):
|
|
134
|
+
detected_caps.append("python")
|
|
135
|
+
# docker
|
|
136
|
+
if shutil.which("docker"):
|
|
137
|
+
detected_caps.append("docker")
|
|
138
|
+
# node
|
|
139
|
+
if shutil.which("node"):
|
|
140
|
+
detected_caps.append("node")
|
|
141
|
+
|
|
142
|
+
if detected_caps:
|
|
143
|
+
click.echo(f"Detected capabilities: {', '.join(detected_caps)}")
|
|
144
|
+
else:
|
|
145
|
+
click.echo("No capabilities auto-detected.")
|
|
146
|
+
caps_input = click.prompt(
|
|
147
|
+
"Capabilities (comma-separated)", default=",".join(detected_caps)
|
|
148
|
+
)
|
|
149
|
+
capabilities = [c.strip() for c in caps_input.split(",") if c.strip()]
|
|
150
|
+
|
|
151
|
+
# ── Step 3: Discover repos ──────────────────────────────────────────
|
|
152
|
+
click.echo("\n── Repo discovery ──")
|
|
153
|
+
candidate_dirs: list[Path] = []
|
|
154
|
+
# Scan cwd
|
|
155
|
+
if (cwd / ".git").is_dir():
|
|
156
|
+
candidate_dirs.append(cwd)
|
|
157
|
+
# Scan ~/src/
|
|
158
|
+
src_dir = Path.home() / "src"
|
|
159
|
+
if src_dir.is_dir():
|
|
160
|
+
for child in sorted(src_dir.iterdir()):
|
|
161
|
+
if child.is_dir() and (child / ".git").is_dir():
|
|
162
|
+
if child.resolve() != cwd.resolve():
|
|
163
|
+
candidate_dirs.append(child)
|
|
164
|
+
|
|
165
|
+
# For each candidate, try to get the GitHub remote
|
|
166
|
+
discovered: list[dict] = []
|
|
167
|
+
for d in candidate_dirs:
|
|
168
|
+
try:
|
|
169
|
+
result = subprocess.run(
|
|
170
|
+
["git", "-C", str(d), "remote", "get-url", "origin"],
|
|
171
|
+
capture_output=True,
|
|
172
|
+
text=True,
|
|
173
|
+
check=True,
|
|
174
|
+
)
|
|
175
|
+
remote_url = result.stdout.strip()
|
|
176
|
+
gh = _parse_github_remote(remote_url)
|
|
177
|
+
if gh:
|
|
178
|
+
repo_name = gh.split("/")[-1]
|
|
179
|
+
discovered.append(
|
|
180
|
+
{"name": repo_name, "github": gh, "path": str(d)}
|
|
181
|
+
)
|
|
182
|
+
except (subprocess.CalledProcessError, FileNotFoundError):
|
|
183
|
+
pass
|
|
184
|
+
|
|
185
|
+
if not discovered:
|
|
186
|
+
click.echo("No git repos with GitHub remotes found in cwd or ~/src/.")
|
|
187
|
+
click.echo("You can edit coordinator.yml manually to add repos.")
|
|
188
|
+
# Write a minimal config with just the machine
|
|
189
|
+
yaml_str = _build_init_yaml(
|
|
190
|
+
repos=[],
|
|
191
|
+
machines=[
|
|
192
|
+
{
|
|
193
|
+
"name": machine_name,
|
|
194
|
+
"host": hostname,
|
|
195
|
+
"capabilities": capabilities,
|
|
196
|
+
"repos": [],
|
|
197
|
+
"repo_paths": {},
|
|
198
|
+
}
|
|
199
|
+
],
|
|
200
|
+
max_workers=2,
|
|
201
|
+
stagger_seconds=30,
|
|
202
|
+
)
|
|
203
|
+
config_file.write_text(yaml_str)
|
|
204
|
+
click.echo(f"\nCreated coordinator.yml with 0 repos and 1 machine.")
|
|
205
|
+
click.echo("Next: edit coordinator.yml to add repos, then run 'coord agent'.")
|
|
206
|
+
_ensure_coord_permissions(cwd)
|
|
207
|
+
return
|
|
208
|
+
|
|
209
|
+
click.echo("Found repos:")
|
|
210
|
+
for i, r in enumerate(discovered, 1):
|
|
211
|
+
click.echo(f" [{i}] {r['github']} ({r['path']})")
|
|
212
|
+
|
|
213
|
+
selection = click.prompt(
|
|
214
|
+
'Which repos to include? (comma-separated numbers or "all")',
|
|
215
|
+
default="all",
|
|
216
|
+
)
|
|
217
|
+
if selection.strip().lower() == "all":
|
|
218
|
+
selected_repos = list(discovered)
|
|
219
|
+
else:
|
|
220
|
+
try:
|
|
221
|
+
indices = [int(x.strip()) for x in selection.split(",")]
|
|
222
|
+
selected_repos = [discovered[i - 1] for i in indices if 1 <= i <= len(discovered)]
|
|
223
|
+
except (ValueError, IndexError):
|
|
224
|
+
click.echo("Invalid selection — including all repos.")
|
|
225
|
+
selected_repos = list(discovered)
|
|
226
|
+
|
|
227
|
+
if not selected_repos:
|
|
228
|
+
click.echo("No repos selected.")
|
|
229
|
+
selected_repos = []
|
|
230
|
+
|
|
231
|
+
# Gather per-repo details
|
|
232
|
+
repos_config: list[dict] = []
|
|
233
|
+
repo_names = [r["name"] for r in selected_repos]
|
|
234
|
+
for r in selected_repos:
|
|
235
|
+
click.echo(f"\n Configuring {r['name']} ({r['github']}):")
|
|
236
|
+
|
|
237
|
+
# Detect default branch
|
|
238
|
+
default_branch = "main"
|
|
239
|
+
try:
|
|
240
|
+
result = subprocess.run(
|
|
241
|
+
[
|
|
242
|
+
"git",
|
|
243
|
+
"-C",
|
|
244
|
+
r["path"],
|
|
245
|
+
"symbolic-ref",
|
|
246
|
+
"refs/remotes/origin/HEAD",
|
|
247
|
+
],
|
|
248
|
+
capture_output=True,
|
|
249
|
+
text=True,
|
|
250
|
+
check=True,
|
|
251
|
+
)
|
|
252
|
+
ref = result.stdout.strip() # e.g. refs/remotes/origin/main
|
|
253
|
+
default_branch = ref.rsplit("/", 1)[-1]
|
|
254
|
+
except (subprocess.CalledProcessError, FileNotFoundError):
|
|
255
|
+
pass
|
|
256
|
+
|
|
257
|
+
default_branch = click.prompt(" Default branch", default=default_branch)
|
|
258
|
+
|
|
259
|
+
# Dependencies
|
|
260
|
+
other_repos = [n for n in repo_names if n != r["name"]]
|
|
261
|
+
if other_repos:
|
|
262
|
+
deps_input = click.prompt(
|
|
263
|
+
f" Dependencies ({', '.join(other_repos)} or none)",
|
|
264
|
+
default="none",
|
|
265
|
+
)
|
|
266
|
+
if deps_input.strip().lower() == "none":
|
|
267
|
+
deps: list[str] = []
|
|
268
|
+
else:
|
|
269
|
+
deps = [d.strip() for d in deps_input.split(",") if d.strip()]
|
|
270
|
+
else:
|
|
271
|
+
deps = []
|
|
272
|
+
|
|
273
|
+
build_cmd = click.prompt(" Build command (enter to skip)", default="", show_default=False)
|
|
274
|
+
test_cmd = click.prompt(" Test command (enter to skip)", default="", show_default=False)
|
|
275
|
+
|
|
276
|
+
repos_config.append(
|
|
277
|
+
{
|
|
278
|
+
"name": r["name"],
|
|
279
|
+
"github": r["github"],
|
|
280
|
+
"depends_on": deps,
|
|
281
|
+
"default_branch": default_branch,
|
|
282
|
+
"build_command": build_cmd or None,
|
|
283
|
+
"test_command": test_cmd or None,
|
|
284
|
+
"path": r["path"],
|
|
285
|
+
}
|
|
286
|
+
)
|
|
287
|
+
|
|
288
|
+
# Build this machine's repo list and paths
|
|
289
|
+
local_repo_names = [r["name"] for r in repos_config]
|
|
290
|
+
local_repo_paths = {r["name"]: r["path"] for r in repos_config}
|
|
291
|
+
|
|
292
|
+
machines_config: list[dict] = [
|
|
293
|
+
{
|
|
294
|
+
"name": machine_name,
|
|
295
|
+
"host": hostname,
|
|
296
|
+
"capabilities": capabilities,
|
|
297
|
+
"repos": local_repo_names,
|
|
298
|
+
"repo_paths": local_repo_paths,
|
|
299
|
+
}
|
|
300
|
+
]
|
|
301
|
+
|
|
302
|
+
# ── Step 4: Ask about other machines ────────────────────────────────
|
|
303
|
+
click.echo("\n── Additional machines ──")
|
|
304
|
+
while click.confirm("Add another machine?", default=False):
|
|
305
|
+
m_name = click.prompt(" Machine name")
|
|
306
|
+
m_host = click.prompt(" Tailscale hostname")
|
|
307
|
+
m_caps_input = click.prompt(" Capabilities (comma-separated)", default="")
|
|
308
|
+
m_caps = [c.strip() for c in m_caps_input.split(",") if c.strip()]
|
|
309
|
+
|
|
310
|
+
click.echo(f" Available repos: {', '.join(local_repo_names)}")
|
|
311
|
+
m_repos_input = click.prompt(
|
|
312
|
+
' Which repos? (comma-separated names or "all")', default="all"
|
|
313
|
+
)
|
|
314
|
+
if m_repos_input.strip().lower() == "all":
|
|
315
|
+
m_repos = list(local_repo_names)
|
|
316
|
+
else:
|
|
317
|
+
m_repos = [r.strip() for r in m_repos_input.split(",") if r.strip() in local_repo_names]
|
|
318
|
+
|
|
319
|
+
m_repo_paths: dict[str, str] = {}
|
|
320
|
+
for rn in m_repos:
|
|
321
|
+
m_repo_paths[rn] = click.prompt(f" Path to {rn} on {m_name}", default=f"~/src/{rn}")
|
|
322
|
+
|
|
323
|
+
# Try to reach the machine
|
|
324
|
+
try:
|
|
325
|
+
resp = httpx.get(f"http://{m_host}:7433/health", timeout=3)
|
|
326
|
+
click.echo(f" ✓ {m_host} is reachable (HTTP {resp.status_code})")
|
|
327
|
+
except Exception:
|
|
328
|
+
click.echo(f" ✗ {m_host} is not reachable (agent may not be running yet)")
|
|
329
|
+
|
|
330
|
+
machines_config.append(
|
|
331
|
+
{
|
|
332
|
+
"name": m_name,
|
|
333
|
+
"host": m_host,
|
|
334
|
+
"capabilities": m_caps,
|
|
335
|
+
"repos": m_repos,
|
|
336
|
+
"repo_paths": m_repo_paths,
|
|
337
|
+
}
|
|
338
|
+
)
|
|
339
|
+
|
|
340
|
+
# ── Step 5: Concurrency settings ────────────────────────────────────
|
|
341
|
+
click.echo("\n── Concurrency settings ──")
|
|
342
|
+
max_workers = click.prompt("Max concurrent workers", default=2, type=int)
|
|
343
|
+
stagger_seconds = click.prompt(
|
|
344
|
+
"Stagger seconds between dispatches", default=30, type=int
|
|
345
|
+
)
|
|
346
|
+
|
|
347
|
+
# ── Step 6: Generate coordinator.yml ────────────────────────────────
|
|
348
|
+
yaml_str = _build_init_yaml(
|
|
349
|
+
repos=repos_config,
|
|
350
|
+
machines=machines_config,
|
|
351
|
+
max_workers=max_workers,
|
|
352
|
+
stagger_seconds=stagger_seconds,
|
|
353
|
+
)
|
|
354
|
+
config_file.write_text(yaml_str)
|
|
355
|
+
|
|
356
|
+
# ── Step 7: Validate ────────────────────────────────────────────────
|
|
357
|
+
try:
|
|
358
|
+
from coord.config import load as load_config
|
|
359
|
+
|
|
360
|
+
load_config(config_file)
|
|
361
|
+
except Exception as e:
|
|
362
|
+
click.echo(f"\nWarning: generated config has a validation error: {e}", err=True)
|
|
363
|
+
click.echo("You may need to edit coordinator.yml manually.", err=True)
|
|
364
|
+
return
|
|
365
|
+
|
|
366
|
+
# ── Step 8: Print next steps ────────────────────────────────────────
|
|
367
|
+
click.echo(
|
|
368
|
+
f"\nCreated coordinator.yml with {len(repos_config)} repo(s) "
|
|
369
|
+
f"and {len(machines_config)} machine(s)."
|
|
370
|
+
)
|
|
371
|
+
click.echo("Next: start the agent with 'coord agent', then run 'coord plan'.")
|
|
372
|
+
|
|
373
|
+
# ── Step 9: Ensure Claude Code permissions are configured ────────────
|
|
374
|
+
_ensure_coord_permissions(cwd)
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def _parse_github_remote(url: str) -> str | None:
|
|
378
|
+
"""Extract owner/repo from a GitHub remote URL.
|
|
379
|
+
|
|
380
|
+
Handles both:
|
|
381
|
+
git@github.com:owner/repo.git
|
|
382
|
+
https://github.com/owner/repo.git
|
|
383
|
+
"""
|
|
384
|
+
import re
|
|
385
|
+
|
|
386
|
+
# SSH format
|
|
387
|
+
m = re.match(r"git@github\.com:(.+?)(?:\.git)?$", url)
|
|
388
|
+
if m:
|
|
389
|
+
return m.group(1)
|
|
390
|
+
# HTTPS format
|
|
391
|
+
m = re.match(r"https?://github\.com/(.+?)(?:\.git)?$", url)
|
|
392
|
+
if m:
|
|
393
|
+
return m.group(1)
|
|
394
|
+
return None
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
def _yaml_scalar(value: str) -> str:
|
|
398
|
+
"""Quote a YAML scalar if it contains special chars, otherwise return bare."""
|
|
399
|
+
if not value:
|
|
400
|
+
return '""'
|
|
401
|
+
needs_quoting = any(c in value for c in ":#{}[]|>&*!%@`,?") or value != value.strip()
|
|
402
|
+
if needs_quoting:
|
|
403
|
+
escaped = value.replace('"', '\\"')
|
|
404
|
+
return f'"{escaped}"'
|
|
405
|
+
return value
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
def _build_init_yaml(
|
|
409
|
+
repos: list[dict],
|
|
410
|
+
machines: list[dict],
|
|
411
|
+
max_workers: int,
|
|
412
|
+
stagger_seconds: int,
|
|
413
|
+
) -> str:
|
|
414
|
+
"""Build a coordinator.yml string with inline comments."""
|
|
415
|
+
lines: list[str] = []
|
|
416
|
+
|
|
417
|
+
# Repos
|
|
418
|
+
lines.append("repos:")
|
|
419
|
+
if not repos:
|
|
420
|
+
lines.append(" # Add repos here. Example:")
|
|
421
|
+
lines.append(" # - name: my-project")
|
|
422
|
+
lines.append(" # github: owner/my-project")
|
|
423
|
+
lines.append(" # default_branch: main")
|
|
424
|
+
lines.append(" # build_command: make build")
|
|
425
|
+
lines.append(" # test_command: make test")
|
|
426
|
+
lines.append(" []")
|
|
427
|
+
else:
|
|
428
|
+
for r in repos:
|
|
429
|
+
lines.append(f" - name: {_yaml_scalar(r['name'])}")
|
|
430
|
+
lines.append(f" github: {_yaml_scalar(r['github'])}")
|
|
431
|
+
deps = r.get("depends_on", [])
|
|
432
|
+
if deps:
|
|
433
|
+
deps_str = ", ".join(deps)
|
|
434
|
+
lines.append(f" depends_on: [{deps_str}]")
|
|
435
|
+
else:
|
|
436
|
+
lines.append(" depends_on: []")
|
|
437
|
+
lines.append(f" default_branch: {_yaml_scalar(r['default_branch'])}")
|
|
438
|
+
if r.get("build_command"):
|
|
439
|
+
lines.append(f" build_command: {_yaml_scalar(r['build_command'])}")
|
|
440
|
+
if r.get("test_command"):
|
|
441
|
+
lines.append(f" test_command: {_yaml_scalar(r['test_command'])}")
|
|
442
|
+
lines.append("")
|
|
443
|
+
|
|
444
|
+
lines.append("")
|
|
445
|
+
lines.append("machines:")
|
|
446
|
+
for m in machines:
|
|
447
|
+
lines.append(f" - name: {_yaml_scalar(m['name'])}")
|
|
448
|
+
lines.append(f" host: {_yaml_scalar(m['host'])}")
|
|
449
|
+
caps = m.get("capabilities", [])
|
|
450
|
+
if caps:
|
|
451
|
+
caps_str = ", ".join(caps)
|
|
452
|
+
lines.append(f" capabilities: [{caps_str}]")
|
|
453
|
+
else:
|
|
454
|
+
lines.append(" capabilities: []")
|
|
455
|
+
mrepos = m.get("repos", [])
|
|
456
|
+
if mrepos:
|
|
457
|
+
repos_str = ", ".join(mrepos)
|
|
458
|
+
lines.append(f" repos: [{repos_str}]")
|
|
459
|
+
else:
|
|
460
|
+
lines.append(" repos: []")
|
|
461
|
+
rpaths = m.get("repo_paths", {})
|
|
462
|
+
if rpaths:
|
|
463
|
+
lines.append(" repo_paths:")
|
|
464
|
+
for rn, rp in rpaths.items():
|
|
465
|
+
lines.append(f" {rn}: {_yaml_scalar(rp)}")
|
|
466
|
+
lines.append("")
|
|
467
|
+
|
|
468
|
+
lines.append("")
|
|
469
|
+
lines.append("# Concurrency settings")
|
|
470
|
+
lines.append("concurrency:")
|
|
471
|
+
lines.append(f" max_workers: {max_workers} # max simultaneous claude -p sessions")
|
|
472
|
+
lines.append(f" stagger_seconds: {stagger_seconds} # delay between starting workers")
|
|
473
|
+
lines.append("")
|
|
474
|
+
lines.append("# Lifecycle hooks (optional)")
|
|
475
|
+
lines.append("# hooks:")
|
|
476
|
+
lines.append("# on_round_complete:")
|
|
477
|
+
lines.append("# - summary_report")
|
|
478
|
+
lines.append("# on_session_end:")
|
|
479
|
+
lines.append("# - summary_report")
|
|
480
|
+
lines.append("")
|
|
481
|
+
|
|
482
|
+
return "\n".join(lines) + "\n"
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
@click.command(
|
|
486
|
+
"install-skills",
|
|
487
|
+
help=(
|
|
488
|
+
"Copy bundled coordinator skills to ~/.claude/skills/ so they are "
|
|
489
|
+
"available as slash commands inside Claude Code sessions. "
|
|
490
|
+
"No repo clone required — reads from the installed PyPI package."
|
|
491
|
+
),
|
|
492
|
+
)
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
@click.option(
|
|
496
|
+
"--list",
|
|
497
|
+
"do_list",
|
|
498
|
+
is_flag=True,
|
|
499
|
+
default=False,
|
|
500
|
+
help="Show bundled skills and their installed status without copying.",
|
|
501
|
+
)
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
@click.option(
|
|
505
|
+
"--dry-run",
|
|
506
|
+
is_flag=True,
|
|
507
|
+
default=False,
|
|
508
|
+
help="Print what would be installed without writing any files.",
|
|
509
|
+
)
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
def install_skills(do_list: bool, dry_run: bool) -> None: # noqa: FBT001
|
|
513
|
+
"""Install bundled coordinator skills to ~/.claude/skills/ (#319)."""
|
|
514
|
+
import importlib.resources as _ilr # noqa: PLC0415
|
|
515
|
+
|
|
516
|
+
if do_list and dry_run:
|
|
517
|
+
click.echo("warning: --dry-run has no effect when --list is used", err=True)
|
|
518
|
+
|
|
519
|
+
target_root = Path.home() / ".claude" / "skills"
|
|
520
|
+
|
|
521
|
+
# Locate the skills directory inside the installed package.
|
|
522
|
+
try:
|
|
523
|
+
skills_ref = _ilr.files("coord").joinpath("skills")
|
|
524
|
+
except (TypeError, ModuleNotFoundError) as e:
|
|
525
|
+
click.echo(f"error: cannot locate bundled skills: {e}", err=True)
|
|
526
|
+
sys.exit(1)
|
|
527
|
+
|
|
528
|
+
# Enumerate skill directories (each sub-directory that contains SKILL.md).
|
|
529
|
+
skill_dirs: list[tuple[str, object]] = []
|
|
530
|
+
try:
|
|
531
|
+
for entry in skills_ref.iterdir():
|
|
532
|
+
skill_name = entry.name # type: ignore[attr-defined]
|
|
533
|
+
skill_file = entry.joinpath("SKILL.md")
|
|
534
|
+
try:
|
|
535
|
+
skill_file.read_text(encoding="utf-8")
|
|
536
|
+
skill_dirs.append((skill_name, entry))
|
|
537
|
+
except (FileNotFoundError, IsADirectoryError, TypeError):
|
|
538
|
+
pass
|
|
539
|
+
except (FileNotFoundError, NotADirectoryError) as e:
|
|
540
|
+
click.echo(f"error: bundled skills directory not readable: {e}", err=True)
|
|
541
|
+
sys.exit(1)
|
|
542
|
+
|
|
543
|
+
if not skill_dirs:
|
|
544
|
+
click.echo("No bundled skills found in the installed package.")
|
|
545
|
+
return
|
|
546
|
+
|
|
547
|
+
if do_list:
|
|
548
|
+
click.echo("Bundled skills:")
|
|
549
|
+
for skill_name, _ in sorted(skill_dirs):
|
|
550
|
+
installed_path = target_root / skill_name / "SKILL.md"
|
|
551
|
+
status = "installed" if installed_path.exists() else "not installed"
|
|
552
|
+
click.echo(f" {skill_name:30s} {status}")
|
|
553
|
+
return
|
|
554
|
+
|
|
555
|
+
# Install / update each skill.
|
|
556
|
+
if not dry_run:
|
|
557
|
+
target_root.mkdir(parents=True, exist_ok=True)
|
|
558
|
+
for skill_name, skill_dir_ref in sorted(skill_dirs):
|
|
559
|
+
skill_dest = target_root / skill_name
|
|
560
|
+
skill_file_dest = skill_dest / "SKILL.md"
|
|
561
|
+
if dry_run:
|
|
562
|
+
action = "update" if skill_file_dest.exists() else "install"
|
|
563
|
+
click.echo(f" would {action}: {skill_file_dest}")
|
|
564
|
+
continue
|
|
565
|
+
|
|
566
|
+
skill_dest.mkdir(parents=True, exist_ok=True)
|
|
567
|
+
src_text = skill_dir_ref.joinpath("SKILL.md").read_text(encoding="utf-8")
|
|
568
|
+
action = "updated" if skill_file_dest.exists() else "installed"
|
|
569
|
+
skill_file_dest.write_text(src_text, encoding="utf-8")
|
|
570
|
+
click.echo(f" {action}: {skill_file_dest}")
|
|
571
|
+
|
|
572
|
+
if not dry_run:
|
|
573
|
+
installed_names = sorted(name for name, _ in skill_dirs)
|
|
574
|
+
cmd_list = " ".join(f"/{n}" for n in installed_names)
|
|
575
|
+
click.echo(f"\nDone. Available skills: {cmd_list}")
|
|
576
|
+
click.echo("Type a skill name inside a Claude Code session to use it.")
|