diff --git a/README.md b/README.md index 0f7353f..012dbf5 100644 --- a/README.md +++ b/README.md @@ -339,6 +339,8 @@ uv run octopal skill list uv run octopal skill install / uv run octopal skill update uv run octopal skill verify +uv run octopal skill disable +uv run octopal skill enable uv run octopal skill trust uv run octopal skill remove ``` diff --git a/docs/skills.md b/docs/skills.md index 667a488..88da7c2 100644 --- a/docs/skills.md +++ b/docs/skills.md @@ -114,6 +114,8 @@ uv run octopal skill install uv run octopal skill list uv run octopal skill update uv run octopal skill verify +uv run octopal skill disable +uv run octopal skill enable uv run octopal skill trust uv run octopal skill untrust uv run octopal skill remove @@ -275,6 +277,8 @@ Current requirement checks: - missing config keys from `requires.config` - untrusted imported script bundles +Use `octopal skill disable ` to keep an installed bundle on disk while removing it from active skill use. Re-enable it with `octopal skill enable `. + Config requirements are currently checked via env vars named like: ```text diff --git a/src/octopal/cli/main.py b/src/octopal/cli/main.py index dacf5f1..1778d5c 100644 --- a/src/octopal/cli/main.py +++ b/src/octopal/cli/main.py @@ -54,7 +54,12 @@ update_installed_skill, verify_installed_skill, ) -from octopal.tools.skills.management import list_skill_inventory, remove_skill, set_skill_trust +from octopal.tools.skills.management import ( + list_skill_inventory, + remove_skill, + set_skill_enabled, + set_skill_trust, +) from octopal.tools.skills.runtime_envs import ( prepare_skill_env, remove_skill_env, @@ -2158,6 +2163,60 @@ def skill_trust( console.print(f"[bold green][V] Trusted skill[/bold green] {payload['skill_id']}") +@skill_app.command("enable") +def skill_enable( + skill_id: str = typer.Argument(..., help="Skill id."), + json_output: bool = typer.Option(False, "--json", help="Print machine-readable JSON."), +) -> None: + """Enable a skill without reinstalling it.""" + settings = load_settings() + try: + payload = set_skill_enabled( + skill_id, + workspace_dir=settings.workspace_dir.resolve(), + enabled=True, + ) + except Exception as exc: + if json_output: + typer.echo(json.dumps({"status": "error", "message": str(exc), "skill_id": skill_id}, ensure_ascii=False)) + else: + console.print(f"[bold red]Skill enable failed:[/bold red] {exc}") + raise typer.Exit(code=1) from None + + if json_output: + typer.echo(json.dumps(payload, ensure_ascii=False, indent=2)) + return + + console.print(f"[bold green][V] Enabled skill[/bold green] {payload['skill_id']}") + + +@skill_app.command("disable") +def skill_disable( + skill_id: str = typer.Argument(..., help="Skill id."), + json_output: bool = typer.Option(False, "--json", help="Print machine-readable JSON."), +) -> None: + """Disable a skill without deleting its bundle.""" + settings = load_settings() + try: + payload = set_skill_enabled( + skill_id, + workspace_dir=settings.workspace_dir.resolve(), + enabled=False, + ) + except Exception as exc: + if json_output: + typer.echo(json.dumps({"status": "error", "message": str(exc), "skill_id": skill_id}, ensure_ascii=False)) + else: + console.print(f"[bold red]Skill disable failed:[/bold red] {exc}") + raise typer.Exit(code=1) from None + + if json_output: + typer.echo(json.dumps(payload, ensure_ascii=False, indent=2)) + return + + console.print(f"[bold green][V] Disabled skill[/bold green] {payload['skill_id']}") + + @skill_app.command("verify") def skill_verify( skill_id: str = typer.Argument(..., help="Installer-managed skill id."), diff --git a/src/octopal/gateway/dashboard.py b/src/octopal/gateway/dashboard.py index 2173d28..2bb4673 100644 --- a/src/octopal/gateway/dashboard.py +++ b/src/octopal/gateway/dashboard.py @@ -52,6 +52,12 @@ WorkerLauncherStatus, get_worker_launcher_status, ) +from octopal.tools.skills.installer import install_skill_from_source +from octopal.tools.skills.management import ( + list_skill_inventory, + remove_skill, + set_skill_enabled, +) _WINDOW_CHOICES = {15, 60, 240, 1440} _SERVICE_CHOICES = {"all", "gateway", "octo", "telegram", "whatsapp", "exec_run", "mcp", "workers"} @@ -109,8 +115,8 @@ class DashboardActionsV2(DashboardV2Envelope): actions: dict[str, Any] -class WorkerTemplatePayload(BaseModel): - model_config = ConfigDict(extra="forbid") +class WorkerTemplatePayload(BaseModel): + model_config = ConfigDict(extra="forbid") id: str name: str @@ -121,12 +127,19 @@ class WorkerTemplatePayload(BaseModel): model: str | None = None max_thinking_steps: int = 10 default_timeout_seconds: int = 300 - can_spawn_children: bool = False - allowed_child_templates: list[str] = Field(default_factory=list) - - + can_spawn_children: bool = False + allowed_child_templates: list[str] = Field(default_factory=list) + + +class DashboardSkillInstallPayload(BaseModel): + model_config = ConfigDict(extra="forbid") + + source: str + clawhub_site: str | None = None + + class DashboardConfigPayload(BaseModel): - model_config = ConfigDict(extra="forbid") + model_config = ConfigDict(extra="forbid") user_channel: str telegram: TelegramConfig = Field(default_factory=TelegramConfig) @@ -350,18 +363,51 @@ async def dashboard_update_worker_template( store = _get_store(app, settings) return _update_worker_template(settings, store, template_id, payload) - @app.delete("/api/dashboard/worker-templates/{template_id}") - async def dashboard_delete_worker_template( - template_id: str, - request: Request, - ) -> dict[str, Any]: - settings = _get_settings(app) - _verify_dashboard_token(request, settings) - return _delete_worker_template(settings, template_id) - - @app.post("/api/dashboard/actions") - async def dashboard_actions( - request: Request, + @app.delete("/api/dashboard/worker-templates/{template_id}") + async def dashboard_delete_worker_template( + template_id: str, + request: Request, + ) -> dict[str, Any]: + settings = _get_settings(app) + _verify_dashboard_token(request, settings) + return _delete_worker_template(settings, template_id) + + @app.get("/api/dashboard/skills") + async def dashboard_skills(request: Request) -> dict[str, Any]: + settings = _get_settings(app) + _verify_dashboard_token(request, settings) + return await asyncio.to_thread(_dashboard_skills_payload, settings) + + @app.post("/api/dashboard/skills/install") + async def dashboard_install_skill( + request: Request, + payload: DashboardSkillInstallPayload, + ) -> dict[str, Any]: + settings = _get_settings(app) + _verify_dashboard_token(request, settings) + return await asyncio.to_thread(_dashboard_install_skill, settings, payload) + + @app.post("/api/dashboard/skills/{skill_id}/enable") + async def dashboard_enable_skill(skill_id: str, request: Request) -> dict[str, Any]: + settings = _get_settings(app) + _verify_dashboard_token(request, settings) + return await asyncio.to_thread(_dashboard_set_skill_enabled, settings, skill_id, True) + + @app.post("/api/dashboard/skills/{skill_id}/disable") + async def dashboard_disable_skill(skill_id: str, request: Request) -> dict[str, Any]: + settings = _get_settings(app) + _verify_dashboard_token(request, settings) + return await asyncio.to_thread(_dashboard_set_skill_enabled, settings, skill_id, False) + + @app.delete("/api/dashboard/skills/{skill_id}") + async def dashboard_delete_skill(skill_id: str, request: Request) -> dict[str, Any]: + settings = _get_settings(app) + _verify_dashboard_token(request, settings) + return await asyncio.to_thread(_dashboard_delete_skill, settings, skill_id) + + @app.post("/api/dashboard/actions") + async def dashboard_actions( + request: Request, payload: dict[str, Any] = _EMPTY_ACTION_PAYLOAD, ) -> dict[str, Any]: settings = _get_settings(app) @@ -702,18 +748,192 @@ def _serve_dashboard_asset(app: FastAPI, asset_path: str, *, spa_fallback: bool) raise HTTPException(status_code=404, detail="Dashboard asset not found") -def _get_store(app: FastAPI, settings: Settings) -> SQLiteStore: - store = getattr(app.state, "dashboard_store", None) - if isinstance(store, SQLiteStore): - return store - store = SQLiteStore(settings) - app.state.dashboard_store = store - return store - - -def _serialize_worker_template(template: WorkerTemplateRecord) -> dict[str, Any]: - return { - "id": template.id, +def _get_store(app: FastAPI, settings: Settings) -> SQLiteStore: + store = getattr(app.state, "dashboard_store", None) + if isinstance(store, SQLiteStore): + return store + store = SQLiteStore(settings) + app.state.dashboard_store = store + return store + + +def _dashboard_skills_payload(settings: Settings) -> dict[str, Any]: + workspace_dir = settings.workspace_dir.resolve() + payload = list_skill_inventory(workspace_dir) + skills = [ + _serialize_dashboard_skill(item) + for item in sorted( + payload.get("skills", []), + key=lambda item: ( + str(item.get("name", "") or item.get("id", "")).lower(), + str(item.get("id", "")).lower(), + ), + ) + if isinstance(item, dict) + ] + return { + "contract_version": "dashboard.skills.v1", + "count": len(skills), + "registry_path": str(payload.get("registry_path", "")), + "skills": skills, + "install": { + "supported_sources": [ + "clawhub_slug", + "skill_md_url", + "zip_url", + "local_dir", + "local_skill_md", + "local_zip", + ], + "default_clawhub_site": "https://clawhub.ai", + }, + } + + +def _dashboard_install_skill(settings: Settings, payload: DashboardSkillInstallPayload) -> dict[str, Any]: + source = str(payload.source or "").strip() + if not source: + raise HTTPException(status_code=400, detail="Skill source is required") + + try: + kwargs: dict[str, Any] = {"workspace_dir": settings.workspace_dir.resolve()} + clawhub_site = str(payload.clawhub_site or "").strip() + if clawhub_site: + kwargs["clawhub_site"] = clawhub_site + install_payload = install_skill_from_source(source, **kwargs) + except Exception as exc: + raise _skill_dashboard_http_error(exc) from exc + + skill_id = str(install_payload.get("skill_id", "")).strip() + return { + "status": str(install_payload.get("status", "installed")), + "skill_id": skill_id, + "skill": _require_dashboard_skill(settings, skill_id), + "install": install_payload, + } + + +def _dashboard_set_skill_enabled(settings: Settings, skill_id: str, enabled: bool) -> dict[str, Any]: + normalized_id = str(skill_id or "").strip() + try: + action_payload = set_skill_enabled( + normalized_id, + workspace_dir=settings.workspace_dir.resolve(), + enabled=enabled, + ) + except Exception as exc: + raise _skill_dashboard_http_error(exc) from exc + + return { + "status": str(action_payload.get("status", "enabled" if enabled else "disabled")), + "skill_id": normalized_id, + "skill": _require_dashboard_skill(settings, normalized_id), + "action": action_payload, + } + + +def _dashboard_delete_skill(settings: Settings, skill_id: str) -> dict[str, Any]: + normalized_id = str(skill_id or "").strip() + try: + payload = remove_skill(normalized_id, workspace_dir=settings.workspace_dir.resolve()) + except Exception as exc: + raise _skill_dashboard_http_error(exc) from exc + return { + "status": str(payload.get("status", "removed")), + "skill_id": normalized_id, + "removed": payload, + "skills": _dashboard_skills_payload(settings), + } + + +def _find_dashboard_skill(settings: Settings, skill_id: str) -> dict[str, Any] | None: + normalized_id = str(skill_id or "").strip() + for item in _dashboard_skills_payload(settings).get("skills", []): + if isinstance(item, dict) and str(item.get("id", "")) == normalized_id: + return item + return None + + +def _require_dashboard_skill(settings: Settings, skill_id: str) -> dict[str, Any]: + skill = _find_dashboard_skill(settings, skill_id) + if skill is None: + raise HTTPException( + status_code=500, + detail="Skill operation succeeded but the updated skill could not be reloaded", + ) + return skill + + +def _serialize_dashboard_skill(item: dict[str, Any]) -> dict[str, Any]: + skill_id = str(item.get("id", "")).strip() + name = str(item.get("name", "")).strip() or skill_id + description = str(item.get("description", "")).strip() + enabled = bool(item.get("enabled", True)) + ready = bool(item.get("ready", False)) + installer_managed = bool(item.get("installer_managed", False)) + auto_discovered = bool(item.get("auto_discovered", False)) + origin = "installed" if installer_managed else "auto_discovered" if auto_discovered else "local" + installed_source = str(item.get("installed_source", "")).strip() + installed_source_kind = str(item.get("installed_source_kind", "")).strip() + path = str(item.get("path", "")).strip() + + return { + "id": skill_id, + "name": name, + "description": description, + "scope": str(item.get("scope", "both")), + "enabled": enabled, + "ready": ready, + "status": str(item.get("status", "")), + "reasons": [str(reason) for reason in item.get("reasons", []) if str(reason).strip()], + "origin": origin, + "source": { + "kind": installed_source_kind or str(item.get("source", "registry")), + "label": installed_source or path or origin, + "path": path, + "installer_managed": installer_managed, + "auto_discovered": auto_discovered, + }, + "trust": { + "trusted": bool(item.get("trusted", True)), + "has_scripts": bool(item.get("has_scripts", False)), + "scan_status": str(item.get("scan_status", "")), + "scan_findings_count": int(item.get("scan_findings_count", 0)), + }, + "runtime": { + "kind": str(item.get("runtime_kind", "")), + "required": bool(item.get("runtime_required", False)), + "recommended": bool(item.get("runtime_recommended", False)), + "prepared": bool(item.get("runtime_prepared", False)), + "next_step": str(item.get("runtime_next_step", "")), + }, + "requirements": { + "missing_bins": [str(value) for value in item.get("missing_bins", [])], + "missing_env": [str(value) for value in item.get("missing_env", [])], + "missing_config": [str(value) for value in item.get("missing_config", [])], + }, + "actions": { + "can_enable": not enabled, + "can_disable": enabled, + "can_remove": bool(skill_id), + "can_install": True, + }, + } + + +def _skill_dashboard_http_error(exc: Exception) -> HTTPException: + detail = str(exc).strip() or exc.__class__.__name__ + lowered = detail.lower() + if "not found" in lowered or ("missing" in lowered and "installed bundle" in lowered): + return HTTPException(status_code=404, detail=detail) + if "already exists" in lowered or "different source" in lowered or "refusing to overwrite" in lowered: + return HTTPException(status_code=409, detail=detail) + return HTTPException(status_code=400, detail=detail) + + +def _serialize_worker_template(template: WorkerTemplateRecord) -> dict[str, Any]: + return { + "id": template.id, "name": template.name, "description": template.description, "system_prompt": template.system_prompt, diff --git a/src/octopal/tools/skills/management.py b/src/octopal/tools/skills/management.py index f1f96bb..9fd6315 100644 --- a/src/octopal/tools/skills/management.py +++ b/src/octopal/tools/skills/management.py @@ -126,6 +126,21 @@ def get_skill_management_tools() -> list[ToolSpec]: permission="skill_manage", handler=_tool_remove_skill, ), + ToolSpec( + name="set_skill_enabled", + description="Enable or disable an internal skill without deleting its bundle.", + parameters={ + "type": "object", + "properties": { + "id": {"type": "string", "description": "Skill id to update."}, + "enabled": {"type": "boolean", "description": "Whether the skill should be enabled."}, + }, + "required": ["id", "enabled"], + "additionalProperties": False, + }, + permission="skill_manage", + handler=_tool_set_skill_enabled, + ), ToolSpec( name="run_skill_script", description="Run a script from a Octopal skill bundle scripts/ directory without invoking a shell. Prefer this over exec_run for skill scripts.", @@ -353,6 +368,24 @@ def _tool_remove_skill(args: dict[str, Any], ctx: dict[str, Any]) -> str: return json.dumps(payload, ensure_ascii=False) +def _tool_set_skill_enabled(args: dict[str, Any], ctx: dict[str, Any]) -> str: + workspace_dir = _workspace_root() + skill_id = str(args.get("id", "")).strip() + if not _SKILL_ID_RE.fullmatch(skill_id): + return "set_skill_enabled error: id must match ^[a-z0-9][a-z0-9_-]*$." + if "enabled" not in args: + return "set_skill_enabled error: enabled is required." + try: + payload = set_skill_enabled( + skill_id, + workspace_dir=workspace_dir, + enabled=bool(args.get("enabled")), + ) + except ValueError as exc: + return f"set_skill_enabled error: {exc}" + return json.dumps(payload, ensure_ascii=False) + + def _tool_run_skill_script(args: dict[str, Any], ctx: dict[str, Any]) -> str: workspace_dir = _workspace_root() skill_id = str(args.get("skill_id", "")).strip() @@ -848,6 +881,30 @@ def set_skill_trust( return _set_local_skill_trust(skill_data, workspace_dir=workspace_dir, trusted=trusted) +def set_skill_enabled(skill_id: str, *, workspace_dir: Path, enabled: bool) -> dict[str, Any]: + skill_id = str(skill_id).strip() + if not _SKILL_ID_RE.fullmatch(skill_id): + raise ValueError("skill id must match ^[a-z0-9][a-z0-9_-]*$") + + inventory = _load_skill_inventory(workspace_dir) + skill_data = next((item for item in inventory if str(item.get("id", "")) == skill_id), None) + if skill_data is None: + raise ValueError(f"skill '{skill_id}' not found") + + _upsert_registry_skill_override( + skill_data, + workspace_dir=workspace_dir, + updates={"enabled": bool(enabled)}, + ) + return { + "status": "enabled" if enabled else "disabled", + "skill_id": skill_id, + "enabled": bool(enabled), + "installer_managed": bool(skill_data.get("installer_managed", False)), + "manifest_path": str(_registry_path(workspace_dir)), + } + + def remove_skill(skill_id: str, *, workspace_dir: Path) -> dict[str, Any]: skill_id = str(skill_id).strip() if not _SKILL_ID_RE.fullmatch(skill_id): @@ -861,29 +918,47 @@ def remove_skill(skill_id: str, *, workspace_dir: Path) -> dict[str, Any]: if bool(skill_data.get("installer_managed", False)): payload = _remove_installed_skill(skill_id, workspace_dir=workspace_dir) env_payload = remove_skill_env(skill_id, workspace_dir=workspace_dir) + registry_removed = _remove_registry_skill_override(skill_id, workspace_dir=workspace_dir) payload["removed_env"] = bool(env_payload.get("removed", False)) + payload["removed_registry_override"] = registry_removed return payload removed_path = _remove_local_skill_path(skill_data, workspace_dir=workspace_dir) env_payload = remove_skill_env(skill_id, workspace_dir=workspace_dir) - registry = _load_registry(workspace_dir) - registry["skills"] = [ - item - for item in registry.get("skills", []) - if not isinstance(item, dict) or str(item.get("id", "")).strip() != skill_id - ] - _write_registry(workspace_dir, registry) + registry_removed = _remove_registry_skill_override(skill_id, workspace_dir=workspace_dir) return { "status": "removed", "skill_id": skill_id, "removed_path": removed_path, "removed_env": bool(env_payload.get("removed", False)), + "removed_registry_override": registry_removed, "installer_managed": False, "manifest_path": str(_registry_path(workspace_dir)), } def _set_local_skill_trust(skill_data: dict[str, Any], *, workspace_dir: Path, trusted: bool) -> dict[str, Any]: + _upsert_registry_skill_override( + skill_data, + workspace_dir=workspace_dir, + updates={"trusted": bool(trusted)}, + ) + skill_id = str(skill_data.get("id", "")).strip() + return { + "status": "trusted" if trusted else "untrusted", + "skill_id": skill_id, + "trusted": bool(trusted), + "installer_managed": False, + "manifest_path": str(_registry_path(workspace_dir)), + } + + +def _upsert_registry_skill_override( + skill_data: dict[str, Any], + *, + workspace_dir: Path, + updates: dict[str, Any], +) -> None: registry = _load_registry(workspace_dir) skills = [item for item in registry.get("skills", []) if isinstance(item, dict)] skill_id = str(skill_data.get("id", "")).strip() @@ -891,30 +966,40 @@ def _set_local_skill_trust(skill_data: dict[str, Any], *, workspace_dir: Path, t for item in skills: if str(item.get("id", "")).strip() != skill_id: continue - item["trusted"] = bool(trusted) + item.setdefault("name", str(skill_data.get("name", "")).strip()) + item.setdefault("description", str(skill_data.get("description", "")).strip()) + item.setdefault("path", str(skill_data.get("path", "")).strip()) + item.setdefault("scope", _resolve_scope_value(skill_data.get("scope"))) + item.setdefault("enabled", bool(skill_data.get("enabled", True))) + item.setdefault("trusted", bool(skill_data.get("trusted", True))) + item.update(updates) updated = True break if not updated: - skills.append( - { - "id": skill_id, - "name": str(skill_data.get("name", "")).strip(), - "description": str(skill_data.get("description", "")).strip(), - "path": str(skill_data.get("path", "")).strip(), - "scope": _resolve_scope_value(skill_data.get("scope")), - "enabled": bool(skill_data.get("enabled", True)), - "trusted": bool(trusted), - } - ) + record = { + "id": skill_id, + "name": str(skill_data.get("name", "")).strip(), + "description": str(skill_data.get("description", "")).strip(), + "path": str(skill_data.get("path", "")).strip(), + "scope": _resolve_scope_value(skill_data.get("scope")), + "enabled": bool(skill_data.get("enabled", True)), + "trusted": bool(skill_data.get("trusted", True)), + } + record.update(updates) + skills.append(record) registry["skills"] = sorted(skills, key=lambda item: str(item.get("id", ""))) _write_registry(workspace_dir, registry) - return { - "status": "trusted" if trusted else "untrusted", - "skill_id": skill_id, - "trusted": bool(trusted), - "installer_managed": False, - "manifest_path": str(_registry_path(workspace_dir)), - } + + +def _remove_registry_skill_override(skill_id: str, *, workspace_dir: Path) -> bool: + registry = _load_registry(workspace_dir) + skills = [item for item in registry.get("skills", []) if isinstance(item, dict)] + kept = [item for item in skills if str(item.get("id", "")).strip() != skill_id] + if len(kept) == len(skills): + return False + registry["skills"] = kept + _write_registry(workspace_dir, registry) + return True def _remove_local_skill_path(skill_data: dict[str, Any], *, workspace_dir: Path) -> bool: diff --git a/tests/test_cli_skill_install.py b/tests/test_cli_skill_install.py index 615d4d3..4f2f874 100644 --- a/tests/test_cli_skill_install.py +++ b/tests/test_cli_skill_install.py @@ -62,6 +62,43 @@ def test_skill_list_command_includes_local_skill(tmp_path: Path, monkeypatch) -> assert payload["skills"][0]["installer_managed"] is False +def test_skill_enable_disable_commands_toggle_skill(tmp_path: Path, monkeypatch) -> None: + workspace_dir = tmp_path / "workspace" + skill_dir = workspace_dir / "skills" / "writer" + skill_dir.mkdir(parents=True) + (skill_dir / "SKILL.md").write_text( + """--- +name: writer +description: Helps write copy +--- +""", + encoding="utf-8", + ) + monkeypatch.setattr( + "octopal.cli.main.load_settings", + lambda: SimpleNamespace(workspace_dir=workspace_dir), + ) + + disabled = runner.invoke(app, ["skill", "disable", "writer", "--json"]) + + assert disabled.exit_code == 0 + disabled_payload = json.loads(disabled.stdout) + assert disabled_payload["status"] == "disabled" + assert disabled_payload["enabled"] is False + + listed = runner.invoke(app, ["skill", "list", "--json"]) + listed_payload = json.loads(listed.stdout) + assert listed_payload["skills"][0]["enabled"] is False + assert listed_payload["skills"][0]["status"] == "disabled" + + enabled = runner.invoke(app, ["skill", "enable", "writer", "--json"]) + + assert enabled.exit_code == 0 + enabled_payload = json.loads(enabled.stdout) + assert enabled_payload["status"] == "enabled" + assert enabled_payload["enabled"] is True + + def test_skill_update_command_uses_saved_source(tmp_path: Path, monkeypatch) -> None: workspace_dir = tmp_path / "workspace" source_dir = tmp_path / "writer" diff --git a/tests/test_dashboard_v2_api.py b/tests/test_dashboard_v2_api.py index 3f0979c..774bbc5 100644 --- a/tests/test_dashboard_v2_api.py +++ b/tests/test_dashboard_v2_api.py @@ -51,8 +51,8 @@ def test_dashboard_v2_routes_return_contract_envelopes(tmp_path) -> None: assert "filters" in payload -def test_dashboard_v2_routes_require_token_when_configured(tmp_path) -> None: - client = _make_client(tmp_path, token="secret-token") +def test_dashboard_v2_routes_require_token_when_configured(tmp_path) -> None: + client = _make_client(tmp_path, token="secret-token") unauthorized = client.get("/api/dashboard/v2/overview") assert unauthorized.status_code == 401 @@ -61,13 +61,103 @@ def test_dashboard_v2_routes_require_token_when_configured(tmp_path) -> None: "/api/dashboard/v2/overview", headers={"x-octopal-token": "secret-token"}, ) - assert authorized.status_code == 200 - assert authorized.json()["contract_version"] == "dashboard.v2.overview" - - -def test_dashboard_v2_stream_route_is_registered(tmp_path) -> None: - client = _make_client(tmp_path) - schema = client.get("/openapi.json") + assert authorized.status_code == 200 + assert authorized.json()["contract_version"] == "dashboard.v2.overview" + + +def test_dashboard_skills_route_requires_token_when_configured(tmp_path) -> None: + client = _make_client(tmp_path, token="secret-token") + + unauthorized = client.get("/api/dashboard/skills") + assert unauthorized.status_code == 401 + + authorized = client.get( + "/api/dashboard/skills", + headers={"x-octopal-token": "secret-token"}, + ) + assert authorized.status_code == 200 + assert authorized.json()["contract_version"] == "dashboard.skills.v1" + + +def test_dashboard_skills_api_manages_local_skill(tmp_path) -> None: + client = _make_client(tmp_path) + skill_dir = tmp_path / "workspace" / "skills" / "writer" + skill_dir.mkdir(parents=True) + (skill_dir / "SKILL.md").write_text( + """--- +name: Writer +description: Helps write copy +--- + +# Writer +""", + encoding="utf-8", + ) + + listed = client.get("/api/dashboard/skills") + + assert listed.status_code == 200 + payload = listed.json() + assert payload["contract_version"] == "dashboard.skills.v1" + assert payload["count"] == 1 + skill = payload["skills"][0] + assert skill["id"] == "writer" + assert skill["name"] == "Writer" + assert skill["description"] == "Helps write copy" + assert skill["enabled"] is True + assert skill["actions"]["can_disable"] is True + + disabled = client.post("/api/dashboard/skills/writer/disable") + + assert disabled.status_code == 200 + disabled_skill = disabled.json()["skill"] + assert disabled_skill["enabled"] is False + assert disabled_skill["status"] == "disabled" + assert disabled_skill["actions"]["can_enable"] is True + + enabled = client.post("/api/dashboard/skills/writer/enable") + + assert enabled.status_code == 200 + assert enabled.json()["skill"]["enabled"] is True + + deleted = client.delete("/api/dashboard/skills/writer") + + assert deleted.status_code == 200 + assert deleted.json()["skills"]["count"] == 0 + assert not skill_dir.exists() + + +def test_dashboard_skills_api_installs_local_skill_source(tmp_path) -> None: + client = _make_client(tmp_path) + source_dir = tmp_path / "source-writer" + source_dir.mkdir(parents=True) + (source_dir / "SKILL.md").write_text( + """--- +name: Imported Writer +description: Imported copy helper +--- +""", + encoding="utf-8", + ) + + installed = client.post("/api/dashboard/skills/install", json={"source": str(source_dir)}) + + assert installed.status_code == 200 + payload = installed.json() + assert payload["status"] == "installed" + assert payload["skill_id"] == "imported_writer" + assert payload["install"]["source_kind"] == "local_dir" + assert payload["skill"]["name"] == "Imported Writer" + assert payload["skill"]["origin"] == "installed" + + listed = client.get("/api/dashboard/skills") + assert listed.json()["count"] == 1 + assert listed.json()["skills"][0]["id"] == "imported_writer" + + +def test_dashboard_v2_stream_route_is_registered(tmp_path) -> None: + client = _make_client(tmp_path) + schema = client.get("/openapi.json") assert schema.status_code == 200 payload = schema.json() assert "/api/dashboard/v2/stream" in payload.get("paths", {}) diff --git a/tests/test_skill_management.py b/tests/test_skill_management.py index f9a6e48..fb83a1a 100644 --- a/tests/test_skill_management.py +++ b/tests/test_skill_management.py @@ -4,6 +4,7 @@ import sys from pathlib import Path +from octopal.tools.skills.installer import install_skill_from_source from octopal.tools.skills.management import ( _load_skill_inventory, _run_skill, @@ -11,11 +12,13 @@ _tool_list_skills, _tool_remove_skill, _tool_run_skill_script, + _tool_set_skill_enabled, _tool_use_skill, get_registered_skill_tools, get_skill_management_tools, list_skill_inventory, remove_skill, + set_skill_enabled, set_skill_trust, ) @@ -263,6 +266,7 @@ def test_skill_management_tools_include_run_skill_script() -> None: assert "run_skill_script" in [tool.name for tool in tools] assert "use_skill" in [tool.name for tool in tools] + assert "set_skill_enabled" in [tool.name for tool in tools] def test_use_skill_reads_guidance_by_id(tmp_path: Path, monkeypatch) -> None: @@ -288,6 +292,81 @@ def test_use_skill_reads_guidance_by_id(tmp_path: Path, monkeypatch) -> None: assert "# Writer" in payload["guidance"] +def test_set_skill_enabled_disables_auto_discovered_bundle(tmp_path: Path, monkeypatch) -> None: + workspace_dir = tmp_path / "workspace" + skill_dir = workspace_dir / "skills" / "writer" + skill_dir.mkdir(parents=True) + (skill_dir / "SKILL.md").write_text( + """--- +name: writer +description: Helps write copy +--- + +# Writer +""", + encoding="utf-8", + ) + monkeypatch.setenv("OCTOPAL_WORKSPACE_DIR", str(workspace_dir)) + + disabled = set_skill_enabled("writer", workspace_dir=workspace_dir, enabled=False) + + assert disabled["status"] == "disabled" + listed = list_skill_inventory(workspace_dir) + assert listed["skills"][0]["enabled"] is False + assert listed["skills"][0]["status"] == "disabled" + assert get_registered_skill_tools() == [] + assert "is disabled" in _tool_use_skill({"skill_id": "writer"}, {"worker": object()}) + + enabled = set_skill_enabled("writer", workspace_dir=workspace_dir, enabled=True) + + assert enabled["status"] == "enabled" + assert list_skill_inventory(workspace_dir)["skills"][0]["enabled"] is True + assert [tool.name for tool in get_registered_skill_tools()] == ["skill_writer"] + + +def test_tool_set_skill_enabled_updates_registry_override(tmp_path: Path, monkeypatch) -> None: + workspace_dir = tmp_path / "workspace" + skill_dir = workspace_dir / "skills" / "writer" + skill_dir.mkdir(parents=True) + (skill_dir / "SKILL.md").write_text( + """--- +name: writer +description: Helps write copy +--- +""", + encoding="utf-8", + ) + monkeypatch.setenv("OCTOPAL_WORKSPACE_DIR", str(workspace_dir)) + + payload = json.loads(_tool_set_skill_enabled({"id": "writer", "enabled": False}, {})) + + assert payload["status"] == "disabled" + assert payload["enabled"] is False + assert list_skill_inventory(workspace_dir)["skills"][0]["enabled"] is False + + +def test_remove_installed_skill_cleans_disable_override(tmp_path: Path) -> None: + workspace_dir = tmp_path / "workspace" + source_dir = tmp_path / "writer" + source_dir.mkdir(parents=True) + (source_dir / "SKILL.md").write_text( + """--- +name: writer +description: Helps write copy +--- +""", + encoding="utf-8", + ) + install_skill_from_source(str(source_dir), workspace_dir=workspace_dir) + set_skill_enabled("writer", workspace_dir=workspace_dir, enabled=False) + + payload = remove_skill("writer", workspace_dir=workspace_dir) + + assert payload["installer_managed"] is True + assert payload["removed_registry_override"] is True + assert list_skill_inventory(workspace_dir)["skills"] == [] + + def test_run_skill_script_executes_python_from_bundle_scripts_dir(tmp_path: Path, monkeypatch) -> None: workspace_dir = tmp_path / "workspace" skill_dir = workspace_dir / "skills" / "writer"