From c641af0615b6982aae5e192d8d5130ebdc275387 Mon Sep 17 00:00:00 2001 From: Karolis Date: Fri, 9 Oct 2026 20:02:27 +0400 Subject: [PATCH 1/2] feat(cli): set up pi, Codex, DeepSeek Harness and Cursor with `lightning code` `lightning code setup|status|remove` now take pi, codex, dsh and cursor as well as opencode. Each tool plans its file changes as text, which the commands show for --dry-run and apply, so a tool only decides what its files should say: - pi: provider spliced into ~/.pi/agent/models.json, key in pi's auth.json, default model in settings.json. - Codex: its own profile, ~/.codex/lightning.config.toml (0600), used with `codex --profile lightning`; config.toml is never touched. - dsh: provider and default model in the web and headless profile patches, only those entries rewritten; key in ~/.dsh/.credentials.yaml as LIGHTNING_CODE_API_KEY (not LIGHTNING_API_KEY, which Studios set). - Cursor: can't be configured from outside, so a key is created and the steps to add it in Cursor Settings are printed. Also: OpenCode adds `lightning` to an enabled_providers allowlist, remove deletes files setup created, files holding keys are never backed up, and --dry-run masks keys and summarises shared credential files. --- python/docs/cli/index.rst | 2 +- python/examples/README.md | 2 +- .../examples/code/codex-lightning.config.toml | 14 + python/examples/code/dsh-cordis.patch.yml | 33 ++ python/examples/code/dsh-credentials.yaml | 3 + python/examples/code/pi-models.json | 46 +++ python/examples/code/pi-settings.json | 4 + python/examples/code_cli.rst | 147 +++++-- python/lightning_sdk/cli/code/codex.py | 145 +++++++ python/lightning_sdk/cli/code/cursor.py | 72 ++++ python/lightning_sdk/cli/code/dsh.py | 371 +++++++++++++++++ python/lightning_sdk/cli/code/files.py | 75 ++++ python/lightning_sdk/cli/code/opencode.py | 344 +++++++--------- python/lightning_sdk/cli/code/pi.py | 203 +++++++++ python/lightning_sdk/cli/code/registry.py | 11 + python/lightning_sdk/cli/code/remove.py | 43 +- python/lightning_sdk/cli/code/setup.py | 114 ++--- python/lightning_sdk/cli/code/status.py | 84 ++-- python/lightning_sdk/cli/code/token.py | 2 +- python/lightning_sdk/cli/code/tool.py | 237 +++++++++++ python/lightning_sdk/utils/jsonc.py | 53 ++- python/tests/cli/code/conftest.py | 65 +++ python/tests/cli/code/test_code.py | 150 +++---- python/tests/cli/code/test_tools.py | 388 ++++++++++++++++++ 24 files changed, 2184 insertions(+), 424 deletions(-) create mode 100644 python/examples/code/codex-lightning.config.toml create mode 100644 python/examples/code/dsh-cordis.patch.yml create mode 100644 python/examples/code/dsh-credentials.yaml create mode 100644 python/examples/code/pi-models.json create mode 100644 python/examples/code/pi-settings.json create mode 100644 python/lightning_sdk/cli/code/codex.py create mode 100644 python/lightning_sdk/cli/code/cursor.py create mode 100644 python/lightning_sdk/cli/code/dsh.py create mode 100644 python/lightning_sdk/cli/code/files.py create mode 100644 python/lightning_sdk/cli/code/pi.py create mode 100644 python/lightning_sdk/cli/code/registry.py create mode 100644 python/lightning_sdk/cli/code/tool.py create mode 100644 python/tests/cli/code/conftest.py create mode 100644 python/tests/cli/code/test_tools.py diff --git a/python/docs/cli/index.rst b/python/docs/cli/index.rst index c1c8aeac..41291fc9 100644 --- a/python/docs/cli/index.rst +++ b/python/docs/cli/index.rst @@ -98,7 +98,7 @@ Common Workflows * Configure accounts, organizations, teamspaces, cloud accounts, and SSH with :doc:`config`, :doc:`api-key`, and :doc:`ssh`. * Manage lower-level sandbox sessions with :doc:`sandbox`. -* Point coding agents such as OpenCode at code.lightning.ai with :doc:`code`. +* Point coding agents (OpenCode, pi, Codex, DeepSeek Harness, Cursor) at code.lightning.ai with :doc:`code`. Command details --------------- diff --git a/python/examples/README.md b/python/examples/README.md index 905513cb..8db79a12 100644 --- a/python/examples/README.md +++ b/python/examples/README.md @@ -71,7 +71,7 @@ export LIGHTNING_SANDBOX_API_KEY="..." | [`sandboxes_cli.rst`](sandboxes_cli.rst) | Create sandboxes, run commands, inspect logs, stop, resume, and delete from the CLI | | [`logs_cli.rst`](logs_cli.rst) | Tail, follow, search, and page logs for jobs, MMTs, deployments, and sandboxes | | [`api_cli.rst`](api_cli.rst) | Call authenticated Lightning API endpoints directly from shell scripts | -| [`code_cli.rst`](code_cli.rst) | Set up OpenCode for code.lightning.ai and create keys for other coding tools | +| [`code_cli.rst`](code_cli.rst) | Set up OpenCode, pi, Codex, DeepSeek Harness and Cursor for code.lightning.ai | # Running examples diff --git a/python/examples/code/codex-lightning.config.toml b/python/examples/code/codex-lightning.config.toml new file mode 100644 index 00000000..809450bc --- /dev/null +++ b/python/examples/code/codex-lightning.config.toml @@ -0,0 +1,14 @@ +# Written by `lightning code setup codex`. Use it with: codex --profile lightning +# Run the setup again to update it; `lightning code remove codex` deletes it. +# Models: lightning-ai/glm-5.3, lightning-ai/glm-5.3-flash, lightning-ai/deepseek-v4.1-flash +model_provider = "lightning" +model = "lightning-ai/deepseek-v4.1-flash" +model_context_window = 253952 +model_reasoning_summary = "none" + +[model_providers.lightning] +name = "Lightning AI" +base_url = "https://code.lightning.ai/v1" +wire_api = "responses" +experimental_bearer_token = "sk-lit-replace-me" +stream_idle_timeout_ms = 600000 diff --git a/python/examples/code/dsh-cordis.patch.yml b/python/examples/code/dsh-cordis.patch.yml new file mode 100644 index 00000000..f63843e3 --- /dev/null +++ b/python/examples/code/dsh-cordis.patch.yml @@ -0,0 +1,33 @@ +- id: llm-pi-ai + config: + providers: + lightning: + displayName: Lightning AI + apiKeyEnv: LIGHTNING_CODE_API_KEY + api: openai-completions + baseURL: https://code.lightning.ai/v1 + models: + - id: lightning-ai/glm-5.3 + name: GLM-5.3 + contextWindow: 1048576 + maxTokens: 131072 + input: + - text + - id: lightning-ai/glm-5.3-flash + name: GLM-5.3 Flash + contextWindow: 253952 + maxTokens: 131072 + input: + - text + - image + - id: lightning-ai/deepseek-v4.1-flash + name: DeepSeek V4.1 Flash + contextWindow: 253952 + maxTokens: 131072 + input: + - text + - image +- id: agent-default-model + config: + provider: lightning + model: lightning-ai/deepseek-v4.1-flash diff --git a/python/examples/code/dsh-credentials.yaml b/python/examples/code/dsh-credentials.yaml new file mode 100644 index 00000000..d3d0b944 --- /dev/null +++ b/python/examples/code/dsh-credentials.yaml @@ -0,0 +1,3 @@ +version: 1 +refs: + LIGHTNING_CODE_API_KEY: sk-lit-replace-me diff --git a/python/examples/code/pi-models.json b/python/examples/code/pi-models.json new file mode 100644 index 00000000..7bc2b8c0 --- /dev/null +++ b/python/examples/code/pi-models.json @@ -0,0 +1,46 @@ +{ + "providers": { + "lightning": { + "name": "Lightning AI", + "baseUrl": "https://code.lightning.ai/v1", + "api": "openai-completions", + "compat": { + "sendSessionAffinityHeaders": true + }, + "models": [ + { + "id": "lightning-ai/glm-5.3", + "name": "GLM-5.3", + "reasoning": true, + "input": [ + "text" + ], + "contextWindow": 1048576, + "maxTokens": 131072 + }, + { + "id": "lightning-ai/glm-5.3-flash", + "name": "GLM-5.3 Flash", + "reasoning": true, + "input": [ + "text", + "image" + ], + "contextWindow": 253952, + "maxTokens": 131072 + }, + { + "id": "lightning-ai/deepseek-v4.1-flash", + "name": "DeepSeek V4.1 Flash", + "reasoning": true, + "input": [ + "text", + "image" + ], + "contextWindow": 253952, + "maxTokens": 131072 + } + ] + } + } +} diff --git a/python/examples/code/pi-settings.json b/python/examples/code/pi-settings.json new file mode 100644 index 00000000..8f131588 --- /dev/null +++ b/python/examples/code/pi-settings.json @@ -0,0 +1,4 @@ +{ + "defaultProvider": "lightning", + "defaultModel": "lightning-ai/deepseek-v4.1-flash" +} diff --git a/python/examples/code_cli.rst b/python/examples/code_cli.rst index a735b88f..fb780cd3 100644 --- a/python/examples/code_cli.rst +++ b/python/examples/code_cli.rst @@ -4,54 +4,146 @@ Coding agents CLI examples `code.lightning.ai `_ serves GLM-5.3, GLM-5.3 Flash and DeepSeek V4.1 Flash to coding agents through an OpenAI-compatible API at ``https://code.lightning.ai/v1``. The ``lightning code`` commands set up a coding -tool to use it and create the API key it needs. +tool to use it and create the API key it needs. They work the same on macOS and +Linux. Requests bill the organization you pick, which needs a Pro, Teams or Enterprise plan. A Free organization gets a link to upgrade instead of a key. -Set up OpenCode ---------------- +Set up a tool +------------- .. code-block:: console $ pip install lightning-sdk -U $ lightning login - $ curl -fsSL https://opencode.ai/install | bash $ lightning code setup opencode --org my-org OpenCode now uses code.lightning.ai, billed to My Org [my-org]. Provider: lightning in ~/.config/opencode/opencode.json - API key: opencode on my-laptop 2026-10-09 in ~/.local/share/opencode/auth.json + Key file: ~/.local/share/opencode/auth.json + API key: opencode on my-laptop 2026-10-09 Default: lightning/deepseek-v4.1-flash - $ opencode +The tool is one of ``opencode``, ``pi``, ``codex``, ``dsh`` (DeepSeek Harness) or +``cursor``. Without ``--org``, the command lists your organizations with their +plans and asks which one pays. In scripts and other non-interactive shells, +``--org`` is required. -Without ``--org``, the command lists your organizations with their plans and asks -which one pays. In scripts and other non-interactive shells, ``--org`` is required. +For every tool, the setup: -The setup leaves the rest of OpenCode's config alone: - -- The ``lightning`` provider is added to your global config, keeping its comments - and other settings. The file is backed up as ``opencode.json.lightning-backup`` - first. `code/opencode.json `_ is what a new config looks like. -- The API key goes to OpenCode's credential store, ``auth.json``, next to the keys - ``opencode auth login`` saves, never into the config file. Its metadata records - the organization and key, as in `code/opencode-auth.json `_. -- ``lightning/deepseek-v4.1-flash`` becomes the default model only if you have no default yet. +- Adds Lightning as a provider called ``lightning`` and leaves the rest of the + tool's config alone, comments included. A config file you already had is backed + up as ``.lightning-backup`` first. Files that hold keys aren't copied. +- Saves the API key where the tool keeps its own keys, never in a file you'd + share or commit. +- Makes ``deepseek-v4.1-flash`` the default model only if you have no default yet. Pass ``--model`` to set one anyway. -- The models, with their names, context windows, output limits and image support, - come from ``https://code.lightning.ai/v1/models``, so new models appear without - a CLI update. If it can't be reached, a built-in list is used. +- Takes the models, with their names, context windows, output limits and image + support, from ``https://code.lightning.ai/v1/models``, so new models appear + without a CLI update. If it can't be reached, a built-in list is used. Preview the changes first, or pick a different default model: .. code-block:: console - $ lightning code setup opencode --org my-org --dry-run - $ lightning code setup opencode --org my-org --model glm-5.3 + $ lightning code setup pi --org my-org --dry-run + $ lightning code setup pi --org my-org --model glm-5.3 Run it again to switch organizations, which creates a key there and revokes the old one, or to pick up new models. ``--rotate-key`` replaces the key in place. +``--force`` replaces a Lightning setup you made by hand. + +OpenCode +-------- + +.. code-block:: console + + $ curl -fsSL https://opencode.ai/install | bash + $ lightning code setup opencode --org my-org + $ opencode + +The provider goes into ``~/.config/opencode/opencode.json`` (or ``opencode.jsonc``) +and the key into OpenCode's ``~/.local/share/opencode/auth.json``. If your config +limits providers with ``enabled_providers``, ``lightning`` is added to it. See +`code/opencode.json `_ and +`code/opencode-auth.json `_. + +pi +-- + +.. code-block:: console + + $ curl -fsSL https://pi.dev/install.sh | sh + $ lightning code setup pi --org my-org + $ pi + +The provider goes into ``~/.pi/agent/models.json``, the key into pi's +``auth.json`` and the default model into ``settings.json``, all under +``$PI_CODING_AGENT_DIR`` when it's set. See `code/pi-models.json `_ +and `code/pi-settings.json `_. + +Codex +----- + +.. code-block:: console + + $ npm install -g @openai/codex + $ lightning code setup codex --org my-org + $ codex --profile lightning + $ codex --profile lightning -m lightning-ai/glm-5.3 + +Codex gets a profile of its own, ``~/.codex/lightning.config.toml``, which it +layers over your ``config.toml`` when started with ``--profile lightning``, so +your usual Codex setup doesn't change. The profile holds the key and only you can +read it. See `code/codex-lightning.config.toml `_. + +Codex talks to code.lightning.ai over the Responses API. Image input through +Codex isn't supported yet; the other tools send images fine. + +DeepSeek Harness +---------------- + +.. code-block:: console + + $ npm install -g @deepseek-ai/dsh # needs Node.js 22.19 or newer + $ lightning code setup dsh --org my-org + $ dsh web + +The provider and default model go into the ``web`` and ``headless`` profiles, +``~/.dsh/profiles//cordis.patch.yml``, next to your other entries. The +key goes into dsh's ``~/.dsh/.credentials.yaml`` as ``LIGHTNING_CODE_API_KEY``. +See `code/dsh-cordis.patch.yml `_ and +`code/dsh-credentials.yaml `_. + +Cursor +------ + +Cursor keeps these settings where nothing outside it can safely change them, so +the setup creates a key and prints the steps to add it in Cursor Settings: + +.. code-block:: console + + $ lightning code setup cursor --org my-org + Cursor now uses code.lightning.ai, billed to My Org [my-org]. + API key: cursor on my-laptop 2026-10-09 + + Finish in Cursor (needs a paid Cursor plan): + 1. Open Cursor Settings (Cmd+Shift+J on macOS, Ctrl+Shift+J elsewhere) and go to Models. + 2. Under API Keys, paste this into OpenAI API Key and press Enter: + sk-lit-... + 3. Turn on "Use OpenAI API Key". + 4. Turn on "Override OpenAI Base URL" and enter: + https://code.lightning.ai/v1 + 5. At the top of Models, add these model names and make sure they're switched on: + lightning-ai/glm-5.3 + lightning-ai/glm-5.3-flash + lightning-ai/deepseek-v4.1-flash + 6. Open a new chat and pick lightning-ai/deepseek-v4.1-flash. + +While the base URL override is on, Cursor sends all OpenAI models to Lightning, +so its own GPT and Auto models stop working until you turn it off. The +``cursor-agent`` CLI can't use Lightning models. Check and undo the setup ------------------------ @@ -59,16 +151,19 @@ Check and undo the setup .. code-block:: console $ lightning code status - OpenCode - Org: my-org + OpenCode: billed to my-org API key: opencode on my-laptop 2026-10-09 (01jexamplekey0000000000000) Config: ~/.config/opencode/opencode.json Default: lightning/deepseek-v4.1-flash + pi: not set up + Codex: billed to my-org + ... $ lightning code remove opencode -``remove`` takes out the provider, a default model on it, and the key, -which it also revokes. Pass ``--keep-key`` to leave the key active. +``remove`` takes out the provider, a Lightning default model, and the key, which +it also revokes. Files the setup created are deleted again. Pass ``--keep-key`` to +leave the key active. Use other tools --------------- diff --git a/python/lightning_sdk/cli/code/codex.py b/python/lightning_sdk/cli/code/codex.py new file mode 100644 index 00000000..3557cf29 --- /dev/null +++ b/python/lightning_sdk/cli/code/codex.py @@ -0,0 +1,145 @@ +"""Codex: a `lightning` profile, used with `codex --profile lightning`. + +Codex layers $CODEX_HOME/.config.toml over config.toml when started with +--profile , so the whole setup lives in a file of our own and the user's config.toml +and default model stay as they are. The file holds the key, so only the user can read it. +Codex only speaks the Responses API to custom providers, which code.lightning.ai serves. +""" + +import os +from pathlib import Path +from typing import Any, Optional, Sequence + +import tomllib + +from lightning_sdk.cli.code import files +from lightning_sdk.cli.code.models import CODE_BASE_URL, REQUEST_TIMEOUT_MS, CodingModel +from lightning_sdk.cli.code.tool import ( + FileChange, + KeyRecord, + Plan, + Tool, + ToolConfigError, + ToolState, + default_to_set, + record_change, +) + +PROFILE = "lightning" +PROVIDER_ID = "lightning" +# the first line of a profile `lightning code` wrote +MARKER = "# Written by `lightning code setup codex`." +# Codex caps the context of models it doesn't know at this many tokens +_MAX_UNKNOWN_CONTEXT = 272_000 + + +def codex_home() -> Path: + return Path(os.environ.get("CODEX_HOME") or Path.home() / ".codex").expanduser() + + +def profile_path() -> Path: + return codex_home() / f"{PROFILE}.config.toml" + + +def _toml_string(value: str) -> str: + return '"' + value.replace("\\", "\\\\").replace('"', '\\"') + '"' + + +def profile_text(models: Sequence[CodingModel], *, model: str, key: str) -> str: + # one context window covers every model, since -m switches between them in the same profile + context = min(min(m.context_window for m in models), _MAX_UNKNOWN_CONTEXT) + names = ", ".join(m.id for m in models) + return f"""{MARKER} Use it with: codex --profile {PROFILE} +# Run the setup again to update it; `lightning code remove codex` deletes it. +# Models: {names} +model_provider = "{PROVIDER_ID}" +model = {_toml_string(model)} +model_context_window = {context} +model_reasoning_summary = "none" + +[model_providers.{PROVIDER_ID}] +name = "Lightning AI" +base_url = "{CODE_BASE_URL}" +wire_api = "responses" +experimental_bearer_token = {_toml_string(key)} +stream_idle_timeout_ms = {REQUEST_TIMEOUT_MS} +""" + + +def model_ref(model: CodingModel) -> str: + return model.id + + +class Codex(Tool): + id = "codex" + name = "Codex" + binary = "codex" + install = "npm install -g @openai/codex" + start = f"Start coding with `codex --profile {PROFILE}`, and switch models with -m ." + + def read(self) -> ToolState: + path = profile_path() + text = files.read_text(path) + try: + profile: dict[str, Any] = tomllib.loads(text) if text.strip() else {} + except tomllib.TOMLDecodeError as exc: + raise ToolConfigError(f"{path}: {exc}") from None + provider = (profile.get("model_providers") or {}).get(PROVIDER_ID) or {} + key = provider.get("experimental_bearer_token") + ours = text.startswith(MARKER) + record = KeyRecord.from_dict(files.load_record(self.id)) if ours else None + model = profile.get("model") + return ToolState( + record=record, + key=key if isinstance(key, str) and ours else None, + configured=bool(text.strip()), + default_model=model if isinstance(model, str) else None, + location=str(path), + data=text, + ) + + def plan_setup( + self, + state: ToolState, + *, + models: Sequence[CodingModel], + model: Optional[str], + key: str, + record: KeyRecord, + ) -> Plan: + # the profile only ever holds Lightning models, so a default not served any more is replaced + set_model = default_to_set( + state.default_model, requested=model, models=models, ref=model_ref, ours=lambda ref: True + ) + chosen = set_model or state.default_model or "" + text = profile_text(models, model=chosen, key=key) + return Plan( + changes=[ + FileChange(profile_path(), state.data, text, mode=0o600, secret=True), + record_change(self.id, record), + ], + set_model=set_model, + ) + + def plan_remove(self, state: ToolState) -> Plan: + plan = Plan(changes=[record_change(self.id, None)]) + if state.configured: + plan.changes.insert(0, FileChange(profile_path(), state.data, None, secret=True, backup=False)) + plan.removed.append(f"the {PROFILE} profile {profile_path()}") + return plan + + def summary(self, state: ToolState, plan: Plan) -> list[tuple[str, str]]: + return [("Profile", f"{profile_path()} (holds the API key)")] + + def warnings(self, state: ToolState) -> list[str]: + config = codex_home() / "config.toml" + try: + user = tomllib.loads(files.read_text(config)) + except tomllib.TOMLDecodeError: + return [f"{config} isn't valid TOML, so Codex won't start until it's fixed."] + notes = [] + if PROFILE in (user.get("profiles") or {}): + notes.append(f"{config} has a [profiles.{PROFILE}] table, which makes `codex --profile {PROFILE}` fail.") + if PROVIDER_ID in (user.get("model_providers") or {}): + notes.append(f"{config} defines [model_providers.{PROVIDER_ID}], which the profile overrides.") + return notes diff --git a/python/lightning_sdk/cli/code/cursor.py b/python/lightning_sdk/cli/code/cursor.py new file mode 100644 index 00000000..26ef4117 --- /dev/null +++ b/python/lightning_sdk/cli/code/cursor.py @@ -0,0 +1,72 @@ +"""Cursor: create a key and print the steps to add it in Cursor Settings. + +Cursor keeps these settings in an internal database it holds open and rewrites, with the +key encrypted through the OS keychain, so nothing outside it can set them safely. Its +`cursor-agent` CLI only runs Cursor's own models. +""" + +from typing import Optional, Sequence + +from lightning_sdk.cli.code import files +from lightning_sdk.cli.code.models import CODE_BASE_URL, CodingModel, default_model +from lightning_sdk.cli.code.tool import KeyRecord, Plan, Tool, ToolState, record_change + + +class Cursor(Tool): + id = "cursor" + name = "Cursor" + start = "" + + def read(self) -> ToolState: + # Cursor's copy of the key can't be read back, so every setup creates a new one + record = KeyRecord.from_dict(files.load_record(self.id)) + return ToolState( + record=record, + key=None, + configured=record is not None, + default_model=None, + location="Cursor Settings > Models", + ) + + def plan_setup( + self, + state: ToolState, + *, + models: Sequence[CodingModel], + model: Optional[str], + key: str, + record: KeyRecord, + ) -> Plan: + return Plan(changes=[record_change(self.id, record)]) + + def plan_remove(self, state: ToolState) -> Plan: + plan = Plan(changes=[record_change(self.id, None)]) + if state.record is not None: + plan.removed.append("the record of Cursor's key") + return plan + + def instructions(self, key: str, models: Sequence[CodingModel], plan: Plan) -> Optional[str]: + names = "\n".join(f" {m.id}" for m in models) + first = default_model(tuple(models)) + first_id = next(m.id for m in models if m.key == first) + return f"""Finish in Cursor (needs a paid Cursor plan): + 1. Open Cursor Settings (Cmd+Shift+J on macOS, Ctrl+Shift+J elsewhere) and go to Models. + 2. Under API Keys, paste this into OpenAI API Key and press Enter: + {key} + 3. Turn on "Use OpenAI API Key". + 4. Turn on "Override OpenAI Base URL" and enter: + {CODE_BASE_URL} + 5. At the top of Models, add these model names and make sure they're switched on: +{names} + 6. Open a new chat and pick {first_id}. + +The key is shown only this once. While the base URL override is on, Cursor sends all +OpenAI models to Lightning, so its own GPT and Auto models stop working; turn the +override off to switch back. Tab completion keeps using Cursor's models, and the +cursor-agent CLI can't use Lightning models.""" + + def removal_instructions(self) -> Optional[str]: + return ( + 'In Cursor Settings > Models, turn off "Override OpenAI Base URL" and "Use OpenAI API Key",\n' + "and remove the Lightning model names." + ) diff --git a/python/lightning_sdk/cli/code/dsh.py b/python/lightning_sdk/cli/code/dsh.py new file mode 100644 index 00000000..c5835e21 --- /dev/null +++ b/python/lightning_sdk/cli/code/dsh.py @@ -0,0 +1,371 @@ +"""DeepSeek Harness (dsh): add code.lightning.ai as the `lightning` model provider. + +dsh composes each profile from bundled patch layers plus the user's +$DSH_HOME/profiles//cordis.patch.yml, a YAML list of entries addressed by id. +Custom providers live in the `llm-pi-ai` entry and the default model in +`agent-default-model`, which is where its Settings page writes them too. Only those two +entries are rewritten; every other entry keeps its text and comments. The key goes into +dsh's credential file, $DSH_HOME/.credentials.yaml, under the name the provider reads. +""" + +import copy +import os +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Optional, Sequence + +import yaml + +from lightning_sdk.cli.code import files +from lightning_sdk.cli.code.models import CODE_BASE_URL, CodingModel +from lightning_sdk.cli.code.tool import ( + FileChange, + KeyRecord, + Plan, + Tool, + ToolConfigError, + ToolState, + default_to_set, + record_change, +) + +PROVIDER_ID = "lightning" +# Not LIGHTNING_API_KEY, which dsh would derive from the provider id: that one holds the +# Lightning platform key in Studios, and the environment wins over the credential file. +KEY_ENV = "LIGHTNING_CODE_API_KEY" +PROVIDERS_ENTRY = "llm-pi-ai" +DEFAULT_ENTRY = "agent-default-model" +# the profiles `dsh web` and one-shot `dsh headless` runs boot +PROFILES = ("web", "headless") +PATCH_FILE = "cordis.patch.yml" + + +def dsh_home() -> Path: + return Path(os.environ.get("DSH_HOME") or Path.home() / ".dsh").expanduser() + + +def patch_path(profile: str) -> Path: + return dsh_home() / "profiles" / profile / PATCH_FILE + + +def credentials_path() -> Path: + return dsh_home() / ".credentials.yaml" + + +def provider_config(models: Sequence[CodingModel]) -> dict[str, Any]: + return { + "displayName": "Lightning AI", + "apiKeyEnv": KEY_ENV, + "api": "openai-completions", + "baseURL": CODE_BASE_URL, + "models": [ + { + "id": model.id, + "name": model.name, + "contextWindow": model.context_window, + "maxTokens": model.output_tokens, + "input": ["text", "image"] if model.images else ["text"], + } + for model in models + ], + } + + +def model_ref(model: CodingModel) -> str: + return f"{PROVIDER_ID}/{model.id}" + + +def _ours(ref: str) -> bool: + return ref.startswith(f"{PROVIDER_ID}/") + + +class _Tagged: + """A value with a YAML tag PyYAML doesn't know, such as dsh's `!!js` expressions, kept as written.""" + + def __init__(self, tag: str, value: Any) -> None: + self.tag = tag + self.value = value + + +class _Loader(yaml.SafeLoader): + pass + + +class _Dumper(yaml.SafeDumper): + pass + + +def _construct_tagged(loader: yaml.SafeLoader, suffix: str, node: yaml.Node) -> _Tagged: + if isinstance(node, yaml.ScalarNode): + value: Any = loader.construct_scalar(node) + elif isinstance(node, yaml.SequenceNode): + value = loader.construct_sequence(node, deep=True) + elif isinstance(node, yaml.MappingNode): + value = loader.construct_mapping(node, deep=True) + else: + raise yaml.constructor.ConstructorError(None, None, f"unexpected node for tag {node.tag}", node.start_mark) + return _Tagged(node.tag, value) + + +def _represent_tagged(dumper: yaml.SafeDumper, data: _Tagged) -> yaml.Node: + if isinstance(data.value, list): + return dumper.represent_sequence(data.tag, data.value) + if isinstance(data.value, dict): + return dumper.represent_mapping(data.tag, data.value) + return dumper.represent_scalar(data.tag, data.value) + + +_Loader.add_multi_constructor("!", _construct_tagged) +_Loader.add_multi_constructor("tag:yaml.org,2002:js", _construct_tagged) +_Dumper.add_representer(_Tagged, _represent_tagged) + + +def _load(text: str, path: Path) -> list[Any]: + try: + data = yaml.load(text, Loader=_Loader) if text.strip() else [] # - SafeLoader subclass + except yaml.YAMLError as exc: + raise ToolConfigError(f"{path}: {exc}") from None + if data is None: + return [] + if not isinstance(data, list): + raise ToolConfigError(f"{path} is not a YAML list of patch entries") + return data + + +def _dump(entries: list[Any]) -> str: + return yaml.dump(entries, Dumper=_Dumper, sort_keys=False, default_flow_style=False, allow_unicode=True) + + +def _item_spans(text: str, count: int) -> Optional[list[tuple[int, int]]]: + """Line ranges of the top-level list items, or None when the text isn't a plain block list.""" + lines = text.splitlines(keepends=True) + starts = [i for i, line in enumerate(lines) if line.startswith("- ") or line.rstrip("\r\n") == "-"] + if len(starts) != count: + return None + spans = [] + for n, start in enumerate(starts): + end = starts[n + 1] if n + 1 < len(starts) else len(lines) + # comments and blank lines just before the next item belong to it + while end > start + 1 and (not lines[end - 1].strip() or lines[end - 1].lstrip().startswith("#")): + end -= 1 + spans.append((start, end)) + return spans + + +def _entry_id(entry: Any) -> Optional[str]: + return entry.get("id") if isinstance(entry, dict) else None + + +def _set_entries(text: str, entries: list[Any], updates: dict[str, Optional[dict[str, Any]]]) -> str: + """Replace, add or (with None) drop the entries with these ids, leaving the rest of the text as it is.""" + new_entries = [] + done = set() + for entry in entries: + entry_id = _entry_id(entry) + if entry_id in updates: + done.add(entry_id) + if updates[entry_id] is not None: + new_entries.append(updates[entry_id]) + else: + new_entries.append(entry) + new_entries += [entry for entry_id, entry in updates.items() if entry_id not in done and entry is not None] + + spans = _item_spans(text, len(entries)) if entries else None + if spans is None: + # empty, `[]`, or not a block list we can splice: write the whole list, keeping leading comments + header = "".join(line for line in text.splitlines(keepends=True) if line.lstrip().startswith("#")) + return header + (_dump(new_entries) if new_entries else "[]\n") + + lines = text.splitlines(keepends=True) + out: list[str] = [] + previous_end = 0 + for (start, end), entry in zip(spans, entries): + out += lines[previous_end:start] + entry_id = _entry_id(entry) + if entry_id in updates: + if updates[entry_id] is not None: + out.append(_dump([updates[entry_id]])) + else: + out += lines[start:end] + previous_end = end + out += lines[previous_end:] + result = "".join(out) + added = [entry for entry_id, entry in updates.items() if entry_id not in done and entry is not None] + if added: + if result and not result.endswith("\n"): + result += "\n" + result += _dump(added) + return result if result.strip() else "[]\n" + + +def _find(entries: list[Any], entry_id: str) -> Optional[dict[str, Any]]: + return next((e for e in entries if _entry_id(e) == entry_id), None) + + +def _with_provider(entry: Optional[dict[str, Any]], provider: Optional[dict[str, Any]]) -> Optional[dict[str, Any]]: + """The `llm-pi-ai` entry with our provider set, or removed when ``provider`` is None.""" + entry = copy.deepcopy(entry) if entry else {"id": PROVIDERS_ENTRY} + config = entry.get("config") + if not isinstance(config, dict): + config = entry["config"] = {} + providers = config.get("providers") + if not isinstance(providers, dict): + providers = config["providers"] = {} + if provider is None: + providers.pop(PROVIDER_ID, None) + if not providers and set(config) == {"providers"} and set(entry) == {"id", "config"}: + # the entry was only there for our provider + return None + else: + providers[PROVIDER_ID] = provider + return entry + + +@dataclass +class _Files: + patches: dict[str, tuple[str, list[Any]]] + credentials_text: str + credentials: dict[str, Any] + + +class DeepSeekHarness(Tool): + id = "dsh" + name = "DeepSeek Harness" + binary = "dsh" + install = "npm install -g @deepseek-ai/dsh (needs Node.js 22.19 or newer)" + start = "Start coding with `dsh web`, and pick a Lightning model under Model in the message box." + + def read(self) -> ToolState: + patches = {} + for profile in PROFILES: + path = patch_path(profile) + text = files.read_text(path) + patches[profile] = (text, _load(text, path)) + credentials_text = files.read_text(credentials_path()) + try: + credentials = yaml.safe_load(credentials_text) if credentials_text.strip() else {} + except yaml.YAMLError as exc: + raise ToolConfigError(f"{credentials_path()}: {exc}") from None + if not isinstance(credentials, dict): + raise ToolConfigError(f"{credentials_path()} is not a YAML mapping") + + web = patches["web"][1] + providers_entry = _find(web, PROVIDERS_ENTRY) or {} + provider = ((providers_entry.get("config") or {}).get("providers") or {}).get(PROVIDER_ID) + key = (credentials.get("refs") or {}).get(KEY_ENV) + default_entry = (_find(web, DEFAULT_ENTRY) or {}).get("config") or {} + default = None + if default_entry.get("provider") and default_entry.get("model"): + default = f"{default_entry['provider']}/{default_entry['model']}" + record = KeyRecord.from_dict(files.load_record(self.id)) + return ToolState( + record=record if key is not None else None, + key=key if isinstance(key, str) else None, + configured=provider is not None or key is not None, + default_model=default, + location=str(patch_path("web")), + data=_Files(patches, credentials_text, credentials), + ) + + def plan_setup( + self, + state: ToolState, + *, + models: Sequence[CodingModel], + model: Optional[str], + key: str, + record: KeyRecord, + ) -> Plan: + current: _Files = state.data + set_model = default_to_set(state.default_model, requested=model, models=models, ref=model_ref, ours=_ours) + plan = Plan(set_model=set_model) + for profile in PROFILES: + text, entries = current.patches[profile] + updates: dict[str, Optional[dict[str, Any]]] = { + PROVIDERS_ENTRY: _with_provider(_find(entries, PROVIDERS_ENTRY), provider_config(models)) + } + if set_model is not None: + updates[DEFAULT_ENTRY] = { + "id": DEFAULT_ENTRY, + "config": {"provider": PROVIDER_ID, "model": set_model.split("/", 1)[1]}, + } + plan.changes.append(FileChange(patch_path(profile), text, _set_entries(text, entries, updates))) + + credentials = copy.deepcopy(current.credentials) + credentials.setdefault("version", 1) + refs = credentials.get("refs") + credentials["refs"] = {**(refs if isinstance(refs, dict) else {}), KEY_ENV: key} + plan.changes.append( + FileChange( + credentials_path(), + current.credentials_text, + yaml.safe_dump(credentials, sort_keys=False), + mode=0o600, + secret=True, + summary=f"save the API key as {KEY_ENV}", + ) + ) + plan.changes.append(record_change(self.id, record)) + return plan + + def plan_remove(self, state: ToolState) -> Plan: + current: _Files = state.data + plan = Plan() + for profile in PROFILES: + text, entries = current.patches[profile] + updates: dict[str, Optional[dict[str, Any]]] = {} + providers_entry = _find(entries, PROVIDERS_ENTRY) + if providers_entry is not None: + providers = ((providers_entry.get("config") or {}).get("providers")) or {} + if PROVIDER_ID in providers: + updates[PROVIDERS_ENTRY] = _with_provider(providers_entry, None) + default_entry = _find(entries, DEFAULT_ENTRY) + if default_entry is not None and (default_entry.get("config") or {}).get("provider") == PROVIDER_ID: + # dropping the entry brings back dsh's own default model + updates[DEFAULT_ENTRY] = None + if updates: + plan.changes.append( + FileChange(patch_path(profile), text, _set_entries(text, entries, updates), backup=False) + ) + plan.removed.append(f"the {PROVIDER_ID} provider from {patch_path(profile)}") + if state.default_model and _ours(state.default_model): + plan.removed.append(f"the default model {state.default_model}") + + refs = current.credentials.get("refs") + if isinstance(refs, dict) and KEY_ENV in refs: + credentials = copy.deepcopy(current.credentials) + credentials["refs"].pop(KEY_ENV) + plan.changes.append( + FileChange( + credentials_path(), + current.credentials_text, + yaml.safe_dump(credentials, sort_keys=False), + mode=0o600, + secret=True, + summary=f"remove the {KEY_ENV} API key", + backup=False, + ) + ) + plan.removed.append(f"the API key from {credentials_path()}") + plan.changes.append(record_change(self.id, None)) + return plan + + def summary(self, state: ToolState, plan: Plan) -> list[tuple[str, str]]: + return [ + ("Provider", f"{PROVIDER_ID} in the {' and '.join(PROFILES)} profiles under {dsh_home() / 'profiles'}"), + ("Key file", f"{credentials_path()} ({KEY_ENV})"), + ] + + def warnings(self, state: ToolState) -> list[str]: + notes = [] + home_patch = dsh_home() / PATCH_FILE + try: + home_entries = _load(files.read_text(home_patch), home_patch) + except ToolConfigError: + home_entries = [] + for entry_id in (PROVIDERS_ENTRY, DEFAULT_ENTRY): + if _find(home_entries, entry_id) is not None: + notes.append(f"{home_patch} sets '{entry_id}', which overrides the profiles.") + if os.environ.get(KEY_ENV): + notes.append(f"{KEY_ENV} is set in your environment, and dsh uses it instead of the saved key.") + return notes diff --git a/python/lightning_sdk/cli/code/files.py b/python/lightning_sdk/cli/code/files.py new file mode 100644 index 00000000..4c2afe8d --- /dev/null +++ b/python/lightning_sdk/cli/code/files.py @@ -0,0 +1,75 @@ +"""File helpers shared by the coding tools, and the record of what `lightning code` set up.""" + +import json +import os +import stat +from pathlib import Path +from typing import Any, Optional + +BACKUP_SUFFIX = ".lightning-backup" + + +def read_text(path: Path) -> str: + try: + return path.read_text(encoding="utf-8") + except FileNotFoundError: + return "" + + +def read_json(path: Path) -> dict[str, Any]: + """Read a JSON object, or {} when the file is missing or empty. Raises ValueError otherwise.""" + text = read_text(path) + if not text.strip(): + return {} + data = json.loads(text) + if not isinstance(data, dict): + raise ValueError(f"{path} is not a JSON object") + return data + + +def dump_json(data: dict[str, Any]) -> str: + return json.dumps(data, indent=2) + "\n" + + +def write_text(path: Path, text: str, *, mode: Optional[int] = None) -> None: + """Replace ``path`` atomically, keeping its permissions unless ``mode`` is given.""" + path.parent.mkdir(parents=True, exist_ok=True) + if mode is None: + mode = stat.S_IMODE(path.stat().st_mode) if path.exists() else 0o644 + tmp = path.with_name(f".{path.name}.lightning-tmp") + fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, mode) + with os.fdopen(fd, "w", encoding="utf-8") as f: + f.write(text) + os.chmod(tmp, mode) + os.replace(tmp, path) + + +def delete(path: Path) -> None: + path.unlink(missing_ok=True) + + +def records_path() -> Path: + """Where `lightning code` notes the org and key it set up for tools that have no place for it.""" + return Path.home() / ".lightning" / "code-tools.json" + + +def load_record(tool: str) -> Optional[dict[str, str]]: + try: + entry = read_json(records_path()).get(tool) + except ValueError: + return None + return dict(entry) if isinstance(entry, dict) else None + + +def records_text(tool: str, record: Optional[dict[str, str]]) -> tuple[str, str]: + """The record file before and after setting ``tool``'s entry, or removing it when ``record`` is None.""" + before = read_text(records_path()) + try: + data = read_json(records_path()) + except ValueError: + data = {} + if record is None: + data.pop(tool, None) + else: + data[tool] = record + return before, dump_json(data) diff --git a/python/lightning_sdk/cli/code/opencode.py b/python/lightning_sdk/cli/code/opencode.py index a437928c..fe623428 100644 --- a/python/lightning_sdk/cli/code/opencode.py +++ b/python/lightning_sdk/cli/code/opencode.py @@ -3,29 +3,33 @@ The provider block goes into the user's global config, spliced in so comments and other settings stay as they are. The API key goes into OpenCode's own credential store, auth.json, where `opencode auth login` keeps keys; OpenCode hands it to the provider of the -same id. Its metadata records which org and key `lightning code` set up, for re-runs, -`lightning code status` and `lightning code remove`. +same id. Its metadata records which org and key `lightning code` set up. """ -import difflib import json import os -import shutil -import stat from dataclasses import dataclass from pathlib import Path from typing import Any, Optional, Sequence -from lightning_sdk.cli.code.models import CODE_BASE_URL, REQUEST_TIMEOUT_MS, CodingModel, default_model +from lightning_sdk.cli.code import files +from lightning_sdk.cli.code.models import CODE_BASE_URL, REQUEST_TIMEOUT_MS, CodingModel +from lightning_sdk.cli.code.tool import ( + FileChange, + KeyRecord, + Plan, + Tool, + ToolConfigError, + ToolState, + default_to_set, + emptied, +) from lightning_sdk.utils import jsonc -NAME = "OpenCode" PROVIDER_ID = "lightning" SCHEMA_URL = "https://opencode.ai/config.json" -INSTALL_COMMAND = "curl -fsSL https://opencode.ai/install | bash" # marks the auth.json entries `lightning code` wrote, as opposed to ones a user added MANAGED_BY = "lightning-sdk" -BACKUP_SUFFIX = ".lightning-backup" # Read in this order, later files winning; see OpenCode's config/config.ts. _GLOBAL_CONFIG_FILES = ("config.json", "opencode.json", "opencode.jsonc") @@ -83,171 +87,20 @@ def provider_config(models: Sequence[CodingModel]) -> dict[str, Any]: } -def model_ref(model_key: str) -> str: - return f"{PROVIDER_ID}/{model_key}" +def model_ref(model: CodingModel) -> str: + return f"{PROVIDER_ID}/{model.key}" -def _read(path: Path) -> str: - try: - return path.read_text(encoding="utf-8") - except FileNotFoundError: - return "" - - -def _write(path: Path, text: str, *, mode: Optional[int] = None) -> None: - """Replace ``path`` atomically, keeping its permissions unless ``mode`` is given.""" - path.parent.mkdir(parents=True, exist_ok=True) - if mode is None: - mode = stat.S_IMODE(path.stat().st_mode) if path.exists() else 0o644 - tmp = path.with_name(f".{path.name}.lightning-tmp") - fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, mode) - with os.fdopen(fd, "w", encoding="utf-8") as f: - f.write(text) - os.chmod(tmp, mode) - os.replace(tmp, path) - - -@dataclass -class Credential: - """The `lightning` entry in auth.json.""" - - key: str - metadata: dict[str, str] - - @property - def managed(self) -> bool: - return self.metadata.get("managed_by") == MANAGED_BY - - @property - def org_id(self) -> Optional[str]: - return self.metadata.get("org_id") - - @property - def key_id(self) -> Optional[str]: - return self.metadata.get("key_id") +def _ours(ref: str) -> bool: + return ref.startswith(f"{PROVIDER_ID}/") @dataclass -class State: - """What OpenCode's files say about the Lightning setup right now.""" - +class _Files: config_path: Path config_text: str + auth_text: str auth: dict[str, Any] - credential: Optional[Credential] - - @property - def provider(self) -> Optional[Any]: - return jsonc.get_value(self.config_text, ["provider", PROVIDER_ID]) - - @property - def model(self) -> Optional[str]: - return jsonc.get_value(self.config_text, ["model"]) - - @property - def managed(self) -> bool: - return self.credential is not None and self.credential.managed - - @property - def foreign(self) -> bool: - """Whether a `lightning` provider or key exists that `lightning code` didn't write.""" - return not self.managed and (self.provider is not None or self.credential is not None) - - -def read_state() -> State: - """Read the global config and auth.json. Raises ``jsonc.JSONCError`` if either can't be parsed.""" - path = config_path() - text = _read(path) - if text.strip(): - jsonc.loads(text) - - auth_text = _read(auth_path()) - auth = json.loads(auth_text) if auth_text.strip() else {} - if not isinstance(auth, dict): - raise jsonc.JSONCError(f"{auth_path()} is not a JSON object") - entry = auth.get(PROVIDER_ID) - credential = None - if isinstance(entry, dict) and entry.get("type") == "api" and isinstance(entry.get("key"), str): - credential = Credential(entry["key"], dict(entry.get("metadata") or {})) - return State(path, text, auth, credential) - - -@dataclass -class Plan: - """The changes `setup` makes, worked out before anything is written.""" - - state: State - config_text: str - set_model: Optional[str] - - def config_diff(self) -> str: - before = self.state.config_text.splitlines(keepends=True) - after = self.config_text.splitlines(keepends=True) - name = str(self.state.config_path) - return "".join(difflib.unified_diff(before, after, fromfile=name, tofile=name)) - - -def plan_setup(state: State, *, models: Sequence[CodingModel], model: Optional[str]) -> Plan: - """Work out the new config: our provider block, and `model` only when asked or unset. - - ``model`` is a model key passed with --model; without it the default model is set only - when the user has no default of their own, or has one on a Lightning model that's gone. - """ - text = state.config_text - if not text.strip(): - text = jsonc.set_value("", ["$schema"], SCHEMA_URL) - text = jsonc.set_value(text, ["provider", PROVIDER_ID], provider_config(models)) - - served = {model_ref(m.key) for m in models} - retired = isinstance(state.model, str) and state.model.startswith(f"{PROVIDER_ID}/") and state.model not in served - set_model = None - if model is not None: - set_model = model_ref(model) - elif state.model is None or retired: - # also replace a default on a Lightning model that is no longer served - set_model = model_ref(default_model(tuple(models))) - if set_model is not None: - text = jsonc.set_value(text, ["model"], set_model) - return Plan(state, text, set_model) - - -def apply_setup(plan: Plan, *, key: str, metadata: dict[str, str]) -> Optional[Path]: - """Write auth.json and the config. Returns the backup of the config, if there was one to back up.""" - state = plan.state - auth = dict(state.auth) - auth[PROVIDER_ID] = {"type": "api", "key": key, "metadata": {"managed_by": MANAGED_BY, **metadata}} - _write(auth_path(), json.dumps(auth, indent=2) + "\n", mode=0o600) - - backup = None - if plan.config_text != state.config_text: - if state.config_text: - backup = state.config_path.with_name(state.config_path.name + BACKUP_SUFFIX) - shutil.copy2(state.config_path, backup) - _write(state.config_path, plan.config_text) - return backup - - -def remove(state: State) -> list[str]: - """Remove the provider, a default model on it, and the key. Returns what was removed.""" - removed = [] - text = state.config_text - if state.provider is not None: - text = jsonc.remove_value(text, ["provider", PROVIDER_ID]) - if jsonc.get_value(text, ["provider"]) == {}: - text = jsonc.remove_value(text, ["provider"]) - removed.append(f"the {PROVIDER_ID} provider from {state.config_path}") - # a default on the lightning provider can't work once the provider is gone, whoever set it - if isinstance(state.model, str) and state.model.startswith(f"{PROVIDER_ID}/"): - text = jsonc.remove_value(text, ["model"]) - removed.append(f"the default model {state.model}") - if text != state.config_text: - _write(state.config_path, text) - - if state.credential is not None: - auth = {k: v for k, v in state.auth.items() if k != PROVIDER_ID} - _write(auth_path(), json.dumps(auth, indent=2) + "\n", mode=0o600) - removed.append(f"the API key from {auth_path()}") - return removed def _project_configs(start: Path) -> list[Path]: @@ -264,31 +117,138 @@ def _project_configs(start: Path) -> list[Path]: return found -def warnings(state: State, *, cwd: Optional[Path] = None) -> list[str]: - """Settings elsewhere that would stop OpenCode from using what `setup` wrote.""" - notes = [f"{name} is set, which {effect}." for name, effect in _ENV_OVERRIDES.items() if os.environ.get(name)] - - config = jsonc.loads(state.config_text) if state.config_text.strip() else {} - enabled = config.get("enabled_providers") - if isinstance(enabled, list) and PROVIDER_ID not in enabled: - notes.append(f"enabled_providers in {state.config_path} doesn't include '{PROVIDER_ID}'.") - disabled = config.get("disabled_providers") - if isinstance(disabled, list) and PROVIDER_ID in disabled: - notes.append(f"disabled_providers in {state.config_path} includes '{PROVIDER_ID}'.") +class OpenCode(Tool): + id = "opencode" + name = "OpenCode" + binary = "opencode" + install = "curl -fsSL https://opencode.ai/install | bash" + start = "Start coding with `opencode`, and switch models with /models." - for path in _project_configs(cwd or Path.cwd()): + def read(self) -> ToolState: + path = config_path() + text = files.read_text(path) try: - project = jsonc.loads(_read(path)) - except jsonc.JSONCError: - continue - if not isinstance(project, dict): - continue - if isinstance(project.get("provider"), dict) and PROVIDER_ID in project["provider"]: - notes.append(f"{path} defines its own '{PROVIDER_ID}' provider, which wins in that project.") - if "model" in project: - notes.append(f"{path} sets model to {project['model']}, which wins in that project.") - return notes - - -def installed() -> bool: - return shutil.which("opencode") is not None + config = jsonc.loads(text) if text.strip() else {} + auth_text = files.read_text(auth_path()) + auth = json.loads(auth_text) if auth_text.strip() else {} + except (jsonc.JSONCError, ValueError) as exc: + raise ToolConfigError(str(exc)) from None + if not isinstance(config, dict) or not isinstance(auth, dict): + raise ToolConfigError(f"{path} or {auth_path()} is not a JSON object") + + entry = auth.get(PROVIDER_ID) + credential = entry if isinstance(entry, dict) and entry.get("type") == "api" else None + key = credential.get("key") if credential else None + metadata = (credential or {}).get("metadata") or {} + record = KeyRecord.from_dict(metadata) if metadata.get("managed_by") == MANAGED_BY else None + provider = (config.get("provider") or {}).get(PROVIDER_ID) if isinstance(config.get("provider"), dict) else None + model = config.get("model") + return ToolState( + record=record, + key=key if isinstance(key, str) else None, + configured=provider is not None or credential is not None, + default_model=model if isinstance(model, str) else None, + location=str(path), + data=_Files(path, text, auth_text, auth), + ) + + def plan_setup( + self, + state: ToolState, + *, + models: Sequence[CodingModel], + model: Optional[str], + key: str, + record: KeyRecord, + ) -> Plan: + current: _Files = state.data + text = current.config_text + if not text.strip(): + text = jsonc.set_value("", ["$schema"], SCHEMA_URL) + text = jsonc.set_value(text, ["provider", PROVIDER_ID], provider_config(models)) + set_model = default_to_set(state.default_model, requested=model, models=models, ref=model_ref, ours=_ours) + if set_model is not None: + text = jsonc.set_value(text, ["model"], set_model) + enabled = jsonc.get_value(text, ["enabled_providers"]) + if isinstance(enabled, list) and PROVIDER_ID not in enabled: + # an allowlist of providers would hide ours + text = jsonc.append_item(text, ["enabled_providers"], PROVIDER_ID) + + auth = dict(current.auth) + auth[PROVIDER_ID] = {"type": "api", "key": key, "metadata": {"managed_by": MANAGED_BY, **record.as_dict()}} + return Plan( + changes=[ + FileChange( + auth_path(), + current.auth_text, + files.dump_json(auth), + mode=0o600, + secret=True, + summary=f"save the API key as '{PROVIDER_ID}'", + backup=False, + ), + FileChange(current.config_path, current.config_text, text), + ], + set_model=set_model, + ) + + def plan_remove(self, state: ToolState) -> Plan: + current: _Files = state.data + plan = Plan() + text = current.config_text + if jsonc.get_value(text, ["provider", PROVIDER_ID]) is not None: + text = jsonc.remove_value(text, ["provider", PROVIDER_ID]) + if jsonc.get_value(text, ["provider"]) == {}: + text = jsonc.remove_value(text, ["provider"]) + plan.removed.append(f"the {PROVIDER_ID} provider from {current.config_path}") + # a default on the lightning provider can't work once the provider is gone, whoever set it + if state.default_model and _ours(state.default_model): + text = jsonc.remove_value(text, ["model"]) + plan.removed.append(f"the default model {state.default_model}") + enabled = jsonc.get_value(text, ["enabled_providers"]) + if isinstance(enabled, list) and PROVIDER_ID in enabled: + text = jsonc.remove_item(text, ["enabled_providers"], PROVIDER_ID) + plan.removed.append(f"'{PROVIDER_ID}' from enabled_providers") + plan.changes.append( + FileChange(current.config_path, current.config_text, emptied(text, ignore=["$schema"]), backup=False) + ) + + if PROVIDER_ID in current.auth: + auth = {k: v for k, v in current.auth.items() if k != PROVIDER_ID} + plan.changes.append( + FileChange( + auth_path(), + current.auth_text, + files.dump_json(auth), + mode=0o600, + secret=True, + summary=f"remove the '{PROVIDER_ID}' API key", + backup=False, + ) + ) + plan.removed.append(f"the API key from {auth_path()}") + return plan + + def summary(self, state: ToolState, plan: Plan) -> list[tuple[str, str]]: + return [("Provider", f"{PROVIDER_ID} in {state.location}"), ("Key file", str(auth_path()))] + + def warnings(self, state: ToolState, *, cwd: Optional[Path] = None) -> list[str]: + notes = [f"{name} is set, which {effect}." for name, effect in _ENV_OVERRIDES.items() if os.environ.get(name)] + current: _Files = state.data + config = jsonc.loads(current.config_text) if current.config_text.strip() else {} + disabled = config.get("disabled_providers") + if isinstance(disabled, list) and PROVIDER_ID in disabled: + notes.append(f"disabled_providers in {current.config_path} includes '{PROVIDER_ID}'.") + + for path in _project_configs(cwd or Path.cwd()): + try: + project = jsonc.loads(files.read_text(path)) + except jsonc.JSONCError: + continue + if not isinstance(project, dict): + continue + if isinstance(project.get("provider"), dict) and PROVIDER_ID in project["provider"]: + notes.append(f"{path} defines its own '{PROVIDER_ID}' provider, which wins in that project.") + if "model" in project: + notes.append(f"{path} sets model to {project['model']}, which wins in that project.") + return notes diff --git a/python/lightning_sdk/cli/code/pi.py b/python/lightning_sdk/cli/code/pi.py new file mode 100644 index 00000000..e08e4c30 --- /dev/null +++ b/python/lightning_sdk/cli/code/pi.py @@ -0,0 +1,203 @@ +"""pi: add code.lightning.ai as the `lightning` provider in ~/.pi/agent. + +The provider goes into models.json, spliced in so the user's other providers and comments +stay. pi ignores the whole file when one entry is invalid, so only fields its schema +accepts are written. The key goes into pi's credential store, auth.json, where /login +keeps keys, and the default model into settings.json. +""" + +import os +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Optional, Sequence + +from lightning_sdk.cli.code import files +from lightning_sdk.cli.code.models import CODE_BASE_URL, CodingModel +from lightning_sdk.cli.code.tool import ( + FileChange, + KeyRecord, + Plan, + Tool, + ToolConfigError, + ToolState, + default_to_set, + emptied, + record_change, +) +from lightning_sdk.utils import jsonc + +PROVIDER_ID = "lightning" + + +def agent_dir() -> Path: + return Path(os.environ.get("PI_CODING_AGENT_DIR") or Path.home() / ".pi" / "agent").expanduser() + + +def provider_config(models: Sequence[CodingModel]) -> dict[str, Any]: + """The `providers.lightning` entry of models.json. The key isn't in it: pi reads that from auth.json.""" + return { + "name": "Lightning AI", + "baseUrl": CODE_BASE_URL, + "api": "openai-completions", + # lets code.lightning.ai keep a session on one replica, so its prompt cache is reused + "compat": {"sendSessionAffinityHeaders": True}, + "models": [ + { + "id": model.id, + "name": model.name, + "reasoning": model.reasoning, + "input": ["text", "image"] if model.images else ["text"], + "contextWindow": model.context_window, + "maxTokens": model.output_tokens, + } + for model in models + ], + } + + +def model_ref(model: CodingModel) -> str: + # pi's own reference, as `pi --model` takes it + return f"{PROVIDER_ID}/{model.id}" + + +def _ours(ref: str) -> bool: + return ref.startswith(f"{PROVIDER_ID}/") + + +@dataclass +class _Files: + models_text: str + auth_text: str + auth: dict[str, Any] + settings_text: str + settings: dict[str, Any] + + +class Pi(Tool): + id = "pi" + name = "pi" + binary = "pi" + install = "curl -fsSL https://pi.dev/install.sh | sh" + start = "Start coding with `pi`, and switch models with /model." + + def read(self) -> ToolState: + directory = agent_dir() + models_text = files.read_text(directory / "models.json") + auth_text = files.read_text(directory / "auth.json") + settings_text = files.read_text(directory / "settings.json") + try: + models_config = jsonc.loads(models_text) if models_text.strip() else {} + auth = files.read_json(directory / "auth.json") + settings = files.read_json(directory / "settings.json") + except ValueError as exc: + raise ToolConfigError(str(exc)) from None + if not isinstance(models_config, dict): + raise ToolConfigError(f"{directory / 'models.json'} is not a JSON object") + + providers = models_config.get("providers") + provider = providers.get(PROVIDER_ID) if isinstance(providers, dict) else None + credential = auth.get(PROVIDER_ID) if isinstance(auth.get(PROVIDER_ID), dict) else None + key = credential.get("key") if credential else None + default = None + if settings.get("defaultProvider") and settings.get("defaultModel"): + default = f"{settings['defaultProvider']}/{settings['defaultModel']}" + record = KeyRecord.from_dict(files.load_record(self.id)) + return ToolState( + # a record only counts while the tool still has a key to go with it + record=record if credential is not None else None, + key=key if isinstance(key, str) else None, + configured=provider is not None or credential is not None, + default_model=default, + location=str(directory / "models.json"), + data=_Files(models_text, auth_text, auth, settings_text, settings), + ) + + def plan_setup( + self, + state: ToolState, + *, + models: Sequence[CodingModel], + model: Optional[str], + key: str, + record: KeyRecord, + ) -> Plan: + current: _Files = state.data + directory = agent_dir() + models_text = jsonc.set_value(current.models_text, ["providers", PROVIDER_ID], provider_config(models)) + + auth = dict(current.auth) + auth[PROVIDER_ID] = {"type": "api_key", "key": key} + + settings_text = current.settings_text + set_model = default_to_set(state.default_model, requested=model, models=models, ref=model_ref, ours=_ours) + if set_model is not None: + model_id = set_model.split("/", 1)[1] + settings_text = jsonc.set_value(settings_text, ["defaultProvider"], PROVIDER_ID) + settings_text = jsonc.set_value(settings_text, ["defaultModel"], model_id) + + return Plan( + changes=[ + FileChange(directory / "models.json", current.models_text, models_text), + FileChange( + directory / "auth.json", + current.auth_text, + files.dump_json(auth), + mode=0o600, + secret=True, + summary=f"save the API key as '{PROVIDER_ID}'", + ), + FileChange(directory / "settings.json", current.settings_text, settings_text), + record_change(self.id, record), + ], + set_model=set_model, + ) + + def plan_remove(self, state: ToolState) -> Plan: + current: _Files = state.data + directory = agent_dir() + plan = Plan() + models_text = current.models_text + if jsonc.get_value(models_text, ["providers", PROVIDER_ID]) is not None: + models_text = jsonc.remove_value(models_text, ["providers", PROVIDER_ID]) + plan.removed.append(f"the {PROVIDER_ID} provider from {directory / 'models.json'}") + plan.changes.append( + FileChange(directory / "models.json", current.models_text, emptied(models_text), backup=False) + ) + + if state.default_model and _ours(state.default_model): + settings_text = jsonc.remove_value(current.settings_text, ["defaultProvider"]) + settings_text = jsonc.remove_value(settings_text, ["defaultModel"]) + plan.changes.append( + FileChange(directory / "settings.json", current.settings_text, emptied(settings_text), backup=False) + ) + plan.removed.append(f"the default model {state.default_model}") + + if PROVIDER_ID in current.auth: + auth = {k: v for k, v in current.auth.items() if k != PROVIDER_ID} + plan.changes.append( + FileChange( + directory / "auth.json", + current.auth_text, + files.dump_json(auth), + mode=0o600, + secret=True, + summary=f"remove the '{PROVIDER_ID}' API key", + backup=False, + ) + ) + plan.removed.append(f"the API key from {directory / 'auth.json'}") + plan.changes.append(record_change(self.id, None)) + return plan + + def summary(self, state: ToolState, plan: Plan) -> list[tuple[str, str]]: + return [("Provider", f"{PROVIDER_ID} in {state.location}"), ("Key file", str(agent_dir() / "auth.json"))] + + def warnings(self, state: ToolState) -> list[str]: + current: _Files = state.data + notes = [] + enabled = current.settings.get("enabledModels") + if isinstance(enabled, list) and enabled and not any(str(m).startswith(f"{PROVIDER_ID}/") for m in enabled): + notes.append( + f"enabledModels in {agent_dir() / 'settings.json'} has no Lightning model, so /model hides them." + ) + return notes diff --git a/python/lightning_sdk/cli/code/registry.py b/python/lightning_sdk/cli/code/registry.py new file mode 100644 index 00000000..2293ef10 --- /dev/null +++ b/python/lightning_sdk/cli/code/registry.py @@ -0,0 +1,11 @@ +"""The coding tools `lightning code` can set up, by the name the commands take.""" + +from lightning_sdk.cli.code.codex import Codex +from lightning_sdk.cli.code.cursor import Cursor +from lightning_sdk.cli.code.dsh import DeepSeekHarness +from lightning_sdk.cli.code.opencode import OpenCode +from lightning_sdk.cli.code.pi import Pi +from lightning_sdk.cli.code.tool import Tool + +TOOLS: dict[str, Tool] = {tool.id: tool for tool in (OpenCode(), Pi(), Codex(), DeepSeekHarness(), Cursor())} +TOOL_NAMES = tuple(TOOLS) diff --git a/python/lightning_sdk/cli/code/remove.py b/python/lightning_sdk/cli/code/remove.py index c7580bcf..5142acff 100644 --- a/python/lightning_sdk/cli/code/remove.py +++ b/python/lightning_sdk/cli/code/remove.py @@ -2,13 +2,15 @@ import rich_click as click -from lightning_sdk.cli.code import account, opencode +from lightning_sdk.cli.code import account +from lightning_sdk.cli.code import tool as tools +from lightning_sdk.cli.code.registry import TOOL_NAMES, TOOLS +from lightning_sdk.cli.code.tool import ToolConfigError from lightning_sdk.cli.utils.logging import LightningCommand -from lightning_sdk.utils.jsonc import JSONCError @click.command("remove", cls=LightningCommand) -@click.argument("tool", type=click.Choice(["opencode"], case_sensitive=False)) +@click.argument("tool", type=click.Choice(TOOL_NAMES, case_sensitive=False)) @click.option("--keep-key", is_flag=True, default=False, help="Leave the API key active instead of revoking it.") def remove(tool: str, keep_key: bool) -> None: """Stop a coding tool from using code.lightning.ai. @@ -19,28 +21,31 @@ def remove(tool: str, keep_key: bool) -> None: Examples: lightning code remove opencode """ + target = TOOLS[tool.lower()] try: - state = opencode.read_state() - except (JSONCError, ValueError) as exc: - raise click.ClickException(f"Couldn't read OpenCode's config: {exc}") from None - - credential = state.credential if state.managed else None - removed = opencode.remove(state) - if not removed: - click.echo("OpenCode isn't set up for code.lightning.ai.") + state = target.read() + except ToolConfigError as exc: + raise click.ClickException(f"Couldn't read {target.name}'s config: {exc}") from None + + record = state.record + plan = target.plan_remove(state) + if not plan.removed and record is None: + click.echo(f"{target.name} isn't set up for code.lightning.ai.") return - for item in removed: + tools.apply(plan) + for item in plan.removed: click.echo(f"Removed {item}") + if target.removal_instructions(): + click.echo(target.removal_instructions()) - if credential is None or not credential.key_id or not credential.org_id: + if record is None: return - name = credential.metadata.get("key_name", credential.key_id) if keep_key: - click.echo(f"The API key '{name}' is still active.") - elif account.revoke_key(credential.org_id, credential.key_id): - click.echo(f"Revoked the API key '{name}'.") + click.echo(f"The API key '{record.key_name}' is still active.") + elif account.revoke_key(record.org_id, record.key_id): + click.echo(f"Revoked the API key '{record.key_name}'.") else: click.echo( - f"Couldn't revoke the API key {credential.key_id}. Delete it with: " - f"lightning api-key delete {credential.key_id} --org {credential.metadata.get('org_name')}" + f"Couldn't revoke the API key {record.key_id}. Delete it with: " + f"lightning api-key delete {record.key_id} --org {record.org_name}" ) diff --git a/python/lightning_sdk/cli/code/setup.py b/python/lightning_sdk/cli/code/setup.py index c361edc8..8ad78100 100644 --- a/python/lightning_sdk/cli/code/setup.py +++ b/python/lightning_sdk/cli/code/setup.py @@ -4,14 +4,19 @@ import rich_click as click -from lightning_sdk.cli.code import account, opencode +from lightning_sdk.cli.code import account +from lightning_sdk.cli.code import tool as tools from lightning_sdk.cli.code.models import DEFAULT_MODEL, coding_models +from lightning_sdk.cli.code.registry import TOOL_NAMES, TOOLS +from lightning_sdk.cli.code.tool import KeyRecord, ToolConfigError from lightning_sdk.cli.utils.logging import LightningCommand -from lightning_sdk.utils.jsonc import JSONCError + +# stands in for the key in a --dry-run, which doesn't create one +_DRY_RUN_KEY = "sk-lit-dry-run" @click.command("setup", cls=LightningCommand) -@click.argument("tool", type=click.Choice(["opencode"], case_sensitive=False)) +@click.argument("tool", type=click.Choice(TOOL_NAMES, case_sensitive=False)) @click.option("--org", help="Organization that pays, by name. Asked for when you belong to several.") @click.option( "--model", @@ -27,28 +32,33 @@ "-f", is_flag=True, default=False, - help="Replace a 'lightning' provider or key that `lightning code` didn't set up.", + help="Replace a Lightning setup that `lightning code` didn't make.", ) def setup(tool: str, org: Optional[str], model: Optional[str], rotate_key: bool, dry_run: bool, force: bool) -> None: """Set up a coding tool to use code.lightning.ai. - Adds Lightning as a model provider without touching the rest of the tool's config, - and creates an API key that bills the organization you choose. The organization - needs a Pro, Teams or Enterprise plan. Run it again to switch organizations or - pick up new models. + TOOL is one of opencode, pi, codex, dsh (DeepSeek Harness) or cursor. Lightning is + added as a model provider without touching the rest of the tool's config, with an + API key that bills the organization you choose. The organization needs a Pro, Teams + or Enterprise plan. Cursor can't be configured from outside, so for it the key is + printed with the steps to add it in Cursor Settings. + + Run it again to switch organizations or pick up new models. Examples: lightning code setup opencode --org my-org + lightning code setup codex --org my-org --model glm-5.3 """ + target = TOOLS[tool.lower()] try: - state = opencode.read_state() - except (JSONCError, ValueError) as exc: - raise click.ClickException(f"Couldn't read OpenCode's config: {exc}") from None + state = target.read() + except ToolConfigError as exc: + raise click.ClickException(f"Couldn't read {target.name}'s config: {exc}") from None if state.foreign and not force: raise click.ClickException( - f"OpenCode already has a '{opencode.PROVIDER_ID}' provider or key that `lightning code` didn't set up.\n" - "Re-run with --force to replace it. The config file is backed up first." + f"{target.name} already has a Lightning setup that `lightning code` didn't make ({state.location}).\n" + "Re-run with --force to replace it. Files you had are backed up first." ) models, fetch_error = coding_models() @@ -58,64 +68,72 @@ def setup(tool: str, org: Optional[str], model: Optional[str], rotate_key: bool, if model is not None and model not in keys: raise click.UsageError(f"Unknown model '{model}'. Choose one of: {', '.join(keys)}") - previous = state.credential if state.managed else None + previous = state.record chosen = account.choose_org(org, previous_org_id=previous.org_id if previous else None) account.require_coding_plan(chosen) - # the key set up before, when it bills the chosen org and still exists - reuse = None - if previous is not None and not rotate_key and previous.org_id == chosen.id and previous.key_id: - reuse = previous if account.key_exists(chosen.id, previous.key_id) else None - plan = opencode.plan_setup(state, models=models, model=model) + # the key set up before, when the tool still has it, it bills the chosen org and it still exists + reuse = ( + previous is not None + and state.key is not None + and not rotate_key + and previous.org_id == chosen.id + and account.key_exists(chosen.id, previous.key_id) + ) if dry_run: - diff = plan.config_diff() - click.echo(diff if diff else f"No changes to {state.config_path}") - if reuse: - click.echo(f"Would keep the API key '{reuse.metadata.get('key_name', reuse.key_id)}'.") + record = previous if reuse and previous else KeyRecord(chosen.id, chosen.name, "", "") + plan = target.plan_setup(state, models=models, model=model, key=_DRY_RUN_KEY, record=record) + click.echo(plan.describe() or f"No changes to {target.name}'s config.") + if reuse and previous: + click.echo(f"Would keep the API key '{previous.key_name}'.") else: - click.echo(f"Would create an API key billed to {chosen.label} and save it to {opencode.auth_path()}.") - if previous is not None and previous.key_id: - click.echo("Would revoke the API key set up before.") + click.echo(f"Would create an API key billed to {chosen.label}.") + if previous is not None: + click.echo(f"Would revoke the API key '{previous.key_name}' set up before.") return - if reuse: - key, metadata = reuse.key, dict(reuse.metadata) + if reuse and previous and state.key: + key, record = state.key, previous else: name = account.default_key_name(tool) created = account.create_key( - chosen, name, "For OpenCode on code.lightning.ai, created by `lightning code setup`" + chosen, name, f"For {target.name} on code.lightning.ai, created by `lightning code setup`" ) - key = created.raw_key - metadata = {"org_id": chosen.id, "org_name": chosen.name, "key_id": created.id, "key_name": name} - metadata.pop("managed_by", None) + key, record = created.raw_key, KeyRecord(chosen.id, chosen.name, created.id, name) + plan = target.plan_setup(state, models=models, model=model, key=key, record=record) try: - backup = opencode.apply_setup(plan, key=key, metadata=metadata) + backups = tools.apply(plan) except Exception: - if not reuse: - account.revoke_key(chosen.id, metadata["key_id"]) + if record is not previous: + account.revoke_key(chosen.id, record.key_id) raise - click.echo(f"OpenCode now uses code.lightning.ai, billed to {chosen.label}.") - click.echo(f" Provider: {opencode.PROVIDER_ID} in {state.config_path}") - click.echo(f" API key: {metadata['key_name']} in {opencode.auth_path()}") + click.echo(f"{target.name} now uses code.lightning.ai, billed to {chosen.label}.") + lines = [*target.summary(state, plan), ("API key", record.key_name)] if plan.set_model: - click.echo(f" Default: {plan.set_model}") - if backup: - click.echo(f" Backup: {backup}") + lines.append(("Default", plan.set_model)) + lines += [("Backup", str(path)) for path in backups] + width = max(len(label) for label, _ in lines) + 1 + for label, value in lines: + click.echo(f" {label + ':':<{width}} {value}") - if previous is not None and not reuse and previous.key_id and previous.org_id: + if previous is not None and record is not previous: if account.revoke_key(previous.org_id, previous.key_id): - click.echo(f"Revoked the previous API key '{previous.metadata.get('key_name', previous.key_id)}'.") + click.echo(f"Revoked the previous API key '{previous.key_name}'.") else: click.echo( f"Couldn't revoke the previous API key {previous.key_id}. " - f"Delete it with: lightning api-key delete {previous.key_id} --org {previous.metadata.get('org_name')}" + f"Delete it with: lightning api-key delete {previous.key_id} --org {previous.org_name}" ) - for note in opencode.warnings(state): + for note in target.warnings(state): click.echo(f"Note: {note}") - if not opencode.installed(): - click.echo(f"\nOpenCode isn't on your PATH. Install it with:\n {opencode.INSTALL_COMMAND}") - click.echo("\nStart coding with `opencode`, and switch models with /models.") + instructions = target.instructions(key, models, plan) + if instructions: + click.echo(f"\n{instructions}") + if not target.installed(): + click.echo(f"\n{target.name} isn't on your PATH. Install it with:\n {target.install}") + if target.start: + click.echo(f"\n{target.start}") diff --git a/python/lightning_sdk/cli/code/status.py b/python/lightning_sdk/cli/code/status.py index 9c7bf933..5cba3e08 100644 --- a/python/lightning_sdk/cli/code/status.py +++ b/python/lightning_sdk/cli/code/status.py @@ -1,55 +1,63 @@ """lightning code status: which coding tools use code.lightning.ai.""" +from typing import Any + import rich_click as click -from lightning_sdk.cli.code import opencode +from lightning_sdk.cli.code.registry import TOOLS +from lightning_sdk.cli.code.tool import ToolConfigError from lightning_sdk.cli.utils.json_output import echo_json from lightning_sdk.cli.utils.logging import LightningCommand -from lightning_sdk.utils.jsonc import JSONCError @click.command("status", cls=LightningCommand) @click.option("--json", "as_json", is_flag=True, default=False, help="Output as JSON.") def status(as_json: bool = False) -> None: """Show which coding tools are set up for code.lightning.ai, and which org they bill.""" - try: - state = opencode.read_state() - except (JSONCError, ValueError) as exc: - raise click.ClickException(f"Couldn't read OpenCode's config: {exc}") from None + infos: list[dict[str, Any]] = [] + for tool in TOOLS.values(): + info: dict[str, Any] = {"tool": tool.id, "installed": tool.installed()} + try: + state = tool.read() + except ToolConfigError as exc: + infos.append({**info, "configured": False, "managed": False, "error": str(exc)}) + continue + record = state.record + infos.append( + { + **info, + "configured": state.configured, + "managed": record is not None, + "config": state.location, + "org": record.org_name if record else None, + "key_name": record.key_name if record else None, + "key_id": record.key_id if record else None, + "model": state.default_model, + "notes": tool.warnings(state) if state.configured else [], + } + ) - credential = state.credential - metadata = credential.metadata if credential else {} - info = { - "tool": "opencode", - "installed": opencode.installed(), - "configured": state.provider is not None and credential is not None, - "managed": state.managed, - "config": str(state.config_path), - "org": metadata.get("org_name"), - "key_name": metadata.get("key_name"), - "key_id": metadata.get("key_id"), - "model": state.model, - } if as_json: - echo_json([info]) + echo_json(infos) return - click.echo(opencode.NAME) - if not info["configured"]: - missing = "provider" if state.provider is None else "API key" - if state.provider is None and credential is None: - click.echo(" Not set up. Run: lightning code setup opencode") + for info in infos: + tool = TOOLS[info["tool"]] + if "error" in info: + click.echo(f"{tool.name}: couldn't read its config: {info['error']}") + elif not info["configured"]: + click.echo(f"{tool.name}: not set up") + continue + elif not info["managed"]: + click.echo(f"{tool.name}: set up by hand in {info['config']}, not by `lightning code`") + continue else: - click.echo(f" Incomplete, the {missing} is missing. Run: lightning code setup opencode") - return - if not state.managed: - click.echo(f" Set up by hand in {state.config_path}, not by `lightning code`.") - return - click.echo(f" Org: {info['org']}") - click.echo(f" API key: {info['key_name']} ({info['key_id']})") - click.echo(f" Config: {info['config']}") - click.echo(f" Default: {info['model'] or 'none'}") - if not info["installed"]: - click.echo(f" OpenCode isn't on your PATH. Install it with: {opencode.INSTALL_COMMAND}") - for note in opencode.warnings(state): - click.echo(f" Note: {note}") + click.echo(f"{tool.name}: billed to {info['org']}") + click.echo(f" API key: {info['key_name']} ({info['key_id']})") + click.echo(f" Config: {info['config']}") + if info["model"]: + click.echo(f" Default: {info['model']}") + if not info["installed"] and tool.install: + click.echo(f" Not on your PATH. Install it with: {tool.install}") + for note in info["notes"]: + click.echo(f" Note: {note}") diff --git a/python/lightning_sdk/cli/code/token.py b/python/lightning_sdk/cli/code/token.py index e6856539..9df7047b 100644 --- a/python/lightning_sdk/cli/code/token.py +++ b/python/lightning_sdk/cli/code/token.py @@ -19,7 +19,7 @@ def token(org: Optional[str], name: Optional[str], as_json: bool = False) -> None: """Create an API key for code.lightning.ai and print it. - For tools `lightning code setup` doesn't configure: Claude Code, Cursor, Cline, + For tools `lightning code setup` doesn't configure: Claude Code, Cline, Zed, or your own scripts. The key bills the organization you choose, which needs a Pro, Teams or Enterprise plan. It's shown only once. diff --git a/python/lightning_sdk/cli/code/tool.py b/python/lightning_sdk/cli/code/tool.py new file mode 100644 index 00000000..9ea5e945 --- /dev/null +++ b/python/lightning_sdk/cli/code/tool.py @@ -0,0 +1,237 @@ +"""What every coding tool `lightning code` sets up provides, and the steps they share. + +A tool reads its current state, then plans the file changes for a setup or removal as +text, before and after. The commands show those plans for --dry-run and apply them, so +each tool only decides what its files should say. +""" + +import difflib +import re +import shutil +from abc import ABC, abstractmethod +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, Callable, Optional, Sequence + +from lightning_sdk.cli.code import files +from lightning_sdk.cli.code.models import CodingModel, default_model + +_KEY_PATTERN = re.compile(r"sk-lit-[A-Za-z0-9_-]+") + + +def _redact(text: str) -> str: + return _KEY_PATTERN.sub("sk-lit-…", text) + + +class ToolConfigError(Exception): + """A tool's config can't be read, so it isn't safe to change.""" + + +@dataclass +class KeyRecord: + """The org and API key `lightning code` set a tool up with.""" + + org_id: str + org_name: str + key_id: str + key_name: str + + def as_dict(self) -> dict[str, str]: + return {"org_id": self.org_id, "org_name": self.org_name, "key_id": self.key_id, "key_name": self.key_name} + + @classmethod + def from_dict(cls, data: Optional[dict[str, Any]]) -> Optional["KeyRecord"]: + if not data or not all(isinstance(data.get(k), str) for k in ("org_id", "key_id")): + return None + return cls(data["org_id"], data.get("org_name") or "", data["key_id"], data.get("key_name") or data["key_id"]) + + +@dataclass +class ToolState: + """What a tool's files say about its Lightning setup right now.""" + + # what `lightning code` set up, if it did + record: Optional[KeyRecord] + # the API key the tool holds now, when it can be read back + key: Optional[str] + # Lightning is set up in the tool, by anyone + configured: bool + default_model: Optional[str] + # where the setup lives, for messages + location: str + # anything else the tool needs to plan from its state + data: Any = None + + @property + def foreign(self) -> bool: + """Whether a Lightning setup exists that `lightning code` didn't write.""" + return self.configured and self.record is None + + +@dataclass +class FileChange: + path: Path + before: str + # None deletes the file + after: Optional[str] + mode: Optional[int] = None + # holds the API key, so --dry-run masks it + secret: bool = False + # a copy is kept before a file the user already had is changed, unless it holds keys + backup: bool = True + # our own bookkeeping, not worth showing in --dry-run + quiet: bool = False + # shown by --dry-run instead of a diff, for files that hold other credentials too + summary: Optional[str] = None + + @property + def changed(self) -> bool: + return self.before != (self.after or "") or (self.after is None and self.path.exists()) + + def describe(self) -> str: + if not self.changed: + return "" + if self.after is None: + return f"Would delete {self.path}\n" + if self.summary: + return f"Would {self.summary} in {self.path}\n" + before, after = self.before, self.after + if self.secret: + before, after = _redact(before), _redact(after) + lines = difflib.unified_diff( + before.splitlines(keepends=True), + after.splitlines(keepends=True), + fromfile=str(self.path), + tofile=str(self.path), + ) + return "".join(lines) + + +@dataclass +class Plan: + changes: list[FileChange] = field(default_factory=list) + # the default model the setup sets, if it sets one + set_model: Optional[str] = None + # what a removal takes out, for messages + removed: list[str] = field(default_factory=list) + + def describe(self) -> str: + return "".join(change.describe() for change in self.changes if not change.quiet) + + +def record_change(tool: str, record: Optional[KeyRecord]) -> FileChange: + """The change to the shared record file that notes, or forgets, ``tool``'s org and key.""" + before, after = files.records_text(tool, record.as_dict() if record else None) + return FileChange(files.records_path(), before, after, mode=0o600, backup=False, quiet=True) + + +def apply(plan: Plan) -> list[Path]: + """Write a plan's changes, backing up the user's files first. Returns the backups made.""" + backups = [] + for change in plan.changes: + if not change.changed: + continue + # files holding keys aren't copied, so no second copy of anyone's secrets is left around + if change.backup and not change.secret and change.before and change.path.exists(): + backup = change.path.with_name(change.path.name + files.BACKUP_SUFFIX) + shutil.copy2(change.path, backup) + backups.append(backup) + if change.after is None: + files.delete(change.path) + else: + files.write_text(change.path, change.after, mode=change.mode) + return backups + + +class Tool(ABC): + """A coding tool `lightning code` can point at code.lightning.ai.""" + + id: str + name: str + # the executable to look for on PATH, and how to install it + binary: Optional[str] = None + install: Optional[str] = None + # how to start using it once set up + start: str = "" + + @abstractmethod + def read(self) -> ToolState: + """Read the tool's config. Raises ToolConfigError when it can't be parsed.""" + + @abstractmethod + def plan_setup( + self, + state: ToolState, + *, + models: Sequence[CodingModel], + model: Optional[str], + key: str, + record: KeyRecord, + ) -> Plan: + """The changes that point the tool at code.lightning.ai with ``key``. + + ``model`` is a model key from --model. Without it, a tool sets the default model only + where the user has none, or has one on a Lightning model that's no longer served. + """ + + @abstractmethod + def plan_remove(self, state: ToolState) -> Plan: + """The changes that take Lightning out of the tool again.""" + + def summary(self, state: ToolState, plan: Plan) -> list[tuple[str, str]]: + """Labelled lines describing a finished setup.""" + return [] + + def instructions(self, key: str, models: Sequence[CodingModel], plan: Plan) -> Optional[str]: + """Steps the user still has to take by hand after setup, with the new key, if any.""" + return None + + def removal_instructions(self) -> Optional[str]: + return None + + def warnings(self, state: ToolState) -> list[str]: + """Settings elsewhere that would stop the tool from using the setup.""" + return [] + + def installed(self) -> bool: + return self.binary is None or shutil.which(self.binary) is not None + + +def default_to_set( + current: Optional[str], + *, + requested: Optional[str], + models: Sequence[CodingModel], + ref: Callable[[CodingModel], str], + ours: Callable[[str], bool], +) -> Optional[str]: + """The default model a setup should set, in the tool's own reference format, or None to leave it. + + --model always wins. Otherwise the default is set only when the user has none, or has one + on a Lightning model (``ours``) that is no longer served. + """ + by_key = {model.key: model for model in models} + if requested is not None: + return ref(by_key[requested]) + if current is None or (ours(current) and current not in {ref(model) for model in models}): + return ref(by_key[default_model(tuple(models))]) + return None + + +def emptied(text: str, *, ignore: Sequence[str] = ()) -> Optional[str]: + """``text``, or None (delete the file) when a removal left a JSON object with nothing in it, not even comments. + + ``ignore`` names keys that don't count, such as a ``$schema`` setup added to a new file. + """ + from lightning_sdk.utils import jsonc + + try: + data = jsonc.loads(text) if text.strip() else {} + except jsonc.JSONCError: + return text + if jsonc.has_comments(text): + # the user's comments are worth keeping the file for + return text + if isinstance(data, dict) and not {k: v for k, v in data.items() if k not in ignore and v != {}}: + return None + return text diff --git a/python/lightning_sdk/utils/jsonc.py b/python/lightning_sdk/utils/jsonc.py index 3941ab16..96cb6ec4 100644 --- a/python/lightning_sdk/utils/jsonc.py +++ b/python/lightning_sdk/utils/jsonc.py @@ -27,6 +27,7 @@ class _Node: end: int kind: str # "object", "array" or "scalar" members: list[_Member] = field(default_factory=list) + items: list["_Node"] = field(default_factory=list) def member(self, key: str) -> Optional[_Member]: return next((m for m in self.members if m.key == key), None) @@ -36,6 +37,8 @@ class _Parser: def __init__(self, text: str, pos: int = 0) -> None: self.text = text self.pos = pos + # set once a comment has been skipped + self.comments = False def fail(self, message: str) -> JSONCError: line = self.text.count("\n", 0, self.pos) + 1 @@ -48,9 +51,11 @@ def skip(self) -> None: if text[self.pos] in " \t\r\n": self.pos += 1 elif text.startswith("//", self.pos): + self.comments = True newline = text.find("\n", self.pos) self.pos = len(text) if newline < 0 else newline + 1 elif text.startswith("/*", self.pos): + self.comments = True close = text.find("*/", self.pos + 2) if close < 0: raise self.fail("unterminated comment") @@ -125,7 +130,7 @@ def array(self) -> _Node: self.pos += 1 node.end = self.pos return node - self.value() + node.items.append(self.value()) char = self.peek() if char == ",": self.pos += 1 @@ -167,6 +172,14 @@ def _strip(text: str) -> str: out.append(token) +def has_comments(text: str) -> bool: + """Whether ``text`` has comments, which parsing it would lose.""" + parser = _Parser(text) + parser.value() + parser.skip() + return parser.comments + + def loads(text: str) -> Any: """Parse a JSONC document into Python values.""" _parse(text) @@ -319,3 +332,41 @@ def remove_value(text: str, path: Sequence[str]) -> str: comma = previous.pos text = text[:start] + text[end:] return text[:comma] + text[comma + 1 :] + + +def _array_at(text: str, path: Sequence[str]) -> Optional[_Node]: + node, depth = _object_at(_root(text), path[:-1]) + member = node.member(path[-1]) if depth == len(path) - 1 else None + return member.value if member is not None and member.value.kind == "array" else None + + +def append_item(text: str, path: Sequence[str], value: Any) -> str: + """Append ``value`` to the array at ``path``, leaving the items already there as they are.""" + array = _array_at(text, path) + if array is None: + raise JSONCError(f"'{'.'.join(path)}' is not an array") + rendered = json.dumps(value, ensure_ascii=False) + if array.items: + last = array.items[-1] + separator = ", " + if "\n" in text[array.start : last.start]: + separator = ",\n" + _line_indent(text, last.start) + return text[: last.end] + separator + rendered + text[last.end :] + return text[: array.start + 1] + rendered + text[array.end - 1 :] + + +def remove_item(text: str, path: Sequence[str], value: Any) -> str: + """Remove the first item equal to ``value`` from the array at ``path``, if it's there.""" + array = _array_at(text, path) + if array is None: + return text + index = next((i for i, item in enumerate(array.items) if loads(text[item.start : item.end]) == value), None) + if index is None: + return text + item = array.items[index] + if index > 0: + # from the end of the item before, so the comma between them goes too + return text[: array.items[index - 1].end] + text[item.end :] + if len(array.items) > 1: + return text[: item.start] + text[array.items[1].start :] + return text[: array.start + 1] + text[array.end - 1 :] diff --git a/python/tests/cli/code/conftest.py b/python/tests/cli/code/conftest.py new file mode 100644 index 00000000..0c89d024 --- /dev/null +++ b/python/tests/cli/code/conftest.py @@ -0,0 +1,65 @@ +import json +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import MagicMock, patch + +import click +import pytest +from click.testing import CliRunner, Result + +from lightning_sdk.cli.code import account +from lightning_sdk.cli.code import models as coding_models + + +@pytest.fixture(autouse=True) +def tool_homes(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + """Point every coding tool's config, and the home directory, into the test's own folder.""" + monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "config")) + monkeypatch.setenv("XDG_DATA_HOME", str(tmp_path / "data")) + monkeypatch.setenv("PI_CODING_AGENT_DIR", str(tmp_path / "pi")) + monkeypatch.setenv("CODEX_HOME", str(tmp_path / "codex")) + monkeypatch.setenv("DSH_HOME", str(tmp_path / "dsh")) + monkeypatch.setenv("HOME", str(tmp_path)) + for name in ("OPENCODE_CONFIG", "OPENCODE_CONFIG_CONTENT", "OPENCODE_AUTH_CONTENT", "LIGHTNING_CODE_API_KEY"): + monkeypatch.delenv(name, raising=False) + monkeypatch.chdir(tmp_path) + return tmp_path + + +MODELS_RESPONSE = json.loads((Path(__file__).parent / "models_response.json").read_text()) + + +@pytest.fixture(autouse=True) +def models_api(): + """Serve /v1/models from a copy of what code.lightning.ai returned, instead of the network.""" + response = MagicMock() + response.json.return_value = MODELS_RESPONSE + with patch.object(coding_models.requests, "get", return_value=response) as get: + yield get + + +def make_org(plan="Enterprise", name="my-org", org_id="org-1", **kwargs) -> account.CodingOrg: + fields = {"display_name": "", "personal": False, "coding_disabled": False, **kwargs} + return account.CodingOrg(id=org_id, name=name, plan=plan, **fields) + + +@pytest.fixture() +def api(): + """Mock everything that talks to Lightning: org choice, key creation and revocation.""" + keys = iter(range(1, 100)) + + def create_key(org, name, description): + n = next(keys) + return SimpleNamespace(id=f"key-{n}", name=name, raw_key=f"sk-lit-{n}") + + with patch.object(account, "choose_org", return_value=make_org()) as choose, patch.object( + account, "create_key", side_effect=create_key + ) as create, patch.object(account, "key_exists", return_value=True) as exists, patch.object( + account, "revoke_key", return_value=True + ) as revoke: + yield SimpleNamespace(choose_org=choose, create_key=create, key_exists=exists, revoke_key=revoke) + + +def invoke(command: click.Command, *args: str) -> Result: + with patch("lightning_sdk.cli.utils.logging._log_command", new=MagicMock()): + return CliRunner().invoke(command, list(args), catch_exceptions=False) diff --git a/python/tests/cli/code/test_code.py b/python/tests/cli/code/test_code.py index 0e180106..a94b92a0 100644 --- a/python/tests/cli/code/test_code.py +++ b/python/tests/cli/code/test_code.py @@ -6,16 +6,19 @@ import click import pytest -from click.testing import CliRunner, Result +from click.testing import CliRunner from lightning_sdk.cli.code import account, opencode from lightning_sdk.cli.code import models as coding_models +from lightning_sdk.cli.code import tool as tool_module from lightning_sdk.cli.code.models import DEFAULT_MODEL, FALLBACK_MODELS from lightning_sdk.cli.code.remove import remove from lightning_sdk.cli.code.setup import setup from lightning_sdk.cli.code.status import status from lightning_sdk.cli.code.token import token +from lightning_sdk.cli.code.tool import KeyRecord from lightning_sdk.utils import jsonc +from tests.cli.code.conftest import invoke, make_org from tests.cli.help import assert_help_contains, mock_command_logging USER_CONFIG = """{ @@ -27,56 +30,6 @@ """ -@pytest.fixture(autouse=True) -def opencode_home(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: - monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "config")) - monkeypatch.setenv("XDG_DATA_HOME", str(tmp_path / "data")) - monkeypatch.setenv("HOME", str(tmp_path)) - for name in ("OPENCODE_CONFIG", "OPENCODE_CONFIG_CONTENT", "OPENCODE_AUTH_CONTENT"): - monkeypatch.delenv(name, raising=False) - monkeypatch.chdir(tmp_path) - return tmp_path - - -MODELS_RESPONSE = json.loads((Path(__file__).parent / "models_response.json").read_text()) - - -@pytest.fixture(autouse=True) -def models_api(): - """Serve /v1/models from a copy of what code.lightning.ai returned, instead of the network.""" - response = MagicMock() - response.json.return_value = MODELS_RESPONSE - with patch.object(coding_models.requests, "get", return_value=response) as get: - yield get - - -def _org(plan="Enterprise", name="my-org", org_id="org-1", **kwargs) -> account.CodingOrg: - fields = {"display_name": "", "personal": False, "coding_disabled": False, **kwargs} - return account.CodingOrg(id=org_id, name=name, plan=plan, **fields) - - -@pytest.fixture() -def api(): - """Mock everything that talks to Lightning: org choice, key creation and revocation.""" - keys = iter(range(1, 100)) - - def create_key(org, name, description): - n = next(keys) - return SimpleNamespace(id=f"key-{n}", name=name, raw_key=f"sk-lit-{n}") - - with patch.object(account, "choose_org", return_value=_org()) as choose, patch.object( - account, "create_key", side_effect=create_key - ) as create, patch.object(account, "key_exists", return_value=True) as exists, patch.object( - account, "revoke_key", return_value=True - ) as revoke: - yield SimpleNamespace(choose_org=choose, create_key=create, key_exists=exists, revoke_key=revoke) - - -def _invoke(command: click.Command, *args: str) -> Result: - with patch("lightning_sdk.cli.utils.logging._log_command", new=MagicMock()): - return CliRunner().invoke(command, list(args), catch_exceptions=False) - - def _auth() -> dict: return json.loads(opencode.auth_path().read_text()) @@ -93,7 +46,7 @@ def test_code_help() -> None: def test_setup_creates_config_and_key(api) -> None: - result = _invoke(setup, "opencode", "--org", "my-org") + result = invoke(setup, "opencode", "--org", "my-org") assert result.exit_code == 0, result.output config = jsonc.loads(_config_path().read_text()) @@ -124,7 +77,7 @@ def test_setup_keeps_the_rest_of_the_users_files(api) -> None: opencode.auth_path().parent.mkdir(parents=True) opencode.auth_path().write_text(json.dumps({"anthropic": {"type": "api", "key": "sk-ant"}})) - assert _invoke(setup, "opencode", "--org", "my-org").exit_code == 0 + assert invoke(setup, "opencode", "--org", "my-org").exit_code == 0 text = _config_path().read_text() assert "// keep this comment" in text @@ -140,7 +93,7 @@ def test_setup_prefers_the_jsonc_file(api) -> None: (opencode.config_dir() / "opencode.json").write_text("{}") (opencode.config_dir() / "opencode.jsonc").write_text("{\n // mine\n}\n") - assert _invoke(setup, "opencode", "--org", "my-org").exit_code == 0 + assert invoke(setup, "opencode", "--org", "my-org").exit_code == 0 assert "lightning" in jsonc.loads((opencode.config_dir() / "opencode.jsonc").read_text())["provider"] assert (opencode.config_dir() / "opencode.json").read_text() == "{}" @@ -149,13 +102,13 @@ def test_setup_with_model_sets_the_default(api) -> None: _config_path().parent.mkdir(parents=True) _config_path().write_text(USER_CONFIG) - assert _invoke(setup, "opencode", "--org", "my-org", "--model", "glm-5.3-flash").exit_code == 0 + assert invoke(setup, "opencode", "--org", "my-org", "--model", "glm-5.3-flash").exit_code == 0 assert jsonc.loads(_config_path().read_text())["model"] == "lightning/glm-5.3-flash" def test_setup_rerun_reuses_the_key(api) -> None: - assert _invoke(setup, "opencode", "--org", "my-org").exit_code == 0 - assert _invoke(setup, "opencode", "--org", "my-org").exit_code == 0 + assert invoke(setup, "opencode", "--org", "my-org").exit_code == 0 + assert invoke(setup, "opencode", "--org", "my-org").exit_code == 0 assert api.create_key.call_count == 1 assert _auth()["lightning"]["key"] == "sk-lit-1" @@ -164,8 +117,8 @@ def test_setup_rerun_reuses_the_key(api) -> None: def test_setup_rotate_key_revokes_the_old_one(api) -> None: - assert _invoke(setup, "opencode", "--org", "my-org").exit_code == 0 - result = _invoke(setup, "opencode", "--org", "my-org", "--rotate-key") + assert invoke(setup, "opencode", "--org", "my-org").exit_code == 0 + result = invoke(setup, "opencode", "--org", "my-org", "--rotate-key") assert result.exit_code == 0 assert _auth()["lightning"]["key"] == "sk-lit-2" @@ -174,23 +127,23 @@ def test_setup_rotate_key_revokes_the_old_one(api) -> None: def test_setup_switching_org_creates_a_key_there(api) -> None: - assert _invoke(setup, "opencode", "--org", "my-org").exit_code == 0 - api.choose_org.return_value = _org(name="other", org_id="org-2") - assert _invoke(setup, "opencode", "--org", "other").exit_code == 0 + assert invoke(setup, "opencode", "--org", "my-org").exit_code == 0 + api.choose_org.return_value = make_org(name="other", org_id="org-2") + assert invoke(setup, "opencode", "--org", "other").exit_code == 0 assert _auth()["lightning"]["metadata"]["org_id"] == "org-2" api.revoke_key.assert_called_once_with("org-1", "key-1") def test_setup_replaces_a_deleted_key(api) -> None: - assert _invoke(setup, "opencode", "--org", "my-org").exit_code == 0 + assert invoke(setup, "opencode", "--org", "my-org").exit_code == 0 api.key_exists.return_value = False - assert _invoke(setup, "opencode", "--org", "my-org").exit_code == 0 + assert invoke(setup, "opencode", "--org", "my-org").exit_code == 0 assert _auth()["lightning"]["key"] == "sk-lit-2" def test_setup_free_plan_asks_to_upgrade(api) -> None: - api.choose_org.return_value = _org(plan="Free") + api.choose_org.return_value = make_org(plan="Free") with patch.object(account, "_get_cloud_url", return_value="https://lightning.ai"): result = CliRunner().invoke(setup, ["opencode", "--org", "my-org"]) @@ -202,7 +155,7 @@ def test_setup_free_plan_asks_to_upgrade(api) -> None: def test_setup_coding_disabled_org_is_refused(api) -> None: - api.choose_org.return_value = _org(coding_disabled=True) + api.choose_org.return_value = make_org(coding_disabled=True) result = CliRunner().invoke(setup, ["opencode", "--org", "my-org"]) assert result.exit_code == 1 assert "turned off" in result.output @@ -218,12 +171,12 @@ def test_setup_will_not_replace_a_hand_made_provider_without_force(api) -> None: assert "--force" in result.output api.create_key.assert_not_called() - assert _invoke(setup, "opencode", "--org", "my-org", "--force").exit_code == 0 + assert invoke(setup, "opencode", "--org", "my-org", "--force").exit_code == 0 assert "apiKey" not in jsonc.loads(_config_path().read_text())["provider"]["lightning"]["options"] def test_setup_dry_run_writes_nothing(api) -> None: - result = _invoke(setup, "opencode", "--org", "my-org", "--dry-run") + result = invoke(setup, "opencode", "--org", "my-org", "--dry-run") assert result.exit_code == 0 assert '+ "lightning": {' in result.output @@ -244,51 +197,51 @@ def test_setup_invalid_config_fails_before_creating_a_key(api) -> None: def test_setup_revokes_the_new_key_when_writing_fails(api) -> None: - with patch.object(opencode, "apply_setup", side_effect=OSError("disk full")), pytest.raises( + with patch.object(tool_module, "apply", side_effect=OSError("disk full")), pytest.raises( OSError, match="disk full" ): - _invoke(setup, "opencode", "--org", "my-org") + invoke(setup, "opencode", "--org", "my-org") api.revoke_key.assert_called_once_with("org-1", "key-1") def test_status_and_remove(api) -> None: _config_path().parent.mkdir(parents=True) _config_path().write_text("{\n // keep this comment\n}\n") - assert _invoke(setup, "opencode", "--org", "my-org").exit_code == 0 + assert invoke(setup, "opencode", "--org", "my-org").exit_code == 0 - result = _invoke(status, "--json") - [info] = json.loads(result.output) + result = invoke(status, "--json") + info = next(entry for entry in json.loads(result.output) if entry["tool"] == "opencode") assert info["configured"] is True assert info["managed"] is True assert info["org"] == "my-org" assert info["key_id"] == "key-1" assert info["model"] == "lightning/deepseek-v4.1-flash" - result = _invoke(remove, "opencode") + result = invoke(remove, "opencode") assert result.exit_code == 0 assert "Revoked the API key" in result.output api.revoke_key.assert_called_once_with("org-1", "key-1") assert _config_path().read_text() == "{\n // keep this comment\n}\n" assert "lightning" not in _auth() - assert "isn't set up" in _invoke(remove, "opencode").output - assert "Not set up" in _invoke(status).output + assert "isn't set up" in invoke(remove, "opencode").output + assert "OpenCode: not set up" in invoke(status).output def test_remove_takes_out_a_lightning_default_the_user_set(api) -> None: _config_path().parent.mkdir(parents=True) _config_path().write_text('{"provider": {"lightning": {}}, "model": "lightning/glm-5.3", "theme": "x"}') - assert _invoke(setup, "opencode", "--org", "my-org", "--force").exit_code == 0 + assert invoke(setup, "opencode", "--org", "my-org", "--force").exit_code == 0 - assert _invoke(remove, "opencode").exit_code == 0 + assert invoke(remove, "opencode").exit_code == 0 assert jsonc.loads(_config_path().read_text()) == {"theme": "x"} def test_remove_keeps_a_default_model_the_user_changed(api) -> None: - assert _invoke(setup, "opencode", "--org", "my-org").exit_code == 0 + assert invoke(setup, "opencode", "--org", "my-org").exit_code == 0 _config_path().write_text(jsonc.set_value(_config_path().read_text(), ["model"], "anthropic/claude")) - assert _invoke(remove, "opencode", "--keep-key").exit_code == 0 + assert invoke(remove, "opencode", "--keep-key").exit_code == 0 assert jsonc.loads(_config_path().read_text()) == { "$schema": opencode.SCHEMA_URL, "model": "anthropic/claude", @@ -296,28 +249,28 @@ def test_remove_keeps_a_default_model_the_user_changed(api) -> None: api.revoke_key.assert_not_called() -def test_warnings_point_at_overrides(opencode_home: Path, monkeypatch: pytest.MonkeyPatch) -> None: +def test_warnings_point_at_overrides(tool_homes: Path, monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setenv("OPENCODE_AUTH_CONTENT", "{}") _config_path().parent.mkdir(parents=True) _config_path().write_text('{"enabled_providers": ["anthropic"], "disabled_providers": ["lightning"]}') - project = opencode_home / "repo" + project = tool_homes / "repo" (project / ".git").mkdir(parents=True) (project / "opencode.json").write_text('{"model": "anthropic/claude"}') - notes = opencode.warnings(opencode.read_state(), cwd=project) + tool = opencode.OpenCode() + notes = tool.warnings(tool.read(), cwd=project) assert any("OPENCODE_AUTH_CONTENT" in note for note in notes) - assert any("enabled_providers" in note for note in notes) assert any("disabled_providers" in note for note in notes) assert any("sets model to anthropic/claude" in note for note in notes) def test_token_prints_a_new_key(api) -> None: - result = _invoke(token, "--org", "my-org") + result = invoke(token, "--org", "my-org") assert result.exit_code == 0 assert result.output == "sk-lit-1\n" - result = _invoke(token, "--org", "my-org", "--name", "ci", "--json") + result = invoke(token, "--org", "my-org", "--name", "ci", "--json") assert json.loads(result.output) == { "api_key": "sk-lit-2", "base_url": "https://code.lightning.ai/v1", @@ -329,14 +282,14 @@ def test_token_prints_a_new_key(api) -> None: def test_token_free_plan_asks_to_upgrade(api) -> None: - api.choose_org.return_value = _org(plan="Free") + api.choose_org.return_value = make_org(plan="Free") result = CliRunner().invoke(token, ["--org", "my-org"]) assert result.exit_code == 1 assert "Upgrade to Pro or Teams" in result.output api.create_key.assert_not_called() -def _v1_org(name: str, org_id: str, personal: bool = False) -> SimpleNamespace: +def _v1make_org(name: str, org_id: str, personal: bool = False) -> SimpleNamespace: return SimpleNamespace( id=org_id, name=name, display_name=name.title(), is_personal_org=personal, disable_coding_agents=False ) @@ -346,9 +299,9 @@ def _v1_org(name: str, org_id: str, personal: bool = False) -> SimpleNamespace: def client(inline_executor_cls): client = MagicMock() client.organizations_service_list_organizations.return_value.organizations = [ - _v1_org("zeta", "org-z"), - _v1_org("alpha", "org-a"), - _v1_org("me", "org-me", personal=True), + _v1make_org("zeta", "org-z"), + _v1make_org("alpha", "org-a"), + _v1make_org("me", "org-me", personal=True), ] plans = {"org-z": "Teams", "org-a": "Free", "org-me": "Professional"} client.billing_service_get_billing_subscription.side_effect = lambda org_id: SimpleNamespace(name=plans[org_id]) @@ -392,14 +345,17 @@ def test_choose_org_unknown_name(client) -> None: def test_unreadable_plan_is_allowed_with_a_note() -> None: with patch.object(account.click, "echo") as echo: - account.require_coding_plan(_org(plan=None)) + account.require_coding_plan(make_org(plan=None)) assert "Couldn't read the plan" in echo.call_args.args[0] def test_example_config_matches_what_setup_writes() -> None: example = Path(__file__).parents[3] / "examples" / "code" / "opencode.json" - plan = opencode.plan_setup(opencode.read_state(), models=FALLBACK_MODELS, model=None) - assert plan.config_text == example.read_text() + tool = opencode.OpenCode() + record = KeyRecord("org-1", "my-org", "key-1", "key") + plan = tool.plan_setup(tool.read(), models=FALLBACK_MODELS, model=None, key="sk-lit-1", record=record) + config = next(change for change in plan.changes if change.path == _config_path()) + assert config.after == example.read_text() def test_default_model_is_one_setup_configures() -> None: @@ -463,7 +419,7 @@ def test_setup_writes_the_models_the_api_lists(api, models_api) -> None: } ] } - assert _invoke(setup, "opencode", "--org", "my-org").exit_code == 0 + assert invoke(setup, "opencode", "--org", "my-org").exit_code == 0 config = jsonc.loads(_config_path().read_text()) assert config["provider"]["lightning"]["models"] == { @@ -481,7 +437,7 @@ def test_setup_writes_the_models_the_api_lists(api, models_api) -> None: def test_setup_falls_back_to_the_built_in_models(api, models_api) -> None: models_api.side_effect = OSError("network down") - result = _invoke(setup, "opencode", "--org", "my-org") + result = invoke(setup, "opencode", "--org", "my-org") assert result.exit_code == 0 assert "using the built-in one" in result.output @@ -499,5 +455,5 @@ def test_setup_rejects_an_unknown_model(api) -> None: def test_setup_replaces_a_default_on_a_retired_model(api) -> None: _config_path().parent.mkdir(parents=True) _config_path().write_text('{"model": "lightning/glm-4"}') - assert _invoke(setup, "opencode", "--org", "my-org").exit_code == 0 + assert invoke(setup, "opencode", "--org", "my-org").exit_code == 0 assert jsonc.loads(_config_path().read_text())["model"] == "lightning/deepseek-v4.1-flash" diff --git a/python/tests/cli/code/test_tools.py b/python/tests/cli/code/test_tools.py new file mode 100644 index 00000000..084e8b1f --- /dev/null +++ b/python/tests/cli/code/test_tools.py @@ -0,0 +1,388 @@ +"""pi, Codex, DeepSeek Harness and Cursor, through `lightning code setup`, `status` and `remove`.""" + +import json +import stat +from pathlib import Path + +import pytest +import tomllib +import yaml +from click.testing import CliRunner + +from lightning_sdk.cli.code import codex, dsh, files, pi +from lightning_sdk.cli.code.remove import remove +from lightning_sdk.cli.code.setup import setup +from lightning_sdk.cli.code.status import status +from lightning_sdk.utils import jsonc +from tests.cli.code.conftest import invoke, make_org + + +def _mode(path: Path) -> int: + return stat.S_IMODE(path.stat().st_mode) + + +def _records() -> dict: + return json.loads(files.records_path().read_text()) + + +def _no_backups(directory: Path) -> None: + assert not list(directory.rglob(f"*{files.BACKUP_SUFFIX}")) + + +# pi + + +PI_MODELS = """{ + // my own provider + "providers": { + "ollama": {"baseUrl": "http://localhost:11434/v1", "api": "openai-completions", "models": [{"id": "qwen"}]} + } +} +""" + + +def test_pi_setup_writes_provider_key_and_default(api) -> None: + assert invoke(setup, "pi", "--org", "my-org").exit_code == 0 + + directory = pi.agent_dir() + provider = jsonc.loads((directory / "models.json").read_text())["providers"]["lightning"] + assert provider["baseUrl"] == "https://code.lightning.ai/v1" + assert provider["api"] == "openai-completions" + assert "apiKey" not in provider + flash = next(model for model in provider["models"] if model["id"] == "lightning-ai/deepseek-v4.1-flash") + assert flash == { + "id": "lightning-ai/deepseek-v4.1-flash", + "name": "DeepSeek V4.1 Flash", + "reasoning": True, + "input": ["text", "image"], + "contextWindow": 253_952, + "maxTokens": 131_072, + } + assert json.loads((directory / "auth.json").read_text()) == {"lightning": {"type": "api_key", "key": "sk-lit-1"}} + assert _mode(directory / "auth.json") == 0o600 + assert json.loads((directory / "settings.json").read_text()) == { + "defaultProvider": "lightning", + "defaultModel": "lightning-ai/deepseek-v4.1-flash", + } + assert _records()["pi"]["key_id"] == "key-1" + + +def test_pi_keeps_the_users_providers_default_and_keys(api) -> None: + directory = pi.agent_dir() + directory.mkdir(parents=True) + (directory / "models.json").write_text(PI_MODELS) + (directory / "settings.json").write_text('{\n "defaultProvider": "ollama",\n "defaultModel": "qwen"\n}\n') + (directory / "auth.json").write_text('{"anthropic": {"type": "api_key", "key": "sk-ant"}}') + + result = invoke(setup, "pi", "--org", "my-org") + assert result.exit_code == 0 + assert "Default" not in result.output + + text = (directory / "models.json").read_text() + assert "// my own provider" in text + assert set(jsonc.loads(text)["providers"]) == {"ollama", "lightning"} + assert json.loads((directory / "settings.json").read_text())["defaultProvider"] == "ollama" + assert json.loads((directory / "auth.json").read_text())["anthropic"] == {"type": "api_key", "key": "sk-ant"} + # the user's models.json is backed up, the key file isn't + assert (directory / "models.json.lightning-backup").read_text() == PI_MODELS + assert not (directory / "auth.json.lightning-backup").exists() + + assert invoke(remove, "pi").exit_code == 0 + assert (directory / "models.json").read_text() == PI_MODELS + assert json.loads((directory / "auth.json").read_text()) == {"anthropic": {"type": "api_key", "key": "sk-ant"}} + assert json.loads((directory / "settings.json").read_text())["defaultModel"] == "qwen" + + +def test_pi_remove_deletes_the_files_setup_created(api) -> None: + assert invoke(setup, "pi", "--org", "my-org").exit_code == 0 + result = invoke(remove, "pi") + + assert result.exit_code == 0 + api.revoke_key.assert_called_once_with("org-1", "key-1") + directory = pi.agent_dir() + assert not (directory / "models.json").exists() + assert not (directory / "settings.json").exists() + assert json.loads((directory / "auth.json").read_text()) == {} + assert "pi" not in _records() + + +def test_pi_hand_made_provider_needs_force(api) -> None: + directory = pi.agent_dir() + directory.mkdir(parents=True) + (directory / "models.json").write_text('{"providers": {"lightning": {"apiKey": "sk-lit-pasted"}}}') + + result = CliRunner().invoke(setup, ["pi", "--org", "my-org"]) + assert result.exit_code == 1 + assert "--force" in result.output + assert invoke(setup, "pi", "--org", "my-org", "--force").exit_code == 0 + assert "apiKey" not in jsonc.loads((directory / "models.json").read_text())["providers"]["lightning"] + + +# Codex + + +def test_codex_setup_writes_only_its_profile(api, tool_homes: Path) -> None: + home = codex.codex_home() + home.mkdir(parents=True) + (home / "config.toml").write_text('model = "gpt-5"\n# mine\n') + + result = invoke(setup, "codex", "--org", "my-org") + assert result.exit_code == 0 + assert "codex --profile lightning" in result.output + + assert (home / "config.toml").read_text() == 'model = "gpt-5"\n# mine\n' + path = codex.profile_path() + assert path.name == "lightning.config.toml" + assert _mode(path) == 0o600 + text = path.read_text() + assert text.startswith(codex.MARKER) + profile = tomllib.loads(text) + assert profile["model_provider"] == "lightning" + assert profile["model"] == "lightning-ai/deepseek-v4.1-flash" + assert profile["model_context_window"] == 253_952 + assert profile["model_providers"]["lightning"] == { + "name": "Lightning AI", + "base_url": "https://code.lightning.ai/v1", + "wire_api": "responses", + "experimental_bearer_token": "sk-lit-1", + "stream_idle_timeout_ms": 600_000, + } + _no_backups(tool_homes) + + +def test_codex_rerun_keeps_the_key_and_a_model_picked_in_codex(api) -> None: + assert invoke(setup, "codex", "--org", "my-org").exit_code == 0 + # Codex's /model writes the profile's model + path = codex.profile_path() + path.write_text( + path.read_text().replace('model = "lightning-ai/deepseek-v4.1-flash"', 'model = "lightning-ai/glm-5.3"') + ) + + assert invoke(setup, "codex", "--org", "my-org").exit_code == 0 + profile = tomllib.loads(path.read_text()) + assert profile["model"] == "lightning-ai/glm-5.3" + assert profile["model_providers"]["lightning"]["experimental_bearer_token"] == "sk-lit-1" + assert api.create_key.call_count == 1 + + assert invoke(setup, "codex", "--org", "my-org", "--model", "glm-5.3-flash").exit_code == 0 + assert tomllib.loads(path.read_text())["model"] == "lightning-ai/glm-5.3-flash" + + +def test_codex_replaces_a_retired_model(api) -> None: + assert invoke(setup, "codex", "--org", "my-org").exit_code == 0 + path = codex.profile_path() + path.write_text( + path.read_text().replace('model = "lightning-ai/deepseek-v4.1-flash"', 'model = "lightning-ai/glm-4"') + ) + assert tomllib.loads(path.read_text())["model"] == "lightning-ai/glm-4" + assert invoke(setup, "codex", "--org", "my-org").exit_code == 0 + assert tomllib.loads(path.read_text())["model"] == "lightning-ai/deepseek-v4.1-flash" + + +def test_codex_profile_it_did_not_write_needs_force(api) -> None: + codex.codex_home().mkdir(parents=True) + codex.profile_path().write_text('model = "mine"\n') + + result = CliRunner().invoke(setup, ["codex", "--org", "my-org"]) + assert result.exit_code == 1 + assert "--force" in result.output + assert codex.profile_path().read_text() == 'model = "mine"\n' + + +def test_codex_remove_deletes_the_profile(api) -> None: + assert invoke(setup, "codex", "--org", "my-org").exit_code == 0 + assert invoke(remove, "codex").exit_code == 0 + assert not codex.profile_path().exists() + api.revoke_key.assert_called_once_with("org-1", "key-1") + + +def test_codex_dry_run_masks_the_key(api) -> None: + assert invoke(setup, "codex", "--org", "my-org").exit_code == 0 + result = invoke(setup, "codex", "--org", "my-org", "--dry-run", "--rotate-key") + assert "sk-lit-1" not in result.output + assert "Would revoke the API key" in result.output + + +def test_codex_warns_about_a_legacy_profile_table() -> None: + codex.codex_home().mkdir(parents=True) + (codex.codex_home() / "config.toml").write_text("[profiles.lightning]\nmodel = 'x'\n") + tool = codex.Codex() + assert any("[profiles.lightning]" in note for note in tool.warnings(tool.read())) + + +# DeepSeek Harness + + +DSH_PATCH = """# my dsh tweaks +- id: session-title + config: + fallbackMaxWords: 7 # keep titles short + +- id: hmr + disabled: !!js "!ctx.get('profileContext')" +""" + + +def _patch(profile: str = "web") -> list: + return yaml.load(dsh.patch_path(profile).read_text(), Loader=dsh._Loader) + + +def test_dsh_setup_writes_both_profiles_and_the_key(api) -> None: + result = invoke(setup, "dsh", "--org", "my-org") + assert result.exit_code == 0 + + for profile in ("web", "headless"): + entries = {entry["id"]: entry for entry in _patch(profile)} + provider = entries["llm-pi-ai"]["config"]["providers"]["lightning"] + assert provider["apiKeyEnv"] == "LIGHTNING_CODE_API_KEY" + assert provider["api"] == "openai-completions" + assert provider["baseURL"] == "https://code.lightning.ai/v1" + assert len(provider["models"]) == 3 + assert entries["agent-default-model"]["config"] == { + "provider": "lightning", + "model": "lightning-ai/deepseek-v4.1-flash", + } + + credentials = yaml.safe_load(dsh.credentials_path().read_text()) + assert credentials == {"version": 1, "refs": {"LIGHTNING_CODE_API_KEY": "sk-lit-1"}} + assert _mode(dsh.credentials_path()) == 0o600 + + +def test_dsh_splices_into_the_users_patch_and_remove_restores_it(api) -> None: + dsh.patch_path("web").parent.mkdir(parents=True) + dsh.patch_path("web").write_text(DSH_PATCH) + + assert invoke(setup, "dsh", "--org", "my-org").exit_code == 0 + text = dsh.patch_path("web").read_text() + assert text.startswith(DSH_PATCH) + assert "!!js \"!ctx.get('profileContext')\"" in text + assert {entry["id"] for entry in _patch()} == {"session-title", "hmr", "llm-pi-ai", "agent-default-model"} + + assert invoke(remove, "dsh").exit_code == 0 + assert dsh.patch_path("web").read_text() == DSH_PATCH + assert yaml.safe_load(dsh.credentials_path().read_text())["refs"] == {} + + +def test_dsh_keeps_the_users_default_and_other_providers(api) -> None: + dsh.patch_path("web").parent.mkdir(parents=True) + dsh.patch_path("web").write_text( + "- id: llm-pi-ai\n" + " config:\n" + " providers:\n" + " mine: {api: openai-completions, baseURL: 'http://localhost:8000/v1', models: [{id: m}]}\n" + "- id: agent-default-model\n" + " config: {provider: mine, model: m}\n" + ) + assert invoke(setup, "dsh", "--org", "my-org").exit_code == 0 + + entries = {entry["id"]: entry for entry in _patch()} + assert set(entries["llm-pi-ai"]["config"]["providers"]) == {"mine", "lightning"} + assert entries["agent-default-model"]["config"] == {"provider": "mine", "model": "m"} + + assert invoke(remove, "dsh").exit_code == 0 + entries = {entry["id"]: entry for entry in _patch()} + assert set(entries["llm-pi-ai"]["config"]["providers"]) == {"mine"} + assert entries["agent-default-model"]["config"] == {"provider": "mine", "model": "m"} + + +def test_dsh_keeps_other_credentials(api) -> None: + dsh.dsh_home().mkdir(parents=True) + dsh.credentials_path().write_text("version: 1\nrefs:\n DEEPSEEK_API_KEY: sk-ds\nrecords: {}\n") + assert invoke(setup, "dsh", "--org", "my-org").exit_code == 0 + assert yaml.safe_load(dsh.credentials_path().read_text())["refs"] == { + "DEEPSEEK_API_KEY": "sk-ds", + "LIGHTNING_CODE_API_KEY": "sk-lit-1", + } + assert not dsh.credentials_path().with_name(".credentials.yaml.lightning-backup").exists() + + result = invoke(setup, "dsh", "--org", "my-org", "--dry-run", "--rotate-key") + assert "sk-ds" not in result.output + assert "sk-lit-1" not in result.output + + +def test_dsh_invalid_patch_fails_before_creating_a_key(api) -> None: + dsh.patch_path("web").parent.mkdir(parents=True) + dsh.patch_path("web").write_text("- id: x\n config: [unclosed\n") + result = CliRunner().invoke(setup, ["dsh", "--org", "my-org"]) + assert result.exit_code == 1 + assert "Couldn't read DeepSeek Harness's config" in result.output + api.create_key.assert_not_called() + + +# Cursor + + +def test_cursor_prints_the_key_and_steps(api) -> None: + result = invoke(setup, "cursor", "--org", "my-org") + + assert result.exit_code == 0 + assert "sk-lit-1" in result.output + assert "Override OpenAI Base URL" in result.output + assert "https://code.lightning.ai/v1" in result.output + for model_id in ("lightning-ai/glm-5.3", "lightning-ai/glm-5.3-flash", "lightning-ai/deepseek-v4.1-flash"): + assert model_id in result.output + assert _records()["cursor"]["key_id"] == "key-1" + + +def test_cursor_rerun_creates_a_new_key_and_revokes_the_old(api) -> None: + assert invoke(setup, "cursor", "--org", "my-org").exit_code == 0 + result = invoke(setup, "cursor", "--org", "my-org") + assert "sk-lit-2" in result.output + api.revoke_key.assert_called_once_with("org-1", "key-1") + + +def test_cursor_remove_revokes_and_explains(api) -> None: + assert invoke(setup, "cursor", "--org", "my-org").exit_code == 0 + result = invoke(remove, "cursor") + assert "Override OpenAI Base URL" in result.output + api.revoke_key.assert_called_once_with("org-1", "key-1") + assert "cursor" not in _records() + assert "isn't set up" in invoke(remove, "cursor").output + + +# all of them + + +@pytest.mark.parametrize("tool", ["opencode", "pi", "codex", "dsh", "cursor"]) +def test_free_plan_stops_every_tool(api, tool: str) -> None: + api.choose_org.return_value = make_org(plan="Free") + result = CliRunner().invoke(setup, [tool, "--org", "my-org"]) + assert result.exit_code == 1 + assert "Free plan" in result.output + api.create_key.assert_not_called() + + +def test_status_lists_every_tool(api) -> None: + for tool in ("pi", "codex", "dsh"): + assert invoke(setup, tool, "--org", "my-org").exit_code == 0 + infos = {info["tool"]: info for info in json.loads(invoke(status, "--json").output)} + + assert set(infos) == {"opencode", "pi", "codex", "dsh", "cursor"} + assert [infos[t]["key_id"] for t in ("pi", "codex", "dsh")] == ["key-1", "key-2", "key-3"] + assert infos["opencode"]["configured"] is False + assert "DeepSeek Harness: billed to my-org" in invoke(status).output + + +EXAMPLES = Path(__file__).parents[3] / "examples" / "code" + + +@pytest.mark.parametrize( + ("tool_id", "file_name", "example"), + [ + ("pi", "models.json", "pi-models.json"), + ("pi", "settings.json", "pi-settings.json"), + ("codex", "lightning.config.toml", "codex-lightning.config.toml"), + ("dsh", "cordis.patch.yml", "dsh-cordis.patch.yml"), + ("dsh", ".credentials.yaml", "dsh-credentials.yaml"), + ], +) +def test_examples_match_what_setup_writes(tool_id: str, file_name: str, example: str) -> None: + from lightning_sdk.cli.code.models import FALLBACK_MODELS + from lightning_sdk.cli.code.registry import TOOLS + from lightning_sdk.cli.code.tool import KeyRecord + + tool = TOOLS[tool_id] + record = KeyRecord("01jexample0rg0000000000000", "my-org", "01jexamplekey0000000000000", "example") + plan = tool.plan_setup(tool.read(), models=FALLBACK_MODELS, model=None, key="sk-lit-replace-me", record=record) + change = next(change for change in plan.changes if change.path.name == file_name) + assert change.after == (EXAMPLES / example).read_text() From 6d493709e8f8dad064003457b57ddda89dd201f9 Mon Sep 17 00:00:00 2001 From: Karolis Date: Sat, 10 Oct 2026 12:18:36 +0400 Subject: [PATCH 2/2] docs: align the code_cli row in the examples table --- python/examples/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/python/examples/README.md b/python/examples/README.md index 8db79a12..08a71867 100644 --- a/python/examples/README.md +++ b/python/examples/README.md @@ -71,7 +71,7 @@ export LIGHTNING_SANDBOX_API_KEY="..." | [`sandboxes_cli.rst`](sandboxes_cli.rst) | Create sandboxes, run commands, inspect logs, stop, resume, and delete from the CLI | | [`logs_cli.rst`](logs_cli.rst) | Tail, follow, search, and page logs for jobs, MMTs, deployments, and sandboxes | | [`api_cli.rst`](api_cli.rst) | Call authenticated Lightning API endpoints directly from shell scripts | -| [`code_cli.rst`](code_cli.rst) | Set up OpenCode, pi, Codex, DeepSeek Harness and Cursor for code.lightning.ai | +| [`code_cli.rst`](code_cli.rst) | Set up OpenCode, pi, Codex, DeepSeek Harness and Cursor for code.lightning.ai | # Running examples