diff --git a/README.md b/README.md index 6dca888..53c480d 100644 --- a/README.md +++ b/README.md @@ -43,10 +43,10 @@ skore skills remove # remove installed skills On the first run, `skore agent` logs in when needed, lets you pick a workspace and harness, creates a workspace API key, writes the harness configuration and -launches the agent. Supported harnesses: **Claude**, **OpenCode**, **Pi** and -**GitHub Copilot** (must be on `PATH`). Later runs reuse `.skore` in the project -directory (gitignored). Use `SKORE_HUB_URI` (or `--hub-url`) to point at a -non-default hub. +launches the agent. Supported harnesses: **Claude**, **OpenCode**, **Pi**, +**GitHub Copilot** and **Codex** (must be on `PATH`). Later runs reuse `.skore` +in the project directory (gitignored). Use `SKORE_HUB_URI` (or `--hub-url`) to +point at a non-default hub. ```bash skore agent diff --git a/src/skore_cli/_agents.py b/src/skore_cli/_agents.py index d74d6f8..05c8901 100644 --- a/src/skore_cli/_agents.py +++ b/src/skore_cli/_agents.py @@ -6,6 +6,7 @@ import os import shutil import sys +import tomllib from collections.abc import Callable from dataclasses import dataclass from pathlib import Path @@ -28,6 +29,10 @@ COPILOT_PROVIDER_NAME = "Skore Agent" COPILOT_PROJECT_CONFIG = ".vscode/chatLanguageModels.json" COPILOT_BINARIES = ("code", "code-insiders") +CODEX_PROVIDER_KEY = "skore" +CODEX_PROVIDER_NAME = "Skore Agent" +CODEX_PROJECT_CONFIG = ".codex/skore-provider.toml" +CODEX_API_KEY_ENV = "SKORE_AGENT_API_KEY" @dataclass(frozen=True) @@ -184,13 +189,12 @@ def _copilot_provider(ctx: HarnessContext) -> dict[str, Any]: def _configure_copilot(ctx: HarnessContext) -> dict[str, Any]: - """Write the project configuration for GitHub Copilot in VS Code.""" + """Write ``.vscode/chatLanguageModels.json`` for GitHub Copilot in VS Code.""" from skore_cli._style import console from skore_cli.agent._skore_file import ensure_gitignore_entry config_path = ctx.workspace / COPILOT_PROJECT_CONFIG - config_path.parent.mkdir(parents=True, exist_ok=True) - config_path.write_text(json.dumps([_copilot_provider(ctx)], indent=2) + "\n") + _upsert_copilot_provider(config_path, _copilot_provider(ctx)) ensure_gitignore_entry(ctx.workspace, COPILOT_PROJECT_CONFIG) console.print(f"[skore.ok]+[/] wrote [skore.path]{config_path}[/]") return {"config_path": str(config_path)} @@ -228,25 +232,86 @@ def _copilot_user_config_path(binary: str, *, home: Path | None = None) -> Path: return home / ".config" / app / "User" / "chatLanguageModels.json" -def _upsert_copilot_provider(user_config_path: Path, provider: dict[str, Any]) -> None: - """Upsert the Skore provider into a user-level language-model file.""" +def _upsert_copilot_provider(config_path: Path, provider: dict[str, Any]) -> None: + """Upsert the Skore provider into a language-model file.""" providers: list[Any] = [] - if user_config_path.is_file(): + if config_path.is_file(): try: - providers = json.loads(user_config_path.read_text() or "[]") + parsed = json.loads(config_path.read_text() or "[]") except json.JSONDecodeError as error: raise RuntimeError( - f"could not parse {user_config_path}; " - "add the Skore Agent provider from VS Code instead." + f"could not parse {config_path}; " + "fix the file or add the Skore Agent provider manually." ) from error + if not isinstance(parsed, list): + raise RuntimeError( + f"could not parse {config_path}; " + "fix the file or add the Skore Agent provider manually." + ) + providers = parsed updated = [ entry for entry in providers if not (isinstance(entry, dict) and entry.get("name") == COPILOT_PROVIDER_NAME) ] updated.append(provider) - user_config_path.parent.mkdir(parents=True, exist_ok=True) - user_config_path.write_text(json.dumps(updated, indent=2) + "\n") + config_path.parent.mkdir(parents=True, exist_ok=True) + config_path.write_text(json.dumps(updated, indent=2) + "\n") + + +def _toml_string(value: str) -> str: + """Quote ``value`` as a TOML basic string. + + JSON string escapes are a valid subset of TOML basic-string escapes, so + values containing quotes or backslashes stay parseable. + """ + return json.dumps(value) + + +def _codex_config_overrides(base_url: str) -> list[str]: + """Build the runtime ``--config`` overrides that declare the provider. + + Codex refuses ``model_providers`` in project-local config files, so the + provider is passed per run instead. The API key is referenced through + ``env_http_headers`` (header name -> environment variable): it travels in + the process environment and never appears on the command line. + """ + provider = f"model_providers.{CODEX_PROVIDER_KEY}" + return [ + f"model_provider={_toml_string(CODEX_PROVIDER_KEY)}", + f"{provider}.name={_toml_string(CODEX_PROVIDER_NAME)}", + f"{provider}.base_url={_toml_string(base_url)}", + f'{provider}.wire_api="responses"', + f'{provider}.env_http_headers={{ "X-API-Key" = "{CODEX_API_KEY_ENV}" }}', + ] + + +def _configure_codex(ctx: HarnessContext) -> dict[str, Any]: + """Write the project-local Codex provider file. + + Everything lives in the workspace so each worktree keeps its own hub + credentials; nothing is written under ``~/.codex`` and plain ``codex`` + runs keep the user's default model. + """ + from skore_cli._style import console + from skore_cli.agent._skore_file import ensure_gitignore_entry + + config_path = ctx.workspace / CODEX_PROJECT_CONFIG + config_path.parent.mkdir(parents=True, exist_ok=True) + config_path.write_text( + f"model = {_toml_string(ctx.model_id)}\n" + f"model_provider = {_toml_string(CODEX_PROVIDER_KEY)}\n" + f"base_url = {_toml_string(ctx.base_url)}\n" + f"api_key = {_toml_string(ctx.api_key)}\n" + ) + ensure_gitignore_entry(ctx.workspace, CODEX_PROJECT_CONFIG) + console.print(f"[skore.ok]+[/] wrote [skore.path]{config_path}[/]") + console.print( + "[skore.muted]Note:[/] configuration is project-local and " + "[skore.path]~/.codex[/] [skore.muted]is untouched. Launch through[/] " + "[skore.skill]skore agent[/] [skore.muted]to use the Skore hub model.[/]" + ) + return {"config_path": str(config_path)} def _launch_opencode(_workspace: Path, model_id: str) -> None: @@ -282,7 +347,31 @@ def _launch_copilot(workspace: Path, _model_id: str) -> None: if binary is None: raise RuntimeError("GitHub Copilot is not installed or not on PATH.") - provider = json.loads((workspace / COPILOT_PROJECT_CONFIG).read_text())[0] + # VS Code only reads providers from the user profile, so the project config + # written by ``_configure_copilot`` has to be mirrored there. + project_config = workspace / COPILOT_PROJECT_CONFIG + if not project_config.is_file(): + raise RuntimeError( + f"missing {COPILOT_PROJECT_CONFIG}; " + "run skore agent --harness copilot first." + ) + try: + providers = json.loads(project_config.read_text() or "[]") + except json.JSONDecodeError as error: + raise RuntimeError(f"could not parse {COPILOT_PROJECT_CONFIG}.") from error + provider = next( + ( + entry + for entry in providers + if isinstance(entry, dict) and entry.get("name") == COPILOT_PROVIDER_NAME + ), + None, + ) + if provider is None: + raise RuntimeError( + f"{COPILOT_PROJECT_CONFIG} has no {COPILOT_PROVIDER_NAME} provider; " + "run skore agent --harness copilot first." + ) user_config = _copilot_user_config_path(binary) _upsert_copilot_provider(user_config, provider) console.print(f"[skore.ok]+[/] synced [skore.path]{user_config}[/]") @@ -293,6 +382,36 @@ def _launch_copilot(workspace: Path, _model_id: str) -> None: _exec_harness(binary, [binary, str(workspace)]) +def _launch_codex(workspace: Path, model_id: str) -> None: + project_config = workspace / CODEX_PROJECT_CONFIG + if not project_config.is_file(): + raise RuntimeError( + f"missing {CODEX_PROJECT_CONFIG}; run skore agent --harness codex first." + ) + try: + data = tomllib.loads(project_config.read_text()) + except tomllib.TOMLDecodeError as error: + raise RuntimeError( + f"could not parse {CODEX_PROJECT_CONFIG}; " + "run skore agent --harness codex first." + ) from error + base_url = data.get("base_url") + api_key = data.get("api_key") + if not isinstance(base_url, str) or not base_url: + raise RuntimeError(f"{CODEX_PROJECT_CONFIG} is missing a valid base_url.") + if not isinstance(api_key, str) or not api_key: + raise RuntimeError(f"{CODEX_PROJECT_CONFIG} is missing a valid api_key.") + model = data.get("model") + if not isinstance(model, str) or not model: + model = model_id + env = os.environ.copy() + env[CODEX_API_KEY_ENV] = api_key + argv = ["codex", "--model", model] + for override in _codex_config_overrides(base_url): + argv.extend(["--config", override]) + _exec_harness("codex", argv, env=env) + + def _exec_harness( name: str, argv: list[str], *, env: dict[str, str] | None = None ) -> None: @@ -336,6 +455,10 @@ def _exec_harness( detection_priority=3, user_skills_dir=".agents/skills", project_skills_dir=".agents/skills", + harness_name="codex", + harness_label="Codex", + configure=_configure_codex, + launch=_launch_codex, ), "gemini": Agent( name="gemini", diff --git a/src/skore_cli/agent/_commands.py b/src/skore_cli/agent/_commands.py index f049255..76fcb79 100644 --- a/src/skore_cli/agent/_commands.py +++ b/src/skore_cli/agent/_commands.py @@ -190,7 +190,7 @@ def agent( harness config, and launches the agent. Later runs reuse ``.skore`` in the project directory. - Supported harnesses: Claude, OpenCode, Pi and GitHub Copilot + Supported harnesses: Claude, OpenCode, Pi, GitHub Copilot and Codex (must be on ``PATH``). """ workspace = workspace.resolve() diff --git a/src/skore_cli/agent/app/_picker.py b/src/skore_cli/agent/app/_picker.py index cb730cb..4ee7567 100644 --- a/src/skore_cli/agent/app/_picker.py +++ b/src/skore_cli/agent/app/_picker.py @@ -25,6 +25,7 @@ • OpenCode — writes opencode.json • Pi — writes .pi/agent/models.json • Copilot — writes .vscode/chatLanguageModels.json + • Codex — writes .codex/skore-provider.toml (+ ~/.codex/config.toml) Skore stores your hub credentials in .skore and selects the skore-agent model when the harness starts. diff --git a/tests/test_agents.py b/tests/test_agents.py index ae4191f..49ee95f 100644 --- a/tests/test_agents.py +++ b/tests/test_agents.py @@ -37,7 +37,7 @@ def test_agent_names_match_registry(): def test_harness_names_come_from_registry(): - assert HARNESS_NAMES == ["claude", "opencode", "pi", "copilot"] + assert HARNESS_NAMES == ["claude", "codex", "opencode", "pi", "copilot"] def test_harness_rows_are_complete(): diff --git a/tests/test_detect.py b/tests/test_detect.py index fd2cd5a..d8f75bb 100644 --- a/tests/test_detect.py +++ b/tests/test_detect.py @@ -118,7 +118,7 @@ def test_priority_pi_before_opencode(monkeypatch): ("CLAUDECODE", "1", "claude"), ("CURSOR_AGENT", "1", None), ("GEMINI_CLI", "1", None), - ("CODEX_SANDBOX", "seatbelt", None), + ("CODEX_SANDBOX", "seatbelt", "codex"), ("PI_CODING_AGENT", "true", "pi"), ("OPENCODE_CLIENT", "1", "opencode"), ], diff --git a/tests/test_harnesses.py b/tests/test_harnesses.py index 882b271..a78dd73 100644 --- a/tests/test_harnesses.py +++ b/tests/test_harnesses.py @@ -3,6 +3,8 @@ from __future__ import annotations import json +import tomllib +from typing import Any import pytest @@ -254,6 +256,23 @@ def test_copilot_config_matches_custom_endpoint(tmp_path): assert ".vscode/chatLanguageModels.json" in gitignore +def test_configure_copilot_preserves_other_entries(tmp_path): + config_path = tmp_path / ".vscode" / "chatLanguageModels.json" + config_path.parent.mkdir(parents=True) + config_path.write_text( + json.dumps( + [{"name": "My Other Endpoint", "vendor": "customendpoint", "models": []}] + ) + + "\n" + ) + AGENTS["github-copilot"].configure(_ctx(tmp_path)) + providers = json.loads(config_path.read_text()) + assert [entry["name"] for entry in providers] == [ + "My Other Endpoint", + "Skore Agent", + ] + + def test_launch_copilot_prefers_code_and_opens_workspace(tmp_path, monkeypatch): AGENTS["github-copilot"].configure(_ctx(tmp_path)) user_root = tmp_path / "vscode-user" @@ -314,6 +333,77 @@ def test_launch_copilot_errors_when_binary_missing(tmp_path, monkeypatch): _agents._launch_copilot(tmp_path, "skore-agent") +def test_launch_copilot_errors_when_project_config_missing(tmp_path, monkeypatch): + monkeypatch.setattr( + _agents.shutil, + "which", + lambda cmd: "/usr/bin/code" if cmd == "code" else None, + ) + with pytest.raises(RuntimeError, match="missing .vscode/chatLanguageModels.json"): + _agents._launch_copilot(tmp_path, "skore-agent") + + +def test_launch_copilot_errors_when_project_config_unparsable(tmp_path, monkeypatch): + config_path = tmp_path / ".vscode" / "chatLanguageModels.json" + config_path.parent.mkdir(parents=True) + config_path.write_text("{not json\n") + monkeypatch.setattr( + _agents.shutil, + "which", + lambda cmd: "/usr/bin/code" if cmd == "code" else None, + ) + with pytest.raises(RuntimeError, match="could not parse"): + _agents._launch_copilot(tmp_path, "skore-agent") + + +def test_launch_copilot_finds_provider_among_others(tmp_path, monkeypatch): + config_path = tmp_path / ".vscode" / "chatLanguageModels.json" + config_path.parent.mkdir(parents=True) + config_path.write_text( + json.dumps( + [ + {"name": "My Other Endpoint", "vendor": "customendpoint", "models": []}, + { + "name": "Skore Agent", + "vendor": "customendpoint", + "apiKey": "skore", + "models": [ + { + "id": "skore-agent", + "requestHeaders": {"X-API-Key": "secret-key"}, + } + ], + }, + ] + ) + + "\n" + ) + user_root = tmp_path / "vscode-user" + captured: dict[str, list[str]] = {} + + def fake_exec(name, argv, *, env=None): + captured["argv"] = argv + + monkeypatch.setattr( + _agents.shutil, + "which", + lambda cmd: "/usr/bin/code" if cmd == "code" else None, + ) + monkeypatch.setattr( + _agents, + "_copilot_user_config_path", + lambda binary, home=None: user_root / binary / "chatLanguageModels.json", + ) + monkeypatch.setattr(_agents, "_exec_harness", fake_exec) + + _agents.launch_harness(AGENTS["github-copilot"], tmp_path, model_id="skore-agent") + assert captured["argv"] == ["code", str(tmp_path)] + user_config = user_root / "code" / "chatLanguageModels.json" + providers = json.loads(user_config.read_text()) + assert providers[-1]["name"] == "Skore Agent" + assert providers[-1]["models"][0]["requestHeaders"] == {"X-API-Key": "secret-key"} + + def test_upsert_copilot_provider_preserves_other_entries(tmp_path): user_config = tmp_path / "User" / "chatLanguageModels.json" user_config.parent.mkdir(parents=True) @@ -353,6 +443,14 @@ def test_upsert_copilot_provider_errors_on_unparsable_file(tmp_path): _agents._upsert_copilot_provider(user_config, {"name": "Skore Agent"}) +def test_upsert_copilot_provider_errors_on_non_list_file(tmp_path): + user_config = tmp_path / "User" / "chatLanguageModels.json" + user_config.parent.mkdir(parents=True) + user_config.write_text('{"name": "not a list"}\n') + with pytest.raises(RuntimeError, match="could not parse"): + _agents._upsert_copilot_provider(user_config, {"name": "Skore Agent"}) + + @pytest.mark.parametrize( ("platform", "expected"), [ @@ -381,3 +479,185 @@ def test_copilot_user_config_path_windows_uses_appdata(tmp_path, monkeypatch): assert fallback == tmp_path.joinpath( "AppData", "Roaming", "Code", "User", "chatLanguageModels.json" ) + + +def test_detect_codex_by_binary(monkeypatch): + monkeypatch.setattr( + _agents.shutil, + "which", + lambda name: "/usr/bin/codex" if name == "codex" else None, + ) + assert is_harness_installed(AGENTS["codex"]) is True + assert [agent.harness_name for agent in installed_harnesses()] == ["codex"] + + +def _codex_home(tmp_path, monkeypatch): + """Point ``Path.home`` at a scratch dir to detect any global write.""" + monkeypatch.delenv("CODEX_HOME", raising=False) + home = tmp_path / "home" + monkeypatch.setattr(_agents.Path, "home", classmethod(lambda cls: home)) + return home + + +def _prepare_codex_launch(tmp_path, monkeypatch) -> dict[str, Any]: + captured: dict[str, Any] = {} + + def fake_exec(name, argv, *, env=None): + captured["argv"] = argv + captured["env"] = env + + monkeypatch.setattr( + _agents.shutil, + "which", + lambda cmd: "/usr/bin/codex" if cmd == "codex" else None, + ) + monkeypatch.setattr(_agents, "_exec_harness", fake_exec) + return captured + + +def _parse_config_overrides(argv: list[str]) -> dict[str, object]: + """Parse the ``--config key=value`` tail of a codex argv as one TOML doc.""" + overrides = argv[4::2] + assert argv[3::2] == ["--config"] * len(overrides) + return tomllib.loads("\n".join(overrides)) + + +def test_configure_codex_writes_project_config_only(tmp_path, monkeypatch): + home = _codex_home(tmp_path, monkeypatch) + + AGENTS["codex"].configure(_ctx(tmp_path)) + + project = tomllib.loads((tmp_path / ".codex" / "skore-provider.toml").read_text()) + assert project["model"] == "skore-agent" + assert project["model_provider"] == "skore" + assert project["base_url"] == "http://hub.test/v1" + assert project["api_key"] == "secret-key" + gitignore = (tmp_path / ".gitignore").read_text().splitlines() + assert ".codex/skore-provider.toml" in gitignore + assert not home.exists() + + +def test_configure_codex_overwrites_previous_project_config(tmp_path): + stale = HarnessContext( + workspace=tmp_path, hub_url="http://old.test", api_key="old-key" + ) + AGENTS["codex"].configure(stale) + AGENTS["codex"].configure(_ctx(tmp_path)) + project = tomllib.loads((tmp_path / ".codex" / "skore-provider.toml").read_text()) + assert project["base_url"] == "http://hub.test/v1" + assert project["api_key"] == "secret-key" + + +def test_configure_codex_escapes_toml_special_characters(tmp_path): + api_key = 'sec"ret\\key' + ctx = HarnessContext(workspace=tmp_path, hub_url="http://hub.test", api_key=api_key) + + AGENTS["codex"].configure(ctx) + + project = tomllib.loads((tmp_path / ".codex" / "skore-provider.toml").read_text()) + assert project["api_key"] == api_key + + +def test_launch_codex_passes_provider_as_runtime_overrides(tmp_path, monkeypatch): + home = _codex_home(tmp_path, monkeypatch) + captured = _prepare_codex_launch(tmp_path, monkeypatch) + AGENTS["codex"].configure(_ctx(tmp_path)) + + _agents.launch_harness(AGENTS["codex"], tmp_path, model_id="skore-agent") + + argv = captured["argv"] + assert argv[:3] == ["codex", "--model", "skore-agent"] + data = _parse_config_overrides(argv) + assert data["model_provider"] == "skore" + provider = data["model_providers"]["skore"] + assert provider["name"] == "Skore Agent" + assert provider["base_url"] == "http://hub.test/v1" + assert provider["wire_api"] == "responses" + assert provider["env_http_headers"] == {"X-API-Key": "SKORE_AGENT_API_KEY"} + assert "http_headers" not in provider + assert "env_key" not in provider + assert "secret-key" not in argv # the key never rides the command line + assert captured["env"]["SKORE_AGENT_API_KEY"] == "secret-key" + assert not home.exists() + + +def test_launch_codex_prefers_project_model(tmp_path, monkeypatch): + captured = _prepare_codex_launch(tmp_path, monkeypatch) + config = tmp_path / ".codex" / "skore-provider.toml" + config.parent.mkdir(parents=True) + config.write_text( + 'model = "custom-model"\n' + 'model_provider = "skore"\n' + 'base_url = "http://hub.test/v1"\n' + 'api_key = "secret-key"\n' + ) + + _agents.launch_harness(AGENTS["codex"], tmp_path, model_id="skore-agent") + + assert captured["argv"][:3] == ["codex", "--model", "custom-model"] + + +def test_launch_codex_falls_back_to_model_argument(tmp_path, monkeypatch): + captured = _prepare_codex_launch(tmp_path, monkeypatch) + config = tmp_path / ".codex" / "skore-provider.toml" + config.parent.mkdir(parents=True) + config.write_text('base_url = "http://hub.test/v1"\napi_key = "secret-key"\n') + + _agents.launch_harness(AGENTS["codex"], tmp_path, model_id="fallback-model") + + assert captured["argv"][:3] == ["codex", "--model", "fallback-model"] + + +def test_launch_codex_errors_when_project_config_missing(tmp_path, monkeypatch): + monkeypatch.setattr( + _agents.shutil, + "which", + lambda cmd: "/usr/bin/codex" if cmd == "codex" else None, + ) + with pytest.raises(RuntimeError, match="missing .codex/skore-provider.toml"): + _agents.launch_harness(AGENTS["codex"], tmp_path) + + +@pytest.mark.parametrize( + ("payload", "match"), + [ + ( + 'model = "skore-agent"\nmodel_provider = "skore"\napi_key = "secret-key"\n', + "missing a valid base_url", + ), + ( + 'model = "skore-agent"\n' + 'model_provider = "skore"\n' + 'base_url = ""\n' + 'api_key = "secret-key"\n', + "missing a valid base_url", + ), + ( + 'model = "skore-agent"\n' + 'model_provider = "skore"\n' + 'base_url = "http://hub.test/v1"\n', + "missing a valid api_key", + ), + ( + 'model = "skore-agent"\n' + 'model_provider = "skore"\n' + 'base_url = "http://hub.test/v1"\n' + 'api_key = ""\n', + "missing a valid api_key", + ), + ("model = [unterminated\n", "could not parse"), + ], +) +def test_launch_codex_errors_when_project_config_invalid( + tmp_path, monkeypatch, payload, match +): + config = tmp_path / ".codex" / "skore-provider.toml" + config.parent.mkdir(parents=True) + config.write_text(payload) + monkeypatch.setattr( + _agents.shutil, + "which", + lambda cmd: "/usr/bin/codex" if cmd == "codex" else None, + ) + with pytest.raises(RuntimeError, match=match): + _agents.launch_harness(AGENTS["codex"], tmp_path) diff --git a/uv.lock b/uv.lock index 92c4de5..b8ae97d 100644 --- a/uv.lock +++ b/uv.lock @@ -13,15 +13,6 @@ resolution-markers = [ "python_full_version < '3.12' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] -[options] -exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included for backwards compatibility when using relative exclude-newer values. -exclude-newer-span = "P7D" - -[options.exclude-newer-package] -skrub = false -scikit-learn = false -skore = false - [[package]] name = "annotated-types" version = "0.7.0" @@ -1251,7 +1242,7 @@ name = "pexpect" version = "4.9.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "ptyprocess" }, + { name = "ptyprocess", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/42/92/cc564bf6381ff43ce1f4d06852fc19a2f11d180f23dc32d9588bee2f149d/pexpect-4.9.0.tar.gz", hash = "sha256:ee7d41123f3c9911050ea2c2dac107568dc43b2d3b0c7557a33212c398ead30f", size = 166450, upload-time = "2023-11-25T09:07:26.339Z" } wheels = [ @@ -1861,7 +1852,7 @@ resolution-markers = [ "python_full_version < '3.12' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] dependencies = [ - { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" } }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/7a/97/5a3609c4f8d58b039179648e62dd220f89864f56f7357f5d4f45c29eb2cc/scipy-1.17.1.tar.gz", hash = "sha256:95d8e012d8cb8816c226aef832200b1d45109ed4464303e997c5b13122b297c0", size = 30573822, upload-time = "2026-02-23T00:26:24.851Z" } wheels = [ @@ -1940,7 +1931,7 @@ resolution-markers = [ "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] dependencies = [ - { name = "numpy", version = "2.5.0", source = { registry = "https://pypi.org/simple" } }, + { name = "numpy", version = "2.5.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/a7/25/c2700dfaf6442b4effaa91af24ebce5dc9d31bb4a69706313aae70d72cd0/scipy-1.18.0.tar.gz", hash = "sha256:67b2ad2ad54c72ca6d04975a9b2df8c3638c34ddd5b28738e94fc2b57929d378", size = 30774447, upload-time = "2026-06-19T15:01:43.456Z" } wheels = [ @@ -2047,7 +2038,7 @@ hub = [ [[package]] name = "skore-cli" -version = "0.1.1" +version = "0.2.0" source = { editable = "." } dependencies = [ { name = "click" },