For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.
Goal: Make --tool a list everywhere it's accepted so a project can configure (and a user can pilot) two coding tools — e.g., Codex + Claude Code — in a single setup run. Deprecate the legacy --tool both literal (which only ever meant opencode + openclaude) with a clear two-release path.
Architecture: One canonical normalizer (normalize_tools) in intake.py handles every form of --tool input (single str, list, comma-separated, both legacy, conflict detection). IntakeAnswers.tools: list[str] replaces IntakeAnswers.tool end-to-end across intake.py, cli.py, writers.py, adapters.py, updater.py, and pilot.py. JSON outputs (project.json, routing.json, pilot JSON) carry tools only — no singular tool back-compat. A small _normalize_persisted_intake helper back-fills legacy reads for one release window.
Tech Stack: Python 3.11+, stdlib only. Tests via pytest. Lint via ruff. Run gates uv run ruff check src/ tests/ and uv run pytest tests/ -q clean after every commit.
The helper accepts every form --tool can take across the codebase (string, list, comma-string, list with comma-strings inside, None) and returns a canonical deduped list[str]. It also: expands the deprecated both literal, prints a one-line stderr deprecation warning the first time it fires per process, and raises CliError for manual + real-tool mixes.
Step 1: Write the failing tests
Create tests/test_normalize_tools.py:
"""Coverage for the canonical --tool normalizer (spec §4.3)."""from __future__ import annotationsimport ioimport pytestfrom coding_scaffold.errors import CliErrorfrom coding_scaffold.intake import ( DEFAULT_TOOLS, normalize_tools, reset_deprecation_state,)@pytest.fixture(autouse=True)def _reset_deprecation(): # The "both" warning only fires once per process; reset between tests. reset_deprecation_state() yield reset_deprecation_state()def test_none_returns_default_tools() -> None: assert normalize_tools(None) == list(DEFAULT_TOOLS)def test_empty_list_returns_default_tools() -> None: assert normalize_tools([]) == list(DEFAULT_TOOLS)def test_single_string_returns_singleton_list() -> None: assert normalize_tools("codex") == ["codex"]def test_list_of_strings_passes_through() -> None: assert normalize_tools(["codex", "claude-code"]) == ["codex", "claude-code"]def test_comma_separated_string_is_split() -> None: assert normalize_tools("codex,claude-code") == ["codex", "claude-code"]def test_mixed_repeats_and_commas_are_flattened() -> None: assert normalize_tools(["codex,opencode", "claude-code"]) == [ "codex", "opencode", "claude-code", ]def test_duplicates_are_removed_preserving_order() -> None: assert normalize_tools(["codex", "codex", "claude-code", "codex"]) == [ "codex", "claude-code", ]def test_both_expands_to_opencode_openclaude_and_warns(capsys: pytest.CaptureFixture[str]) -> None: result = normalize_tools(["both"]) assert result == ["opencode", "openclaude"] err = capsys.readouterr().err assert "deprecated" in err.lower() assert "0.7.0" in err assert "opencode,openclaude" in errdef test_both_deprecation_warning_fires_once_per_process( capsys: pytest.CaptureFixture[str],) -> None: normalize_tools(["both"]) normalize_tools(["both"]) err = capsys.readouterr().err # Single "deprecated" line even across two calls. assert err.count("deprecated") == 1def test_manual_with_real_tool_raises_clierror() -> None: with pytest.raises(CliError) as excinfo: normalize_tools(["manual", "codex"]) assert "manual" in excinfo.value.cause.lower() assert "next" in excinfo.value.next_step.lower() or "pick" in excinfo.value.next_step.lower()def test_manual_alone_is_accepted() -> None: assert normalize_tools("manual") == ["manual"]def test_whitespace_around_comma_is_trimmed() -> None: assert normalize_tools(" codex , claude-code ") == ["codex", "claude-code"]
Step 2: Run the tests to verify they fail
uv run pytest tests/test_normalize_tools.py -v
Expected: ImportError for normalize_tools / DEFAULT_TOOLS / reset_deprecation_state from coding_scaffold.intake.
Step 3: Implement the helper
Add to src/coding_scaffold/intake.py (at top, just after the existing imports):
import sysfrom .errors import CliErrorDEFAULT_TOOLS: tuple[str, ...] = ("opencode",)# Legacy `--tool both` literal expansion. Removed in 0.7.0 alongside the value# itself; see docs/docs/wiki/Upgrading.md._BOTH_EXPANSION: tuple[str, ...] = ("opencode", "openclaude")# Single-fire deprecation warning state. Reset between tests via# `reset_deprecation_state()`._BOTH_WARNING_FIRED: bool = Falsedef reset_deprecation_state() -> None: """Reset the once-per-process deprecation warning latch. Test-only.""" global _BOTH_WARNING_FIRED _BOTH_WARNING_FIRED = Falsedef normalize_tools(value: str | list[str] | None) -> list[str]: """Return a canonical deduped tool list from any accepted input shape. Accepts: None, "", "codex", "codex,claude-code", ["codex"], ["codex", "claude-code"], ["codex,opencode", "claude-code"], ["both"]. Expands the deprecated `both` literal to `opencode,openclaude` with a one-line stderr warning that fires at most once per process. Raises `CliError` when `manual` appears alongside any real tool — `manual` means "no adapter," which is incompatible with also picking a real one. """ if value is None or value == "": return list(DEFAULT_TOOLS) if isinstance(value, str): raw_parts = [value] else: raw_parts = list(value) if not raw_parts: return list(DEFAULT_TOOLS) # Flatten commas + trim whitespace. flat: list[str] = [] for part in raw_parts: for chunk in str(part).split(","): chunk = chunk.strip() if chunk: flat.append(chunk) if not flat: return list(DEFAULT_TOOLS) # Expand `both` with a one-fire deprecation warning. global _BOTH_WARNING_FIRED expanded: list[str] = [] for chunk in flat: if chunk == "both": if not _BOTH_WARNING_FIRED: print( "warning: '--tool both' is deprecated; " "using '--tool opencode,openclaude' instead.\n" " Will be removed in 0.7.0. " "See https://jrs1986.github.io/CodingScaffold/wiki/Upgrading.", file=sys.stderr, ) _BOTH_WARNING_FIRED = True expanded.extend(_BOTH_EXPANSION) continue expanded.append(chunk) # Dedupe, preserve first-seen order. seen: set[str] = set() canonical: list[str] = [] for chunk in expanded: if chunk in seen: continue seen.add(chunk) canonical.append(chunk) # `manual` is exclusive — it means "no adapter." if "manual" in canonical and len(canonical) > 1: others = [t for t in canonical if t != "manual"] raise CliError( cause=f"`--tool manual` excludes other tools; got manual + {', '.join(others)}", next_step="pick one of: `--tool manual` OR `--tool <real-tool>...`", link="https://jrs1986.github.io/CodingScaffold/wiki/Glossary", ) return canonical
Step 4: Run the tests to verify they pass
uv run pytest tests/test_normalize_tools.py -v
Expected: all 12 tests PASS.
Step 5: Run the full suite + ruff
uv run pytest tests/ -q | tail -3uv run ruff check src/ tests/
The Python attribute IntakeAnswers.tool is removed; IntakeAnswers.tools: list[str] is the only canonical field. collect_intake already calls into _value; that becomes normalize_tools(_value(...)). The agent property remains and returns the first tool (it's used by adapter selection elsewhere; keeping the property avoids cascading edits in this task — the next task migrates callers).
The persisted-form back-fill lets coding-scaffold setup update read an older .coding-scaffold/project.json that still has tool: "opencode" (no tools field) and silently translate it into tools: ["opencode"] in memory. Removed in 0.7.0.
Step 1: Write the failing tests
Add to tests/test_intake.py (at the bottom):
import jsonfrom pathlib import Pathfrom coding_scaffold.intake import ( IntakeAnswers, _normalize_persisted_intake,)def test_intake_answers_carries_tools_list() -> None: answers = IntakeAnswers(tools=["codex", "claude-code"]) assert answers.tools == ["codex", "claude-code"] # Agent property still returns the first tool. assert answers.agent == "codex"def test_intake_answers_to_dict_emits_tools_only() -> None: answers = IntakeAnswers(tools=["codex"]) payload = answers.to_dict() assert payload["tools"] == ["codex"] assert "tool" not in payload, "singular `tool` must be gone from persisted form"def test_normalize_persisted_intake_back_fills_legacy_tool_key() -> None: legacy = {"language": "python", "tool": "opencode"} normalized = _normalize_persisted_intake(legacy) assert normalized["tools"] == ["opencode"] assert "tool" not in normalized, "back-fill must strip the legacy key after migrating"def test_normalize_persisted_intake_passes_through_modern_payload() -> None: modern = {"language": "python", "tools": ["codex", "claude-code"]} assert _normalize_persisted_intake(modern) == moderndef test_normalize_persisted_intake_handles_missing_tool(tmp_path: Path) -> None: # Some very old project.json files may have neither `tool` nor `tools`. no_tool = {"language": "python"} normalized = _normalize_persisted_intake(no_tool) assert normalized["tools"] == ["opencode"]def test_intake_answers_default_tools_is_opencode() -> None: answers = IntakeAnswers() assert answers.tools == ["opencode"]
Step 2: Run the tests to verify they fail
uv run pytest tests/test_intake.py::test_intake_answers_carries_tools_list -v
Step 3: Update IntakeAnswers + add the back-fill helper
In src/coding_scaffold/intake.py, replace the dataclass body and add the helper. The full updated dataclass + helper:
@dataclass(frozen=True)class IntakeAnswers: language: str | None = None project_target: str | None = None existing_codebase: bool | None = None privacy: str | None = None tools: list[str] = field(default_factory=lambda: list(DEFAULT_TOOLS)) preferred_local_model: str | None = None mode: str | None = None def to_dict(self) -> dict[str, object]: payload = asdict(self) return payload @property def agent(self) -> str | None: """First tool, or None if the list is empty. Adapter selection elsewhere reads `.agent`; preserving the property keeps those call-sites stable while the migration to `.tools` lands across the rest of the codebase. """ return self.tools[0] if self.tools else Nonedef _normalize_persisted_intake(payload: dict[str, object]) -> dict[str, object]: """Migrate a persisted intake payload to the canonical `tools` shape. Legacy `.coding-scaffold/project.json` files written before 0.6.0 carry `tool: "opencode"` (singular). New files carry `tools: ["opencode", ...]`. This helper accepts either and returns a payload with only `tools` populated. Removed in 0.7.0 once the migration window closes; see Upgrading.md. """ result = dict(payload) legacy = result.pop("tool", None) if "tools" in result: return result if legacy: result["tools"] = [str(legacy)] else: result["tools"] = list(DEFAULT_TOOLS) return result
You'll also need to add field to the existing from dataclasses import line:
from dataclasses import asdict, dataclass, field
Step 4: Update collect_intake to populate tools
In src/coding_scaffold/intake.py, replace the existing collect_intake body. The tool= line becomes:
def collect_intake(target: Path, provided: IntakeAnswers, interactive: bool) -> IntakeAnswers: detected_language = _detect_language(target) if provided.language is None else None # `provided.tools` is the upstream-supplied list (from CLI or programmatic call). # If absent (default factory empty? — no, the dataclass default is ["opencode"]), # the interactive prompt can edit it. raw_tool_answer = _value( ",".join(provided.tools) if provided.tools else None, "Coding tools to set up (comma-separated, e.g. `codex,claude-code`)", "opencode", interactive, ) return IntakeAnswers( language=_value( provided.language, "Primary language", detected_language or "python", interactive, ), project_target=_value(provided.project_target, "Project target", "CLI/tooling", interactive), existing_codebase=( provided.existing_codebase if provided.existing_codebase is not None else _bool_value("Existing codebase", _has_code(target), interactive) ), privacy=_value(provided.privacy, "Privacy mode", "local-first", interactive), tools=normalize_tools(raw_tool_answer), preferred_local_model=_value( provided.preferred_local_model, "Preferred local model", "auto", interactive, ), mode=_value(provided.mode, "Guidance mode", "standard", interactive), )
Step 5: Run the new tests to verify they pass
uv run pytest tests/test_intake.py -v
Expected: every test in the file PASSes. (The legacy tests in this file used IntakeAnswers(tool=...); if any do, update them to tools=[...] — the assertion error message will name them.)
Step 6: Run the full suite + ruff
uv run pytest tests/ -q | tail -10uv run ruff check src/ tests/
Expected: failures only from intake.tool references in writers.py / updater.py (we migrate those in Task 3) plus any test that constructed IntakeAnswers(tool=...). Do not "fix" the writers/updater here — that's Task 3. Confirm the failure pattern matches before committing.
If only intake/writers/updater-related tests fail, proceed. Otherwise revisit Step 4.
Step 7: Commit (intentionally red — Task 3 turns it green)
#Task 3 — Migrate in-src callers to intake.tools[0]
Closes the data-model migration named in spec §5.1.
Files:
Modify: src/coding_scaffold/writers.py:282 and :295
Modify: src/coding_scaffold/updater.py:54-55
Modify: tests/test_writers.py, tests/test_updater.py if they reference .tool
Every reference to intake.tool becomes intake.tools[0]. The agent property keeps working unchanged (still returns first tool).
Step 1: Update writers.py
In src/coding_scaffold/writers.py, the two intake.tool references inside _getting_started_md:
def _getting_started_md(intake: IntakeAnswers, routing: RoutingPlan) -> str: selected_tool = intake.tools[0] if intake.tools else "opencode" setup_hint = ( "Validate or install the selected coding environment with " f"`coding-scaffold setup tool --tool {selected_tool}`." if selected_tool != "manual" else "Use your manually selected coding environment and keep its config next to this scaffold." ) return render_template( "writers/getting-started.md", setup_hint=setup_hint, language=intake.language, project_target=intake.project_target, privacy=intake.privacy, tool=selected_tool, mode=intake.mode, weak_model=routing.weak_model, strong_model=routing.strong_model, )
Step 2: Update updater.py
In src/coding_scaffold/updater.py, lines 53-56:
manifest = write_scaffold(temp_root, intake, hardware, providers, routing) primary_tool = intake.tools[0] if intake.tools else "manual" adapter = ( write_tool_adapter(temp_root, intake.tools) if primary_tool and primary_tool != "manual" else None )
Note: passes intake.tools (list) to write_tool_adapter. The adapter writer learns to accept a list in Task 5; until then this is a deliberately-red intermediate state.
Step 3: Update _load_project_intake to use the back-fill helper
In src/coding_scaffold/cli.py (around line 2167), replace the body so it routes through _normalize_persisted_intake and constructs IntakeAnswers with tools= instead of tool=:
def _load_project_intake(target: Path) -> IntakeAnswers: from .intake import _normalize_persisted_intake path = target / ".coding-scaffold" / "project.json" try: payload = json.loads(path.read_text(encoding="utf-8")) except (OSError, json.JSONDecodeError): return collect_intake(target, IntakeAnswers(), interactive=False) if not isinstance(payload, dict): return collect_intake(target, IntakeAnswers(), interactive=False) # Back-fill: legacy `tool` becomes modern `tools`; modern files pass through. payload = _normalize_persisted_intake(payload) # Also accept the historical `agent` alias for `tool`. tools_value = payload.get("tools") or ( [_string_or_none(payload.get("agent"))] if payload.get("agent") else None ) return collect_intake( target, IntakeAnswers( language=_string_or_none(payload.get("language")), project_target=_string_or_none(payload.get("project_target")), existing_codebase=_bool_or_none(payload.get("existing_codebase")), privacy=_string_or_none(payload.get("privacy")), tools=[t for t in (tools_value or []) if t] or list(DEFAULT_TOOLS), preferred_local_model=_string_or_none(payload.get("preferred_local_model")), mode=_string_or_none(payload.get("mode")), ), interactive=False, )
Also add DEFAULT_TOOLS to the existing from .intake import ... line at the top of cli.py.
For each hit, change tool="X" to tools=["X"]. Expected files: at least tests/test_writers.py, tests/test_intake.py (test fixtures), possibly tests/conftest.py factories.
Step 5: Run the suite
uv run pytest tests/ -q | tail -10
Expected: still some failures in test_adapters.py (Task 5 migrates the adapter signature) and possibly test_cli.py (Task 6 migrates the parser). Confirm those are the only remaining failures, then proceed.
write_tool_adapter already has a tool == "both" branch that loops over ["opencode", "openclaude"]. Generalize so it accepts either a string (back-compat for ad-hoc callers) or a list. The legacy both branch is retained until Task 9 (deprecation), at which point normalize_tools handles it.
Step 1: Write the failing test
Add to tests/test_adapters.py:
def test_write_tool_adapter_accepts_list_of_tools(tmp_path: Path) -> None: from coding_scaffold.adapters import write_tool_adapter from coding_scaffold.writers import write_scaffold from coding_scaffold.intake import IntakeAnswers # ... bootstrap a scaffold dir with routing.json so adapters can read it # Use the existing fixture/factory if present; otherwise inline: intake = IntakeAnswers(tools=["codex", "claude-code"], language="python") # write_scaffold needs hardware, providers, routing — reuse a factory if # tests/conftest.py defines one. Otherwise: from tests.conftest import sample_hardware, sample_routing # adapt to actual fixture names write_scaffold(tmp_path, intake, sample_hardware(), [], sample_routing(intake)) result = write_tool_adapter(tmp_path, ["codex", "claude-code"]) written_paths = {p.name for p in result.files} # Codex writes AGENTS.md at root + codex template files; Claude writes CLAUDE.md + .claude/ assert "AGENTS.md" in written_paths assert "CLAUDE.md" in written_pathsdef test_write_tool_adapter_string_argument_still_works(tmp_path: Path) -> None: """Backward-compat: existing ad-hoc callers still pass a string.""" from coding_scaffold.adapters import write_tool_adapter # ... bootstrap as above ... result = write_tool_adapter(tmp_path, "codex") assert any(p.name == "AGENTS.md" for p in result.files)
(Adapt fixture imports to whatever tests/conftest.py actually exposes.)
Step 2: Run the tests to verify they fail
uv run pytest tests/test_adapters.py::test_write_tool_adapter_accepts_list_of_tools -v
Expected: TypeError or similar — the function doesn't accept a list yet.
Step 3: Generalize write_tool_adapter
In src/coding_scaffold/adapters.py replace the top of the function:
def write_tool_adapter(target: Path, tool: str | list[str]) -> AdapterResult: from .intake import normalize_tools root = target.expanduser().resolve() files: list[Path] = [] skipped: list[Path] = [] routing = load_routing_payload(root) tools = normalize_tools(tool) # accepts str | list, deduped, with `both` expansion for selected in tools: if selected == "opencode": result = _write_opencode(root, routing) elif selected == "claude-code": result = _write_claude_code(root, routing) elif selected == "codex": result = _write_codex(root, routing) elif selected == "openclaude": result = _write_openclaude(root, routing) elif selected == "hermes": ... # keep existing branches
Delete the original tools = ["opencode", "openclaude"] if tool == "both" else [tool] line — normalize_tools covers it.
Step 4: Run the tests to verify they pass
uv run pytest tests/test_adapters.py -v
Expected: all adapter tests pass (the existing --tool both test still passes because normalize_tools expands it).
Step 5: Run full suite + ruff
uv run pytest tests/ -q | tail -5uv run ruff check src/ tests/
Switch --tool to action="append" on every relevant surface: setup run, init (hidden), wizard (hidden), setup tool, setup-tool (hidden), tools adapt. Pilot is NOT touched in this task — its multi-tool output is more involved and lands in Task 7.
After argparse, every command-handler that consumes args.tool (now args.tool_raw after rename) normalizes via normalize_tools and writes back args.tools. To keep the diff small, do this in main() immediately after parse_args.
Step 1: Write the failing tests
Add to tests/test_cli.py:
def test_cli_setup_run_accepts_repeatable_tool_flag() -> None: from coding_scaffold.cli import build_parser args = build_parser().parse_args( ["setup", "run", "--tool", "codex", "--tool", "claude-code"] ) # action="append" puts each value into a list; normalization happens later. assert args.tool_raw == ["codex", "claude-code"]def test_cli_setup_run_accepts_comma_separated_tool() -> None: from coding_scaffold.cli import build_parser args = build_parser().parse_args( ["setup", "run", "--tool", "codex,claude-code"] ) assert args.tool_raw == ["codex,claude-code"]def test_cli_main_normalizes_tools_into_args_tools( tmp_path, monkeypatch, capsys) -> None: """`main()` runs normalize_tools on args.tool_raw and stashes args.tools.""" from coding_scaffold.cli import build_parser, main # Use a flag-only command that doesn't actually do filesystem work, e.g., # `setup tool` with a list — or assert via a custom dispatch fixture if # appropriate. Simplest path: invoke through main with `--help`-like # dry-run support, or test the normalization wrapper directly: from coding_scaffold.cli import _normalize_args_tools_in_place import argparse ns = argparse.Namespace(tool_raw=["codex,claude-code"]) _normalize_args_tools_in_place(ns) assert ns.tools == ["codex", "claude-code"]def test_cli_setup_tool_install_accepts_list_of_tools() -> None: from coding_scaffold.cli import build_parser args = build_parser().parse_args( ["setup", "tool", "--tool", "codex", "--tool", "claude-code", "--install"] ) assert args.tool_raw == ["codex", "claude-code"]def test_cli_tools_adapt_accepts_repeatable_tool_flag() -> None: from coding_scaffold.cli import build_parser args = build_parser().parse_args( ["tools", "adapt", "--tool", "codex", "--tool", "claude-code"] ) assert args.tool_raw == ["codex", "claude-code"]def test_cli_main_rejects_manual_with_real_tool(capsys) -> None: from coding_scaffold.cli import main rc = main(["setup", "run", "--tool", "manual", "--tool", "codex", "--target", "."]) err = capsys.readouterr().err assert rc == 1 assert "manual" in err.lower() assert "next:" in err
Step 2: Run them to verify they fail
uv run pytest tests/test_cli.py -v -k "tool_raw or tools"
Expected: AttributeError for tool_raw / _normalize_args_tools_in_place.
Step 3: Update the six subparsers
For each subparser that currently has --tool (the list from spec §4.2 — find them with grep -n '"--tool"' src/coding_scaffold/cli.py), change:
parser.add_argument( "--tool", choices=CODING_TOOLS, action="append", dest="tool_raw", default=None, help="Coding tool to set up. Repeat or comma-separate for multi-tool projects.",)
Also update the existing --agent and --coding-tool hidden aliases to point at dest="tool_raw" (they currently point at dest="tool").
Important: the existing tool=intake.tool plumbing inside setup-orchestration code paths must switch to reading args.tools (the normalized list). Search:
grep -n "args.tool\b" src/coding_scaffold/cli.py
For each hit, change to args.tools (now a list) and pass through accordingly.
Step 4: Add the post-parse normalizer
Add to src/coding_scaffold/cli.py (near _hide_suppressed_subcommands):
def _normalize_args_tools_in_place(args: argparse.Namespace) -> None: """If args carries a `tool_raw` from `action="append"`, normalize it into a canonical `tools` list. Surfaces that don't use --tool leave args untouched. """ raw = getattr(args, "tool_raw", None) if raw is None and not hasattr(args, "tools"): # Nothing to do for surfaces that don't take --tool. return try: from .intake import normalize_tools args.tools = normalize_tools(raw) except CliError as exc: from .errors import fail_with fail_with(cause=exc.cause, next_step=exc.next_step, link=exc.link)
(Add from .errors import CliError near the top of cli.py if it isn't already imported.)
In main(), call this immediately after args = parser.parse_args(argv):
The pilot subparser also switches to action="append" (so it now accepts --tool codex,claude-code). run_pilot accepts tools: list[str]. The tool: str parameter stays as a deprecated kwarg that calls normalize_tools so any in-process Python callers (and a few tests) keep working without edit.
Output shape per spec §7.1 (single-tool unchanged; multi-tool adds the Tools: header and per-tool agent steps).
JSON shape per spec §7.2 (only tools, no singular tool).
Step 1: Write the failing tests
Add to tests/test_pilot.py:
def test_pilot_accepts_multi_tool_list(tmp_path: Path) -> None: from coding_scaffold.pilot import run_pilot report = run_pilot(tmp_path, tools=["codex", "claude-code"]) assert report.tools == ["codex", "claude-code"] # Setup step is shared assert "--tool codex,claude-code" in report.steps[0] # One agent step per tool at the tail agent_steps = [s for s in report.steps if "/first-session" in s] assert len(agent_steps) == 2def test_pilot_environment_ok_requires_all_tools_installed( tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: """AND across selected tools — if any is missing, environment_ok is False.""" import coding_scaffold.pilot as pilot_module monkeypatch.setattr(pilot_module.shutil, "which", lambda name: f"/usr/bin/{name}" if name in {"git", "codex", "ollama"} else None) from coding_scaffold.pilot import run_pilot report = run_pilot(tmp_path, tools=["codex", "claude-code"]) assert report.environment_ok is False # Per-tool entries record individual status per_tool = report.environment["tools"] by_name = {entry["name"]: entry["installed"] for entry in per_tool} assert by_name["codex"] is True assert by_name["claude-code"] is Falsedef test_pilot_json_emits_tools_list_only( tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None: from coding_scaffold.cli import main rc = main(["pilot", "--target", str(tmp_path), "--tool", "codex,claude-code", "--json"]) assert rc == 0 payload = json.loads(capsys.readouterr().out) assert payload["tools"] == ["codex", "claude-code"] assert "tool" not in payload, "pilot JSON must not carry singular tool" assert "tool" not in payload["environment"], "environment must not carry singular tool" assert len(payload["environment"]["tools"]) == 2def test_pilot_single_tool_output_is_unchanged(tmp_path: Path) -> None: """Golden: single-tool pilot keeps today's text format bit-for-bit.""" from coding_scaffold.pilot import format_pilot_text, run_pilot text = format_pilot_text(run_pilot(tmp_path, tool="opencode")) # Today's format has no "Tools:" header for single-tool. assert "Tools:" not in text or "Tools: opencode" in text # Either omit or single line OK assert "Run these next" in text # Three numbered steps as before. assert " 1. " in text and " 2. " in text and " 3. " in textdef test_pilot_text_multi_tool_has_tools_header_and_shared_setup(tmp_path: Path) -> None: from coding_scaffold.pilot import format_pilot_text, run_pilot text = format_pilot_text(run_pilot(tmp_path, tools=["codex", "claude-code"])) assert "Tools: codex, claude-code" in text # Shared setup step lives in "Run these once" assert "Run these once" in text # Per-tool agent steps live in "Then start a session" assert "Then start a session" in text assert "codex" in text and "claude" in text
Step 2: Run them to verify they fail
uv run pytest tests/test_pilot.py -v -k "multi_tool or json_emits"
Expected: TypeError on the tools= kwarg, or AssertionError on missing fields.
Step 3: Update PilotReport to carry tools
In src/coding_scaffold/pilot.py, replace the tool: str field with tools: list[str]. The current tool attribute is removed. to_dict() emits tools only.
def run_pilot( target: Path | None = None, *, tool: str | None = None, # back-compat shim (kwarg) tools: list[str] | None = None, persona: str = DEFAULT_PERSONA,) -> PilotReport: """Build a structured PilotReport. Pass `tools=[...]` for the canonical multi-tool path. The `tool=` kwarg is preserved for backward-compatible callers; it is normalized via `normalize_tools` (so legacy passes like `tool="opencode"` keep working). """ from .intake import normalize_tools if tools is not None: canonical = normalize_tools(tools) else: canonical = normalize_tools(tool if tool is not None else "opencode") ...
The rest of the function loops over canonical for the per-tool environment probes (build env_info["tools"]: list[dict] with {name, binary, installed} per tool), computes environment_ok = ... and all(per_tool installed), and produces steps per spec §7.1.
Step 5: Update format_pilot_text for the multi-tool branch
When len(report.tools) == 1, render today's format unchanged (preserve golden tests). When len(report.tools) > 1, add the Tools: header, per-tool environment lines, swap "Run these next (in order):" for "Run these once (covers all selected tools):", and append "Then start a session with whichever tool you reach for today:" with one line per tool.
(The full template lives in spec §7.1 — copy it verbatim.)
Step 6: Update the pilot subparser in cli.py
pilot.add_argument( "--tool", choices=list(PILOT_SUPPORTED_TOOLS), action="append", dest="tool_raw", default=None, help="Coding tool(s) to weave into the recipe (default: opencode). " "Repeat or comma-separate for multi-tool projects.",)
uv run pytest tests/test_pilot.py tests/test_cli_ux.py -v
Expected: all pass. If single-tool golden tests fail, the len(report.tools) == 1 branch in format_pilot_text is rendering differently — fix until bit-for-bit equivalent.
Step 8: Run full suite + ruff
uv run pytest tests/ -q | tail -5uv run ruff check src/ tests/
In CHANGELOG.md, under [Unreleased], add (or extend if Added / Changed / Deprecated headings already exist):
### Added- **Multi-tool projects: `--tool` accepts a list.** `coding-scaffold setup run --tool codex --tool claude-code` (or `--tool codex,claude-code`) generates both tools' adapters in a single pass. Supported on `setup run`, `tools adapt`, `setup tool`, and `pilot`. `pilot` prints a shared setup step plus one per-tool agent step.### Changed- **Breaking (single-key removal): `routing.json`, `project.json`, and pilot JSON output now carry `tools` (a list) only.** The singular `tool` key is gone. Read `tools[0]` if you need a single value. Legacy `project.json` files with `tool` are back-filled on read for one release window (removed in 0.7.0). See [Upgrading](docs/docs/wiki/Upgrading.md).### Deprecated- **`--tool both`** is deprecated and will be removed in 0.7.0. Use `--tool opencode,openclaude` instead.
Step 2: Glossary entry
In docs/docs/wiki/Glossary.md, alphabetically (between memory and orchestration if those exist):
## multi-tool projectA project configured with more than one coding tool — e.g., Codex + ClaudeCode in the same repo. Generated via `coding-scaffold setup run --tool codex--tool claude-code` (or comma-separated). Each tool gets its native adapterfiles (e.g., `AGENTS.md` for Codex, `CLAUDE.md` for Claude Code) generatedside-by-side; the scaffold's `setup update` keeps them in sync goingforward.
Step 3: Getting-Started subsection
Add to docs/docs/wiki/Getting-Started.md under "Smallest Useful Path":
### Two tools in one repoIf you use more than one coding tool on the same project (e.g., Codex + ClaudeCode), pass `--tool` for each one in a single setup run:```bashcoding-scaffold setup run --target . --tool codex --tool claude-code --mode beginner# or equivalently:coding-scaffold setup run --target . --tool codex,claude-code --mode beginner
AGENTS.md (Codex's project rules) and CLAUDE.md (Claude Code's project
rules) are both generated; the scaffold's setup update keeps them in sync.
coding-scaffold pilot --target . --tool codex,claude-code prints one shared
setup step plus a per-tool agent step at the bottom.
- [ ] **Step 4: Upgrading.md — 0.6.0 + 0.7.0 Breaking blocks**Add to `docs/docs/wiki/Upgrading.md`, before the "When `setup update` is not the right tool" section:```markdown## Breaking change in 0.6.0 — singular `tool` JSON key removed`routing.json`, `project.json`, and the pilot JSON output used to carry asingular `tool` key. These are gone — only `tools` (a list) remains. Themigration is a one-line change for any script that read them:```python# Beforeconfig["tool"] # "codex"# Afterconfig["tools"][0] # "codex"
Legacy project.json files written by 0.5.x (with tool instead of tools)
are back-filled when setup update reads them, so existing projects upgrade
cleanly. The back-fill is removed in 0.7.0.
#Breaking change planned for 0.7.0 — --tool both removed
--tool both is currently a deprecated alias for --tool opencode,openclaude
and prints a warning when used. In 0.7.0 it is removed entirely; argparse
will reject it with an "invalid choice" error.
Update any scripts or CI invocations now:
# Beforecoding-scaffold setup run --tool both# Aftercoding-scaffold setup run --tool opencode,openclaude
The _normalize_persisted_intake back-fill helper also lands its sunset
in 0.7.0; any project.json file still on the legacy tool shape must be
upgraded by running setup update in 0.6.x first.
- [ ] **Step 5: Tool-Adapters.md preamble + example**Find the capability matrix preamble in `docs/docs/wiki/Tool-Adapters.md` and add:```markdown**Multi-tool projects:** every adapter listed here can be generated alongsideanother via `setup run --tool <a> --tool <b>` (or `--tool a,b`). Codex + ClaudeCode in the same repo is the most common pair; see[Getting-Started](./Getting-Started.md#two-tools-in-one-repo).
git push -u origin feat/multi-tool-intakegh pr create \ --title "Multi-tool intake: --tool accepts a list across setup, tools adapt, pilot (spec)" \ --body "$(cat <<'EOF'## SummaryOne PR implementing the [Multi-Tool Intake design](docs/docs/superpowers/specs/2026-05-25-multi-tool-intake-design.md). `--tool` now accepts a list everywhere it's accepted, so projects with two coding tools (e.g., Codex + Claude Code) can be configured in one `setup run`.**Breaking** (one release): `routing.json`, `project.json`, pilot JSON, and the `IntakeAnswers.tool` Python attribute drop the singular `tool` key/field in favor of `tools` (a list). Back-fill for legacy `project.json` is in place for one release window; removed in 0.7.0 alongside the `--tool both` alias.**Deprecated**: `--tool both` warns now, removed in 0.7.0.## Test plan- [x] \`uv run pytest -q\` — all tests pass.- [x] \`uv run ruff check src/ tests/\` — clean.- [x] \`cd docs && npx rspress build\` — exits 0, no dead-link warnings.- [x] \`coding-scaffold setup run --target /tmp/scratch --tool codex --tool claude-code\` writes both AGENTS.md and CLAUDE.md.- [x] \`coding-scaffold pilot --target . --tool codex,claude-code\` prints the multi-tool recipe.- [x] \`--tool both\` still works in 0.6.x with a deprecation warning.- [x] \`--tool manual --tool codex\` exits 1 with a three-line error.EOF)"
#Self-review checklist (run after writing the plan; before handing off)
Every section in the spec maps to at least one task (cross-reference §4 → Tasks 1 & 6, §5 → Tasks 2 & 4 & 7, §6 → Task 5, §7 → Task 7, §8 → Task 1 & 9, §9 → Tasks 1-8, §10 → Task 9).
No "TBD" / "TODO" / "implement later" in any task body — every step has either complete code or a complete command.
Names used across tasks are stable: normalize_tools, _normalize_persisted_intake, _normalize_args_tools_in_place, IntakeAnswers.tools, PilotReport.tools, args.tool_raw, args.tools — same spellings everywhere.
Order of tasks supports TDD: each task lands tests + code together and ends with pytest + ruff green or with explicitly-named expected failures (clearly marked, Task 2 → Task 3 is the only such handoff).
Frequent commits — every task ends with one or two git commit.
Single PR shape preserved (one branch, one PR at Task 9 step 9).