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
29 changes: 29 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -285,13 +285,42 @@ whole-manifest write — every field ucode authors is sent — but because `ucod
other sections forward, a re-run no longer silently drops them. Developers pick the new config up on
their next ucode run.

### Exporting the config

Any user (not only admins) can print the workspace's managed config as portable JSON with `ucode
export`. The output leads with the source `workspace` URL and a `spec_version` (the export format
version), followed by the canonical external config; credentials and server-assigned fields (the
resource name, timestamps, user ids) are excluded. Without `--output` the JSON is written to stdout;
with `--output`/`-o` the same bytes are written to a file (atomically, and the destination's parent
directory must already exist) while stdout stays empty.

```bash
# Print the managed config as JSON.
ucode export

# Write it to a file; stdout stays empty.
ucode export --output ./managed-config.json
```

The output looks like:

```json
{
"workspace": "https://<workspace-host>",
"spec_version": 1,
"default_agent": "CODING_AGENT_CLAUDE_CODE",
"enabled_agents": [ ... ]
}
```

---

## Other Commands

| Command | Description |
|---------|-------------|
| `ucode status` | Show current workspace, base URLs, managed config files, and selected models |
| `ucode export` | Print the workspace's managed config as portable JSON (`--output <file>` / `-o` to write a file) |
| `ucode usage` | Show AI Gateway usage summary, plus your budget spend against its alert threshold when the workspace reports one |
| `ucode usage --warehouse-id <id>` | Query a specific SQL warehouse instead of discovering one |
| `ucode revert` | Clear saved state and restore backed-up config files |
Expand Down
29 changes: 29 additions & 0 deletions src/ucode/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -2923,6 +2923,35 @@ def apply_cmd(
raise typer.Exit(code)


@app.command("export")
def export_cmd(
output: Annotated[
str | None,
typer.Option(
"--output",
"-o",
help="Write the exported config JSON to this file (atomically) instead of stdout. "
"The parent directory must already exist.",
),
] = None,
) -> None:
"""Export this workspace's managed coding-agent config as portable JSON.

Serializes the local managed config to the external `CodingAgentConfig` format that
`ucode publish -f <path>` consumes, with credentials and server-owned fields (resource name,
workspace id, timestamps, user ids) excluded. Any user can run it; it makes no network calls
and mutates no workspace or local state. Without --output the JSON is printed to stdout;
diagnostics and errors go to stderr.
"""
from ucode.managed_export import export_command

try:
export_command(output=output)
except RuntimeError as exc:
print_err(str(exc))
raise typer.Exit(1) from None


@app.command("status")
def status_cmd() -> None:
"""Show current workspace, tool configs, and saved model selections."""
Expand Down
106 changes: 106 additions & 0 deletions src/ucode/managed_export.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
"""`ucode export`: serialize the workspace's managed coding-agent config to portable JSON.

Reads the local managed config (the one file :mod:`ucode.managed_config` owns, authored by
``ucode setup`` and refreshed by a launch), validates and serializes it through the same path
``ucode apply`` uses, and writes the external proto-JSON ``CodingAgentConfig`` — prefixed with the
source ``workspace`` and a ``spec_version`` envelope, the format a future ``ucode publish -f <path>``
will consume — to stdout or a file.

Deliberately read-only and offline: no auth, no admin check, no discovery, no publish, and no write
except the explicitly requested ``--output`` file. That makes it role-agnostic (any developer can
run it) and keeps the machine-readable stream on stdout uncontaminated by Rich output.
"""

from __future__ import annotations

import json
import os
import sys
import tempfile
from pathlib import Path

from ucode.managed_config import load_managed_state, managed_state_workspace
from ucode.managed_setup import serialize_managed_config, validate_manifest
from ucode.state import load_state

_SERVER_OWNED_FIELDS = ("name",)

EXPORT_SPEC_VERSION = 1


def build_export_payload() -> dict:
"""Validate the local managed config and return the export payload.

The payload is the source ``workspace`` URL and a ``spec_version`` (identifying the export
format), followed by the external proto-JSON ``CodingAgentConfig`` with its server-owned resource
``name`` stripped. Reads only local state — no network, no auth. Raises RuntimeError with an
actionable message when no config is authored locally or the config fails structural validation.
"""
workspace = load_state().get("workspace") or managed_state_workspace()
manifest = load_managed_state(workspace)
if not manifest:
raise RuntimeError(
"No managed coding-agent config found locally. Run `ucode setup` to author one, or run "
"`ucode` against a workspace that publishes one, then re-run `ucode export`."
)
errors = validate_manifest(manifest, None)
if errors:
detail = "\n".join(f" - {error}" for error in errors)
raise RuntimeError(f"The managed config is not valid, so it was not exported:\n{detail}")
config = serialize_managed_config(manifest)
for field in _SERVER_OWNED_FIELDS:
config.pop(field, None)
return {"workspace": workspace, "spec_version": EXPORT_SPEC_VERSION, **config}


def export_command(output: str | None = None) -> None:
"""Serialize the managed config once and write it to ``output`` or stdout.

The complete payload is built and serialized before the destination is touched, so a validation
or serialization failure never creates or truncates it. With no ``output`` the JSON goes to
stdout with exactly one trailing newline and nothing else; with ``output`` the identical bytes
are written atomically and stdout stays empty. Raises RuntimeError on failure.
"""
payload = build_export_payload()
json_text = json.dumps(payload, indent=2) + "\n"
if output is None:
sys.stdout.write(json_text)
return
_write_atomic(Path(output).expanduser(), json_text)


def _write_atomic(destination: Path, json_text: str) -> None:
"""Write ``json_text`` to ``destination`` via a temp file in its directory, then ``os.replace``.

The parent directory must already exist — a missing one is a clear error rather than a silent
``mkdir``. A partial write leaves the temp file behind, so it is removed on any failure and only
the atomic replace makes the new content visible; an existing destination is replaced without a
``--force``.
"""
directory = destination.parent
if not directory.is_dir():
raise RuntimeError(
f"Cannot write {destination}: its parent directory does not exist. Create it first "
"(ucode export does not create parent directories)."
)
try:
fd, tmp_name = tempfile.mkstemp(dir=directory, prefix=".ucode-export-", suffix=".tmp")
except OSError as exc:
raise RuntimeError(f"Failed to write {destination}: {exc}") from exc
tmp_path = Path(tmp_name)
try:
with os.fdopen(fd, "w", encoding="utf-8") as handle:
handle.write(json_text)
os.replace(tmp_path, destination)
except OSError as exc:
raise RuntimeError(f"Failed to write {destination}: {exc}") from exc
finally:
_cleanup(tmp_path)


def _cleanup(tmp_path: Path) -> None:
"""Best-effort removal of a leftover temp file (a no-op once os.replace has consumed it)."""
try:
tmp_path.unlink(missing_ok=True)
except OSError:
pass
Loading
Loading