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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -163,7 +163,9 @@ cython_debug/
#.idea/

.DS_Store
*.datamander

# Skore agent credentials (project-local)
.skore

# Visual studio code
.vscode
Expand Down
40 changes: 32 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,28 +2,52 @@

Command-line interface for [skore](https://github.com/probabl-ai/skore).

`skore-cli` lets you discover, install and manage [Agent
Skills](https://agentskills.io) for AI coding agents (Claude Code, Cursor,
Codex, Gemini, and the cross-client `.agents/` convention) directly from your
terminal.
`skore-cli` installs a single `skore` command with two areas:

- **skills** — discover, install and manage [Agent Skills](https://agentskills.io)
from the [probabl-ai/skills](https://github.com/probabl-ai/skills) catalog
- **agent** — connect a project to the Skore Hub agent, write harness config
and launch a local coding agent

## Installation

```bash
pip install skore-cli
```

The base install is batteries-included: it bundles the `agent` feature (so it
pulls in `skore`). No extras are required.

## Usage

The package installs a `skore` command exposing a `skills` group:
### Skills

Install skills into the current project by default. Pass `--global`/`-g` for a
user-wide install and `--agent`/`-a` to target specific agents (`agents`,
`claude-code`, `cursor`, `codex`, `gemini`).

```bash
skore skills find # search the catalog interactively
skore skills find # browse the catalog interactively
skore skills list # list installed skills
skore skills install # install skills (interactive or by id)
skore skills update # update installed skills
skore skills remove # remove installed skills
```

Skills are installed into the current project by default; pass `--global`/`-g`
to target the user directory, and `--agent`/`-a` to select specific agents.
### Agent

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** and **Pi**
(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
skore agent --harness claude # non-interactive harness choice
skore agent --workspace ./myapp # configure another project directory
```

## License

MIT
9 changes: 9 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,10 @@ dependencies = [
"rich>=14.2",
"rich-click>=1.9",
"textual",
# The `agent` command reuses skore's authentication machinery and calls the
# hub identity API directly with httpx.
"skore[hub]",
"httpx",
]

[project.optional-dependencies]
Expand Down Expand Up @@ -160,4 +164,9 @@ ignore_missing_imports = true
module = [
"rich_click.*",
"textual.*",
"httpx",
"httpx.*",
]

[tool.ty.environment]
python = ".venv"
33 changes: 30 additions & 3 deletions src/skore_cli/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,40 @@
import rich_click as click

import skore_cli._style # noqa: F401 (applies the CLI palette and rich-click config)
from skore_cli._plugins import load_plugins

# These command modules defer their heavy `skore` imports into the callbacks, so
# importing them (to build the CLI / show `--help`) stays instant. Each merges its
# own `rich_click.COMMAND_GROUPS` entry, so import order does not matter.
from skore_cli.agent import agent
from skore_cli.skills import skills

click.rich_click.COMMAND_GROUPS = {
**getattr(click.rich_click, "COMMAND_GROUPS", {}),
"cli": [
{"name": "Agent", "commands": ["agent"]},
{"name": "Skills", "commands": ["skills"]},
],
}


@click.group()
@click.group(invoke_without_command=True)
@click.pass_context
@click.version_option(package_name="skore-cli")
def cli() -> None:
"""Skore command-line interface."""
def cli(ctx) -> None:
"""Skore command-line interface.

Use ``skore agent`` to connect a project to the Skore Hub agent, and
``skore skills`` to install probabl-skills locally.
"""
if ctx.invoked_subcommand is None:
click.echo(ctx.get_help())


cli.add_command(skills)
cli.add_command(agent)

# Commands contributed by other packages via the `skore_cli.plugins` entry-point
# group. Kept for third-party extensibility; the built-in `agent` command no
# longer goes through it so the CLI never imports `skore` just to show help.
load_plugins(cli)
70 changes: 70 additions & 0 deletions src/skore_cli/_hub_auth.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
"""Bridge ``skore-cli`` to ``skore``'s in-process hub authentication."""

from __future__ import annotations

import os
from typing import Literal

import rich_click as click

from skore_cli._skore import auth as _auth

API_KEY_ENV = "SKORE_HUB_API_KEY"
AuthKind = Literal["api_key", "bearer", "none"]


def _login_module():
return _auth("login")


def auth_kind() -> AuthKind:
"""Return how the current process authenticates to the hub."""
credentials = _login_module().credentials
if credentials is None:
if os.environ.get(API_KEY_ENV):
return "api_key"
return "none"
headers = credentials()
if "Authorization" in headers:
return "bearer"
if "X-API-Key" in headers:
return "api_key"
return "none"


def bearer_token() -> str | None:
"""Return the current OAuth access token, if logged in interactively."""
credentials = _login_module().credentials
if credentials is None:
return None
authorization = credentials().get("Authorization", "")
if authorization.startswith("Bearer "):
return authorization.removeprefix("Bearer ")
return None


def ensure_login(*, timeout: int = 600) -> str:
"""Ensure an interactive session exists and return its bearer access token."""
if auth_kind() == "api_key":
raise click.ClickException(
"set up a workspace API key with an interactive login first; "
f"`{API_KEY_ENV}` alone cannot mint project keys."
)

login_mod = _login_module()
if login_mod.credentials is None:
login_mod.login(timeout=timeout)

token = bearer_token()
if not token:
raise click.ClickException("not logged in; run `skore agent` again.")
return token


def clear_login() -> bool:
"""Drop in-process hub credentials. Returns whether a session was cleared."""
login_mod = _login_module()
if login_mod.credentials is None:
return False
login_mod.credentials = None
return True
60 changes: 60 additions & 0 deletions src/skore_cli/_plugins.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
"""Entry-point plugin host for the ``skore`` CLI.

Lets other packages (``skore``, ``skore-hub``, ...) contribute command groups to
the ``skore`` CLI without ``skore-cli`` importing them directly. A plugin is a
Python entry point in the ``skore_cli.plugins`` group whose loaded object is
either a ``click.Command`` / ``click.Group`` or a zero-argument callable
returning one.

Example (in another package's ``pyproject.toml``)::

[project.entry-points."skore_cli.plugins"]
agent = "skore._cli.agent:agent"

Plugins that fail to load are skipped with a warning so a broken third-party
plugin never takes down the whole CLI.
"""

from __future__ import annotations

from importlib.metadata import entry_points

import rich_click as click

PLUGIN_GROUP = "skore_cli.plugins"


def _iter_plugin_entry_points():
try:
return list(entry_points(group=PLUGIN_GROUP))
except TypeError: # pragma: no cover - Python < 3.10 selection API
return list(entry_points().get(PLUGIN_GROUP, []))


def load_plugins(group: click.Group) -> None:
"""Discover ``skore_cli.plugins`` entry points and attach their commands."""
for entry_point in _iter_plugin_entry_points():
try:
obj = entry_point.load()
command = obj if isinstance(obj, click.Command) else obj()
except Exception as error: # noqa: BLE001 - never break the CLI on a plugin
click.echo(
click.style(
f"warning: failed to load CLI plugin {entry_point.name!r}: {error}",
fg="yellow",
),
err=True,
)
continue

if isinstance(command, click.Command):
group.add_command(command)
else:
click.echo(
click.style(
f"warning: CLI plugin {entry_point.name!r} did not return a "
"click command; skipping",
fg="yellow",
),
err=True,
)
50 changes: 50 additions & 0 deletions src/skore_cli/_skore.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
"""Lazy access to the (heavy, optional) ``skore`` package for the agent command.

The ``agent`` command reuses the authentication machinery that lives in
``skore`` (``skore._plugins.hub.authentication``). Importing it is expensive and
only needed when a command actually runs, so it is deferred here and surfaced as
a friendly error when ``skore`` is not installed.
"""

from __future__ import annotations

import importlib
import os
from collections.abc import Callable
from types import ModuleType

import rich_click as click

_MISSING = (
"this command needs the `skore` package (install it with `pip install "
"skore-cli` or `pip install skore`)."
)

# Mirrors ``skore._plugins.hub.authentication``'s env var; kept as a local literal
# so showing help never imports the (heavy) ``skore`` package.
URI_ENV = "SKORE_HUB_URI"


def auth(submodule: str) -> ModuleType:
"""Import ``skore._plugins.hub.authentication.<submodule>`` or fail nicely."""
try:
return importlib.import_module(f"skore._plugins.hub.authentication.{submodule}")
except ImportError as error: # pragma: no cover - exercised via the CLI
raise click.ClickException(_MISSING) from error


def resolve_hub_uri(
hub_url: str | None, auth_fn: Callable[[str], ModuleType] = auth
) -> str:
"""Resolve the hub base URL.

An explicit ``hub_url`` seeds the ``SKORE_HUB_URI`` environment variable;
resolution then defers to ``skore``'s canonical ``URI()`` (which reads that
env var, falling back to the public hub).

``auth_fn`` defaults to :func:`auth` but is injectable so each command can
pass the ``_auth`` accessor that its tests monkeypatch.
"""
if hub_url:
os.environ[URI_ENV] = hub_url
return auth_fn("uri").URI()
13 changes: 13 additions & 0 deletions src/skore_cli/agent/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
"""The ``skore agent`` command to connect a project to the Skore Hub agent.

The command authenticates with the hub, stores workspace credentials in a local
``.skore`` file, writes the harness configuration, and launches Claude,
OpenCode or Pi when installed.

Heavy ``skore`` (and ``textual``) imports are deferred into the command callback
so building the CLI (and ``--help``) never imports them.
"""

from skore_cli.agent._commands import agent

__all__ = ["agent"]
Loading
Loading