Skip to content

Commit e523721

Browse files
alexkromanclaude
andauthored
Split init/dev/share/deploy/eval into options/run exec modules (#138)
Apply the gh-CLI-style options/run split (already used by transcribe, stream, agent, speak, llm, clip, dictate, dub) to the remaining flag-heavy single-run commands. Each command module now only parses argv into a frozen <Cmd>Options dataclass and hands it to a module-level run_<cmd>(opts, state, *, json_mode) in a dedicated <cmd>_exec module: - aai_cli/dev_exec.py (DevOptions / run_dev) - aai_cli/share_exec.py (ShareOptions / run_share) - aai_cli/deploy_exec.py (DeployOptions / run_deploy; flag->target resolution moved into run_deploy) - aai_cli/evaluate_exec.py (EvalOptions / run_evaluate; EvalSpeechModel and all scoring/render helpers) - aai_cli/init_exec.py (InitOptions / run_init + launch_app, called by the onboarding wizard) The onboarding wizard now scaffolds via init_exec.run_init/launch_app instead of reaching into the command module. The new exec modules are registered in .importlinter contract 1 (core modules must not import command modules), and the moved tests are repointed at the exec modules. Co-authored-by: Claude <noreply@anthropic.com>
1 parent 8e16afb commit e523721

17 files changed

Lines changed: 989 additions & 833 deletions

.importlinter

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,16 +19,20 @@ source_modules =
1919
aai_cli.config_builder
2020
aai_cli.context
2121
aai_cli.debuglog
22+
aai_cli.deploy_exec
23+
aai_cli.dev_exec
2224
aai_cli.dictate_exec
2325
aai_cli.dub_exec
2426
aai_cli.environments
2527
aai_cli.errors
2628
aai_cli.eval_data
29+
aai_cli.evaluate_exec
2730
aai_cli.follow
2831
aai_cli.help_panels
2932
aai_cli.help_text
3033
aai_cli.hotkey
3134
aai_cli.init
35+
aai_cli.init_exec
3236
aai_cli.llm
3337
aai_cli.llm_exec
3438
aai_cli.microphone
@@ -37,6 +41,7 @@ source_modules =
3741
aai_cli.procs
3842
aai_cli.remotefs
3943
aai_cli.render
44+
aai_cli.share_exec
4045
aai_cli.speak_exec
4146
aai_cli.stdio
4247
aai_cli.stream_exec

aai_cli/commands/deploy.py

Lines changed: 10 additions & 126 deletions
Original file line numberDiff line numberDiff line change
@@ -1,132 +1,16 @@
11
# aai_cli/commands/deploy.py
22
from __future__ import annotations
33

4-
import shutil
5-
import subprocess
6-
import sys
7-
from dataclasses import dataclass
8-
from pathlib import Path
9-
104
import typer
115

12-
from aai_cli import help_panels, options, output
13-
from aai_cli.context import AppState, run_command
14-
from aai_cli.errors import CLIError, UsageError
6+
from aai_cli import deploy_exec, help_panels, options
7+
from aai_cli.context import run_command
158
from aai_cli.help_text import examples_epilog
16-
from aai_cli.init import procfile
179

1810
# Flattened single-command sub-typer (same pattern as `assembly dev`).
1911
app = typer.Typer()
2012

2113

22-
@dataclass(frozen=True)
23-
class Target:
24-
name: str # human label, e.g. "Vercel"
25-
bin: str # executable resolved via shutil.which
26-
flag: str # CLI selector, e.g. "--vercel"
27-
install: str # hint sentence shown when the CLI is missing (everywhere, or macOS-only)
28-
deploy_args: tuple[str, ...] # subcommand(s) appended after `bin`
29-
supports_prod: bool = False # whether `--prod` adds a production flag
30-
post_deploy_args: tuple[str, ...] | None = None # command run after a successful deploy
31-
install_non_darwin: str | None = None # hint off-macOS, when `install` is brew-specific
32-
33-
def command(self, *, prod: bool) -> list[str]:
34-
argv = [self.bin, *self.deploy_args]
35-
if prod and self.supports_prod:
36-
argv.append("--prod")
37-
return argv
38-
39-
40-
VERCEL = Target(
41-
name="Vercel",
42-
bin="vercel",
43-
flag="--vercel",
44-
install="Install it with `npm i -g vercel`.",
45-
deploy_args=("deploy",),
46-
supports_prod=True,
47-
)
48-
RAILWAY = Target(
49-
name="Railway",
50-
bin="railway",
51-
flag="--railway",
52-
install="Install it with `npm i -g @railway/cli`.",
53-
deploy_args=("up",),
54-
post_deploy_args=("domain",),
55-
)
56-
FLY = Target(
57-
name="Fly",
58-
bin="fly",
59-
flag="--fly",
60-
# brew is macOS-specific; elsewhere point at the official install docs.
61-
install="Install it with `brew install flyctl`.",
62-
install_non_darwin="Install it: https://fly.io/docs/flyctl/install/",
63-
# `fly launch` does it all: creates the app, generates fly.toml (detecting the
64-
# shipped Dockerfile), and deploys — so no fly.toml needs to exist beforehand.
65-
deploy_args=("launch",),
66-
)
67-
68-
TARGETS = (VERCEL, RAILWAY, FLY)
69-
70-
71-
def _resolve_target(selected: list[Target]) -> Target:
72-
if len(selected) > 1:
73-
flags = " / ".join(t.flag for t in TARGETS)
74-
raise UsageError(f"Pass at most one deploy target ({flags}).")
75-
return selected[0] if selected else VERCEL # Vercel is the default
76-
77-
78-
def _install_hint(target: Target) -> str:
79-
"""The platform-appropriate install hint: brew on macOS, docs URL elsewhere."""
80-
if target.install_non_darwin is not None and sys.platform != "darwin":
81-
return target.install_non_darwin
82-
return target.install
83-
84-
85-
def _require_cli(target: Target) -> None:
86-
if shutil.which(target.bin) is None:
87-
raise CLIError(
88-
f"The {target.name} CLI is required to deploy. {_install_hint(target)}",
89-
error_type="missing_dependency",
90-
exit_code=1,
91-
)
92-
93-
94-
def _confirmed(target: Target, *, assume_yes: bool) -> bool:
95-
"""True when the deploy should proceed: --yes, or an interactive yes.
96-
97-
Refuses to guess in a non-interactive/agent session."""
98-
if assume_yes:
99-
return True
100-
if output.is_agentic():
101-
raise UsageError(
102-
"Refusing to deploy without confirmation in a non-interactive session. "
103-
"Pass --yes to deploy."
104-
)
105-
return typer.confirm(f"Deploy this project to {target.name}?")
106-
107-
108-
def run_deploy(*, target: Target, prod: bool, assume_yes: bool, json_mode: bool) -> None:
109-
"""Confirm, then run the target's deploy command in the current directory."""
110-
if prod and not target.supports_prod:
111-
raise UsageError(
112-
"--prod is only supported for Vercel deploys.",
113-
suggestion=f"Drop --prod, or drop {target.flag} to deploy to Vercel.",
114-
)
115-
# Same not-a-project guard as `assembly dev`/`assembly share`, checked before CLI presence
116-
# so an empty directory says "run `assembly init`", not "install the Vercel CLI".
117-
procfile.require_procfile(Path.cwd())
118-
_require_cli(target)
119-
if not _confirmed(target, assume_yes=assume_yes):
120-
aborted = {"status": "aborted", "target": target.name}
121-
output.emit(aborted, lambda _d: "Aborted.", json_mode=json_mode)
122-
return
123-
result = subprocess.run(target.command(prod=prod), cwd=Path.cwd(), check=False)
124-
if result.returncode:
125-
raise typer.Exit(code=result.returncode)
126-
if target.post_deploy_args is not None:
127-
subprocess.run([target.bin, *target.post_deploy_args], cwd=Path.cwd(), check=False)
128-
129-
13014
@app.command(
13115
rich_help_panel=help_panels.BUILD,
13216
epilog=examples_epilog(
@@ -153,11 +37,11 @@ def deploy(
15337
`railway up`, or `fly launch`). Requires that target's CLI to be installed.
15438
(Render deploys from a connected Git repo — see the project README.)
15539
"""
156-
157-
def body(_state: AppState, json_mode: bool) -> None:
158-
selected = [t for t, on in ((VERCEL, vercel), (RAILWAY, railway), (FLY, fly)) if on]
159-
run_deploy(
160-
target=_resolve_target(selected), prod=prod, assume_yes=assume_yes, json_mode=json_mode
161-
)
162-
163-
run_command(ctx, body, json=json_out)
40+
opts = deploy_exec.DeployOptions(
41+
prod=prod, vercel=vercel, railway=railway, fly=fly, assume_yes=assume_yes
42+
)
43+
run_command(
44+
ctx,
45+
lambda state, json_mode: deploy_exec.run_deploy(opts, state, json_mode=json_mode),
46+
json=json_out,
47+
)

aai_cli/commands/dev.py

Lines changed: 9 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -1,59 +1,18 @@
11
# aai_cli/commands/dev.py
22
from __future__ import annotations
33

4-
import os
5-
from pathlib import Path
6-
74
import typer
8-
from rich.markup import escape
95

10-
from aai_cli import help_panels, options, output, steps
11-
from aai_cli.context import AppState, run_command
6+
from aai_cli import dev_exec, help_panels, options
7+
from aai_cli.context import run_command
128
from aai_cli.help_text import examples_epilog
13-
from aai_cli.init import devserver, procfile, runner
9+
from aai_cli.init import devserver
1410

1511
# Flattened single-command sub-typer (same pattern as `assembly init`): one
1612
# @app.command() registered via app.add_typer(dev.app) with no name.
1713
app = typer.Typer()
1814

1915

20-
def run_dev(
21-
*, port: int, host: str, no_install: bool, no_open: bool, json_mode: bool, quiet: bool
22-
) -> None:
23-
"""Boot the project's Procfile `web:` process locally, with live reload."""
24-
target = Path.cwd()
25-
use_uv = runner.has_uv()
26-
27-
chosen_port = runner.find_free_port(port)
28-
devserver.notify_port_change(port, chosen_port, json_mode=json_mode, quiet=quiet)
29-
env = {**os.environ, "PORT": str(chosen_port)}
30-
# Resolves the start command AND validates we're inside a scaffolded project.
31-
web = procfile.web_argv(target, env=env)
32-
33-
report: list[steps.Step] = [
34-
devserver.install_step(target, no_install=no_install, use_uv=use_uv)
35-
]
36-
output.emit(report, lambda d: steps.render_steps(d, heading="Dev"), json_mode=json_mode)
37-
if any(s["status"] == "failed" for s in report):
38-
raise typer.Exit(code=1)
39-
40-
command = devserver.dev_command(target, web, use_uv=use_uv, host=host)
41-
# The printed URL reflects the actual bind: "localhost" for the loopback
42-
# default, the literal host for an explicit --host.
43-
url_host = "localhost" if host == devserver.LOCAL_HOST else host
44-
url = f"http://{url_host}:{chosen_port}"
45-
if not json_mode:
46-
output.console.print(
47-
f"[aai.heading]Starting[/aai.heading] [aai.url]{escape(url)}[/aai.url]"
48-
" [aai.muted](Ctrl-C to stop)[/aai.muted]"
49-
)
50-
code = runner.run_server(
51-
target, command=command, port=chosen_port, env=env, open_browser=not no_open
52-
)
53-
if code:
54-
raise typer.Exit(code=code)
55-
56-
5716
@app.command(
5817
rich_help_panel=help_panels.BUILD,
5918
epilog=examples_epilog(
@@ -84,15 +43,9 @@ def dev(
8443
Run this from inside a project created by `assembly init`. It installs dependencies
8544
if needed, then starts the FastAPI server with live reload and opens the browser.
8645
"""
87-
88-
def body(state: AppState, json_mode: bool) -> None:
89-
run_dev(
90-
port=port,
91-
host=host,
92-
no_install=no_install,
93-
no_open=no_open,
94-
json_mode=json_mode,
95-
quiet=state.quiet,
96-
)
97-
98-
run_command(ctx, body, json=json_out)
46+
opts = dev_exec.DevOptions(port=port, host=host, no_install=no_install, no_open=no_open)
47+
run_command(
48+
ctx,
49+
lambda state, json_mode: dev_exec.run_dev(opts, state, json_mode=json_mode),
50+
json=json_out,
51+
)

0 commit comments

Comments
 (0)