devcouncil 0.1.1 → 0.3.0
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.
- package/README.md +201 -6
- package/package.json +9 -2
- package/pyproject.toml +34 -2
- package/src/devcouncil/app/config.py +348 -12
- package/src/devcouncil/app/orchestrator.py +10 -6
- package/src/devcouncil/app/state_machine.py +4 -0
- package/src/devcouncil/artifacts/graph.py +32 -5
- package/src/devcouncil/assets/__init__.py +1 -0
- package/src/devcouncil/assets/devcouncil-logo.svg +60 -0
- package/src/devcouncil/assets/devcouncil_logo_premium.png +0 -0
- package/src/devcouncil/cli/commands/agents.py +292 -0
- package/src/devcouncil/cli/commands/artifacts.py +6 -3
- package/src/devcouncil/cli/commands/check.py +220 -0
- package/src/devcouncil/cli/commands/config.py +43 -4
- package/src/devcouncil/cli/commands/cost.py +57 -0
- package/src/devcouncil/cli/commands/dashboard.py +6 -1
- package/src/devcouncil/cli/commands/design.py +186 -0
- package/src/devcouncil/cli/commands/doctor.py +379 -22
- package/src/devcouncil/cli/commands/evidence.py +48 -0
- package/src/devcouncil/cli/commands/go.py +532 -33
- package/src/devcouncil/cli/commands/handoff.py +69 -0
- package/src/devcouncil/cli/commands/hook.py +296 -15
- package/src/devcouncil/cli/commands/init.py +161 -20
- package/src/devcouncil/cli/commands/integrate.py +1371 -124
- package/src/devcouncil/cli/commands/logs.py +106 -0
- package/src/devcouncil/cli/commands/map.py +80 -10
- package/src/devcouncil/cli/commands/okf.py +245 -0
- package/src/devcouncil/cli/commands/plan.py +256 -55
- package/src/devcouncil/cli/commands/prompt.py +18 -7
- package/src/devcouncil/cli/commands/repair.py +50 -24
- package/src/devcouncil/cli/commands/report.py +8 -0
- package/src/devcouncil/cli/commands/reset_demo_state.py +4 -2
- package/src/devcouncil/cli/commands/rollback.py +27 -28
- package/src/devcouncil/cli/commands/run.py +195 -54
- package/src/devcouncil/cli/commands/runs.py +223 -0
- package/src/devcouncil/cli/commands/scaffold.py +32 -0
- package/src/devcouncil/cli/commands/semantic.py +47 -0
- package/src/devcouncil/cli/commands/setup.py +145 -6
- package/src/devcouncil/cli/commands/shell.py +73 -0
- package/src/devcouncil/cli/commands/skills.py +267 -0
- package/src/devcouncil/cli/commands/status.py +30 -15
- package/src/devcouncil/cli/commands/trace.py +47 -3
- package/src/devcouncil/cli/commands/verify.py +144 -3
- package/src/devcouncil/cli/commands/watch.py +32 -12
- package/src/devcouncil/cli/commands/watch_fs.py +40 -0
- package/src/devcouncil/cli/main.py +91 -7
- package/src/devcouncil/domain/evidence.py +29 -2
- package/src/devcouncil/domain/gap.py +27 -1
- package/src/devcouncil/domain/task.py +31 -2
- package/src/devcouncil/execution/checkpoints.py +256 -0
- package/src/devcouncil/execution/context_builder.py +1 -1
- package/src/devcouncil/execution/fs_watcher.py +205 -0
- package/src/devcouncil/execution/handoff.py +102 -0
- package/src/devcouncil/execution/hook_policy.py +162 -74
- package/src/devcouncil/execution/patch.py +65 -10
- package/src/devcouncil/execution/permissions.py +24 -24
- package/src/devcouncil/execution/policy_engine.py +350 -0
- package/src/devcouncil/execution/prompt_builder.py +751 -23
- package/src/devcouncil/execution/shell_session.py +231 -0
- package/src/devcouncil/execution/task_runner.py +24 -9
- package/src/devcouncil/executors/agent_registry.py +596 -0
- package/src/devcouncil/executors/coding_cli.py +791 -39
- package/src/devcouncil/executors/mini_swe.py +6 -0
- package/src/devcouncil/executors/native/agent.py +135 -19
- package/src/devcouncil/executors/openhands.py +6 -0
- package/src/devcouncil/gating/checks/clean_git.py +3 -1
- package/src/devcouncil/gating/checks/secret_scan_check.py +47 -21
- package/src/devcouncil/gating/policy.py +190 -11
- package/src/devcouncil/hardware.py +184 -0
- package/src/devcouncil/indexing/ast_matcher.py +17 -7
- package/src/devcouncil/indexing/lsp.py +45 -4
- package/src/devcouncil/indexing/repo_mapper.py +1284 -15
- package/src/devcouncil/indexing/semantic_index.py +221 -0
- package/src/devcouncil/integrations/actions.py +166 -0
- package/src/devcouncil/integrations/check.py +426 -0
- package/src/devcouncil/integrations/claude_assets.py +444 -0
- package/src/devcouncil/integrations/code_review_graph.py +13 -2
- package/src/devcouncil/integrations/github_intent.py +149 -0
- package/src/devcouncil/integrations/gitnexus.py +45 -2
- package/src/devcouncil/integrations/mcp/server.py +1944 -32
- package/src/devcouncil/integrations/opencode_devcouncil_plugin.mjs +24 -0
- package/src/devcouncil/integrations/pr_comments.py +9 -0
- package/src/devcouncil/knowledge/__init__.py +23 -0
- package/src/devcouncil/knowledge/design.py +374 -0
- package/src/devcouncil/knowledge/design_conformance.py +317 -0
- package/src/devcouncil/knowledge/fetch.py +223 -0
- package/src/devcouncil/knowledge/frontmatter.py +51 -0
- package/src/devcouncil/knowledge/okf.py +202 -0
- package/src/devcouncil/knowledge/skill_bridge.py +96 -0
- package/src/devcouncil/knowledge/sources.py +239 -0
- package/src/devcouncil/live/cards.py +181 -25
- package/src/devcouncil/live/repair_prompt.py +29 -6
- package/src/devcouncil/live/reviewer.py +72 -13
- package/src/devcouncil/live/signals.py +2 -2
- package/src/devcouncil/live/summary.py +18 -8
- package/src/devcouncil/live/transcripts.py +47 -11
- package/src/devcouncil/llm/cache.py +20 -8
- package/src/devcouncil/llm/model_defaults.yaml +44 -0
- package/src/devcouncil/llm/provider.py +617 -49
- package/src/devcouncil/llm/router.py +337 -53
- package/src/devcouncil/optimization/__init__.py +1 -0
- package/src/devcouncil/optimization/gepa_agent.py +318 -0
- package/src/devcouncil/optimization/skillopt.py +673 -0
- package/src/devcouncil/planning/arbiter_service.py +10 -2
- package/src/devcouncil/planning/correction_manifest.py +346 -0
- package/src/devcouncil/planning/critique_service.py +16 -4
- package/src/devcouncil/planning/plan_service.py +86 -6
- package/src/devcouncil/planning/prompt_enhancer_service.py +206 -1
- package/src/devcouncil/planning/repair_service.py +8 -2
- package/src/devcouncil/planning/spec_service.py +37 -3
- package/src/devcouncil/repo/ci_scaffold.py +165 -0
- package/src/devcouncil/repo/gitignore.py +123 -0
- package/src/devcouncil/repo/sca.py +384 -0
- package/src/devcouncil/reporting/json_report.py +22 -1
- package/src/devcouncil/reporting/markdown_report.py +29 -1
- package/src/devcouncil/reporting/okf_bundle_writer.py +364 -0
- package/src/devcouncil/reporting/okf_html.py +323 -0
- package/src/devcouncil/reporting/report_builder.py +18 -1
- package/src/devcouncil/skills/__init__.py +19 -0
- package/src/devcouncil/skills/library/README.md +46 -0
- package/src/devcouncil/skills/library/ai-training.md +50 -0
- package/src/devcouncil/skills/library/android.md +50 -0
- package/src/devcouncil/skills/library/backend.md +52 -0
- package/src/devcouncil/skills/library/core-engineering.md +95 -0
- package/src/devcouncil/skills/library/data-engineering.md +47 -0
- package/src/devcouncil/skills/library/desktop.md +46 -0
- package/src/devcouncil/skills/library/devops.md +48 -0
- package/src/devcouncil/skills/library/game-dev.md +46 -0
- package/src/devcouncil/skills/library/ios.md +48 -0
- package/src/devcouncil/skills/library/mobile-cross-platform.md +46 -0
- package/src/devcouncil/skills/library/security.md +48 -0
- package/src/devcouncil/skills/library/systems.md +48 -0
- package/src/devcouncil/skills/library/web.md +47 -0
- package/src/devcouncil/skills/library/windows.md +47 -0
- package/src/devcouncil/skills/registry.py +408 -0
- package/src/devcouncil/storage/db.py +140 -3
- package/src/devcouncil/storage/models.py +125 -0
- package/src/devcouncil/storage/native.py +559 -0
- package/src/devcouncil/storage/repositories.py +157 -78
- package/src/devcouncil/telemetry/cost.py +123 -17
- package/src/devcouncil/telemetry/logging_setup.py +244 -0
- package/src/devcouncil/telemetry/model_pricing.yaml +48 -0
- package/src/devcouncil/telemetry/pricing.py +28 -0
- package/src/devcouncil/telemetry/stages.py +141 -0
- package/src/devcouncil/telemetry/traces.py +62 -7
- package/src/devcouncil/telemetry/tracker.py +24 -10
- package/src/devcouncil/ui/dashboard.py +393 -28
- package/src/devcouncil/utils/redaction.py +9 -3
- package/src/devcouncil/utils/subprocess_env.py +69 -0
- package/src/devcouncil/verification/acceptance_compiler.py +253 -0
- package/src/devcouncil/verification/ad_hoc_check.py +135 -0
- package/src/devcouncil/verification/diff_coverage.py +353 -0
- package/src/devcouncil/verification/implementation_reviewer.py +11 -2
- package/src/devcouncil/verification/next_actions.py +189 -0
- package/src/devcouncil/verification/sandbox.py +181 -0
- package/src/devcouncil/verification/test_resolver.py +91 -0
- package/src/devcouncil/verification/verifier.py +1549 -143
- package/uv.lock +205 -64
- package/src/devcouncil/indexing/symbol_index.py +0 -0
|
@@ -3,26 +3,64 @@ import shlex
|
|
|
3
3
|
import shutil
|
|
4
4
|
import subprocess
|
|
5
5
|
import sys
|
|
6
|
+
from contextlib import contextmanager
|
|
6
7
|
from pathlib import Path
|
|
7
8
|
|
|
8
9
|
import typer
|
|
9
|
-
import yaml
|
|
10
|
+
import yaml # type: ignore[import-untyped]
|
|
10
11
|
from rich.console import Console
|
|
11
12
|
from rich.table import Table
|
|
12
13
|
|
|
14
|
+
from devcouncil.executors.agent_registry import (
|
|
15
|
+
BUILTIN_CODING_EXECUTOR_NAMES,
|
|
16
|
+
CODING_CLI_INTEGRATION_INFO,
|
|
17
|
+
VALID_INPUT_MODES,
|
|
18
|
+
agent_config_entry,
|
|
19
|
+
detect_available_coding_cli,
|
|
20
|
+
integration_tier_label,
|
|
21
|
+
is_reserved_agent_name,
|
|
22
|
+
load_agent_profiles,
|
|
23
|
+
load_cli_agent_specs,
|
|
24
|
+
normalize_agent_name,
|
|
25
|
+
resolve_automated_executor,
|
|
26
|
+
resolve_coding_cli_executable,
|
|
27
|
+
resolve_coding_cli_probe_order,
|
|
28
|
+
)
|
|
29
|
+
from devcouncil.integrations.actions import apply_integration_target
|
|
30
|
+
from devcouncil.utils.subprocess_env import clean_subprocess_env
|
|
31
|
+
from devcouncil.integrations.check import (
|
|
32
|
+
build_integration_check_report,
|
|
33
|
+
integration_status_summary,
|
|
34
|
+
)
|
|
35
|
+
|
|
13
36
|
app = typer.Typer(help="Set up DevCouncil integrations with coding CLIs.")
|
|
14
37
|
setup_app = typer.Typer(help="Set up optional external companion integrations.")
|
|
15
38
|
app.add_typer(setup_app, name="setup")
|
|
16
39
|
console = Console()
|
|
17
40
|
|
|
18
|
-
SUPPORTED_TOOLS = ("codex", "gemini", "claude", "cursor")
|
|
19
|
-
SUPPORTED_HOOK_TOOLS = ("codex", "gemini", "claude")
|
|
41
|
+
SUPPORTED_TOOLS = ("codex", "gemini", "claude", "cursor", "opencode", "antigravity", "warp", "aider")
|
|
42
|
+
SUPPORTED_HOOK_TOOLS = ("codex", "gemini", "claude", "cursor")
|
|
43
|
+
OPENCODE_HOOK_PLUGIN_NAME = "opencode_devcouncil_plugin.mjs"
|
|
20
44
|
PREFERRED_COMMAND = "dev integrate"
|
|
21
45
|
LEGACY_COMMAND = "dev setup --integrate"
|
|
22
46
|
|
|
23
47
|
|
|
24
|
-
def _project_root(path: Path | None) -> Path:
|
|
25
|
-
return (path or
|
|
48
|
+
def _project_root(path: str | Path | None) -> Path:
|
|
49
|
+
return Path(path or ".").expanduser().resolve()
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _warn_if_verify_only(client: str) -> None:
|
|
53
|
+
"""Print a prominent containment warning when wiring a verify-only client.
|
|
54
|
+
|
|
55
|
+
Verify-only clients have no native pre-tool-use hook, so DevCouncil cannot block a
|
|
56
|
+
forbidden write or command before it happens — it is only caught post-hoc at verify
|
|
57
|
+
time. Surface this loudly so users don't assume hard containment."""
|
|
58
|
+
info = CODING_CLI_INTEGRATION_INFO.get(normalize_agent_name(client))
|
|
59
|
+
if info is not None and not info.hooks:
|
|
60
|
+
console.print(
|
|
61
|
+
f"[bold yellow]Warning ({info.label}): No pre-action containment — "
|
|
62
|
+
"forbidden writes/commands are caught only at verify time.[/bold yellow]"
|
|
63
|
+
)
|
|
26
64
|
|
|
27
65
|
|
|
28
66
|
def _server_args(project_root: Path) -> list[str]:
|
|
@@ -57,28 +95,306 @@ def _gemini_command(project_root: Path, scope: str) -> list[str]:
|
|
|
57
95
|
|
|
58
96
|
|
|
59
97
|
def _claude_command(project_root: Path, scope: str) -> list[str]:
|
|
98
|
+
# The server name must come BEFORE --env: the current Claude CLI treats --env
|
|
99
|
+
# as variadic, so `--env KEY=VALUE devcouncil` swallows the name `devcouncil`
|
|
100
|
+
# as a second (invalid) env var. Putting the name first — matching the working
|
|
101
|
+
# codex form — and terminating options with `--` avoids that.
|
|
60
102
|
return [
|
|
61
103
|
"claude",
|
|
62
104
|
"mcp",
|
|
63
105
|
"add",
|
|
64
106
|
"--scope",
|
|
65
107
|
scope,
|
|
108
|
+
"devcouncil",
|
|
66
109
|
"--env",
|
|
67
110
|
f"DEVCOUNCIL_PROJECT_ROOT={project_root}",
|
|
68
|
-
"devcouncil",
|
|
69
111
|
"--",
|
|
70
112
|
*_server_args(project_root),
|
|
71
113
|
]
|
|
72
114
|
|
|
73
115
|
|
|
74
|
-
def
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
116
|
+
def _cursor_config_path(project_root: Path) -> Path:
|
|
117
|
+
return project_root / ".cursor" / "mcp.json"
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _cursor_mcp_config(project_root: Path) -> dict:
|
|
121
|
+
return {
|
|
122
|
+
"mcpServers": {
|
|
123
|
+
"devcouncil": {
|
|
124
|
+
"type": "stdio",
|
|
125
|
+
"command": "devcouncil",
|
|
126
|
+
"args": ["mcp-server"],
|
|
127
|
+
"env": {"DEVCOUNCIL_PROJECT_ROOT": str(project_root)},
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _warp_mcp_config(project_root: Path) -> dict:
|
|
134
|
+
return {
|
|
135
|
+
"devcouncil": {
|
|
136
|
+
"command": "devcouncil",
|
|
137
|
+
"args": ["mcp-server"],
|
|
138
|
+
"env": {"DEVCOUNCIL_PROJECT_ROOT": str(project_root)},
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _warp_mcp_path(project_root: Path) -> Path:
|
|
144
|
+
return project_root / ".devcouncil" / "integrations" / "warp-mcp.json"
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _opencode_config_path(project_root: Path) -> Path:
|
|
148
|
+
return project_root / "opencode.json"
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _opencode_mcp_entry(project_root: Path) -> dict:
|
|
152
|
+
return {
|
|
153
|
+
"type": "local",
|
|
154
|
+
"command": ["devcouncil", "mcp-server"],
|
|
155
|
+
"environment": {"DEVCOUNCIL_PROJECT_ROOT": str(project_root)},
|
|
156
|
+
"enabled": True,
|
|
157
|
+
"timeout": 10000,
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _antigravity_mcp_path(project_root: Path) -> Path:
|
|
162
|
+
return project_root / ".agents" / "mcp_config.json"
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _antigravity_mcp_config(project_root: Path) -> dict:
|
|
166
|
+
return {
|
|
167
|
+
"mcpServers": {
|
|
168
|
+
"devcouncil": {
|
|
169
|
+
"command": "devcouncil",
|
|
170
|
+
"args": ["mcp-server"],
|
|
171
|
+
"env": {"DEVCOUNCIL_PROJECT_ROOT": str(project_root)},
|
|
172
|
+
"cwd": str(project_root),
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _write_warp_mcp_config(project_root: Path) -> Path:
|
|
179
|
+
path = _warp_mcp_path(project_root)
|
|
180
|
+
_save_json(path, _warp_mcp_config(project_root))
|
|
181
|
+
return path
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _write_cursor_config(project_root: Path) -> Path:
|
|
185
|
+
path = _cursor_config_path(project_root)
|
|
186
|
+
data = _load_json_strict(path, "Cursor")
|
|
187
|
+
mcp_servers = data.setdefault("mcpServers", {})
|
|
188
|
+
mcp_servers["devcouncil"] = _cursor_mcp_config(project_root)["mcpServers"]["devcouncil"]
|
|
189
|
+
_save_json(path, data)
|
|
190
|
+
return path
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
# When set, _mutate_raw_config applies record mutations in memory and
|
|
194
|
+
# _batched_raw_config saves config.yaml once at the end (used by
|
|
195
|
+
# `dev integrate all --apply`, which otherwise re-parses YAML per tool).
|
|
196
|
+
_PENDING_RAW_CONFIG: dict | None = None
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
@contextmanager
|
|
200
|
+
def _batched_raw_config(project_root: Path):
|
|
201
|
+
global _PENDING_RAW_CONFIG
|
|
202
|
+
# Re-entrant: if a batch is already active, participate in it instead of
|
|
203
|
+
# starting a nested load/save (which would otherwise reset the shared
|
|
204
|
+
# buffer to None on inner exit and drop the outer batch's mutations).
|
|
205
|
+
if _PENDING_RAW_CONFIG is not None:
|
|
206
|
+
yield
|
|
207
|
+
return
|
|
208
|
+
_PENDING_RAW_CONFIG = _load_raw_config(project_root)
|
|
209
|
+
try:
|
|
210
|
+
yield
|
|
211
|
+
finally:
|
|
212
|
+
# Persist whatever mutations accumulated, even if an inner installer raised
|
|
213
|
+
# partway through — matching the old per-installer save, which committed each
|
|
214
|
+
# installer's change immediately rather than dropping the whole batch on a
|
|
215
|
+
# mid-loop failure.
|
|
216
|
+
pending = _PENDING_RAW_CONFIG
|
|
217
|
+
_PENDING_RAW_CONFIG = None
|
|
218
|
+
if pending is not None:
|
|
219
|
+
_save_raw_config(project_root, pending)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _mutate_raw_config(project_root: Path, mutate) -> None:
|
|
223
|
+
if _PENDING_RAW_CONFIG is not None:
|
|
224
|
+
mutate(_PENDING_RAW_CONFIG)
|
|
225
|
+
return
|
|
226
|
+
config = _load_raw_config(project_root)
|
|
227
|
+
mutate(config)
|
|
228
|
+
_save_raw_config(project_root, config)
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _record_cursor_config(project_root: Path) -> None:
|
|
232
|
+
def mutate(config: dict) -> None:
|
|
233
|
+
cursor = config.setdefault("integrations", {}).setdefault("cursor", {})
|
|
234
|
+
cursor.update({
|
|
235
|
+
"enabled": True,
|
|
236
|
+
"config_path": str(_cursor_config_path(project_root).relative_to(project_root)),
|
|
237
|
+
})
|
|
238
|
+
|
|
239
|
+
_mutate_raw_config(project_root, mutate)
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _record_warp_config(project_root: Path) -> None:
|
|
243
|
+
def mutate(config: dict) -> None:
|
|
244
|
+
warp = config.setdefault("integrations", {}).setdefault("warp", {})
|
|
245
|
+
warp.update({
|
|
246
|
+
"enabled": True,
|
|
247
|
+
"command": warp.get("command", "oz"),
|
|
248
|
+
"run_mode": warp.get("run_mode", "local"),
|
|
249
|
+
"mcp_config_path": str(_warp_mcp_path(project_root).relative_to(project_root)),
|
|
250
|
+
})
|
|
251
|
+
|
|
252
|
+
_mutate_raw_config(project_root, mutate)
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def _record_opencode_config(project_root: Path) -> None:
|
|
256
|
+
def mutate(config: dict) -> None:
|
|
257
|
+
opencode = config.setdefault("integrations", {}).setdefault("opencode", {})
|
|
258
|
+
opencode.update({
|
|
259
|
+
"enabled": True,
|
|
260
|
+
"config_path": str(_opencode_config_path(project_root).relative_to(project_root)),
|
|
261
|
+
})
|
|
262
|
+
|
|
263
|
+
_mutate_raw_config(project_root, mutate)
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _record_antigravity_config(project_root: Path) -> None:
|
|
267
|
+
def mutate(config: dict) -> None:
|
|
268
|
+
antigravity = config.setdefault("integrations", {}).setdefault("antigravity", {})
|
|
269
|
+
antigravity.update({
|
|
270
|
+
"enabled": True,
|
|
271
|
+
"mcp_config_path": str(_antigravity_mcp_path(project_root).relative_to(project_root)),
|
|
272
|
+
})
|
|
273
|
+
|
|
274
|
+
_mutate_raw_config(project_root, mutate)
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def _load_json_strict(path: Path, label: str = "JSON") -> dict:
|
|
278
|
+
if not path.exists():
|
|
279
|
+
return {}
|
|
280
|
+
try:
|
|
281
|
+
return json.loads(path.read_text(encoding="utf-8")) or {}
|
|
282
|
+
except json.JSONDecodeError as exc:
|
|
283
|
+
raise ValueError(f"{path} is not valid JSON. Fix the {label} config before rerunning integration setup.") from exc
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def _write_opencode_config(project_root: Path) -> Path:
|
|
287
|
+
path = _opencode_config_path(project_root)
|
|
288
|
+
data = _load_json_strict(path, "OpenCode")
|
|
289
|
+
data.setdefault("$schema", "https://opencode.ai/config.json")
|
|
290
|
+
mcp = data.setdefault("mcp", {})
|
|
291
|
+
mcp["devcouncil"] = _opencode_mcp_entry(project_root)
|
|
292
|
+
_save_json(path, data)
|
|
293
|
+
return path
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def _write_antigravity_mcp_config(project_root: Path) -> Path:
|
|
297
|
+
path = _antigravity_mcp_path(project_root)
|
|
298
|
+
data = _load_json_strict(path, "Antigravity")
|
|
299
|
+
mcp_servers = data.setdefault("mcpServers", {})
|
|
300
|
+
mcp_servers["devcouncil"] = _antigravity_mcp_config(project_root)["mcpServers"]["devcouncil"]
|
|
301
|
+
_save_json(path, data)
|
|
302
|
+
return path
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def _configure_cursor(project_root: Path, apply: bool) -> bool:
|
|
306
|
+
path = _cursor_config_path(project_root)
|
|
307
|
+
config = _cursor_mcp_config(project_root)
|
|
308
|
+
if not apply:
|
|
309
|
+
console.print("[bold]Cursor[/bold]")
|
|
310
|
+
console.print(f"Project MCP config file: [dim]{path}[/dim]")
|
|
311
|
+
console.print(json.dumps(config, separators=(",", ":")), soft_wrap=True)
|
|
312
|
+
console.print("Verify in Cursor CLI with: [dim]cursor-agent mcp list[/dim]")
|
|
313
|
+
return True
|
|
314
|
+
|
|
315
|
+
if not shutil.which("cursor") and not shutil.which("cursor-agent"):
|
|
316
|
+
console.print("[yellow]Cursor CLI not found on PATH. Project MCP config will still be available to Cursor.[/yellow]")
|
|
317
|
+
try:
|
|
318
|
+
written = _write_cursor_config(project_root)
|
|
319
|
+
except ValueError as exc:
|
|
320
|
+
console.print(f"[red]{exc}[/red]")
|
|
321
|
+
return False
|
|
322
|
+
_record_cursor_config(project_root)
|
|
323
|
+
console.print(f"[green]Cursor MCP config written:[/green] {written}")
|
|
324
|
+
return True
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def _configure_opencode(project_root: Path, apply: bool) -> bool:
|
|
328
|
+
path = _opencode_config_path(project_root)
|
|
329
|
+
config = {
|
|
330
|
+
"$schema": "https://opencode.ai/config.json",
|
|
331
|
+
"mcp": {"devcouncil": _opencode_mcp_entry(project_root)},
|
|
80
332
|
}
|
|
81
|
-
|
|
333
|
+
if not apply:
|
|
334
|
+
console.print("[bold]OpenCode[/bold]")
|
|
335
|
+
console.print(f"Project config file: [dim]{path}[/dim]")
|
|
336
|
+
console.print(json.dumps(config, separators=(",", ":")), soft_wrap=True)
|
|
337
|
+
console.print(
|
|
338
|
+
"Direct executor command: "
|
|
339
|
+
"[dim]opencode run --file .devcouncil/TASK-001-opencode-task.md "
|
|
340
|
+
'"Execute the DevCouncil task described in the attached prompt file."[/dim]'
|
|
341
|
+
)
|
|
342
|
+
return True
|
|
343
|
+
|
|
344
|
+
if not shutil.which("opencode"):
|
|
345
|
+
console.print("[yellow]OpenCode CLI not found on PATH. Install it before using `dev run --executor opencode`.[/yellow]")
|
|
346
|
+
try:
|
|
347
|
+
written = _write_opencode_config(project_root)
|
|
348
|
+
except ValueError as exc:
|
|
349
|
+
console.print(f"[red]{exc}[/red]")
|
|
350
|
+
return False
|
|
351
|
+
_record_opencode_config(project_root)
|
|
352
|
+
console.print(f"[green]OpenCode MCP config written:[/green] {written}")
|
|
353
|
+
return True
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def _configure_antigravity(project_root: Path, apply: bool) -> bool:
|
|
357
|
+
path = _antigravity_mcp_path(project_root)
|
|
358
|
+
config = _antigravity_mcp_config(project_root)
|
|
359
|
+
if not apply:
|
|
360
|
+
console.print("[bold]Google Antigravity CLI[/bold]")
|
|
361
|
+
console.print(f"Project MCP config file: [dim]{path}[/dim]")
|
|
362
|
+
console.print(json.dumps(config, separators=(",", ":")), soft_wrap=True)
|
|
363
|
+
console.print(
|
|
364
|
+
"Direct executor command: "
|
|
365
|
+
"[dim]agy --print --print-timeout 30m "
|
|
366
|
+
'"Read and execute the DevCouncil task prompt at .devcouncil/TASK-001-antigravity-task.md."[/dim]'
|
|
367
|
+
)
|
|
368
|
+
return True
|
|
369
|
+
|
|
370
|
+
if not shutil.which("agy"):
|
|
371
|
+
console.print("[yellow]Antigravity CLI (`agy`) not found on PATH. Install it before using `dev run --executor antigravity`.[/yellow]")
|
|
372
|
+
try:
|
|
373
|
+
written = _write_antigravity_mcp_config(project_root)
|
|
374
|
+
except ValueError as exc:
|
|
375
|
+
console.print(f"[red]{exc}[/red]")
|
|
376
|
+
return False
|
|
377
|
+
_record_antigravity_config(project_root)
|
|
378
|
+
console.print(f"[green]Antigravity MCP config written:[/green] {written}")
|
|
379
|
+
return True
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def _configure_warp(project_root: Path, apply: bool) -> bool:
|
|
383
|
+
path = _warp_mcp_path(project_root)
|
|
384
|
+
config = _warp_mcp_config(project_root)
|
|
385
|
+
if not apply:
|
|
386
|
+
console.print("[bold]Warp / Oz[/bold]")
|
|
387
|
+
console.print(f"MCP config file: [dim]{path}[/dim]")
|
|
388
|
+
console.print(json.dumps(config, separators=(",", ":")), soft_wrap=True)
|
|
389
|
+
console.print(f"Direct executor command: [dim]oz agent run --cwd {project_root} --mcp {path} --prompt <task prompt>[/dim]")
|
|
390
|
+
return True
|
|
391
|
+
|
|
392
|
+
written = _write_warp_mcp_config(project_root)
|
|
393
|
+
_record_warp_config(project_root)
|
|
394
|
+
console.print(f"[green]Warp MCP config written:[/green] {written}")
|
|
395
|
+
if not shutil.which("oz"):
|
|
396
|
+
console.print("[yellow]oz CLI not found on PATH. Install Warp/Oz before using `dev run --executor warp`.[/yellow]")
|
|
397
|
+
return True
|
|
82
398
|
|
|
83
399
|
|
|
84
400
|
def _format_command(command: list[str]) -> str:
|
|
@@ -96,6 +412,14 @@ def _quote_powershell_arg(arg: str) -> str:
|
|
|
96
412
|
return "'" + arg.replace("'", "''") + "'"
|
|
97
413
|
|
|
98
414
|
|
|
415
|
+
def _opencode_plugin_source() -> Path:
|
|
416
|
+
return Path(__file__).resolve().parents[2] / "integrations" / OPENCODE_HOOK_PLUGIN_NAME
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def _opencode_plugin_path(project_root: Path) -> Path:
|
|
420
|
+
return project_root / ".devcouncil" / "integrations" / OPENCODE_HOOK_PLUGIN_NAME
|
|
421
|
+
|
|
422
|
+
|
|
99
423
|
def _hook_command(project_root: Path, client: str, event: str) -> str:
|
|
100
424
|
return _format_command([
|
|
101
425
|
"devcouncil",
|
|
@@ -108,6 +432,35 @@ def _hook_command(project_root: Path, client: str, event: str) -> str:
|
|
|
108
432
|
])
|
|
109
433
|
|
|
110
434
|
|
|
435
|
+
def _probe_mcp_tools(root: Path, *, timeout_seconds: float = 30.0) -> list[str]:
|
|
436
|
+
from mcp import ClientSession, StdioServerParameters
|
|
437
|
+
from mcp.client.stdio import stdio_client
|
|
438
|
+
import asyncio
|
|
439
|
+
import os
|
|
440
|
+
|
|
441
|
+
async def _list_tools() -> list[str]:
|
|
442
|
+
env = os.environ.copy()
|
|
443
|
+
env["DEVCOUNCIL_PROJECT_ROOT"] = str(root)
|
|
444
|
+
params = StdioServerParameters(
|
|
445
|
+
command=sys.executable,
|
|
446
|
+
args=["-m", "devcouncil", "mcp-server"],
|
|
447
|
+
cwd=str(root),
|
|
448
|
+
env=env,
|
|
449
|
+
)
|
|
450
|
+
async with stdio_client(params) as (read, write):
|
|
451
|
+
async with ClientSession(read, write) as session:
|
|
452
|
+
await session.initialize()
|
|
453
|
+
tools = await session.list_tools()
|
|
454
|
+
return [tool.name for tool in tools.tools]
|
|
455
|
+
|
|
456
|
+
async def _list_tools_with_deadline() -> list[str]:
|
|
457
|
+
# A wedged server process would otherwise block `dev integrate check`
|
|
458
|
+
# indefinitely; the caller treats TimeoutError as a failed probe.
|
|
459
|
+
return await asyncio.wait_for(_list_tools(), timeout=timeout_seconds)
|
|
460
|
+
|
|
461
|
+
return asyncio.run(_list_tools_with_deadline())
|
|
462
|
+
|
|
463
|
+
|
|
111
464
|
def _run(command: list[str]) -> int:
|
|
112
465
|
executable = shutil.which(command[0])
|
|
113
466
|
if not executable:
|
|
@@ -115,7 +468,10 @@ def _run(command: list[str]) -> int:
|
|
|
115
468
|
resolved = [executable, *command[1:]]
|
|
116
469
|
use_shell = sys.platform == "win32" and Path(executable).suffix.lower() in {".bat", ".cmd", ".ps1"}
|
|
117
470
|
invocation = subprocess.list2cmdline(resolved) if use_shell else resolved
|
|
118
|
-
|
|
471
|
+
try:
|
|
472
|
+
result = subprocess.run(invocation, text=True, shell=use_shell)
|
|
473
|
+
except (FileNotFoundError, OSError):
|
|
474
|
+
return 127
|
|
119
475
|
return result.returncode
|
|
120
476
|
|
|
121
477
|
|
|
@@ -136,9 +492,12 @@ def _run_capture(command: list[str], timeout: int = 10) -> tuple[int, str]:
|
|
|
136
492
|
errors="replace",
|
|
137
493
|
shell=use_shell,
|
|
138
494
|
timeout=timeout,
|
|
495
|
+
env=clean_subprocess_env(),
|
|
139
496
|
)
|
|
140
497
|
except subprocess.TimeoutExpired:
|
|
141
498
|
return 124, "timed out"
|
|
499
|
+
except (FileNotFoundError, OSError) as exc:
|
|
500
|
+
return 127, f"{command[0]} could not be executed: {exc}"
|
|
142
501
|
return result.returncode, (result.stdout + result.stderr).strip()
|
|
143
502
|
|
|
144
503
|
|
|
@@ -272,24 +631,104 @@ def _install_gemini_hooks(project_root: Path) -> list[Path]:
|
|
|
272
631
|
return [path]
|
|
273
632
|
|
|
274
633
|
|
|
275
|
-
def
|
|
276
|
-
|
|
634
|
+
def _upsert_cursor_hook(settings: dict, event: str, matcher: str, command: str) -> None:
|
|
635
|
+
hooks = settings.setdefault("hooks", {})
|
|
636
|
+
entries = hooks.setdefault(event, [])
|
|
637
|
+
for entry in entries:
|
|
638
|
+
if entry.get("command") == command:
|
|
639
|
+
return
|
|
640
|
+
payload: dict = {"command": command}
|
|
641
|
+
if matcher:
|
|
642
|
+
payload["matcher"] = matcher
|
|
643
|
+
entries.append(payload)
|
|
644
|
+
|
|
645
|
+
|
|
646
|
+
def _install_cursor_hooks(project_root: Path) -> list[Path]:
|
|
647
|
+
path = project_root / ".cursor" / "hooks.json"
|
|
277
648
|
settings = _load_json(path)
|
|
278
|
-
|
|
279
|
-
|
|
649
|
+
settings.setdefault("version", 1)
|
|
650
|
+
matcher = "Shell|Write|Edit|MultiEdit|Read|Task"
|
|
651
|
+
_upsert_cursor_hook(
|
|
280
652
|
settings,
|
|
281
|
-
"
|
|
653
|
+
"preToolUse",
|
|
282
654
|
matcher,
|
|
283
|
-
_hook_command(project_root, "
|
|
284
|
-
"devcouncil-pre-tool-use",
|
|
655
|
+
_hook_command(project_root, "cursor", "pre-tool-use"),
|
|
285
656
|
)
|
|
286
|
-
|
|
657
|
+
_upsert_cursor_hook(
|
|
287
658
|
settings,
|
|
288
|
-
"
|
|
659
|
+
"postToolUse",
|
|
289
660
|
matcher,
|
|
290
|
-
_hook_command(project_root, "
|
|
291
|
-
"devcouncil-post-tool-use",
|
|
661
|
+
_hook_command(project_root, "cursor", "post-tool-use"),
|
|
292
662
|
)
|
|
663
|
+
_save_json(path, settings)
|
|
664
|
+
|
|
665
|
+
def mutate(config: dict) -> None:
|
|
666
|
+
cursor = config.setdefault("integrations", {}).setdefault("cursor", {})
|
|
667
|
+
cursor.update({
|
|
668
|
+
"hooks_path": str(path.relative_to(project_root)),
|
|
669
|
+
})
|
|
670
|
+
|
|
671
|
+
_mutate_raw_config(project_root, mutate)
|
|
672
|
+
return [path]
|
|
673
|
+
|
|
674
|
+
|
|
675
|
+
def _install_opencode_hooks(project_root: Path) -> list[Path]:
|
|
676
|
+
source = _opencode_plugin_source()
|
|
677
|
+
if not source.exists():
|
|
678
|
+
raise FileNotFoundError(f"Missing bundled OpenCode hook plugin: {source}")
|
|
679
|
+
destination = _opencode_plugin_path(project_root)
|
|
680
|
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
681
|
+
destination.write_text(source.read_text(encoding="utf-8"), encoding="utf-8")
|
|
682
|
+
|
|
683
|
+
path = _opencode_config_path(project_root)
|
|
684
|
+
data = _load_json_strict(path, "OpenCode") if path.exists() else {"$schema": "https://opencode.ai/config.json"}
|
|
685
|
+
data.setdefault("$schema", "https://opencode.ai/config.json")
|
|
686
|
+
plugins_raw = data.setdefault("plugin", [])
|
|
687
|
+
if not isinstance(plugins_raw, list):
|
|
688
|
+
plugins_raw = []
|
|
689
|
+
data["plugin"] = plugins_raw
|
|
690
|
+
plugins: list[str] = [str(item) for item in plugins_raw]
|
|
691
|
+
data["plugin"] = plugins
|
|
692
|
+
plugin_ref = f"./.devcouncil/integrations/{OPENCODE_HOOK_PLUGIN_NAME}"
|
|
693
|
+
if plugin_ref not in plugins:
|
|
694
|
+
plugins.append(plugin_ref)
|
|
695
|
+
_save_json(path, data)
|
|
696
|
+
_record_opencode_config(project_root)
|
|
697
|
+
return [destination, path]
|
|
698
|
+
|
|
699
|
+
|
|
700
|
+
def _install_claude_hooks(project_root: Path, *, write_gate: bool = False) -> list[Path]:
|
|
701
|
+
"""Install DevCouncil's Claude Code hooks into .claude/settings.local.json.
|
|
702
|
+
|
|
703
|
+
By default this installs only the *assistive* lifecycle hooks (status injection on
|
|
704
|
+
SessionStart/UserPromptSubmit, the live-review Stop signal, and the SessionEnd/
|
|
705
|
+
PreCompact/SubagentStop/Notification trace hooks). These never block a tool call.
|
|
706
|
+
|
|
707
|
+
The blocking pre-action **write-gate** (PreToolUse/PostToolUse, which denies any
|
|
708
|
+
Bash/Write/Edit not authorized by an active task lease) is installed ONLY when
|
|
709
|
+
``write_gate`` is True. It is meant for autonomous executor runs, not interactive
|
|
710
|
+
human sessions — in an interactive session there is no task lease, so the gate would
|
|
711
|
+
fail-closed and deny every command. (``dev run --executor claude`` does its own
|
|
712
|
+
post-hoc scope enforcement and does not depend on this hook, so leaving it off by
|
|
713
|
+
default loses no containment.)"""
|
|
714
|
+
path = project_root / ".claude" / "settings.local.json"
|
|
715
|
+
settings = _load_json(path)
|
|
716
|
+
matcher = "Bash|Write|Edit|MultiEdit"
|
|
717
|
+
if write_gate:
|
|
718
|
+
_upsert_hook(
|
|
719
|
+
settings,
|
|
720
|
+
"PreToolUse",
|
|
721
|
+
matcher,
|
|
722
|
+
_hook_command(project_root, "claude", "pre-tool-use"),
|
|
723
|
+
"devcouncil-pre-tool-use",
|
|
724
|
+
)
|
|
725
|
+
_upsert_hook(
|
|
726
|
+
settings,
|
|
727
|
+
"PostToolUse",
|
|
728
|
+
matcher,
|
|
729
|
+
_hook_command(project_root, "claude", "post-tool-use"),
|
|
730
|
+
"devcouncil-post-tool-use",
|
|
731
|
+
)
|
|
293
732
|
_upsert_hook(
|
|
294
733
|
settings,
|
|
295
734
|
"Stop",
|
|
@@ -297,41 +736,324 @@ def _install_claude_hooks(project_root: Path) -> list[Path]:
|
|
|
297
736
|
_hook_command(project_root, "claude", "agent-response"),
|
|
298
737
|
"devcouncil-agent-response-ready",
|
|
299
738
|
)
|
|
739
|
+
# Lifecycle events: status-on-start/prompt, teardown, compaction, subagent finish,
|
|
740
|
+
# and notifications. These complete DevCouncil's coverage of the documented Claude
|
|
741
|
+
# Code hook surface beyond the pre/post/stop gate.
|
|
742
|
+
_upsert_hook(
|
|
743
|
+
settings,
|
|
744
|
+
"SessionStart",
|
|
745
|
+
"startup|resume",
|
|
746
|
+
_hook_command(project_root, "claude", "session-start"),
|
|
747
|
+
"devcouncil-session-start",
|
|
748
|
+
)
|
|
749
|
+
_upsert_hook(
|
|
750
|
+
settings,
|
|
751
|
+
"UserPromptSubmit",
|
|
752
|
+
"",
|
|
753
|
+
_hook_command(project_root, "claude", "user-prompt-submit"),
|
|
754
|
+
"devcouncil-user-prompt-submit",
|
|
755
|
+
)
|
|
756
|
+
_upsert_hook(
|
|
757
|
+
settings,
|
|
758
|
+
"SessionEnd",
|
|
759
|
+
"",
|
|
760
|
+
_hook_command(project_root, "claude", "session-end"),
|
|
761
|
+
"devcouncil-session-end",
|
|
762
|
+
)
|
|
763
|
+
_upsert_hook(
|
|
764
|
+
settings,
|
|
765
|
+
"PreCompact",
|
|
766
|
+
"",
|
|
767
|
+
_hook_command(project_root, "claude", "pre-compact"),
|
|
768
|
+
"devcouncil-pre-compact",
|
|
769
|
+
)
|
|
770
|
+
_upsert_hook(
|
|
771
|
+
settings,
|
|
772
|
+
"SubagentStop",
|
|
773
|
+
"",
|
|
774
|
+
_hook_command(project_root, "claude", "subagent-stop"),
|
|
775
|
+
"devcouncil-subagent-stop",
|
|
776
|
+
)
|
|
777
|
+
_upsert_hook(
|
|
778
|
+
settings,
|
|
779
|
+
"Notification",
|
|
780
|
+
"",
|
|
781
|
+
_hook_command(project_root, "claude", "notification"),
|
|
782
|
+
"devcouncil-notification",
|
|
783
|
+
)
|
|
300
784
|
_save_json(path, settings)
|
|
301
785
|
return [path]
|
|
302
786
|
|
|
303
787
|
|
|
788
|
+
def _devcouncil_version() -> str:
|
|
789
|
+
"""Package version for plugin manifests, or a stable placeholder when uninstalled."""
|
|
790
|
+
import importlib.metadata
|
|
791
|
+
|
|
792
|
+
try:
|
|
793
|
+
return importlib.metadata.version("devcouncil")
|
|
794
|
+
except importlib.metadata.PackageNotFoundError:
|
|
795
|
+
return "0.0.0"
|
|
796
|
+
|
|
797
|
+
|
|
798
|
+
# Read-only DevCouncil commands the generated slash commands / hooks shell out to. Adding
|
|
799
|
+
# them to the Claude permissions allow-list keeps the integration from prompting on every
|
|
800
|
+
# `dev status`/`dev report` the slash commands run.
|
|
801
|
+
_CLAUDE_PERMISSION_ALLOW = [
|
|
802
|
+
"Bash(dev status:*)",
|
|
803
|
+
"Bash(dev report:*)",
|
|
804
|
+
"Bash(dev tasks:*)",
|
|
805
|
+
"Bash(dev verify:*)",
|
|
806
|
+
"Bash(dev repair:*)",
|
|
807
|
+
"Bash(dev plan:*)",
|
|
808
|
+
"Bash(dev watch:*)",
|
|
809
|
+
"Bash(devcouncil mcp-server)",
|
|
810
|
+
]
|
|
811
|
+
|
|
812
|
+
|
|
813
|
+
def _install_claude_settings(project_root: Path) -> tuple[Path, bool]:
|
|
814
|
+
"""Write the statusLine, MCP enablement, and permission allow-list into Claude settings.
|
|
815
|
+
|
|
816
|
+
Merges into .claude/settings.local.json without clobbering existing user entries.
|
|
817
|
+
Returns (path, changed); only rewrites the file when the merge changes something so
|
|
818
|
+
re-running integration is a true no-op."""
|
|
819
|
+
path = project_root / ".claude" / "settings.local.json"
|
|
820
|
+
settings = _load_json(path)
|
|
821
|
+
before = json.dumps(settings, sort_keys=True)
|
|
822
|
+
|
|
823
|
+
settings["statusLine"] = {
|
|
824
|
+
"type": "command",
|
|
825
|
+
"command": "devcouncil hook claude-statusline",
|
|
826
|
+
}
|
|
827
|
+
# Auto-enable the project-scoped DevCouncil MCP server so a teammate cloning the repo
|
|
828
|
+
# doesn't have to approve it interactively.
|
|
829
|
+
enabled = settings.setdefault("enabledMcpjsonServers", [])
|
|
830
|
+
if isinstance(enabled, list) and "devcouncil" not in enabled:
|
|
831
|
+
enabled.append("devcouncil")
|
|
832
|
+
|
|
833
|
+
permissions = settings.setdefault("permissions", {})
|
|
834
|
+
if isinstance(permissions, dict):
|
|
835
|
+
allow = permissions.setdefault("allow", [])
|
|
836
|
+
if isinstance(allow, list):
|
|
837
|
+
for rule in _CLAUDE_PERMISSION_ALLOW:
|
|
838
|
+
if rule not in allow:
|
|
839
|
+
allow.append(rule)
|
|
840
|
+
|
|
841
|
+
changed = json.dumps(settings, sort_keys=True) != before
|
|
842
|
+
if changed:
|
|
843
|
+
_save_json(path, settings)
|
|
844
|
+
return path, changed
|
|
845
|
+
|
|
846
|
+
|
|
847
|
+
def _selected_skill_assets(project_root: Path):
|
|
848
|
+
"""Scaffold the applicable skills and return them as GeneratedAsset-like records.
|
|
849
|
+
|
|
850
|
+
Returns (written_paths, skill_assets) where skill_assets carry (path, content) for the
|
|
851
|
+
plugin bundler so the plugin ships the same skill bodies that land in .claude/skills/."""
|
|
852
|
+
from devcouncil.integrations.claude_assets import GeneratedAsset
|
|
853
|
+
from devcouncil.skills.registry import scaffold_skills, select_skills
|
|
854
|
+
|
|
855
|
+
skills = select_skills("", project_root)
|
|
856
|
+
written = scaffold_skills(project_root, skills)
|
|
857
|
+
assets: list[GeneratedAsset] = []
|
|
858
|
+
skills_root = project_root / ".claude" / "skills"
|
|
859
|
+
for skill in skills:
|
|
860
|
+
target = skills_root / skill.name / "SKILL.md"
|
|
861
|
+
if target.exists():
|
|
862
|
+
assets.append(GeneratedAsset(target, target.read_text(encoding="utf-8")))
|
|
863
|
+
return written, assets
|
|
864
|
+
|
|
865
|
+
|
|
866
|
+
def _install_claude_assets(project_root: Path) -> list[Path]:
|
|
867
|
+
"""Generate the static Claude Code asset surface (commands, agents, output style,
|
|
868
|
+
statusline, permissions) and scaffold the applicable skills. Idempotent."""
|
|
869
|
+
from devcouncil.integrations import claude_assets
|
|
870
|
+
|
|
871
|
+
written: list[Path] = []
|
|
872
|
+
assets: list[claude_assets.GeneratedAsset] = []
|
|
873
|
+
assets += claude_assets.build_slash_commands(project_root)
|
|
874
|
+
assets += claude_assets.build_subagents(project_root)
|
|
875
|
+
assets += claude_assets.build_output_style(project_root)
|
|
876
|
+
for asset in assets:
|
|
877
|
+
if asset.write_if_changed():
|
|
878
|
+
written.append(asset.path)
|
|
879
|
+
|
|
880
|
+
skills_written, _ = _selected_skill_assets(project_root)
|
|
881
|
+
written.extend(skills_written)
|
|
882
|
+
settings_path, settings_changed = _install_claude_settings(project_root)
|
|
883
|
+
if settings_changed:
|
|
884
|
+
written.append(settings_path)
|
|
885
|
+
return written
|
|
886
|
+
|
|
887
|
+
|
|
888
|
+
def _install_claude_plugin(project_root: Path, *, write_gate: bool = False) -> list[Path]:
|
|
889
|
+
"""Build the self-contained Claude Code plugin + single-repo marketplace bundle.
|
|
890
|
+
|
|
891
|
+
Bundles the commands, agents, applicable skills, hooks, and MCP config so the entire
|
|
892
|
+
DevCouncil integration installs with one `/plugin install`. Assist-mode hooks by
|
|
893
|
+
default; pass write_gate=True to bundle the blocking containment gate."""
|
|
894
|
+
from devcouncil.integrations import claude_assets
|
|
895
|
+
|
|
896
|
+
_, skill_assets = _selected_skill_assets(project_root)
|
|
897
|
+
bundle = claude_assets.build_plugin_bundle(
|
|
898
|
+
project_root, version=_devcouncil_version(), skill_assets=skill_assets, write_gate=write_gate
|
|
899
|
+
)
|
|
900
|
+
return [asset.path for asset in bundle if asset.write_if_changed()]
|
|
901
|
+
|
|
902
|
+
|
|
903
|
+
def _uninstall_claude(project_root: Path) -> list[str]:
|
|
904
|
+
"""Remove everything DevCouncil installed into a Claude Code project. Idempotent.
|
|
905
|
+
|
|
906
|
+
Strips DevCouncil's hooks (every event), the DevCouncil statusLine, the MCP enablement
|
|
907
|
+
and permission rules from .claude/settings.local.json (leaving any user-authored
|
|
908
|
+
entries untouched), deletes the generated commands/subagents/output-style files, and
|
|
909
|
+
best-effort de-registers the MCP server via `claude mcp remove`. Returns a list of the
|
|
910
|
+
changes made. The recoverable, in-band counterpart to a fail-closed write-gate."""
|
|
911
|
+
removed: list[str] = []
|
|
912
|
+
path = project_root / ".claude" / "settings.local.json"
|
|
913
|
+
settings = _load_json(path)
|
|
914
|
+
before = json.dumps(settings, sort_keys=True)
|
|
915
|
+
|
|
916
|
+
# Hooks: drop any entry whose command invokes `devcouncil hook`, then prune empties.
|
|
917
|
+
hooks = settings.get("hooks")
|
|
918
|
+
if isinstance(hooks, dict):
|
|
919
|
+
for event in list(hooks):
|
|
920
|
+
groups = hooks.get(event)
|
|
921
|
+
if not isinstance(groups, list):
|
|
922
|
+
continue
|
|
923
|
+
kept_groups = []
|
|
924
|
+
for group in groups:
|
|
925
|
+
inner = group.get("hooks", []) if isinstance(group, dict) else []
|
|
926
|
+
inner_kept = [
|
|
927
|
+
h for h in inner
|
|
928
|
+
if "devcouncil hook" not in str(h.get("command", ""))
|
|
929
|
+
]
|
|
930
|
+
if inner_kept:
|
|
931
|
+
group["hooks"] = inner_kept
|
|
932
|
+
kept_groups.append(group)
|
|
933
|
+
if kept_groups:
|
|
934
|
+
hooks[event] = kept_groups
|
|
935
|
+
else:
|
|
936
|
+
hooks.pop(event)
|
|
937
|
+
if not hooks:
|
|
938
|
+
settings.pop("hooks")
|
|
939
|
+
removed.append(f"hooks in {path.name}")
|
|
940
|
+
|
|
941
|
+
# statusLine: only remove ours.
|
|
942
|
+
status = settings.get("statusLine")
|
|
943
|
+
if isinstance(status, dict) and "devcouncil" in str(status.get("command", "")):
|
|
944
|
+
settings.pop("statusLine")
|
|
945
|
+
removed.append("statusLine")
|
|
946
|
+
|
|
947
|
+
enabled = settings.get("enabledMcpjsonServers")
|
|
948
|
+
if isinstance(enabled, list) and "devcouncil" in enabled:
|
|
949
|
+
enabled.remove("devcouncil")
|
|
950
|
+
if not enabled:
|
|
951
|
+
settings.pop("enabledMcpjsonServers")
|
|
952
|
+
removed.append("enabledMcpjsonServers entry")
|
|
953
|
+
|
|
954
|
+
permissions = settings.get("permissions")
|
|
955
|
+
if isinstance(permissions, dict) and isinstance(permissions.get("allow"), list):
|
|
956
|
+
kept = [r for r in permissions["allow"] if r not in _CLAUDE_PERMISSION_ALLOW]
|
|
957
|
+
if len(kept) != len(permissions["allow"]):
|
|
958
|
+
permissions["allow"] = kept
|
|
959
|
+
removed.append("permission allow-rules")
|
|
960
|
+
if not permissions.get("allow"):
|
|
961
|
+
permissions.pop("allow", None)
|
|
962
|
+
if not permissions:
|
|
963
|
+
settings.pop("permissions")
|
|
964
|
+
|
|
965
|
+
if json.dumps(settings, sort_keys=True) != before:
|
|
966
|
+
if settings:
|
|
967
|
+
_save_json(path, settings)
|
|
968
|
+
elif path.exists():
|
|
969
|
+
path.unlink()
|
|
970
|
+
removed.append(f"deleted empty {path.name}")
|
|
971
|
+
|
|
972
|
+
# Generated asset files.
|
|
973
|
+
targets = [
|
|
974
|
+
project_root / ".claude" / "commands" / "devcouncil",
|
|
975
|
+
project_root / ".claude" / "output-styles" / "devcouncil.md",
|
|
976
|
+
]
|
|
977
|
+
targets += [
|
|
978
|
+
project_root / ".claude" / "agents" / f"{name}.md"
|
|
979
|
+
for name in ("devcouncil-implementer", "devcouncil-verifier", "devcouncil-reviewer")
|
|
980
|
+
]
|
|
981
|
+
for target in targets:
|
|
982
|
+
if target.is_dir():
|
|
983
|
+
shutil.rmtree(target)
|
|
984
|
+
removed.append(str(target.relative_to(project_root)))
|
|
985
|
+
elif target.exists():
|
|
986
|
+
target.unlink()
|
|
987
|
+
removed.append(str(target.relative_to(project_root)))
|
|
988
|
+
|
|
989
|
+
# De-register the MCP server (best-effort; only if the claude CLI is present).
|
|
990
|
+
if shutil.which("claude"):
|
|
991
|
+
code = _run(["claude", "mcp", "remove", "devcouncil"])
|
|
992
|
+
if code == 0:
|
|
993
|
+
removed.append("claude mcp server registration")
|
|
994
|
+
|
|
995
|
+
return removed
|
|
996
|
+
|
|
997
|
+
|
|
304
998
|
def _preview_hook_paths(project_root: Path, tool: str) -> list[tuple[str, Path]]:
|
|
305
999
|
paths = {
|
|
306
1000
|
"codex": [project_root / ".codex" / "hooks.json", project_root / ".codex" / "config.toml"],
|
|
307
1001
|
"gemini": [project_root / ".gemini" / "settings.json"],
|
|
308
1002
|
"claude": [project_root / ".claude" / "settings.local.json"],
|
|
1003
|
+
"cursor": [project_root / ".cursor" / "hooks.json"],
|
|
1004
|
+
"opencode": [_opencode_plugin_path(project_root), _opencode_config_path(project_root)],
|
|
309
1005
|
}
|
|
310
|
-
selected
|
|
311
|
-
|
|
1006
|
+
selected: tuple[str, ...]
|
|
1007
|
+
if tool == "all":
|
|
1008
|
+
selected = (*SUPPORTED_HOOK_TOOLS, "opencode")
|
|
1009
|
+
elif tool == "opencode":
|
|
1010
|
+
selected = ("opencode",)
|
|
1011
|
+
else:
|
|
1012
|
+
selected = (tool,)
|
|
1013
|
+
return [(client, path) for client in selected for path in paths.get(client, [])]
|
|
312
1014
|
|
|
313
1015
|
|
|
314
|
-
def _configure_native_hooks(
|
|
315
|
-
|
|
316
|
-
|
|
1016
|
+
def _configure_native_hooks(
|
|
1017
|
+
project_root: Path, tool: str = "all", apply: bool = False, *, claude_write_gate: bool = False
|
|
1018
|
+
) -> None:
|
|
1019
|
+
allowed = {"all", *SUPPORTED_HOOK_TOOLS, "opencode"}
|
|
1020
|
+
if tool not in allowed:
|
|
1021
|
+
console.print("[red]--tool must be one of: all, codex, gemini, claude, cursor, opencode.[/red]")
|
|
317
1022
|
raise typer.Exit(code=2)
|
|
318
1023
|
|
|
319
1024
|
if not apply:
|
|
320
1025
|
console.print("[bold]Native hook config preview[/bold]")
|
|
321
1026
|
for client, path in _preview_hook_paths(project_root, tool):
|
|
322
|
-
console.print(f"{client}: {path}")
|
|
1027
|
+
console.print(f"{client}: {path}", soft_wrap=True)
|
|
323
1028
|
console.print("[yellow]Preview only. Rerun with --apply to write hook config files.[/yellow]")
|
|
324
1029
|
return
|
|
325
1030
|
|
|
326
|
-
selected
|
|
1031
|
+
selected: tuple[str, ...]
|
|
1032
|
+
if tool == "all":
|
|
1033
|
+
selected = (*SUPPORTED_HOOK_TOOLS, "opencode")
|
|
1034
|
+
elif tool == "opencode":
|
|
1035
|
+
selected = ("opencode",)
|
|
1036
|
+
else:
|
|
1037
|
+
selected = (tool,)
|
|
327
1038
|
installers = {
|
|
328
1039
|
"codex": _install_codex_hooks,
|
|
329
1040
|
"gemini": _install_gemini_hooks,
|
|
330
|
-
|
|
1041
|
+
# Claude's blocking write-gate is opt-in (assist-mode default); the other clients
|
|
1042
|
+
# install their native pre/post hooks unconditionally as before.
|
|
1043
|
+
"claude": lambda root: _install_claude_hooks(root, write_gate=claude_write_gate),
|
|
1044
|
+
"cursor": _install_cursor_hooks,
|
|
1045
|
+
"opencode": _install_opencode_hooks,
|
|
331
1046
|
}
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
1047
|
+
# Batch the per-installer config.yaml record updates (cursor/opencode)
|
|
1048
|
+
# into one load/save instead of re-parsing YAML per tool.
|
|
1049
|
+
with _batched_raw_config(project_root):
|
|
1050
|
+
for client in selected:
|
|
1051
|
+
try:
|
|
1052
|
+
written = installers[client](project_root)
|
|
1053
|
+
except (ValueError, FileNotFoundError) as exc:
|
|
1054
|
+
console.print(f"[red]{client} hook setup failed: {exc}[/red]")
|
|
1055
|
+
raise typer.Exit(code=1) from exc
|
|
1056
|
+
console.print(f"[green]{client} native hooks configured:[/green] {', '.join(str(path) for path in written)}")
|
|
335
1057
|
|
|
336
1058
|
|
|
337
1059
|
def _print_command(tool: str, command: list[str], apply: bool):
|
|
@@ -378,22 +1100,37 @@ def overview(ctx: typer.Context):
|
|
|
378
1100
|
table.add_column("Notes")
|
|
379
1101
|
table.add_row("Codex CLI", f"{PREFERRED_COMMAND} codex --apply", "Adds DevCouncil as a stdio MCP server.")
|
|
380
1102
|
table.add_row("Gemini CLI", f"{PREFERRED_COMMAND} gemini --apply", "Adds DevCouncil as a project-scoped stdio MCP server.")
|
|
381
|
-
table.add_row("Claude Code", f"{PREFERRED_COMMAND} claude --apply", "
|
|
382
|
-
table.add_row("
|
|
1103
|
+
table.add_row("Claude Code", f"{PREFERRED_COMMAND} claude --apply", "MCP + assistive hooks + slash commands, subagents, output style, skills, statusline. Add --write-gate for blocking containment.")
|
|
1104
|
+
table.add_row("Claude assets", f"{PREFERRED_COMMAND} claude-assets --apply", "Slash commands, subagents, output style, statusline, permissions, skills (no MCP/hooks).")
|
|
1105
|
+
table.add_row("Claude plugin", f"{PREFERRED_COMMAND} claude-plugin --apply", "Self-contained Claude Code plugin + marketplace bundling everything for /plugin install.")
|
|
1106
|
+
table.add_row("Claude uninstall", f"{PREFERRED_COMMAND} claude --uninstall", "Remove DevCouncil hooks, statusline, MCP enablement, and generated assets from .claude/.")
|
|
1107
|
+
table.add_row("Cursor", f"{PREFERRED_COMMAND} cursor --apply", "Writes project .cursor/mcp.json for Cursor editor and cursor-agent.")
|
|
1108
|
+
table.add_row("OpenCode", f"{PREFERRED_COMMAND} opencode --apply", "Adds DevCouncil as a project-scoped OpenCode MCP server and executor.")
|
|
1109
|
+
table.add_row("Google Antigravity CLI", f"{PREFERRED_COMMAND} antigravity --apply", "Writes project .agents/mcp_config.json and enables the agy executor.")
|
|
1110
|
+
table.add_row("Warp / Oz", f"{PREFERRED_COMMAND} warp --apply", "Writes a Warp-compatible MCP JSON file for local agents and Oz CLI.")
|
|
1111
|
+
table.add_row("Aider", f"{PREFERRED_COMMAND} aider --apply", "Enables the built-in Aider headless executor (no MCP).")
|
|
1112
|
+
table.add_row("Bring your own CLI", f"{PREFERRED_COMMAND} cli-agent NAME --command TOOL --apply", "Registers any prompt-taking CLI as a DevCouncil executor.")
|
|
383
1113
|
table.add_row("All", f"{PREFERRED_COMMAND} all --apply", "Runs MCP setup and installs native hooks.")
|
|
384
|
-
table.add_row("Native hooks", f"{PREFERRED_COMMAND} hooks --apply", "Installs Codex, Gemini, and
|
|
1114
|
+
table.add_row("Native hooks", f"{PREFERRED_COMMAND} hooks --apply", "Installs Codex, Gemini, Claude, Cursor, and OpenCode hook files.")
|
|
1115
|
+
table.add_row("Recommend", f"{PREFERRED_COMMAND} recommend", "Show the best executor for this machine and project.")
|
|
1116
|
+
table.add_row("Status", f"{PREFERRED_COMMAND} status", "Compact PATH + config summary (no MCP probe).")
|
|
1117
|
+
table.add_row("Matrix", f"{PREFERRED_COMMAND} matrix", "Print built-in coding CLI integration tiers.")
|
|
1118
|
+
table.add_row("Check", f"{PREFERRED_COMMAND} check", "Verify MCP, hooks, and optional CLIs (--strict, --json for CI).")
|
|
385
1119
|
console.print(table)
|
|
386
1120
|
console.print(f"\nIf your install exposes only the setup flow, use: {LEGACY_COMMAND} --apply")
|
|
387
1121
|
console.print("\nRun without [bold]--apply[/bold] to preview the exact commands first.")
|
|
388
1122
|
|
|
389
1123
|
|
|
390
1124
|
@app.command("doctor")
|
|
391
|
-
def integrations_doctor(
|
|
1125
|
+
def integrations_doctor(
|
|
1126
|
+
project_root: Path | None = typer.Option(None, "--project-root", help="Repository root containing .devcouncil/."),
|
|
1127
|
+
):
|
|
392
1128
|
"""Check optional integration tools and local client wiring prerequisites."""
|
|
1129
|
+
root = _project_root(project_root)
|
|
393
1130
|
table = Table(title="DevCouncil Integration Doctor")
|
|
394
|
-
table.add_column("Integration", style="cyan")
|
|
1131
|
+
table.add_column("Integration", style="cyan", no_wrap=True)
|
|
395
1132
|
table.add_column("Status")
|
|
396
|
-
table.add_column("Notes")
|
|
1133
|
+
table.add_column("Notes", overflow="fold")
|
|
397
1134
|
|
|
398
1135
|
checks = [
|
|
399
1136
|
("Agent Flow", "agent-flow-app", "Optional live/replay visualizer for trace JSONL."),
|
|
@@ -401,14 +1138,34 @@ def integrations_doctor():
|
|
|
401
1138
|
("Claude Code", "claude", "Optional MCP client and native hook runtime for pre-tool-use enforcement."),
|
|
402
1139
|
("Codex CLI", "codex", "Optional MCP client, headless executor companion, and native hook runtime."),
|
|
403
1140
|
("Gemini CLI", "gemini", "Optional MCP client companion and native hook runtime."),
|
|
404
|
-
("Cursor", "cursor", "Optional MCP client and
|
|
405
|
-
("
|
|
1141
|
+
("Cursor", "cursor-agent", "Optional MCP client, cursor-agent executor, and native hooks."),
|
|
1142
|
+
("OpenCode", "opencode", "Optional MCP client and headless coding-agent executor."),
|
|
1143
|
+
("Google Antigravity CLI", "agy", "Optional Antigravity CLI companion and headless coding-agent executor."),
|
|
1144
|
+
("Warp / Oz", "oz", "Optional Warp/Oz CLI companion and agent executor."),
|
|
1145
|
+
("Aider", "aider", "Optional headless executor via `dev run --executor aider` (no MCP)."),
|
|
406
1146
|
]
|
|
407
1147
|
for label, executable, notes in checks:
|
|
408
1148
|
found = shutil.which(executable)
|
|
409
1149
|
table.add_row(label, "[green]OK[/green]" if found else "[yellow]Missing[/yellow]", found or notes)
|
|
410
1150
|
|
|
411
|
-
|
|
1151
|
+
profiles = load_agent_profiles(root)
|
|
1152
|
+
for name, spec in load_cli_agent_specs(root).items():
|
|
1153
|
+
if spec.built_in:
|
|
1154
|
+
continue
|
|
1155
|
+
found = shutil.which(spec.executable)
|
|
1156
|
+
mode_ok = spec.input_mode in VALID_INPUT_MODES
|
|
1157
|
+
profile_ok = spec.default_profile in profiles
|
|
1158
|
+
status = "[green]OK[/green]" if found and mode_ok and profile_ok else "[red]Invalid[/red]"
|
|
1159
|
+
if not found:
|
|
1160
|
+
status = "[yellow]Missing[/yellow]"
|
|
1161
|
+
details = found or f"{spec.executable} not found on PATH"
|
|
1162
|
+
if not mode_ok:
|
|
1163
|
+
details = f"invalid input_mode={spec.input_mode}"
|
|
1164
|
+
if not profile_ok:
|
|
1165
|
+
details = f"{details}; missing profile={spec.default_profile}"
|
|
1166
|
+
table.add_row(f"CLI agent: {name}", status, details)
|
|
1167
|
+
|
|
1168
|
+
config = _config_path(root)
|
|
412
1169
|
table.add_row(
|
|
413
1170
|
"DevCouncil config",
|
|
414
1171
|
"[green]OK[/green]" if config.exists() else "[red]Missing[/red]",
|
|
@@ -457,36 +1214,346 @@ def claude(
|
|
|
457
1214
|
apply: bool = typer.Option(False, "--apply", help="Run the setup command instead of printing it."),
|
|
458
1215
|
project_root: Path | None = typer.Option(None, "--project-root", help="Repository root containing .devcouncil/."),
|
|
459
1216
|
scope: str = typer.Option("local", "--scope", help="Claude MCP config scope: local, project, or user."),
|
|
1217
|
+
write_gate: bool = typer.Option(
|
|
1218
|
+
False,
|
|
1219
|
+
"--write-gate/--no-write-gate",
|
|
1220
|
+
"--contain/--no-contain",
|
|
1221
|
+
help="Also install the blocking PreToolUse/PostToolUse write-gate (containment). "
|
|
1222
|
+
"Off by default — it denies any tool call not authorized by an active task lease, "
|
|
1223
|
+
"which fail-closes an interactive session. Use it for autonomous executor runs.",
|
|
1224
|
+
),
|
|
1225
|
+
uninstall: bool = typer.Option(
|
|
1226
|
+
False,
|
|
1227
|
+
"--uninstall",
|
|
1228
|
+
help="Remove DevCouncil's Claude hooks, statusline, MCP enablement, and generated assets.",
|
|
1229
|
+
),
|
|
460
1230
|
):
|
|
461
1231
|
"""
|
|
462
|
-
Set up DevCouncil MCP
|
|
1232
|
+
Set up DevCouncil for Claude Code: MCP server + assistive hooks + slash commands,
|
|
1233
|
+
subagents, output style, skills, and statusline. The blocking write-gate is opt-in
|
|
1234
|
+
via --write-gate. Use --uninstall to remove everything DevCouncil installed.
|
|
463
1235
|
"""
|
|
1236
|
+
root = _project_root(project_root)
|
|
1237
|
+
if uninstall:
|
|
1238
|
+
removed = _uninstall_claude(root)
|
|
1239
|
+
if removed:
|
|
1240
|
+
console.print(f"[green]Removed DevCouncil Claude integration[/green] ({len(removed)} change(s)):")
|
|
1241
|
+
for item in removed:
|
|
1242
|
+
console.print(f" {item}")
|
|
1243
|
+
else:
|
|
1244
|
+
console.print("[dim]Nothing to remove — DevCouncil Claude integration not found.[/dim]")
|
|
1245
|
+
return
|
|
1246
|
+
|
|
464
1247
|
if scope not in {"local", "project", "user"}:
|
|
465
1248
|
console.print("[red]--scope must be 'local', 'project', or 'user'.[/red]")
|
|
466
1249
|
raise typer.Exit(code=2)
|
|
467
1250
|
|
|
468
|
-
root = _project_root(project_root)
|
|
469
1251
|
command = _claude_command(root, scope)
|
|
470
1252
|
ok = _configure("Claude Code", command, apply)
|
|
1253
|
+
if apply:
|
|
1254
|
+
# One-shot: MCP server + assistive hooks (write-gate only with --write-gate) + the
|
|
1255
|
+
# static asset surface (slash commands, subagents, output style, skills, statusline).
|
|
1256
|
+
try:
|
|
1257
|
+
written = _install_claude_hooks(root, write_gate=write_gate)
|
|
1258
|
+
written += _install_claude_assets(root)
|
|
1259
|
+
except (ValueError, FileNotFoundError, OSError) as exc:
|
|
1260
|
+
console.print(f"[red]Claude asset setup failed: {exc}[/red]")
|
|
1261
|
+
raise typer.Exit(code=1) from exc
|
|
1262
|
+
mode = "with write-gate (containment)" if write_gate else "assist mode (no write-gate)"
|
|
1263
|
+
console.print(
|
|
1264
|
+
f"[green]Claude Code integration installed[/green] ({len(written)} file(s), {mode}): "
|
|
1265
|
+
"MCP, hooks, slash commands, subagents, output style, skills, statusline, permissions."
|
|
1266
|
+
)
|
|
1267
|
+
if not write_gate:
|
|
1268
|
+
console.print(
|
|
1269
|
+
"[dim]Add pre-action containment for autonomous runs with[/dim] "
|
|
1270
|
+
f"[dim]{PREFERRED_COMMAND} claude --apply --write-gate[/dim]"
|
|
1271
|
+
)
|
|
1272
|
+
console.print(
|
|
1273
|
+
"Bundle everything as an installable plugin with: "
|
|
1274
|
+
f"[dim]{PREFERRED_COMMAND} claude-plugin --apply[/dim]"
|
|
1275
|
+
)
|
|
471
1276
|
if not ok and apply:
|
|
472
1277
|
raise typer.Exit(code=1)
|
|
473
1278
|
|
|
474
1279
|
|
|
1280
|
+
@app.command("claude-assets")
|
|
1281
|
+
def claude_assets_cmd(
|
|
1282
|
+
apply: bool = typer.Option(False, "--apply", help="Write the Claude asset files instead of previewing them."),
|
|
1283
|
+
project_root: Path | None = typer.Option(None, "--project-root", help="Repository root containing .devcouncil/."),
|
|
1284
|
+
):
|
|
1285
|
+
"""
|
|
1286
|
+
Generate the Claude Code asset surface: slash commands, subagents, output style,
|
|
1287
|
+
statusline, permissions, and scaffolded skills (no MCP/hook registration).
|
|
1288
|
+
"""
|
|
1289
|
+
from devcouncil.integrations import claude_assets as _assets
|
|
1290
|
+
|
|
1291
|
+
root = _project_root(project_root)
|
|
1292
|
+
if not apply:
|
|
1293
|
+
console.print("[bold]Claude Code assets (preview)[/bold]")
|
|
1294
|
+
preview = (
|
|
1295
|
+
_assets.build_slash_commands(root)
|
|
1296
|
+
+ _assets.build_subagents(root)
|
|
1297
|
+
+ _assets.build_output_style(root)
|
|
1298
|
+
)
|
|
1299
|
+
for asset in preview:
|
|
1300
|
+
console.print(f" {asset.path}", soft_wrap=True)
|
|
1301
|
+
console.print(" .claude/settings.local.json (statusLine + permissions + enabledMcpjsonServers)")
|
|
1302
|
+
console.print(" .claude/skills/<applicable>/SKILL.md")
|
|
1303
|
+
console.print("[yellow]Preview only. Rerun with --apply to write the files.[/yellow]")
|
|
1304
|
+
return
|
|
1305
|
+
|
|
1306
|
+
try:
|
|
1307
|
+
written = _install_claude_assets(root)
|
|
1308
|
+
except (ValueError, FileNotFoundError, OSError) as exc:
|
|
1309
|
+
console.print(f"[red]Claude asset setup failed: {exc}[/red]")
|
|
1310
|
+
raise typer.Exit(code=1) from exc
|
|
1311
|
+
console.print(f"[green]Wrote {len(written)} Claude asset file(s).[/green]")
|
|
1312
|
+
for path in written:
|
|
1313
|
+
try:
|
|
1314
|
+
console.print(f" {path.relative_to(root).as_posix()}")
|
|
1315
|
+
except ValueError:
|
|
1316
|
+
console.print(f" {path}")
|
|
1317
|
+
|
|
1318
|
+
|
|
1319
|
+
@app.command("claude-plugin")
|
|
1320
|
+
def claude_plugin_cmd(
|
|
1321
|
+
apply: bool = typer.Option(False, "--apply", help="Write the plugin bundle instead of previewing it."),
|
|
1322
|
+
project_root: Path | None = typer.Option(None, "--project-root", help="Repository root containing .devcouncil/."),
|
|
1323
|
+
write_gate: bool = typer.Option(
|
|
1324
|
+
False,
|
|
1325
|
+
"--write-gate/--no-write-gate",
|
|
1326
|
+
"--contain/--no-contain",
|
|
1327
|
+
help="Bundle Claude's blocking write-gate in the plugin hooks (off by default).",
|
|
1328
|
+
),
|
|
1329
|
+
):
|
|
1330
|
+
"""
|
|
1331
|
+
Build a self-contained Claude Code plugin + single-repo marketplace bundling the
|
|
1332
|
+
DevCouncil commands, subagents, skills, hooks, and MCP server under
|
|
1333
|
+
.devcouncil/claude-plugin/ for one-command `/plugin install`.
|
|
1334
|
+
"""
|
|
1335
|
+
from devcouncil.integrations.claude_assets import PLUGIN_ROOT_REL
|
|
1336
|
+
|
|
1337
|
+
root = _project_root(project_root)
|
|
1338
|
+
market_dir = root / PLUGIN_ROOT_REL
|
|
1339
|
+
if not apply:
|
|
1340
|
+
console.print("[bold]Claude Code plugin bundle (preview)[/bold]")
|
|
1341
|
+
console.print(f"Marketplace + plugin root: [dim]{market_dir}[/dim]")
|
|
1342
|
+
console.print("Install after --apply with:")
|
|
1343
|
+
console.print(f" [dim]/plugin marketplace add {market_dir}[/dim]")
|
|
1344
|
+
console.print(" [dim]/plugin install devcouncil@devcouncil-local[/dim]")
|
|
1345
|
+
console.print("[yellow]Preview only. Rerun with --apply to write the bundle.[/yellow]")
|
|
1346
|
+
return
|
|
1347
|
+
|
|
1348
|
+
try:
|
|
1349
|
+
written = _install_claude_plugin(root, write_gate=write_gate)
|
|
1350
|
+
except (ValueError, FileNotFoundError, OSError) as exc:
|
|
1351
|
+
console.print(f"[red]Claude plugin build failed: {exc}[/red]")
|
|
1352
|
+
raise typer.Exit(code=1) from exc
|
|
1353
|
+
console.print(f"[green]Built Claude plugin bundle[/green] ({len(written)} file(s)) at {market_dir}")
|
|
1354
|
+
console.print("Install it in Claude Code with:")
|
|
1355
|
+
console.print(f" [dim]/plugin marketplace add {market_dir}[/dim]")
|
|
1356
|
+
console.print(" [dim]/plugin install devcouncil@devcouncil-local[/dim]")
|
|
1357
|
+
|
|
1358
|
+
|
|
475
1359
|
@app.command("cursor")
|
|
476
1360
|
def cursor(
|
|
477
|
-
apply: bool = typer.Option(False, "--apply", help="
|
|
1361
|
+
apply: bool = typer.Option(False, "--apply", help="Write project Cursor MCP config instead of printing it."),
|
|
478
1362
|
project_root: Path | None = typer.Option(None, "--project-root", help="Repository root containing .devcouncil/."),
|
|
479
1363
|
):
|
|
480
1364
|
"""
|
|
481
1365
|
Set up DevCouncil MCP tools for Cursor.
|
|
482
1366
|
"""
|
|
483
1367
|
root = _project_root(project_root)
|
|
484
|
-
|
|
485
|
-
|
|
1368
|
+
if apply:
|
|
1369
|
+
report = apply_integration_target(root, "cursor")
|
|
1370
|
+
if not report.ok:
|
|
1371
|
+
console.print(report.to_json())
|
|
1372
|
+
raise typer.Exit(code=1)
|
|
1373
|
+
console.print("[green]Cursor integration configured.[/green]")
|
|
1374
|
+
return
|
|
1375
|
+
ok = _configure_cursor(root, apply)
|
|
486
1376
|
if not ok and apply:
|
|
487
1377
|
raise typer.Exit(code=1)
|
|
488
1378
|
|
|
489
1379
|
|
|
1380
|
+
@app.command("opencode")
|
|
1381
|
+
def opencode(
|
|
1382
|
+
apply: bool = typer.Option(False, "--apply", help="Write project OpenCode config instead of printing it."),
|
|
1383
|
+
project_root: Path | None = typer.Option(None, "--project-root", help="Repository root containing .devcouncil/."),
|
|
1384
|
+
):
|
|
1385
|
+
"""
|
|
1386
|
+
Set up DevCouncil MCP tools for OpenCode.
|
|
1387
|
+
"""
|
|
1388
|
+
root = _project_root(project_root)
|
|
1389
|
+
if apply:
|
|
1390
|
+
report = apply_integration_target(root, "opencode")
|
|
1391
|
+
if not report.ok:
|
|
1392
|
+
console.print(report.to_json())
|
|
1393
|
+
raise typer.Exit(code=1)
|
|
1394
|
+
console.print("[green]OpenCode integration configured.[/green]")
|
|
1395
|
+
return
|
|
1396
|
+
ok = _configure_opencode(root, apply)
|
|
1397
|
+
if not ok and apply:
|
|
1398
|
+
raise typer.Exit(code=1)
|
|
1399
|
+
|
|
1400
|
+
|
|
1401
|
+
@app.command("agy")
|
|
1402
|
+
@app.command("antigravity")
|
|
1403
|
+
def antigravity(
|
|
1404
|
+
apply: bool = typer.Option(False, "--apply", help="Write project Antigravity MCP config instead of printing it."),
|
|
1405
|
+
project_root: Path | None = typer.Option(None, "--project-root", help="Repository root containing .devcouncil/."),
|
|
1406
|
+
):
|
|
1407
|
+
"""
|
|
1408
|
+
Set up DevCouncil MCP tools for Google Antigravity CLI.
|
|
1409
|
+
"""
|
|
1410
|
+
root = _project_root(project_root)
|
|
1411
|
+
if apply:
|
|
1412
|
+
report = apply_integration_target(root, "antigravity")
|
|
1413
|
+
if not report.ok:
|
|
1414
|
+
console.print(report.to_json())
|
|
1415
|
+
raise typer.Exit(code=1)
|
|
1416
|
+
console.print("[green]Antigravity integration configured.[/green]")
|
|
1417
|
+
_warn_if_verify_only("antigravity")
|
|
1418
|
+
return
|
|
1419
|
+
ok = _configure_antigravity(root, apply)
|
|
1420
|
+
if not ok and apply:
|
|
1421
|
+
raise typer.Exit(code=1)
|
|
1422
|
+
|
|
1423
|
+
|
|
1424
|
+
@app.command("warp")
|
|
1425
|
+
def warp(
|
|
1426
|
+
apply: bool = typer.Option(False, "--apply", help="Write Warp MCP config instead of printing it."),
|
|
1427
|
+
project_root: Path | None = typer.Option(None, "--project-root", help="Repository root containing .devcouncil/."),
|
|
1428
|
+
):
|
|
1429
|
+
"""
|
|
1430
|
+
Set up DevCouncil MCP tools for Warp local agents and the Oz CLI.
|
|
1431
|
+
"""
|
|
1432
|
+
root = _project_root(project_root)
|
|
1433
|
+
if apply:
|
|
1434
|
+
report = apply_integration_target(root, "warp")
|
|
1435
|
+
if not report.ok:
|
|
1436
|
+
console.print(report.to_json())
|
|
1437
|
+
raise typer.Exit(code=1)
|
|
1438
|
+
console.print("[green]Warp integration configured.[/green]")
|
|
1439
|
+
_warn_if_verify_only("warp")
|
|
1440
|
+
return
|
|
1441
|
+
_configure_warp(root, apply)
|
|
1442
|
+
|
|
1443
|
+
|
|
1444
|
+
def _record_aider_config(project_root: Path) -> None:
|
|
1445
|
+
def mutate(config: dict) -> None:
|
|
1446
|
+
config.setdefault("integrations", {}).setdefault("aider", {}).update({"enabled": True})
|
|
1447
|
+
|
|
1448
|
+
_mutate_raw_config(project_root, mutate)
|
|
1449
|
+
|
|
1450
|
+
|
|
1451
|
+
def _configure_aider(project_root: Path, apply: bool) -> bool:
|
|
1452
|
+
command = ["aider", "--yes", "--no-show-model-warnings", "--message", "<task prompt>"]
|
|
1453
|
+
if not apply:
|
|
1454
|
+
console.print("[bold]Aider[/bold]")
|
|
1455
|
+
console.print("Built-in executor: [dim]dev run TASK-001 --executor aider[/dim]")
|
|
1456
|
+
console.print("Launch command: [dim]" + _format_command(command) + "[/dim]")
|
|
1457
|
+
console.print("Aider does not expose a first-party DevCouncil MCP server.")
|
|
1458
|
+
return True
|
|
1459
|
+
|
|
1460
|
+
if not shutil.which("aider"):
|
|
1461
|
+
console.print("[yellow]Aider CLI not found on PATH. Install it before using `dev run --executor aider`.[/yellow]")
|
|
1462
|
+
_record_aider_config(project_root)
|
|
1463
|
+
console.print("[green]Aider executor enabled in .devcouncil/config.yaml.[/green]")
|
|
1464
|
+
return True
|
|
1465
|
+
|
|
1466
|
+
|
|
1467
|
+
@app.command("aider")
|
|
1468
|
+
def aider(
|
|
1469
|
+
apply: bool = typer.Option(False, "--apply", help="Record the built-in Aider executor in DevCouncil config."),
|
|
1470
|
+
project_root: Path | None = typer.Option(None, "--project-root", help="Repository root containing .devcouncil/."),
|
|
1471
|
+
):
|
|
1472
|
+
"""
|
|
1473
|
+
Enable the built-in Aider headless executor (no MCP integration).
|
|
1474
|
+
"""
|
|
1475
|
+
root = _project_root(project_root)
|
|
1476
|
+
if apply:
|
|
1477
|
+
report = apply_integration_target(root, "aider")
|
|
1478
|
+
if not report.ok:
|
|
1479
|
+
console.print(report.to_json())
|
|
1480
|
+
raise typer.Exit(code=1)
|
|
1481
|
+
console.print("[green]Aider integration configured.[/green]")
|
|
1482
|
+
_warn_if_verify_only("aider")
|
|
1483
|
+
return
|
|
1484
|
+
ok = _configure_aider(root, apply)
|
|
1485
|
+
if not ok and apply:
|
|
1486
|
+
raise typer.Exit(code=1)
|
|
1487
|
+
|
|
1488
|
+
|
|
1489
|
+
@app.command("cli-agent")
|
|
1490
|
+
def cli_agent(
|
|
1491
|
+
name: str = typer.Argument(..., help="Executor name to register, for example opencode or aider."),
|
|
1492
|
+
command: str = typer.Option(..., "--command", help="Executable to launch."),
|
|
1493
|
+
arg: list[str] | None = typer.Option(None, "--arg", help="Argument to pass to the CLI. Repeat for multiple args."),
|
|
1494
|
+
input_mode: str = typer.Option("stdin", "--input-mode", help="Prompt input mode: stdin, argument, or prompt-file."),
|
|
1495
|
+
prompt_arg: str | None = typer.Option(None, "--prompt-arg", help="Flag used before the prompt or prompt file, for example --prompt."),
|
|
1496
|
+
timeout_seconds: int | None = typer.Option(None, "--timeout-seconds", help="Agent-specific timeout override."),
|
|
1497
|
+
display_name: str | None = typer.Option(None, "--display-name", help="Human-readable agent name."),
|
|
1498
|
+
kind: str = typer.Option("custom", "--kind", help="Agent kind, for example coding-cli or review-cli."),
|
|
1499
|
+
supports_mcp: bool = typer.Option(False, "--supports-mcp", help="Mark this agent as MCP-capable."),
|
|
1500
|
+
supports_diff_review: bool = typer.Option(False, "--supports-diff-review", help="Mark this agent as able to review diffs."),
|
|
1501
|
+
default_profile: str = typer.Option("default", "--default-profile", help="Default execution profile for this agent."),
|
|
1502
|
+
help_arg: list[str] | None = typer.Option(None, "--help-arg", help="Argument for the agent help command. Repeat for multiple args."),
|
|
1503
|
+
apply: bool = typer.Option(False, "--apply", help="Write .devcouncil/config.yaml instead of previewing."),
|
|
1504
|
+
project_root: Path | None = typer.Option(None, "--project-root", help="Repository root containing .devcouncil/."),
|
|
1505
|
+
):
|
|
1506
|
+
"""
|
|
1507
|
+
Register an arbitrary prompt-taking CLI as a DevCouncil executor.
|
|
1508
|
+
"""
|
|
1509
|
+
if input_mode not in VALID_INPUT_MODES:
|
|
1510
|
+
console.print("[red]--input-mode must be one of: stdin, argument, prompt-file.[/red]")
|
|
1511
|
+
raise typer.Exit(code=2)
|
|
1512
|
+
if not name.strip():
|
|
1513
|
+
console.print("[red]Agent name cannot be empty.[/red]")
|
|
1514
|
+
raise typer.Exit(code=2)
|
|
1515
|
+
if not command.strip():
|
|
1516
|
+
console.print("[red]--command cannot be empty.[/red]")
|
|
1517
|
+
raise typer.Exit(code=2)
|
|
1518
|
+
|
|
1519
|
+
root = _project_root(project_root)
|
|
1520
|
+
if is_reserved_agent_name(name):
|
|
1521
|
+
console.print(f"[red]'{name}' is reserved for a built-in DevCouncil agent.[/red]")
|
|
1522
|
+
raise typer.Exit(code=2)
|
|
1523
|
+
if default_profile not in load_agent_profiles(root):
|
|
1524
|
+
console.print(f"[red]Unknown --default-profile '{default_profile}'.[/red]")
|
|
1525
|
+
raise typer.Exit(code=2)
|
|
1526
|
+
|
|
1527
|
+
normalized = normalize_agent_name(name)
|
|
1528
|
+
entry = agent_config_entry(
|
|
1529
|
+
command=command,
|
|
1530
|
+
args=arg or [],
|
|
1531
|
+
input_mode=input_mode,
|
|
1532
|
+
prompt_arg=prompt_arg,
|
|
1533
|
+
timeout_seconds=timeout_seconds,
|
|
1534
|
+
display_name=display_name,
|
|
1535
|
+
kind=kind,
|
|
1536
|
+
supports_mcp=supports_mcp,
|
|
1537
|
+
supports_diff_review=supports_diff_review,
|
|
1538
|
+
default_profile=default_profile,
|
|
1539
|
+
help_command=[command, *(help_arg or [])] if help_arg else [],
|
|
1540
|
+
)
|
|
1541
|
+
|
|
1542
|
+
if not apply:
|
|
1543
|
+
console.print("[bold]Bring your own CLI executor preview[/bold]")
|
|
1544
|
+
console.print(f"Executor: [cyan]{normalized}[/cyan]")
|
|
1545
|
+
console.print(json.dumps(entry, indent=2), soft_wrap=True)
|
|
1546
|
+
console.print(f"Run with: [dim]dev run TASK-001 --executor {normalized}[/dim]")
|
|
1547
|
+
console.print("[yellow]Preview only. Rerun with --apply to update .devcouncil/config.yaml.[/yellow]")
|
|
1548
|
+
return
|
|
1549
|
+
|
|
1550
|
+
config = _load_raw_config(root)
|
|
1551
|
+
agents = config.setdefault("integrations", {}).setdefault("cli_agents", {}).setdefault("agents", {})
|
|
1552
|
+
agents[normalized] = entry
|
|
1553
|
+
_save_raw_config(root, config)
|
|
1554
|
+
console.print(f"[green]Registered CLI executor '{normalized}' in .devcouncil/config.yaml.[/green]")
|
|
1555
|
+
|
|
1556
|
+
|
|
490
1557
|
@app.command("all")
|
|
491
1558
|
def all_tools(
|
|
492
1559
|
apply: bool = typer.Option(False, "--apply", help="Run setup commands instead of printing them."),
|
|
@@ -494,6 +1561,17 @@ def all_tools(
|
|
|
494
1561
|
gemini_scope: str = typer.Option("project", "--gemini-scope", help="Gemini MCP config scope: project or user."),
|
|
495
1562
|
claude_scope: str = typer.Option("local", "--claude-scope", help="Claude MCP config scope: local, project, or user."),
|
|
496
1563
|
hooks: bool = typer.Option(True, "--hooks/--no-hooks", help="Include native Codex, Gemini, and Claude hook setup."),
|
|
1564
|
+
write_gate: bool = typer.Option(
|
|
1565
|
+
False,
|
|
1566
|
+
"--write-gate/--no-write-gate",
|
|
1567
|
+
"--contain/--no-contain",
|
|
1568
|
+
help="Install Claude's blocking write-gate too (off by default; for autonomous executor runs).",
|
|
1569
|
+
),
|
|
1570
|
+
strict: bool = typer.Option(
|
|
1571
|
+
False,
|
|
1572
|
+
"--strict",
|
|
1573
|
+
help="After --apply, run dev integrate check --strict and fail on missing optional CLIs.",
|
|
1574
|
+
),
|
|
497
1575
|
):
|
|
498
1576
|
"""
|
|
499
1577
|
Set up DevCouncil MCP tools and native hooks for every supported coding CLI found on PATH.
|
|
@@ -506,116 +1584,285 @@ def all_tools(
|
|
|
506
1584
|
raise typer.Exit(code=2)
|
|
507
1585
|
|
|
508
1586
|
root = _project_root(project_root)
|
|
1587
|
+
if apply:
|
|
1588
|
+
report = apply_integration_target(
|
|
1589
|
+
root,
|
|
1590
|
+
"all",
|
|
1591
|
+
include_hooks=hooks,
|
|
1592
|
+
strict=strict,
|
|
1593
|
+
gemini_scope=gemini_scope,
|
|
1594
|
+
claude_scope=claude_scope,
|
|
1595
|
+
claude_write_gate=write_gate,
|
|
1596
|
+
)
|
|
1597
|
+
if not report.ok:
|
|
1598
|
+
console.print(report.to_json())
|
|
1599
|
+
raise typer.Exit(code=1)
|
|
1600
|
+
console.print("[green]Coding CLI integrations configured.[/green]")
|
|
1601
|
+
return
|
|
1602
|
+
|
|
509
1603
|
commands = [
|
|
510
1604
|
("Codex CLI", _codex_command(root)),
|
|
511
1605
|
("Gemini CLI", _gemini_command(root, gemini_scope)),
|
|
512
1606
|
("Claude Code", _claude_command(root, claude_scope)),
|
|
513
|
-
("Cursor", _cursor_command(root)),
|
|
514
1607
|
]
|
|
515
|
-
results = []
|
|
516
1608
|
for tool, command in commands:
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
1609
|
+
_configure(tool, command, apply)
|
|
1610
|
+
_configure_cursor(root, apply)
|
|
1611
|
+
_configure_opencode(root, apply)
|
|
1612
|
+
_configure_antigravity(root, apply)
|
|
1613
|
+
_configure_warp(root, apply)
|
|
1614
|
+
_configure_aider(root, apply)
|
|
521
1615
|
if hooks:
|
|
522
1616
|
_configure_native_hooks(root, "all", apply)
|
|
523
|
-
|
|
524
|
-
|
|
1617
|
+
|
|
1618
|
+
|
|
1619
|
+
@app.command("recommend")
|
|
1620
|
+
def recommend(
|
|
1621
|
+
project_root: Path | None = typer.Option(None, "--project-root", help="Repository root containing .devcouncil/."),
|
|
1622
|
+
):
|
|
1623
|
+
"""Recommend a coding CLI executor for this machine and project."""
|
|
1624
|
+
root = _project_root(project_root)
|
|
1625
|
+
probe_order = resolve_coding_cli_probe_order(root)
|
|
1626
|
+
detected = detect_available_coding_cli(root, probe_order=probe_order)
|
|
1627
|
+
resolved = resolve_automated_executor(root, None)
|
|
1628
|
+
|
|
1629
|
+
table = Table(title="DevCouncil Integration Recommendations")
|
|
1630
|
+
table.add_column("Client", style="cyan")
|
|
1631
|
+
table.add_column("PATH")
|
|
1632
|
+
table.add_column("Tier")
|
|
1633
|
+
table.add_column("MCP")
|
|
1634
|
+
table.add_column("Hooks")
|
|
1635
|
+
|
|
1636
|
+
for client in probe_order:
|
|
1637
|
+
info = CODING_CLI_INTEGRATION_INFO.get(client)
|
|
1638
|
+
on_path = resolve_coding_cli_executable(root, client)
|
|
1639
|
+
table.add_row(
|
|
1640
|
+
client,
|
|
1641
|
+
"[green]yes[/green]" if on_path else "[dim]no[/dim]",
|
|
1642
|
+
integration_tier_label(client),
|
|
1643
|
+
"yes" if info and info.mcp else "no",
|
|
1644
|
+
"yes" if info and info.hooks else "no",
|
|
1645
|
+
)
|
|
1646
|
+
|
|
1647
|
+
console.print(table)
|
|
1648
|
+
if summary := integration_status_summary(root):
|
|
1649
|
+
if summary.get("custom_probe_order"):
|
|
1650
|
+
console.print(
|
|
1651
|
+
f"\n[dim]Probe order:[/dim] {', '.join(summary['probe_order'])} "
|
|
1652
|
+
f"(from execution.coding_cli_probe_order)"
|
|
1653
|
+
)
|
|
1654
|
+
else:
|
|
1655
|
+
console.print(f"\n[dim]Probe order:[/dim] {', '.join(summary['probe_order'])} (default)")
|
|
1656
|
+
if detected:
|
|
1657
|
+
console.print(f"\n[bold]Recommended executor:[/bold] [cyan]{resolved}[/cyan]")
|
|
1658
|
+
console.print(f"Run: [dim]dev run TASK-001 --executor {resolved}[/dim]")
|
|
1659
|
+
console.print(f"Or: [dim]dev go \"Your goal\" --executor {resolved}[/dim]")
|
|
1660
|
+
console.print(f"Setup: [dim]{PREFERRED_COMMAND} {resolved} --apply[/dim]")
|
|
1661
|
+
else:
|
|
1662
|
+
console.print("\n[yellow]No built-in coding CLI was found on PATH.[/yellow]")
|
|
1663
|
+
console.print("Install Codex, Gemini, Claude Code, Cursor Agent, OpenCode, or register a custom CLI:")
|
|
1664
|
+
console.print(f"[dim]{PREFERRED_COMMAND} cli-agent NAME --command TOOL --apply[/dim]")
|
|
1665
|
+
|
|
1666
|
+
|
|
1667
|
+
@app.command("status")
|
|
1668
|
+
def status(
|
|
1669
|
+
project_root: Path | None = typer.Option(None, "--project-root", help="Repository root containing .devcouncil/."),
|
|
1670
|
+
as_json: bool = typer.Option(False, "--json", help="Emit machine-readable JSON."),
|
|
1671
|
+
):
|
|
1672
|
+
"""Show a compact integration summary without running the MCP server probe."""
|
|
1673
|
+
root = _project_root(project_root)
|
|
1674
|
+
summary = integration_status_summary(root)
|
|
1675
|
+
raw_config = _load_raw_config(root) if (root / ".devcouncil").exists() else {}
|
|
1676
|
+
integrations = raw_config.get("integrations", {})
|
|
1677
|
+
|
|
1678
|
+
if as_json:
|
|
1679
|
+
payload = {
|
|
1680
|
+
**summary,
|
|
1681
|
+
"integrations_enabled": {
|
|
1682
|
+
name: bool(integrations.get(name, {}).get("enabled"))
|
|
1683
|
+
for name in ("cursor", "opencode", "antigravity", "warp", "aider")
|
|
1684
|
+
},
|
|
1685
|
+
}
|
|
1686
|
+
typer.echo(json.dumps(payload, indent=2))
|
|
1687
|
+
return
|
|
1688
|
+
|
|
1689
|
+
table = Table(title="DevCouncil Integration Status")
|
|
1690
|
+
table.add_column("Setting", style="cyan")
|
|
1691
|
+
table.add_column("Value")
|
|
1692
|
+
|
|
1693
|
+
table.add_row("Project", "[green]initialized[/green]" if summary["project_initialized"] else "[yellow]not initialized[/yellow]")
|
|
1694
|
+
table.add_row("Default executor", summary["default_executor"])
|
|
1695
|
+
table.add_row("Resolved executor", summary["resolved_executor"])
|
|
1696
|
+
table.add_row("CLIs on PATH", ", ".join(summary["coding_clis_on_path"]) or "[dim]none[/dim]")
|
|
1697
|
+
table.add_row("Probe order", ", ".join(summary["probe_order"]))
|
|
1698
|
+
table.add_row("Stream CLI output", "yes" if summary["stream_cli_output"] else "no")
|
|
1699
|
+
table.add_row("Cursor resume mode", summary["cursor_resume_mode"])
|
|
1700
|
+
|
|
1701
|
+
for name in ("cursor", "opencode", "antigravity", "warp", "aider"):
|
|
1702
|
+
enabled = bool(integrations.get(name, {}).get("enabled"))
|
|
1703
|
+
table.add_row(f"{name} integration", "[green]enabled[/green]" if enabled else "[dim]off[/dim]")
|
|
1704
|
+
|
|
1705
|
+
console.print(table)
|
|
1706
|
+
if summary["resolved_executor"] not in {"", "manual"}:
|
|
1707
|
+
console.print(
|
|
1708
|
+
f"\n[dim]Next:[/dim] dev run TASK-001 --executor {summary['resolved_executor']} "
|
|
1709
|
+
f"| {PREFERRED_COMMAND} check for full readiness"
|
|
1710
|
+
)
|
|
1711
|
+
else:
|
|
1712
|
+
console.print(f"\n[dim]Next:[/dim] {PREFERRED_COMMAND} recommend | {PREFERRED_COMMAND} check")
|
|
1713
|
+
|
|
1714
|
+
|
|
1715
|
+
@app.command("matrix")
|
|
1716
|
+
def matrix(
|
|
1717
|
+
project_root: Path | None = typer.Option(None, "--project-root", help="Repository root containing .devcouncil/."),
|
|
1718
|
+
):
|
|
1719
|
+
"""Print built-in coding CLI integration tiers and capabilities."""
|
|
1720
|
+
root = _project_root(project_root)
|
|
1721
|
+
_ = root
|
|
1722
|
+
table = Table(title="DevCouncil Coding CLI Integration Matrix")
|
|
1723
|
+
table.add_column("Client", style="cyan")
|
|
1724
|
+
table.add_column("Tier")
|
|
1725
|
+
table.add_column("Headless")
|
|
1726
|
+
table.add_column("MCP setup")
|
|
1727
|
+
table.add_column("Native hooks")
|
|
1728
|
+
table.add_column("Enforcement")
|
|
1729
|
+
table.add_column("Notes")
|
|
1730
|
+
|
|
1731
|
+
for client in sorted(BUILTIN_CODING_EXECUTOR_NAMES):
|
|
1732
|
+
info = CODING_CLI_INTEGRATION_INFO.get(client)
|
|
1733
|
+
posture = info.enforcement if info else "verify-only"
|
|
1734
|
+
posture_render = "[green]pre-action[/green]" if posture == "pre-action" else "[yellow]verify-only[/yellow]"
|
|
1735
|
+
table.add_row(
|
|
1736
|
+
client,
|
|
1737
|
+
integration_tier_label(client),
|
|
1738
|
+
"yes" if info and info.tier == 1 else "no",
|
|
1739
|
+
"yes" if info and info.mcp else "no",
|
|
1740
|
+
"yes" if info and info.hooks else "verify only",
|
|
1741
|
+
posture_render,
|
|
1742
|
+
info.notes if info else "",
|
|
1743
|
+
)
|
|
1744
|
+
console.print(table)
|
|
1745
|
+
console.print(
|
|
1746
|
+
"\n[dim]Enforcement:[/dim] [green]pre-action[/green] blocks forbidden writes/commands "
|
|
1747
|
+
"before they happen; [yellow]verify-only[/yellow] catches them only at verify time."
|
|
1748
|
+
)
|
|
1749
|
+
console.print("\nSee [dim]docs/integration-tiers.md[/dim] for workflow guidance.")
|
|
525
1750
|
|
|
526
1751
|
|
|
527
1752
|
@app.command("hooks")
|
|
528
1753
|
def hooks(
|
|
529
1754
|
apply: bool = typer.Option(False, "--apply", help="Write native hook config files instead of previewing paths."),
|
|
530
1755
|
project_root: Path | None = typer.Option(None, "--project-root", help="Repository root containing .devcouncil/."),
|
|
531
|
-
tool: str = typer.Option("all", "--tool", help="Hook target: all, codex, gemini, or
|
|
1756
|
+
tool: str = typer.Option("all", "--tool", help="Hook target: all, codex, gemini, claude, cursor, or opencode."),
|
|
1757
|
+
write_gate: bool = typer.Option(
|
|
1758
|
+
False,
|
|
1759
|
+
"--write-gate/--no-write-gate",
|
|
1760
|
+
"--contain/--no-contain",
|
|
1761
|
+
help="Install Claude's blocking PreToolUse/PostToolUse write-gate too (off by default; "
|
|
1762
|
+
"fail-closes an interactive session without a task lease).",
|
|
1763
|
+
),
|
|
532
1764
|
):
|
|
533
1765
|
"""
|
|
534
|
-
Install DevCouncil
|
|
1766
|
+
Install DevCouncil hook configuration for Codex, Gemini, Claude, Cursor, and OpenCode.
|
|
1767
|
+
|
|
1768
|
+
Claude installs only assistive hooks by default; add --write-gate for pre-action
|
|
1769
|
+
containment (intended for autonomous executor runs).
|
|
535
1770
|
"""
|
|
536
1771
|
root = _project_root(project_root)
|
|
537
|
-
|
|
1772
|
+
if apply and tool == "all":
|
|
1773
|
+
report = apply_integration_target(root, "hooks", claude_write_gate=write_gate)
|
|
1774
|
+
if not report.ok:
|
|
1775
|
+
console.print(report.to_json())
|
|
1776
|
+
raise typer.Exit(code=1)
|
|
1777
|
+
console.print("[green]Native hooks configured.[/green]")
|
|
1778
|
+
return
|
|
1779
|
+
_configure_native_hooks(root, tool, apply, claude_write_gate=write_gate)
|
|
1780
|
+
|
|
1781
|
+
|
|
1782
|
+
@app.command("uninstall")
|
|
1783
|
+
def uninstall(
|
|
1784
|
+
project_root: Path | None = typer.Option(None, "--project-root", help="Repository root containing .devcouncil/."),
|
|
1785
|
+
target: str = typer.Option("claude", "--target", help="What to uninstall. Currently: claude."),
|
|
1786
|
+
):
|
|
1787
|
+
"""
|
|
1788
|
+
Remove a DevCouncil integration. Reverses `dev integrate claude` — hooks, statusline,
|
|
1789
|
+
MCP enablement, permission rules, and the generated commands/subagents/output style.
|
|
1790
|
+
"""
|
|
1791
|
+
root = _project_root(project_root)
|
|
1792
|
+
if target != "claude":
|
|
1793
|
+
console.print("[red]--target must be 'claude'.[/red]")
|
|
1794
|
+
raise typer.Exit(code=2)
|
|
1795
|
+
removed = _uninstall_claude(root)
|
|
1796
|
+
if removed:
|
|
1797
|
+
console.print(f"[green]Removed DevCouncil Claude integration[/green] ({len(removed)} change(s)):")
|
|
1798
|
+
for item in removed:
|
|
1799
|
+
console.print(f" {item}")
|
|
1800
|
+
else:
|
|
1801
|
+
console.print("[dim]Nothing to remove — DevCouncil Claude integration not found.[/dim]")
|
|
538
1802
|
|
|
539
1803
|
|
|
540
1804
|
@app.command("check")
|
|
541
1805
|
def check(
|
|
542
1806
|
project_root: Path | None = typer.Option(None, "--project-root", help="Repository root containing .devcouncil/."),
|
|
1807
|
+
strict: bool = typer.Option(
|
|
1808
|
+
False,
|
|
1809
|
+
"--strict",
|
|
1810
|
+
help="Treat missing optional coding CLIs as failures instead of warnings.",
|
|
1811
|
+
),
|
|
1812
|
+
as_json: bool = typer.Option(False, "--json", help="Emit machine-readable JSON for CI."),
|
|
1813
|
+
report_file: Path | None = typer.Option(
|
|
1814
|
+
None,
|
|
1815
|
+
"--report-file",
|
|
1816
|
+
"--output",
|
|
1817
|
+
"-o",
|
|
1818
|
+
help="Write the JSON integration report to this file (implies structured output).",
|
|
1819
|
+
),
|
|
543
1820
|
):
|
|
544
1821
|
"""
|
|
545
1822
|
Check whether DevCouncil is ready to integrate with coding CLIs.
|
|
546
1823
|
"""
|
|
547
1824
|
root = _project_root(project_root)
|
|
1825
|
+
report = build_integration_check_report(root, strict=strict)
|
|
548
1826
|
table = Table(title="DevCouncil Integration Check")
|
|
549
1827
|
table.add_column("Check", style="cyan")
|
|
550
1828
|
table.add_column("Status", style="magenta")
|
|
551
1829
|
table.add_column("Details")
|
|
552
1830
|
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
from mcp.client.stdio import stdio_client
|
|
584
|
-
|
|
585
|
-
async def _list_tools() -> list[str]:
|
|
586
|
-
import os
|
|
587
|
-
|
|
588
|
-
env = os.environ.copy()
|
|
589
|
-
env["DEVCOUNCIL_PROJECT_ROOT"] = str(root)
|
|
590
|
-
params = StdioServerParameters(
|
|
591
|
-
command=sys.executable,
|
|
592
|
-
args=["-m", "devcouncil", "mcp-server"],
|
|
593
|
-
cwd=str(root),
|
|
594
|
-
env=env,
|
|
1831
|
+
for row in report.checks:
|
|
1832
|
+
if row.status == "ok":
|
|
1833
|
+
rendered = "[green]OK[/green]"
|
|
1834
|
+
elif row.status == "skip":
|
|
1835
|
+
rendered = "[dim]SKIP[/dim]"
|
|
1836
|
+
elif row.status == "missing":
|
|
1837
|
+
rendered = "[yellow]Missing[/yellow]"
|
|
1838
|
+
else:
|
|
1839
|
+
rendered = "[red]FAIL[/red]"
|
|
1840
|
+
table.add_row(row.name, rendered, row.details)
|
|
1841
|
+
|
|
1842
|
+
write_json = as_json or report_file is not None
|
|
1843
|
+
if write_json:
|
|
1844
|
+
json_text = report.to_json()
|
|
1845
|
+
if report_file is not None:
|
|
1846
|
+
report_path = Path(report_file).expanduser().resolve()
|
|
1847
|
+
report_path.parent.mkdir(parents=True, exist_ok=True)
|
|
1848
|
+
report_path.write_text(json_text + "\n", encoding="utf-8")
|
|
1849
|
+
if not as_json:
|
|
1850
|
+
console.print(f"[dim]Wrote integration report to[/dim] {report_path}")
|
|
1851
|
+
if as_json:
|
|
1852
|
+
typer.echo(json_text)
|
|
1853
|
+
if not write_json or not as_json:
|
|
1854
|
+
console.print(table)
|
|
1855
|
+
|
|
1856
|
+
if report.failures:
|
|
1857
|
+
if not as_json:
|
|
1858
|
+
console.print(
|
|
1859
|
+
f"\n[yellow]Fix failed checks, then run:[/yellow] {PREFERRED_COMMAND} all --apply "
|
|
1860
|
+
f"(or {LEGACY_COMMAND} --apply)."
|
|
595
1861
|
)
|
|
596
|
-
async with stdio_client(params) as (read, write):
|
|
597
|
-
async with ClientSession(read, write) as session:
|
|
598
|
-
await session.initialize()
|
|
599
|
-
tools = await session.list_tools()
|
|
600
|
-
return [tool.name for tool in tools.tools]
|
|
601
|
-
|
|
602
|
-
import asyncio
|
|
603
|
-
|
|
604
|
-
tools = asyncio.run(_list_tools())
|
|
605
|
-
expected = {"devcouncil_status", "devcouncil_report", "devcouncil_get_task"}
|
|
606
|
-
add(expected.issubset(set(tools)), "MCP server", ", ".join(tools))
|
|
607
|
-
except Exception as exc:
|
|
608
|
-
add(False, "MCP server", str(exc))
|
|
609
|
-
|
|
610
|
-
console.print(table)
|
|
611
|
-
if failures:
|
|
612
|
-
console.print(
|
|
613
|
-
f"\n[yellow]Fix failed checks, then run:[/yellow] {PREFERRED_COMMAND} all --apply "
|
|
614
|
-
f"(or {LEGACY_COMMAND} --apply)."
|
|
615
|
-
)
|
|
616
1862
|
raise typer.Exit(code=1)
|
|
617
1863
|
|
|
618
|
-
|
|
1864
|
+
if not as_json:
|
|
1865
|
+
console.print(f"\n[green]Ready.[/green] Run: {PREFERRED_COMMAND} all --apply (or {LEGACY_COMMAND} --apply).")
|
|
619
1866
|
|
|
620
1867
|
|
|
621
1868
|
@setup_app.command("agent-flow")
|