diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index bb01370e..65939d40 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -28,7 +28,7 @@ jobs: - name: Install dependencies run: | python -m pip install --upgrade pip - pip install pytest pytest-cov + pip install pytest pytest-cov jsonschema pip install -e . - name: Run tests diff --git a/.github/workflows/ruff_check.yml b/.github/workflows/ruff_check.yml index 34f022e7..fa9b5673 100644 --- a/.github/workflows/ruff_check.yml +++ b/.github/workflows/ruff_check.yml @@ -23,7 +23,7 @@ jobs: - name: install ruff run: | python -m pip install --upgrade pip - pip install ruff + pip install ruff==0.15.15 - name: lint check and then format check with ruff run: | ruff check diff --git a/.gitignore b/.gitignore index c15d8457..1e542ddf 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,21 @@ __pycache__/ *.py[cod] +# Local agent / editor configs +.claude/settings.local.json + +# Generated artifacts — comfy CLI convention dirs +/workflows/ +/outputs/ +/output/ +/inputs/ +/variants/ +/stories + +# Local projects (creative work, demos, experiments) +/projects/ +conda.listing.txt + #COMMON CONFIGs .DS_Store .src_port @@ -61,3 +76,4 @@ requirements.compiled override.txt .coverage coverage.xml + diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index b78a8268..18af67e9 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -14,7 +14,7 @@ repos: (^.*\.(json|txt)$) - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.12.4 + rev: v0.15.15 hooks: # Run the linter. - id: ruff diff --git a/README.md b/README.md index 38c05daf..f238b13e 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,18 @@ workflows, and call hosted partner image models, all from your terminal. - 🎬 Run workflows against a local ComfyUI server, including auto-conversion of UI-format JSON - 🧪 Test ComfyUI and frontend pull requests with one flag - 💻 Cross-platform: Windows, macOS, Linux +- ☁️ Route any workflow to **Comfy Cloud** with `--where cloud` (no GPU required) +- 🤖 Agent-friendly: every command emits structured `--json` envelopes +- 📚 Bundled skills teach Claude / Cursor to drive comfy natively + +## Quick Start + +```bash +pip install comfy-cli +comfy setup +``` + +`comfy setup` walks you through everything — local or cloud routing, authentication, and agent skill installation — in one interactive wizard. Pass `-y` for non-interactive (CI/scripted) installs. ## Installation diff --git a/codecov.yml b/codecov.yml new file mode 100644 index 00000000..0307b497 --- /dev/null +++ b/codecov.yml @@ -0,0 +1,19 @@ +# Codecov configuration. +# +# The default `project` status uses target `auto` with zero tolerance, i.e. a +# hard ratchet that fails on any drop in total coverage. That doesn't suit a +# repo that periodically lands large feature surfaces (new CLI command wrappers +# are thin and lightly tested by design), so we replace the ratchet with an +# explicit floor: total coverage must stay at/above 70%. This is still a real +# gate — it just tolerates the small dips that come with adding command surface — +# and can be raised over time as coverage backfills. +coverage: + status: + project: + default: + target: 70% + threshold: 1% + # No patch (diff-coverage) status — avoid blocking on lightly-tested CLI glue. + patch: false + +comment: false diff --git a/comfy_cli/auth/__init__.py b/comfy_cli/auth/__init__.py new file mode 100644 index 00000000..16b08745 --- /dev/null +++ b/comfy_cli/auth/__init__.py @@ -0,0 +1,26 @@ +"""Local credential store for comfy-cli. + +Where keys live: + + ${XDG_CONFIG_HOME or platform-equivalent}/comfy-cli/secrets.json + +Format:: + + { + "providers": { + "comfy-cloud": {"key": "sk-…", "updated_at": "2026-05-15T12:00:00Z"}, + "civitai": {"key": "...", "updated_at": "..."} + } + } + +The file is created with mode ``0600``. Phase 5 will replace this plaintext +JSON with an encrypted ``secrets.bin``; the API surface here is the +forward-compatible interface, so call sites don't need to change. + +Local-only: this module never makes a network call. +""" + +from comfy_cli.auth import store +from comfy_cli.auth.store import SUPPORTED_PROVIDERS, AuthRecord + +__all__ = ["AuthRecord", "SUPPORTED_PROVIDERS", "store"] diff --git a/comfy_cli/auth/command.py b/comfy_cli/auth/command.py new file mode 100644 index 00000000..46aeea0a --- /dev/null +++ b/comfy_cli/auth/command.py @@ -0,0 +1,157 @@ +"""``comfy auth`` — manage API tokens for third-party model hosts. + +Tokens here are used by ``comfy model download`` when fetching gated +checkpoints / LoRAs / VAEs from Civitai or Hugging Face. Stored locally, +never transmitted except to the issuing provider. + +Comfy Cloud sign-in lives in a separate namespace — see ``comfy cloud``. +""" + +from __future__ import annotations + +from typing import Annotated + +import typer + +from comfy_cli import tracking +from comfy_cli.auth import store +from comfy_cli.output import get_renderer, rprint + +app = typer.Typer( + no_args_is_help=True, + help="Manage API tokens for model hosts (Civitai, Hugging Face).", +) + + +@app.command("list", help="List third-party API-key providers (civitai, huggingface).") +@tracking.track_command("auth") +def list_cmd(): + renderer = get_renderer() + records = store.list_records() + if renderer.is_pretty(): + _render_pretty_list(records=records) + renderer.emit( + { + "providers": [r.to_dict(redact=True) for r in records], + "supported": list(store.SUPPORTED_PROVIDERS), + "path": str(store.secrets_path()), + "action": "list", + }, + command="auth list", + ) + + +@app.command("set", help="Set or replace the API token for a third-party model host.") +@tracking.track_command("auth") +def set_cmd( + provider: Annotated[ + str, + typer.Argument(help="Provider name — `civitai` or `huggingface`. Comfy Cloud uses `comfy cloud login`."), + ], + key: Annotated[ + str, + typer.Option( + "--key", + show_default=False, + help="The API token. Stored locally; never sent except to the provider.", + ), + ], +): + renderer = get_renderer() + if provider == "comfy-cloud": + renderer.error( + code="auth_use_login_for_cloud", + message="Comfy Cloud uses OAuth — `auth set --key` is not supported for `comfy-cloud`.", + hint="run: comfy cloud login (or `comfy cloud set-key` for the API-key path)", + details={"provider": provider}, + ) + raise typer.Exit(code=1) + if not key: + renderer.error(code="auth_invalid_key", message="--key cannot be empty.") + raise typer.Exit(code=1) + try: + record = store.set(provider, key) + except ValueError as e: + renderer.error(code="auth_invalid_key", message=str(e)) + raise typer.Exit(code=1) + if renderer.is_pretty(): + rprint(f"[bold green]Stored token for {record.provider}[/bold green] ({record.to_dict()['key']})") + if provider not in store.SUPPORTED_PROVIDERS: + rprint(f"[yellow]Note:[/yellow] {provider!r} is not a well-known provider; stored anyway.") + renderer.emit( + { + "providers": [r.to_dict(redact=True) for r in store.list_records()], + "supported": list(store.SUPPORTED_PROVIDERS), + "path": str(store.secrets_path()), + "action": "set", + }, + command="auth set", + changed=True, + ) + + +@app.command("remove", help="Remove a stored third-party API token.") +@tracking.track_command("auth") +def remove_cmd( + provider: Annotated[str, typer.Argument(help="Provider name.")], +): + renderer = get_renderer() + if provider == "comfy-cloud": + renderer.error( + code="auth_use_logout_for_cloud", + message="Comfy Cloud uses OAuth — use `comfy cloud logout` to clear the session.", + hint="run: comfy cloud logout", + details={"provider": provider}, + ) + raise typer.Exit(code=1) + removed = store.remove(provider) + if not removed: + renderer.error( + code="auth_not_found", + message=f"No stored key for {provider!r}.", + hint="run: comfy auth list", + details={"provider": provider}, + ) + raise typer.Exit(code=1) + if renderer.is_pretty(): + rprint(f"[bold]Removed token for {provider}[/bold]") + renderer.emit( + { + "providers": [r.to_dict(redact=True) for r in store.list_records()], + "supported": list(store.SUPPORTED_PROVIDERS), + "path": str(store.secrets_path()), + "action": "remove", + }, + command="auth remove", + changed=True, + ) + + +# --------------------------------------------------------------------------- +# helpers +# --------------------------------------------------------------------------- + + +def _render_pretty_list(*, records): + """Pretty-render the auth list (third-party provider tokens only).""" + from rich.console import Group + from rich.text import Text + + from comfy_cli.config_manager import ConfigManager + from comfy_cli.output.branding import branded_panel + from comfy_cli.output.panels import auth_empty_panel, auth_list_table + + renderer = get_renderer() + path = str(store.secrets_path()) + + if not records: + # Empty-state has its own panel; brand it via the canonical wrapper. + body = auth_empty_panel(supported=list(store.SUPPORTED_PROVIDERS), path=path) + else: + redacted = [r.to_dict(redact=True) for r in records] + body = auth_list_table(redacted, supported=list(store.SUPPORTED_PROVIDERS), path=path) + + hint = Text("Comfy Cloud sign-in lives under `comfy cloud whoami`.", style="dim") + group = Group(body, Text(""), hint) + + renderer.console().print(branded_panel(group, title="auth", version=ConfigManager().get_cli_version())) diff --git a/comfy_cli/auth/store.py b/comfy_cli/auth/store.py new file mode 100644 index 00000000..d415c426 --- /dev/null +++ b/comfy_cli/auth/store.py @@ -0,0 +1,350 @@ +"""Plaintext (Phase 4) secret store for comfy-cli. + +API: + + list_records() -> list[AuthRecord] + get(provider) -> AuthRecord | None + set(provider, key) -> AuthRecord # creates or updates + remove(provider) -> bool # True if removed, False if absent + +Locking uses :mod:`comfy_cli.locking` so concurrent ``auth set`` calls don't +clobber each other. +""" + +from __future__ import annotations + +import json +import os +import re +import secrets +from collections.abc import Iterable +from dataclasses import asdict, dataclass +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +from comfy_cli import constants, locking +from comfy_cli.utils import get_os + +# Comfy Cloud is *not* listed here: it is authenticated via OAuth (see +# `cloud_session` below and `comfy_cli.cloud.oauth`). Only providers whose +# native authentication is an API key remain. +SUPPORTED_PROVIDERS = ("civitai", "huggingface") + + +@dataclass(frozen=True) +class AuthRecord: + provider: str + key: str + updated_at: str + + def to_dict(self, *, redact: bool = True) -> dict[str, Any]: + d = asdict(self) + if redact: + d["key"] = _redact(self.key) + d["key_redacted"] = True + else: + d["key_redacted"] = False + return d + + +def _redact(key: str) -> str: + if len(key) <= 16: + return "***" + return f"{key[:4]}…{key[-4:]}" + + +SECRETS_PATH_ENV = "COMFY_SECRETS_PATH" + + +def secrets_path() -> Path: + override = os.environ.get(SECRETS_PATH_ENV) + if override: + return Path(override) + base = Path(constants.DEFAULT_CONFIG[get_os()]) + return base / "secrets.json" + + +def lock_path() -> Path: + """Stable sidecar path that all secret-store mutations serialize on. + + The lock MUST NOT be the data file itself: ``_write_all`` persists via + ``os.replace(tmp, secrets.json)``, which swaps in a *new inode* on every + write. ``fcntl.flock`` is bound to the open file description (the inode), + so if processes locked ``secrets.json`` directly, a holder mid-refresh + would be flocking the now-unlinked old inode while a newcomer opens — and + flocks — the freshly-renamed inode. The two would run the critical section + simultaneously, replay the same rotated refresh token, and trip the auth + server's reuse-detection (wiping the whole token family). A dedicated + ``secrets.json.lock`` file is created once and never replaced, so every + process serializes on one stable inode regardless of how many times the + data file is atomically rewritten underneath it. + """ + p = secrets_path() + return p.with_name(p.name + ".lock") + + +_EMPTY: dict[str, Any] = {"providers": {}, "cloud_session": None} + + +def _read_all(path: Path) -> dict[str, Any]: + if not path.exists(): + return {"providers": {}, "cloud_session": None} + try: + raw = path.read_text(encoding="utf-8") + except OSError: + return {"providers": {}, "cloud_session": None} + if not raw.strip(): + return {"providers": {}, "cloud_session": None} + try: + data = json.loads(raw) + except json.JSONDecodeError: + # Don't blow up on a corrupt store; return empty and let `set` rewrite. + return {"providers": {}, "cloud_session": None} + providers = data.get("providers") if isinstance(data, dict) else None + cloud_session = data.get("cloud_session") if isinstance(data, dict) else None + return { + "providers": providers if isinstance(providers, dict) else {}, + "cloud_session": cloud_session if isinstance(cloud_session, dict) else None, + } + + +def _write_all(path: Path, payload: dict[str, Any]) -> None: + """Atomic write with 0600 mode from inception. + + The tmp file is opened with ``O_CREAT|O_EXCL`` and explicit mode 0o600 so + the secrets are never world-readable, even briefly, even on systems with a + permissive umask. A unique tmp name (`...tmp`) avoids + collisions with stale tmp files from killed processes. + """ + path.parent.mkdir(parents=True, exist_ok=True, mode=0o700) + # Best-effort tighten parent directory if it already existed permissively. + try: + os.chmod(path.parent, 0o700) + except OSError: + pass + # Unique tmp name per writer; survives kill -9 (the orphan is harmless). + tmp_suffix = f".{os.getpid()}.{secrets.token_hex(4)}.tmp" + tmp = path.with_suffix(path.suffix + tmp_suffix) + flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL + # Open-with-mode is atomic w.r.t. permissions on POSIX; on Windows the + # mode bit is mostly cosmetic but we still set it for consistency. + fd = os.open(tmp, flags, 0o600) + try: + with os.fdopen(fd, "w", encoding="utf-8") as f: + f.write(json.dumps(payload, indent=2, sort_keys=True)) + f.flush() + try: + os.fsync(f.fileno()) + except OSError: + pass + os.replace(tmp, path) + except Exception: + # Clean up the tmp file if we didn't make it to os.replace. + try: + os.unlink(tmp) + except OSError: + pass + raise + # Final defensive chmod in case the inode is being reused with looser perms. + try: + os.chmod(path, 0o600) + except OSError: + pass + + +def list_records() -> list[AuthRecord]: + path = secrets_path() + with locking.file_lock(lock_path()): + data = _read_all(path) + out: list[AuthRecord] = [] + for name, body in data["providers"].items(): + if not isinstance(body, dict): + continue + key = body.get("key") + if not isinstance(key, str): + continue + out.append(AuthRecord(provider=name, key=key, updated_at=str(body.get("updated_at", "")))) + return sorted(out, key=lambda r: r.provider) + + +def get(provider: str) -> AuthRecord | None: + path = secrets_path() + with locking.file_lock(lock_path()): + data = _read_all(path) + body = data["providers"].get(provider) + if not isinstance(body, dict): + return None + key = body.get("key") + if not isinstance(key, str) or not key: + return None + return AuthRecord(provider=provider, key=key, updated_at=str(body.get("updated_at", ""))) + + +_SAFE_PROVIDER = re.compile(r"^[a-zA-Z0-9_\-]{1,64}$") + + +def set(provider: str, key: str) -> AuthRecord: + if not provider: + raise ValueError("provider name is required") + if not _SAFE_PROVIDER.match(provider): + raise ValueError(f"invalid provider name: {provider!r} (must be alphanumeric/dash/underscore, max 64 chars)") + if not key: + raise ValueError("key cannot be empty") + path = secrets_path() + now = datetime.now(timezone.utc).isoformat(timespec="seconds") + with locking.file_lock(lock_path()): + data = _read_all(path) + data["providers"][provider] = {"key": key, "updated_at": now} + _write_all(path, data) + return AuthRecord(provider=provider, key=key, updated_at=now) + + +def remove(provider: str) -> bool: + path = secrets_path() + with locking.file_lock(lock_path()): + data = _read_all(path) + if provider not in data["providers"]: + return False + del data["providers"][provider] + _write_all(path, data) + return True + + +def known_providers() -> Iterable[str]: + return SUPPORTED_PROVIDERS + + +# --------------------------------------------------------------------------- +# Cloud OAuth session +# --------------------------------------------------------------------------- + + +@dataclass(frozen=True) +class CloudSession: + base_url: str + resource: str + client_id: str + scope: str + access_token: str + refresh_token: str | None + token_type: str + expires_at: int | None # absolute epoch seconds + saved_at: str + + def is_expired(self, *, leeway_s: int = 30) -> bool: + """True if the access token is past (or within `leeway_s` of) expiry.""" + if self.expires_at is None: + return False + import time as _time + + return _time.time() + leeway_s >= self.expires_at + + def to_dict(self, *, redact: bool = True) -> dict[str, Any]: + data = { + "base_url": self.base_url, + "resource": self.resource, + "client_id": self.client_id, + "scope": self.scope, + "token_type": self.token_type, + "expires_at": self.expires_at, + "saved_at": self.saved_at, + } + if redact: + data["access_token"] = _redact(self.access_token) if self.access_token else None + data["refresh_token"] = _redact(self.refresh_token) if self.refresh_token else None + data["tokens_redacted"] = True + else: + data["access_token"] = self.access_token + data["refresh_token"] = self.refresh_token + data["tokens_redacted"] = False + return data + + +def _session_from_dict(body: dict[str, Any]) -> CloudSession | None: + if not isinstance(body, dict): + return None + tokens = body.get("tokens") + if not isinstance(tokens, dict): + return None + access = tokens.get("access_token") + if not isinstance(access, str) or not access: + return None + refresh = tokens.get("refresh_token") if isinstance(tokens.get("refresh_token"), str) else None + return CloudSession( + base_url=str(body.get("base_url", "")), + resource=str(body.get("resource", "")), + client_id=str(body.get("client_id", "")), + scope=str(body.get("scope", "")), + access_token=access, + refresh_token=refresh, + token_type=str(tokens.get("token_type", "Bearer")), + expires_at=tokens.get("expires_at") if isinstance(tokens.get("expires_at"), int) else None, + saved_at=str(body.get("saved_at", "")), + ) + + +def get_cloud_session() -> CloudSession | None: + path = secrets_path() + with locking.file_lock(lock_path()): + data = _read_all(path) + session = data.get("cloud_session") + if not isinstance(session, dict): + return None + return _session_from_dict(session) + + +def save_cloud_session( + *, + base_url: str, + resource: str, + client_id: str, + scope: str, + access_token: str, + refresh_token: str | None, + token_type: str, + expires_at: int | None, +) -> CloudSession: + if not access_token: + raise ValueError("access_token cannot be empty") + path = secrets_path() + now = datetime.now(timezone.utc).isoformat(timespec="seconds") + payload = { + "base_url": base_url, + "resource": resource, + "client_id": client_id, + "scope": scope, + "saved_at": now, + "tokens": { + "access_token": access_token, + "refresh_token": refresh_token, + "token_type": token_type, + "expires_at": expires_at, + }, + } + with locking.file_lock(lock_path()): + data = _read_all(path) + data["cloud_session"] = payload + _write_all(path, data) + return CloudSession( + base_url=base_url, + resource=resource, + client_id=client_id, + scope=scope, + access_token=access_token, + refresh_token=refresh_token, + token_type=token_type, + expires_at=expires_at, + saved_at=now, + ) + + +def clear_cloud_session() -> bool: + path = secrets_path() + with locking.file_lock(lock_path()): + data = _read_all(path) + if data.get("cloud_session") is None: + return False + data["cloud_session"] = None + _write_all(path, data) + return True diff --git a/comfy_cli/caller.py b/comfy_cli/caller.py new file mode 100644 index 00000000..0b8c4da9 --- /dev/null +++ b/comfy_cli/caller.py @@ -0,0 +1,71 @@ +"""Detect whether the CLI is being driven by a human or an agent. + +The detection flips three defaults: + - Output mode (agents → JSON, humans → pretty) + - Confirmation prompts (skipped for agents) + - Pretty banner (suppressed for agents) + +Signals (in priority order — most specific first): + 1. ``COMFY_USER_AGENT=