Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions docs/experience/automation-mode.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ Every interactive surface degrades to a deterministic, scriptable path.
| Situation | Behavior |
|---|---|
| Non-TTY bare `apiforge` | product summary, exit 0 |
| Non-TTY `apiforge install` | usage error pointing at `install apply` |
| Non-TTY `apiforge install` | structured approval refusal; no prompt, no writes |
| Interactive fn called headless | `NonInteractive` raised — never blocks |
| `--dry-run` | real plan, zero writes |
| `--yes` | explicit approval for writes |
Expand All @@ -14,7 +14,7 @@ Every interactive surface degrades to a deterministic, scriptable path.
CI pattern:

```bash
apiforge install apply --scope project --profile minimal --yes
apiforge install --scope project --profile minimal --yes
apiforge doctor
```

Expand Down
10 changes: 5 additions & 5 deletions docs/experience/installation-wizard.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
# Installation wizard

Bare `apiforge install` on a TTY opens the guided wizard. On a non-TTY it
exits with a usage error pointing at `install apply` — scripts keep the
flag contract.
returns a structured approval refusal without reading stdin or writing files.
Explicit flags keep the deterministic contract.

## Steps

Expand All @@ -24,10 +24,10 @@ flag contract.
Non-interactive equivalent:

```bash
apiforge install apply --scope project --profile recommended --host all --dry-run
apiforge install apply --scope project --profile recommended --host all --yes
apiforge install --scope project --profile recommended --host all --dry-run
apiforge install --scope project --profile recommended --host all --yes
# optional components:
apiforge install apply --yes --components skills,mcp,tui,graph-studio
apiforge install --yes --components skills,mcp,tui,graph-studio
apiforge install doctor
```

Expand Down
40 changes: 33 additions & 7 deletions scripts/forge_installkit.py
Original file line number Diff line number Diff line change
Expand Up @@ -579,7 +579,7 @@ def _host_dirs(host: str, scope: str) -> list[str]:
def profile_asset_kinds(profile: str) -> tuple[str, ...]:
return {
"minimal": ("config", "mcp", "marker", "state"),
"recommended": ("config", "mcp", "marker", "state", "skill"),
"recommended": ("config", "mcp", "marker", "state", "skill", "agent"),
"full": ("config", "mcp", "marker", "state", "skill", "agent"),
}[profile]

Expand Down Expand Up @@ -725,6 +725,13 @@ def apply_install(ctx: InstallContext, *, approved: bool = False) -> dict[str, A
markers: list[dict[str, Any]] = []
checks: list[dict[str, Any]] = []
ledger = ctx.ledger or Ledger.load(ctx.state_dir)
ledger_before = ledger.path.read_bytes() if ledger.path.exists() else None
component_file = components_path(ctx.state_dir) if ctx.options else None
component_before = (
component_file.read_bytes()
if component_file is not None and component_file.exists()
else None
)
# §14.3 journal — every write is either a creation (removable) or an
# overwrite with a pre-state snapshot; on failure the managed half is
# reverted before the error propagates.
Expand Down Expand Up @@ -867,12 +874,31 @@ def _snapshot(target: Path, rel: str) -> None:
E_WRITE, f"install failed at write stage; managed writes reverted: {exc}"
) from exc

