From 0607257faf5eb8ed343c55afa55753d3e30b6d32 Mon Sep 17 00:00:00 2001 From: David Liu Date: Wed, 26 Aug 2026 16:35:38 +0000 Subject: [PATCH] Add `ucode export` for managed config JSON Add a role-agnostic `ucode export` that serializes the workspace's local managed coding-agent config to the external CodingAgentConfig JSON that a future `ucode publish -f ` will consume. It reads only local state, makes no network / auth / admin calls, and mutates nothing but the optional --output file. - New managed_export.py: validate through validate_manifest, serialize through serialize_managed_config, strip the server-owned resource name; write to stdout (one trailing newline) or atomically to --output/-o (temp file in the destination dir + os.replace, no parent-dir creation, temp cleaned up on failure, ~ expanded). - Wire `ucode export` (--output/-o) in cli.py; errors go to stderr, nonzero exit. - Document `ucode export` in the README. - Tests: stdout/file byte-identity, empty stdout in file mode, atomic replace, validation/write failure paths, no-auth/admin, and parser/validator round-trip. Co-authored-by: Isaac --- README.md | 29 ++++ src/ucode/cli.py | 29 ++++ src/ucode/managed_export.py | 106 +++++++++++++++ tests/test_managed_export.py | 253 +++++++++++++++++++++++++++++++++++ 4 files changed, 417 insertions(+) create mode 100644 src/ucode/managed_export.py create mode 100644 tests/test_managed_export.py diff --git a/README.md b/README.md index 3dfe8bb5..b79b9203 100644 --- a/README.md +++ b/README.md @@ -285,6 +285,34 @@ 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://", + "spec_version": 1, + "default_agent": "CODING_AGENT_CLAUDE_CODE", + "enabled_agents": [ ... ] +} +``` + --- ## Other Commands @@ -292,6 +320,7 @@ their next ucode run. | 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 ` / `-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 ` | Query a specific SQL warehouse instead of discovering one | | `ucode revert` | Clear saved state and restore backed-up config files | diff --git a/src/ucode/cli.py b/src/ucode/cli.py index ca71dd6a..c8d71188 100644 --- a/src/ucode/cli.py +++ b/src/ucode/cli.py @@ -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 ` 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.""" diff --git a/src/ucode/managed_export.py b/src/ucode/managed_export.py new file mode 100644 index 00000000..3fe2896d --- /dev/null +++ b/src/ucode/managed_export.py @@ -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 `` +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 diff --git a/tests/test_managed_export.py b/tests/test_managed_export.py new file mode 100644 index 00000000..cf2c1d85 --- /dev/null +++ b/tests/test_managed_export.py @@ -0,0 +1,253 @@ +"""Tests for `ucode export` and its :mod:`ucode.managed_export` backing module. + +`export` is read-only and offline: it serializes the local managed config to the external +proto-JSON `CodingAgentConfig` that `ucode publish -f ` consumes. These focus on the parts +that must not regress — a clean machine-readable stdout stream, byte-identical file output, atomic +replacement that never truncates on failure, exclusion of server-owned fields, and the absence of +any auth/admin/network call. +""" + +from __future__ import annotations + +import contextlib +import json +import re +from unittest.mock import MagicMock, patch + +import pytest +from typer.testing import CliRunner + +import ucode.config_io as config_io_mod +import ucode.managed_config as managed_config_mod +import ucode.managed_export as export_mod +from ucode.cli import app +from ucode.managed_config import normalize_managed_config +from ucode.managed_setup import serialize_managed_config, validate_manifest + +runner = CliRunner() + +WORKSPACE = "https://ws.example.com" + +_ANSI_RE = re.compile(r"\x1b\[[0-9;]*m") + +FULL_MANIFEST = { + "name": "coding-agent-configs/abc123", + "default_agent": "claude", + "enabled_agents": { + "claude": {"model_config": {"default_model": "system.ai.claude-opus-4-8"}}, + "codex": {"model_config": {"default_model": "system.ai.gpt-5-6"}}, + }, + "mcp_servers": [{"name": "system.ai.slack", "type": "mcp-service"}], + "skills": {"names": ["main.default"]}, +} + + +@pytest.fixture(autouse=True) +def _isolate_settings(tmp_path, monkeypatch): + """Point the managed-config file at a tmp dir so no test touches the real ~/.ucode.""" + monkeypatch.setattr(config_io_mod, "APP_DIR", tmp_path) + monkeypatch.setattr(managed_config_mod, "MANAGED_STATE_PATH", tmp_path / "managed-state.json") + monkeypatch.setattr(config_io_mod, "_dry_run", False) + + +@contextlib.contextmanager +def _with_manifest(manifest: dict | None, workspace: str | None = WORKSPACE): + """Patch the module's local reads so a test controls the source config without disk or network.""" + with ( + patch.object(export_mod, "load_state", return_value={"workspace": workspace}), + patch.object(export_mod, "load_managed_state", return_value=manifest), + ): + yield + + +class TestBuildPayload: + def test_excludes_server_owned_resource_name(self): + with _with_manifest(FULL_MANIFEST): + payload = export_mod.build_export_payload() + assert "name" not in payload + assert payload["default_agent"] == "CODING_AGENT_CLAUDE_CODE" + assert payload["mcp_servers"] == [ + {"name": "system.ai.slack", "type": "MCP_SERVER_TYPE_UC_SERVICE"} + ] + + def test_envelope_workspace_first_then_spec_version(self): + with _with_manifest(FULL_MANIFEST): + payload = export_mod.build_export_payload() + assert list(payload)[:2] == ["workspace", "spec_version"] + assert payload["workspace"] == WORKSPACE + assert payload["spec_version"] == 1 + + def test_matches_serialize_minus_name_under_envelope(self): + config = serialize_managed_config(FULL_MANIFEST) + config.pop("name", None) + expected = {"workspace": WORKSPACE, "spec_version": 1, **config} + with _with_manifest(FULL_MANIFEST): + assert export_mod.build_export_payload() == expected + + def test_config_roundtrips_through_parser_and_validator(self): + with _with_manifest(FULL_MANIFEST): + payload = export_mod.build_export_payload() + config = {k: v for k, v in payload.items() if k not in ("workspace", "spec_version")} + reparsed = normalize_managed_config(config) + assert validate_manifest(reparsed, None) == [] + assert serialize_managed_config(reparsed) == config + + def test_no_config_is_actionable(self): + with ( + patch.object(export_mod, "load_state", return_value={}), + patch.object(export_mod, "load_managed_state", return_value=None), + ): + with pytest.raises(RuntimeError, match="No managed coding-agent config found"): + export_mod.build_export_payload() + + def test_invalid_config_is_rejected(self): + invalid = {"enabled_agents": {"claude": {}}} + with _with_manifest(invalid): + with pytest.raises(RuntimeError, match="not valid"): + export_mod.build_export_payload() + + def test_falls_back_to_managed_state_workspace(self): + managed_config_mod.save_managed_state(WORKSPACE, FULL_MANIFEST) + with patch.object(export_mod, "load_state", return_value={}): + payload = export_mod.build_export_payload() + assert payload["default_agent"] == "CODING_AGENT_CLAUDE_CODE" + + +class TestExportCommandStdout: + def test_emits_valid_json_with_single_trailing_newline(self, capsys): + with _with_manifest(FULL_MANIFEST): + export_mod.export_command() + captured = capsys.readouterr() + out = captured.out + assert out.endswith("}\n") + assert not out.endswith("}\n\n") + json.loads(out) + assert captured.err == "" + + def test_stdout_has_no_rich_or_human_output(self, capsys): + with _with_manifest(FULL_MANIFEST): + export_mod.export_command() + out = capsys.readouterr().out + assert _ANSI_RE.search(out) is None + with _with_manifest(FULL_MANIFEST): + payload = export_mod.build_export_payload() + assert json.loads(out) == payload + + +class TestExportCommandFile: + def test_file_bytes_identical_to_stdout_and_stdout_empty(self, capsys, tmp_path): + with _with_manifest(FULL_MANIFEST): + export_mod.export_command() + stdout_bytes = capsys.readouterr().out + + dest = tmp_path / "config.json" + with _with_manifest(FULL_MANIFEST): + export_mod.export_command(output=str(dest)) + captured = capsys.readouterr() + assert captured.out == "" + assert dest.read_text(encoding="utf-8") == stdout_bytes + + def test_expands_user_home_in_output_path(self, capsys, tmp_path, monkeypatch): + monkeypatch.setenv("HOME", str(tmp_path)) + with _with_manifest(FULL_MANIFEST): + export_mod.export_command(output="~/config.json") + capsys.readouterr() + assert (tmp_path / "config.json").exists() + + def test_replaces_existing_destination(self, tmp_path): + dest = tmp_path / "config.json" + dest.write_text("stale contents", encoding="utf-8") + with _with_manifest(FULL_MANIFEST): + export_mod.export_command(output=str(dest)) + assert json.loads(dest.read_text(encoding="utf-8"))["default_agent"] == ( + "CODING_AGENT_CLAUDE_CODE" + ) + + def test_invalid_config_leaves_existing_destination_unchanged(self, tmp_path): + dest = tmp_path / "config.json" + dest.write_text("original", encoding="utf-8") + invalid = {"enabled_agents": {"claude": {}}} + with _with_manifest(invalid): + with pytest.raises(RuntimeError): + export_mod.export_command(output=str(dest)) + assert dest.read_text(encoding="utf-8") == "original" + + def test_invalid_config_does_not_create_destination(self, tmp_path): + dest = tmp_path / "config.json" + invalid = {"enabled_agents": {"claude": {}}} + with _with_manifest(invalid): + with pytest.raises(RuntimeError): + export_mod.export_command(output=str(dest)) + assert not dest.exists() + + def test_missing_parent_directory_fails_without_creating_it(self, tmp_path): + missing_parent = tmp_path / "nope" + dest = missing_parent / "config.json" + with _with_manifest(FULL_MANIFEST): + with pytest.raises(RuntimeError, match="parent directory does not exist"): + export_mod.export_command(output=str(dest)) + assert not missing_parent.exists() + + def test_write_failure_is_actionable_and_leaves_no_temp_file(self, tmp_path): + dest = tmp_path / "adir" + dest.mkdir() + with _with_manifest(FULL_MANIFEST): + with pytest.raises(RuntimeError, match="Failed to write"): + export_mod.export_command(output=str(dest)) + leftovers = [p.name for p in tmp_path.iterdir() if p.name.startswith(".ucode-export-")] + assert leftovers == [] + + +class TestNoAuthOrAdmin: + def test_no_admin_or_token_lookup_occurs(self, capsys): + with ( + _with_manifest(FULL_MANIFEST), + patch("ucode.databricks.is_workspace_admin") as admin, + patch("ucode.databricks.get_databricks_token") as token, + ): + export_mod.export_command() + capsys.readouterr() + admin.assert_not_called() + token.assert_not_called() + + def test_admin_and_non_admin_produce_identical_output(self, capsys): + outputs = [] + for admin_value in (True, False): + with ( + _with_manifest(FULL_MANIFEST), + patch("ucode.databricks.is_workspace_admin", MagicMock(return_value=admin_value)), + ): + export_mod.export_command() + outputs.append(capsys.readouterr().out) + assert outputs[0] == outputs[1] + + +class TestExportCLI: + def test_help_documents_command_and_flags(self): + top = runner.invoke(app, ["--help"]) + assert top.exit_code == 0 + assert "export" in _ANSI_RE.sub("", top.output) + + result = runner.invoke(app, ["export", "--help"]) + assert result.exit_code == 0 + cleaned = _ANSI_RE.sub("", result.output) + assert "--output" in cleaned + assert "-o" in cleaned + + def test_long_and_short_output_flags_both_write_the_file(self, tmp_path): + for flag in ("--output", "-o"): + dest = tmp_path / f"cfg{flag.strip('-')}.json" + with _with_manifest(FULL_MANIFEST): + result = runner.invoke(app, ["export", flag, str(dest)]) + assert result.exit_code == 0 + assert json.loads(dest.read_text(encoding="utf-8"))["default_agent"] == ( + "CODING_AGENT_CLAUDE_CODE" + ) + + def test_no_config_exits_nonzero(self): + with ( + patch.object(export_mod, "load_state", return_value={}), + patch.object(export_mod, "load_managed_state", return_value=None), + ): + result = runner.invoke(app, ["export"]) + assert result.exit_code == 1