ledger.save()
if ctx.options:
ctx.state_dir.mkdir(parents=True, exist_ok=True)
_atomic_write(
components_path(ctx.state_dir), _json_bytes(dict(ctx.options))
)
try:
ledger.save()
if ctx.options:
ctx.state_dir.mkdir(parents=True, exist_ok=True)
_atomic_write(component_file, _json_bytes(dict(ctx.options)))
except InstallError:
_rollback_writes(ctx, created, backups)
raise
except Exception as exc:
_rollback_writes(ctx, created, backups)
try:
if ledger_before is None:
ledger.path.unlink(missing_ok=True)
else:
_atomic_write(ledger.path, ledger_before)
if component_file is not None:
if component_before is None:
component_file.unlink(missing_ok=True)
else:
_atomic_write(component_file, component_before)
except OSError:
pass
raise InstallError(
E_WRITE, f"install failed at state stage; managed writes reverted: {exc}"
) from exc
written = sum(1 for f in files if f.get("action") in ("created", "updated"))
checks.append({"id": "files-written", "status": "PASS", "detail": f"{written} writes"})
receipt = {
Expand Down
50 changes: 39 additions & 11 deletions src/apiforge/_installkit.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@
_INSTALLKIT_VERSION = "1.0.0"
# Filled by the vendor step (scripts/installkit/vendor.py) — the sha256 of
# the canonical source body, so drift checks can compare vendored copies.
_SOURCE_SHA256 = "7d960bb98bbf7b1e"
_SOURCE_SHA256 = "866b66c20f245e7c"

SCHEMA_MANIFEST = "forge/InstallationManifest/v1"
SCHEMA_RECEIPT = "forge/InstallReceipt/v1"
Expand Down Expand Up @@ -579,7 +579,7 @@ def _host_dirs(host: str, scope: str) -> list[str]:
def profile_asset_kinds(profile: str) -> tuple[str, ...]:
return {
"minimal": ("config", "mcp", "marker", "state"),
"recommended": ("config", "mcp", "marker", "state", "skill"),
"recommended": ("config", "mcp", "marker", "state", "skill", "agent"),
"full": ("config", "mcp", "marker", "state", "skill", "agent"),
}[profile]

Expand Down Expand Up @@ -725,6 +725,13 @@ def apply_install(ctx: InstallContext, *, approved: bool = False) -> dict[str, A
markers: list[dict[str, Any]] = []
checks: list[dict[str, Any]] = []
ledger = ctx.ledger or Ledger.load(ctx.state_dir)
ledger_before = ledger.path.read_bytes() if ledger.path.exists() else None
component_file = components_path(ctx.state_dir) if ctx.options else None
component_before = (
component_file.read_bytes()
if component_file is not None and component_file.exists()
else None
)
# §14.3 journal — every write is either a creation (removable) or an
# overwrite with a pre-state snapshot; on failure the managed half is
# reverted before the error propagates.
Expand Down Expand Up @@ -867,12 +874,31 @@ def _snapshot(target: Path, rel: str) -> None:
E_WRITE, f"install failed at write stage; managed writes reverted: {exc}"
) from exc

ledger.save()
if ctx.options:
ctx.state_dir.mkdir(parents=True, exist_ok=True)
_atomic_write(
components_path(ctx.state_dir), _json_bytes(dict(ctx.options))
)
try:
ledger.save()
if ctx.options:
ctx.state_dir.mkdir(parents=True, exist_ok=True)
_atomic_write(component_file, _json_bytes(dict(ctx.options)))
except InstallError:
_rollback_writes(ctx, created, backups)
raise
except Exception as exc:
_rollback_writes(ctx, created, backups)
try:
if ledger_before is None:
ledger.path.unlink(missing_ok=True)
else:
_atomic_write(ledger.path, ledger_before)
if component_file is not None:
if component_before is None:
component_file.unlink(missing_ok=True)
else:
_atomic_write(component_file, component_before)
except OSError:
pass
raise InstallError(
E_WRITE, f"install failed at state stage; managed writes reverted: {exc}"
) from exc
written = sum(1 for f in files if f.get("action") in ("created", "updated"))
checks.append({"id": "files-written", "status": "PASS", "detail": f"{written} writes"})
if "codex" in ctx.hosts and ctx.scope == "project":
Expand All @@ -884,7 +910,7 @@ def _snapshot(target: Path, rel: str) -> None:
"id": "mcp-scope:codex",
"status": "NOT_APPLICABLE",
"detail": "codex does not read project .mcp.json; MCP needs "
"--scope user or `codex mcp add`",
"--scope user or `codex mcp add`",
}
)
receipt = {
Expand Down Expand Up @@ -1028,9 +1054,11 @@ def doctor(ctx: InstallContext) -> dict[str, Any]:
"id": "forge-portal",
"status": "PASS" if found else "NOT_APPLICABLE",
"detail": presence["install_root"]
or ("vendored assets only — no local forge-portal install found"
or (
"vendored assets only — no local forge-portal install found"
if not found
else presence["manifest"]),
else presence["manifest"]
),
"repairable": False,
}
)
Expand Down
56 changes: 56 additions & 0 deletions src/apiforge/cli_install.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,37 @@ def _call(fn: Callable[..., Any], *args: Any, **kwargs: Any) -> Any:
raise AssertionError("unreachable")


def _bare_install_requested(
ctx: typer.Context,
*,
scope: str,
host: str,
profile: str,
root: Path | None,
yes: bool,
dry_run: bool,
components: str | None,
) -> bool:
"""Detect exact default invocation without guessing explicit flags."""
if (scope, host, profile, root, yes, dry_run, components) != (
"project",
"all",
"recommended",
None,
False,
False,
None,
):
return False
source = getattr(ctx, "get_parameter_source", None)
if source is None:
return True
return all(
getattr(source(name), "name", "") == "DEFAULT"
for name in ("scope", "host", "profile", "root", "yes", "dry_run", "components")
)


@install_app.callback()
def install_apply(
ctx: typer.Context,
Expand All @@ -99,6 +130,31 @@ def install_apply(
import sys

comps = tuple(c.strip() for c in components.split(",") if c.strip()) if components else None
if (
_bare_install_requested(
ctx,
scope=scope,
host=host,
profile=profile,
root=root,
yes=yes,
dry_run=dry_run,
components=components,
)
and sys.stdin.isatty()
and sys.stdout.isatty()
):
from apiforge.ui.kit import UIContext
from apiforge.ui.wizard import run_wizard

raise typer.Exit(
run_wizard(
forge_name="api-forge",
install_fn=lambda **kw: service.install(root=None, **kw),
doctor_fn=lambda **kw: service.doctor(**kw),
ctx=UIContext.detect(),
)
)
approved = yes
if not yes and not dry_run and sys.stdin.isatty() and sys.stdout.isatty():
# Interactive approval: render the real plan, confirm once,
Expand Down
Loading