From d1850aaada68b50a188bfec57bf5d29eaa8bc492 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 20 Dec 2025 12:22:15 +0100 Subject: [PATCH] feat: add managed skills gating --- docs/agent.md | 8 + docs/configuration.md | 26 ++ docs/index.md | 1 + docs/skills.md | 107 +++++ package.json | 6 +- skills/agent-tools/SKILL.md | 15 + skills/bird/SKILL.md | 19 + skills/blucli/SKILL.md | 15 + skills/camsnap/SKILL.md | 15 + skills/clawdis-browser/SKILL.md | 18 + skills/clawdis-canvas/SKILL.md | 22 + skills/eightctl/SKILL.md | 15 + skills/gemini-cli/SKILL.md | 15 + skills/gog/SKILL.md | 15 + skills/imsg/SKILL.md | 15 + skills/mcporter/SKILL.md | 15 + skills/nano-banana-pro/SKILL.md | 42 ++ .../nano-banana-pro/scripts/generate_image.py | 167 ++++++++ skills/openai-whisper/SKILL.md | 15 + skills/openhue/SKILL.md | 15 + skills/oracle/SKILL.md | 15 + skills/peekaboo/SKILL.md | 20 + skills/qmd/SKILL.md | 15 + skills/sag/SKILL.md | 15 + skills/sonoscli/SKILL.md | 17 + skills/spotify-player/SKILL.md | 15 + skills/wacli/SKILL.md | 15 + src/agents/pi-embedded.ts | 37 +- src/agents/skills.test.ts | 35 +- src/agents/skills.ts | 394 +++++++++++++++++- src/auto-reply/reply.ts | 34 ++ src/commands/agent.ts | 28 +- src/config/config.ts | 19 + src/config/sessions.ts | 7 + src/cron/isolated-agent.ts | 18 + src/gateway/server.ts | 1 + 36 files changed, 1235 insertions(+), 16 deletions(-) create mode 100644 docs/skills.md create mode 100644 skills/agent-tools/SKILL.md create mode 100644 skills/bird/SKILL.md create mode 100644 skills/blucli/SKILL.md create mode 100644 skills/camsnap/SKILL.md create mode 100644 skills/clawdis-browser/SKILL.md create mode 100644 skills/clawdis-canvas/SKILL.md create mode 100644 skills/eightctl/SKILL.md create mode 100644 skills/gemini-cli/SKILL.md create mode 100644 skills/gog/SKILL.md create mode 100644 skills/imsg/SKILL.md create mode 100644 skills/mcporter/SKILL.md create mode 100644 skills/nano-banana-pro/SKILL.md create mode 100755 skills/nano-banana-pro/scripts/generate_image.py create mode 100644 skills/openai-whisper/SKILL.md create mode 100644 skills/openhue/SKILL.md create mode 100644 skills/oracle/SKILL.md create mode 100644 skills/peekaboo/SKILL.md create mode 100644 skills/qmd/SKILL.md create mode 100644 skills/sag/SKILL.md create mode 100644 skills/sonoscli/SKILL.md create mode 100644 skills/spotify-player/SKILL.md create mode 100644 skills/wacli/SKILL.md diff --git a/docs/agent.md b/docs/agent.md index cff3f0b4f..f383ec8d4 100644 --- a/docs/agent.md +++ b/docs/agent.md @@ -29,6 +29,14 @@ If a file is missing, CLAWDIS injects a single “missing file” marker line (a Pi’s embedded core tools (read/bash/edit/write and related internals) are defined in code and always available. `TOOLS.md` does **not** control which tools exist; it’s guidance for how *you* want them used. +## Skills + +Clawdis loads skills from two locations (workspace wins on name conflict): +- Managed: `~/.clawdis/skills` +- Workspace: `/skills` + +Managed skills can be gated by config/env (see `skills.*` in `docs/configuration.md`). + ## Sessions Session transcripts are stored as JSONL at: diff --git a/docs/configuration.md b/docs/configuration.md index 3379fadd8..7211d4b66 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -131,6 +131,32 @@ Controls session scoping, idle expiry, reset triggers, and where the session sto } ``` +### `skills` (managed skills config/env) + +Configure **managed** skills (loaded from `~/.clawdis/skills`). Workspace skills always win on name conflicts. + +Common fields per skill: +- `enabled`: set `false` to disable a managed skill even if it’s installed. +- `env`: environment variables injected for the agent run (only if not already set). +- `apiKey`: optional convenience for skills that declare a primary env var (e.g. `nano-banana-pro` → `GEMINI_API_KEY`). + +Example: + +```json5 +{ + skills: { + "nano-banana-pro": { + apiKey: "GEMINI_KEY_HERE", + env: { + GEMINI_API_KEY: "GEMINI_KEY_HERE" + } + }, + peekaboo: { enabled: true }, + sag: { enabled: false } + } +} +``` + ### `browser` (clawd-managed Chrome) Clawdis can start a **dedicated, isolated** Chrome/Chromium instance for clawd and expose a small loopback control server. diff --git a/docs/index.md b/docs/index.md index 7e77e96c8..61f90c66e 100644 --- a/docs/index.md +++ b/docs/index.md @@ -116,6 +116,7 @@ Example: - Start here: - [Configuration](./configuration.md) - [Clawd personal assistant setup](./clawd.md) + - [Skills](./skills.md) - [Workspace templates](./templates/AGENTS.md) - [Gateway runbook](./gateway.md) - [Nodes (iOS/Android)](./nodes.md) diff --git a/docs/skills.md b/docs/skills.md new file mode 100644 index 000000000..c029272bc --- /dev/null +++ b/docs/skills.md @@ -0,0 +1,107 @@ +--- +summary: "Skills: managed vs workspace, gating rules, and config/env wiring" +read_when: + - Adding or modifying skills + - Changing skill gating or load rules +--- + +# Skills (Clawdis) + +Clawdis uses **AgentSkills-compatible** skill folders to teach the agent how to use tools. Each skill is a directory containing a `SKILL.md` with YAML frontmatter and instructions. Clawdis loads **managed skills** plus **workspace skills**, and filters them at load time based on environment, config, and binary presence. + +## Locations and precedence + +Skills are loaded from **two** places: + +1) **Managed skills**: `~/.clawdis/skills` +2) **Workspace skills**: `/skills` + +If a skill name conflicts, the **workspace** version wins (user overrides managed). + +## Format (AgentSkills + Pi-compatible) + +`SKILL.md` must include at least: + +```markdown +--- +name: nano-banana-pro +description: Generate or edit images via Gemini 3 Pro Image +--- +``` + +Notes: +- We follow the AgentSkills spec for layout/intent. +- The parser used by the embedded agent supports **single-line** frontmatter keys only. +- `metadata` should be a **single-line JSON object**. +- Use `{baseDir}` in instructions to reference the skill folder path. + +## Gating (load-time filters) + +Clawdis **filters skills at load time** using `metadata` (single-line JSON): + +```markdown +--- +name: nano-banana-pro +description: Generate or edit images via Gemini 3 Pro Image +metadata: {"clawdis":{"requires":{"bins":["uv"],"env":["GEMINI_API_KEY"],"config":["browser.enabled"]},"primaryEnv":"GEMINI_API_KEY"}} +--- +``` + +Fields under `metadata.clawdis`: +- `always: true` — always include the skill (skip other gates). +- `requires.bins` — list; each must exist on `PATH`. +- `requires.env` — list; env var must exist **or** be provided in config. +- `requires.config` — list of `clawdis.json` paths that must be truthy. +- `primaryEnv` — env var name associated with `skills..apiKey`. + +If no `metadata.clawdis` is present, the skill is always eligible (unless disabled in config). + +## Config overrides (`~/.clawdis/clawdis.json`) + +Managed skills can be toggled and supplied with env values: + +```json5 +{ + skills: { + "nano-banana-pro": { + enabled: true, + apiKey: "GEMINI_KEY_HERE", + env: { + GEMINI_API_KEY: "GEMINI_KEY_HERE" + } + }, + peekaboo: { enabled: true }, + sag: { enabled: false } + } +} +``` + +Note: if the skill name contains hyphens, quote the key (JSON5 allows quoted keys). + +Config keys match the **skill name**. We don’t require a custom `skillKey`. + +Rules: +- `enabled: false` disables the managed skill even if installed. +- `env`: injected **only if** the variable isn’t already set in the process. +- `apiKey`: convenience for skills that declare `metadata.clawdis.primaryEnv`. + +## Environment injection (per agent run) + +When an agent run starts, Clawdis: +1) Reads skill metadata. +2) Applies any `skills..env` or `skills..apiKey` to `process.env`. +3) Builds the system prompt with **eligible** skills. +4) Restores the original environment after the run ends. + +This is **scoped to the agent run**, not a global shell environment. + +## Session snapshot (performance) + +Clawdis snapshots the eligible skills **when a session starts** and reuses that list for subsequent turns in the same session. Changes to skills or config take effect on the next new session. + +## Managed skills lifecycle + +Managed skills are owned by Clawdis (not user-editable). Workspace skills are user-owned and override managed ones by name. The macOS app or installer should copy bundled skills into `~/.clawdis/skills` on install/update. + +--- + diff --git a/package.json b/package.json index 31231a6fd..cb0e9eb31 100644 --- a/package.json +++ b/package.json @@ -41,9 +41,9 @@ "dependencies": { "@grammyjs/transformer-throttler": "^1.2.1", "@homebridge/ciao": "^1.3.4", - "@mariozechner/pi-agent-core": "^0.21.0", - "@mariozechner/pi-ai": "^0.21.0", - "@mariozechner/pi-coding-agent": "^0.21.0", + "@mariozechner/pi-agent-core": "^0.24.5", + "@mariozechner/pi-ai": "^0.24.5", + "@mariozechner/pi-coding-agent": "^0.24.5", "@sinclair/typebox": "^0.34.41", "@whiskeysockets/baileys": "7.0.0-rc.9", "ajv": "^8.17.1", diff --git a/skills/agent-tools/SKILL.md b/skills/agent-tools/SKILL.md new file mode 100644 index 000000000..3a4a3641d --- /dev/null +++ b/skills/agent-tools/SKILL.md @@ -0,0 +1,15 @@ +--- +name: agent-tools +description: Utility toolkit for automations and MCP-friendly scripts. +metadata: {"clawdis":{"requires":{"bins":["agent-tools"]}}} +--- + +# agent-tools + +Use `agent-tools` for helper utilities and automations. Start with: + +```bash +agent-tools --help +``` + +Prefer small, composable commands. diff --git a/skills/bird/SKILL.md b/skills/bird/SKILL.md new file mode 100644 index 000000000..05617a129 --- /dev/null +++ b/skills/bird/SKILL.md @@ -0,0 +1,19 @@ +--- +name: bird +description: X/Twitter CLI to tweet, reply, read threads, and search. +metadata: {"clawdis":{"requires":{"bins":["bird"]}}} +--- + +# bird + +Use `bird` to interact with X/Twitter. + +- Tweet: `bird tweet "..."` +- Reply: `bird reply "..."` +- Read: `bird read ` +- Thread: `bird thread ` +- Search: `bird search "query"` +- Mentions: `bird mentions` +- Whoami: `bird whoami` + +Confirm before posting publicly. diff --git a/skills/blucli/SKILL.md b/skills/blucli/SKILL.md new file mode 100644 index 000000000..236488ed8 --- /dev/null +++ b/skills/blucli/SKILL.md @@ -0,0 +1,15 @@ +--- +name: blucli +description: Control BluOS players from the CLI. +metadata: {"clawdis":{"requires":{"bins":["blucli"]}}} +--- + +# blucli + +Use `blucli` to control BluOS players. Start with: + +```bash +blucli --help +``` + +Confirm target player before changing playback. diff --git a/skills/camsnap/SKILL.md b/skills/camsnap/SKILL.md new file mode 100644 index 000000000..2c877677a --- /dev/null +++ b/skills/camsnap/SKILL.md @@ -0,0 +1,15 @@ +--- +name: camsnap +description: Capture frames or clips from RTSP/ONVIF cameras. +metadata: {"clawdis":{"requires":{"bins":["camsnap"]}}} +--- + +# camsnap + +Use `camsnap` to grab frames or clips from configured cameras. Start with: + +```bash +camsnap --help +``` + +Prefer small test captures before longer clips. diff --git a/skills/clawdis-browser/SKILL.md b/skills/clawdis-browser/SKILL.md new file mode 100644 index 000000000..3f0ddb97b --- /dev/null +++ b/skills/clawdis-browser/SKILL.md @@ -0,0 +1,18 @@ +--- +name: clawdis-browser +description: Control clawd's dedicated browser (tabs, snapshots, actions) via the clawdis CLI. +metadata: {"clawdis":{"requires":{"config":["browser.enabled"]}}} +--- + +# Clawdis Browser + +Use the clawd-managed Chrome/Chromium instance through `clawdis browser` commands. + +## Common commands + +- Status/start/stop: `clawdis browser status|start|stop` +- Tabs: `clawdis browser tabs|open |focus |close ` +- Snapshot/screenshot: `clawdis browser snapshot --format ai|aria`, `clawdis browser screenshot [--full-page]` +- Actions: `clawdis browser click|type|hover|drag|select|upload|press|wait|navigate|back|evaluate|run` + +If disabled, ask the user to enable `browser.enabled` in `~/.clawdis/clawdis.json`. diff --git a/skills/clawdis-canvas/SKILL.md b/skills/clawdis-canvas/SKILL.md new file mode 100644 index 000000000..e02bd5996 --- /dev/null +++ b/skills/clawdis-canvas/SKILL.md @@ -0,0 +1,22 @@ +--- +name: clawdis-canvas +description: Drive the Clawdis Canvas panel (present, eval, snapshot, A2UI) via the clawdis CLI. +metadata: {"clawdis":{"always":true}} +--- + +# Clawdis Canvas + +Use Canvas to render HTML/JS or A2UI surfaces and capture snapshots. + +## Core commands + +- Show/hide: `clawdis canvas present [--node ] [--target ]`, `clawdis canvas hide` +- JS eval: `clawdis canvas eval --js "..."` +- Snapshot: `clawdis canvas snapshot` + +## A2UI + +- Push JSONL: `clawdis canvas a2ui push --jsonl /path/to/file.jsonl` +- Reset: `clawdis canvas a2ui reset` + +If targeting remote nodes, use the canvas host (LAN/tailnet) and keep HTML under `~/clawd/canvas`. diff --git a/skills/eightctl/SKILL.md b/skills/eightctl/SKILL.md new file mode 100644 index 000000000..ae9e51d22 --- /dev/null +++ b/skills/eightctl/SKILL.md @@ -0,0 +1,15 @@ +--- +name: eightctl +description: Control sleep workflows with eightctl. +metadata: {"clawdis":{"requires":{"bins":["eightctl"]}}} +--- + +# eightctl + +Use `eightctl` for sleep/wake automation. Start with: + +```bash +eightctl --help +``` + +Confirm before scheduling disruptive actions. diff --git a/skills/gemini-cli/SKILL.md b/skills/gemini-cli/SKILL.md new file mode 100644 index 000000000..3fa29a386 --- /dev/null +++ b/skills/gemini-cli/SKILL.md @@ -0,0 +1,15 @@ +--- +name: gemini-cli +description: Use the Gemini CLI for fast Q&A and generation. +metadata: {"clawdis":{"requires":{"bins":["gemini"]}}} +--- + +# Gemini CLI + +Use `gemini` for quick prompts. Start with: + +```bash +gemini --help +``` + +If auth is required, set the provider's API key in the environment. diff --git a/skills/gog/SKILL.md b/skills/gog/SKILL.md new file mode 100644 index 000000000..96bf39980 --- /dev/null +++ b/skills/gog/SKILL.md @@ -0,0 +1,15 @@ +--- +name: gog +description: Google Suite CLI for Gmail, Calendar, Drive, and Contacts. +metadata: {"clawdis":{"requires":{"bins":["gog"]}}} +--- + +# gog + +Use `gog` for Gmail/Calendar/Drive/Contacts. Start with: + +```bash +gog --help +``` + +Ask for confirmation before sending emails or creating events. diff --git a/skills/imsg/SKILL.md b/skills/imsg/SKILL.md new file mode 100644 index 000000000..bc326bf8e --- /dev/null +++ b/skills/imsg/SKILL.md @@ -0,0 +1,15 @@ +--- +name: imsg +description: Send or read iMessage/SMS using the imsg CLI. +metadata: {"clawdis":{"requires":{"bins":["imsg"]}}} +--- + +# imsg + +Use `imsg` for iMessage/SMS. Start with: + +```bash +imsg --help +``` + +Confirm recipients and message text before sending. diff --git a/skills/mcporter/SKILL.md b/skills/mcporter/SKILL.md new file mode 100644 index 000000000..436d067df --- /dev/null +++ b/skills/mcporter/SKILL.md @@ -0,0 +1,15 @@ +--- +name: mcporter +description: Manage MCP servers (install, list, sync) with mcporter. +metadata: {"clawdis":{"requires":{"bins":["mcporter"]}}} +--- + +# mcporter + +Use the `mcporter` CLI to install, list, and sync MCP servers. Start with: + +```bash +mcporter --help +``` + +If the tool is missing, ask the user to install it from the Tools tab. diff --git a/skills/nano-banana-pro/SKILL.md b/skills/nano-banana-pro/SKILL.md new file mode 100644 index 000000000..14d7ce17b --- /dev/null +++ b/skills/nano-banana-pro/SKILL.md @@ -0,0 +1,42 @@ +--- +name: nano-banana-pro +description: Generate or edit images via Gemini 3 Pro Image (Nano Banana Pro). +metadata: {"clawdis":{"requires":{"env":["GEMINI_API_KEY"]},"primaryEnv":"GEMINI_API_KEY"}} +--- + +# Nano Banana Pro Image Generation & Editing + +Generate new images or edit existing ones using Google's Nano Banana Pro API. + +## Usage (always run from the current working directory) + +**Generate new image:** +```bash +uv run {baseDir}/scripts/generate_image.py --prompt "your image description" --filename "output-name.png" [--resolution 1K|2K|4K] +``` + +**Edit existing image:** +```bash +uv run {baseDir}/scripts/generate_image.py --prompt "editing instructions" --filename "output-name.png" --input-image "path/to/input.png" [--resolution 1K|2K|4K] +``` + +## API key + +The script uses: +1) `GEMINI_API_KEY` environment variable +2) `--api-key` argument (optional) + +If the key is missing, check `skills."nano-banana-pro".apiKey` or `skills."nano-banana-pro".env.GEMINI_API_KEY` in `~/.clawdis/clawdis.json`, or ask the user to provide one. + +## Resolution + +- `1K` (default), `2K`, `4K` +- Map user intent: low/1080 → `1K`, medium/2K → `2K`, high/ultra/4K → `4K` + +## Filename + +Use `{timestamp}-{short-name}.png` (yyyy-mm-dd-hh-mm-ss, lowercase, hyphens). + +## Output + +Do **not** read the image back; just report the saved path. diff --git a/skills/nano-banana-pro/scripts/generate_image.py b/skills/nano-banana-pro/scripts/generate_image.py new file mode 100755 index 000000000..b3dbf30ba --- /dev/null +++ b/skills/nano-banana-pro/scripts/generate_image.py @@ -0,0 +1,167 @@ +#!/usr/bin/env python3 +# /// script +# requires-python = ">=3.10" +# dependencies = [ +# "google-genai>=1.0.0", +# "pillow>=10.0.0", +# ] +# /// +""" +Generate images using Google's Nano Banana Pro (Gemini 3 Pro Image) API. + +Usage: + uv run generate_image.py --prompt "your image description" --filename "output.png" [--resolution 1K|2K|4K] [--api-key KEY] +""" + +import argparse +import os +import sys +from pathlib import Path + + +def get_api_key(provided_key: str | None) -> str | None: + """Get API key from argument first, then environment.""" + if provided_key: + return provided_key + return os.environ.get("GEMINI_API_KEY") + + +def main(): + parser = argparse.ArgumentParser( + description="Generate images using Nano Banana Pro (Gemini 3 Pro Image)" + ) + parser.add_argument( + "--prompt", "-p", + required=True, + help="Image description/prompt" + ) + parser.add_argument( + "--filename", "-f", + required=True, + help="Output filename (e.g., sunset-mountains.png)" + ) + parser.add_argument( + "--input-image", "-i", + help="Optional input image path for editing/modification" + ) + parser.add_argument( + "--resolution", "-r", + choices=["1K", "2K", "4K"], + default="1K", + help="Output resolution: 1K (default), 2K, or 4K" + ) + parser.add_argument( + "--api-key", "-k", + help="Gemini API key (overrides GEMINI_API_KEY env var)" + ) + + args = parser.parse_args() + + # Get API key + api_key = get_api_key(args.api_key) + if not api_key: + print("Error: No API key provided.", file=sys.stderr) + print("Please either:", file=sys.stderr) + print(" 1. Provide --api-key argument", file=sys.stderr) + print(" 2. Set GEMINI_API_KEY environment variable", file=sys.stderr) + sys.exit(1) + + # Import here after checking API key to avoid slow import on error + from google import genai + from google.genai import types + from PIL import Image as PILImage + + # Initialise client + client = genai.Client(api_key=api_key) + + # Set up output path + output_path = Path(args.filename) + output_path.parent.mkdir(parents=True, exist_ok=True) + + # Load input image if provided + input_image = None + output_resolution = args.resolution + if args.input_image: + try: + input_image = PILImage.open(args.input_image) + print(f"Loaded input image: {args.input_image}") + + # Auto-detect resolution if not explicitly set by user + if args.resolution == "1K": # Default value + # Map input image size to resolution + width, height = input_image.size + max_dim = max(width, height) + if max_dim >= 3000: + output_resolution = "4K" + elif max_dim >= 1500: + output_resolution = "2K" + else: + output_resolution = "1K" + print(f"Auto-detected resolution: {output_resolution} (from input {width}x{height})") + except Exception as e: + print(f"Error loading input image: {e}", file=sys.stderr) + sys.exit(1) + + # Build contents (image first if editing, prompt only if generating) + if input_image: + contents = [input_image, args.prompt] + print(f"Editing image with resolution {output_resolution}...") + else: + contents = args.prompt + print(f"Generating image with resolution {output_resolution}...") + + try: + response = client.models.generate_content( + model="gemini-3-pro-image-preview", + contents=contents, + config=types.GenerateContentConfig( + response_modalities=["TEXT", "IMAGE"], + image_config=types.ImageConfig( + image_size=output_resolution + ) + ) + ) + + # Process response and convert to PNG + image_saved = False + for part in response.parts: + if part.text is not None: + print(f"Model response: {part.text}") + elif part.inline_data is not None: + # Convert inline data to PIL Image and save as PNG + from io import BytesIO + + # inline_data.data is already bytes, not base64 + image_data = part.inline_data.data + if isinstance(image_data, str): + # If it's a string, it might be base64 + import base64 + image_data = base64.b64decode(image_data) + + image = PILImage.open(BytesIO(image_data)) + + # Ensure RGB mode for PNG (convert RGBA to RGB with white background if needed) + if image.mode == 'RGBA': + rgb_image = PILImage.new('RGB', image.size, (255, 255, 255)) + rgb_image.paste(image, mask=image.split()[3]) + rgb_image.save(str(output_path), 'PNG') + elif image.mode == 'RGB': + image.save(str(output_path), 'PNG') + else: + image.convert('RGB').save(str(output_path), 'PNG') + image_saved = True + + if image_saved: + full_path = output_path.resolve() + print(f"\nImage saved: {full_path}") + else: + print("Error: No image was generated in the response.", file=sys.stderr) + sys.exit(1) + + except Exception as e: + print(f"Error generating image: {e}", file=sys.stderr) + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/skills/openai-whisper/SKILL.md b/skills/openai-whisper/SKILL.md new file mode 100644 index 000000000..86ef276c5 --- /dev/null +++ b/skills/openai-whisper/SKILL.md @@ -0,0 +1,15 @@ +--- +name: openai-whisper +description: Local speech-to-text using OpenAI Whisper CLI. +metadata: {"clawdis":{"requires":{"bins":["whisper"]}}} +--- + +# OpenAI Whisper (CLI) + +Use `whisper` for local speech-to-text. Start with: + +```bash +whisper --help +``` + +Prefer small files first; keep outputs in the current working directory. diff --git a/skills/openhue/SKILL.md b/skills/openhue/SKILL.md new file mode 100644 index 000000000..24edf7d52 --- /dev/null +++ b/skills/openhue/SKILL.md @@ -0,0 +1,15 @@ +--- +name: openhue +description: Control Philips Hue lights and scenes via CLI. +metadata: {"clawdis":{"requires":{"bins":["openhue"]}}} +--- + +# OpenHue CLI + +Use `openhue` to control Hue lights/scenes. Start with: + +```bash +openhue --help +``` + +Prefer listing lights/scenes before toggling. diff --git a/skills/oracle/SKILL.md b/skills/oracle/SKILL.md new file mode 100644 index 000000000..f5b88cf14 --- /dev/null +++ b/skills/oracle/SKILL.md @@ -0,0 +1,15 @@ +--- +name: oracle +description: Run a second-model review or debug session with the oracle CLI. +metadata: {"clawdis":{"requires":{"bins":["oracle"]}}} +--- + +# oracle + +Use `oracle` to run a second-model review or debugging pass. + +```bash +oracle --help +``` + +If the binary is missing, use `npx -y @steipete/oracle --help`. diff --git a/skills/peekaboo/SKILL.md b/skills/peekaboo/SKILL.md new file mode 100644 index 000000000..455834b0b --- /dev/null +++ b/skills/peekaboo/SKILL.md @@ -0,0 +1,20 @@ +--- +name: peekaboo +description: Capture and inspect macOS UI via the Peekaboo CLI. +metadata: {"clawdis":{"requires":{"bins":["peekaboo"]}}} +--- + +# Peekaboo + +Fast UI capture and inspection. + +## Common commands + +- Capture: `peekaboo capture` +- Inspect: `peekaboo see` +- Click: `peekaboo click` +- List windows: `peekaboo list` +- Tool info: `peekaboo tools` +- Permissions: `peekaboo permissions status` + +Requires Screen Recording + Accessibility permissions. diff --git a/skills/qmd/SKILL.md b/skills/qmd/SKILL.md new file mode 100644 index 000000000..5d45986b3 --- /dev/null +++ b/skills/qmd/SKILL.md @@ -0,0 +1,15 @@ +--- +name: qmd +description: Search notes/content with qmd (BM25 + vectors + rerank). +metadata: {"clawdis":{"requires":{"bins":["qmd"]}}} +--- + +# qmd + +Use `qmd` to search local content. Start with: + +```bash +qmd --help +``` + +Use concise queries and refine with filters if available. diff --git a/skills/sag/SKILL.md b/skills/sag/SKILL.md new file mode 100644 index 000000000..e17c75876 --- /dev/null +++ b/skills/sag/SKILL.md @@ -0,0 +1,15 @@ +--- +name: sag +description: ElevenLabs text-to-speech with mac-style say UX. +metadata: {"clawdis":{"requires":{"bins":["sag"]}}} +--- + +# sag + +Use `sag` to speak or stream text to speakers. Start with: + +```bash +sag --help +``` + +Confirm the voice/speaker before long output. diff --git a/skills/sonoscli/SKILL.md b/skills/sonoscli/SKILL.md new file mode 100644 index 000000000..19264057d --- /dev/null +++ b/skills/sonoscli/SKILL.md @@ -0,0 +1,17 @@ +--- +name: sonoscli +description: Control Sonos speakers (discover/status/play/volume/group). +metadata: {"clawdis":{"requires":{"bins":["sonos"]}}} +--- + +# Sonos CLI + +Use `sonos` to control Sonos speakers. + +- Discover: `sonos discover` +- Status: `sonos status` +- Playback: `sonos play|pause|stop` +- Volume: `sonos volume set <0-100>` +- Group: `sonos group ` + +If SSDP fails, specify `--ip `. diff --git a/skills/spotify-player/SKILL.md b/skills/spotify-player/SKILL.md new file mode 100644 index 000000000..19794079d --- /dev/null +++ b/skills/spotify-player/SKILL.md @@ -0,0 +1,15 @@ +--- +name: spotify-player +description: Control Spotify playback from the terminal. +metadata: {"clawdis":{"requires":{"bins":["spotify-player"]}}} +--- + +# spotify-player + +Use `spotify-player` to search, queue, and control playback. + +```bash +spotify-player --help +``` + +Confirm the device/target if multiple are available. diff --git a/skills/wacli/SKILL.md b/skills/wacli/SKILL.md new file mode 100644 index 000000000..1a55b5e72 --- /dev/null +++ b/skills/wacli/SKILL.md @@ -0,0 +1,15 @@ +--- +name: wacli +description: WhatsApp CLI for syncing, searching, and sending messages. +metadata: {"clawdis":{"requires":{"bins":["wacli"]}}} +--- + +# wacli + +Use `wacli` to sync/search/send WhatsApp messages. Start with: + +```bash +wacli --help +``` + +Confirm before sending messages. diff --git a/src/agents/pi-embedded.ts b/src/agents/pi-embedded.ts index b5ae33a90..c9d39f36f 100644 --- a/src/agents/pi-embedded.ts +++ b/src/agents/pi-embedded.ts @@ -24,6 +24,7 @@ import { SessionManager, SettingsManager, } from "@mariozechner/pi-coding-agent"; +import type { ClawdisConfig } from "../config/config.js"; import type { ThinkLevel, VerboseLevel } from "../auto-reply/thinking.js"; import { createToolDebouncer, @@ -43,7 +44,13 @@ import { createClawdisCodingTools, sanitizeContentBlocksImages, } from "./pi-tools.js"; -import { buildWorkspaceSkillsPrompt } from "./skills.js"; +import { + applySkillEnvOverrides, + applySkillEnvOverridesFromSnapshot, + buildWorkspaceSkillSnapshot, + type SkillSnapshot, + loadWorkspaceSkillEntries, +} from "./skills.js"; import { buildAgentSystemPrompt } from "./system-prompt.js"; import { loadWorkspaceBootstrapFiles } from "./workspace.js"; @@ -200,6 +207,8 @@ export async function runEmbeddedPiAgent(params: { sessionId: string; sessionFile: string; workspaceDir: string; + config?: ClawdisConfig; + skillsSnapshot?: SkillSnapshot; prompt: string; provider?: string; model?: string; @@ -244,8 +253,30 @@ export async function runEmbeddedPiAgent(params: { thinkingLevel, }); + let restoreSkillEnv: (() => void) | undefined; process.chdir(resolvedWorkspace); try { + const skillEntries = params.skillsSnapshot + ? undefined + : loadWorkspaceSkillEntries(resolvedWorkspace, { + config: params.config, + }); + const skillsSnapshot = + params.skillsSnapshot ?? + buildWorkspaceSkillSnapshot(resolvedWorkspace, { + config: params.config, + entries: skillEntries, + }); + restoreSkillEnv = params.skillsSnapshot + ? applySkillEnvOverridesFromSnapshot({ + snapshot: params.skillsSnapshot, + config: params.config, + }) + : applySkillEnvOverrides({ + skills: skillEntries ?? [], + config: params.config, + }); + const bootstrapFiles = await loadWorkspaceBootstrapFiles(resolvedWorkspace); const systemPrompt = buildAgentSystemPrompt({ @@ -258,8 +289,7 @@ export async function runEmbeddedPiAgent(params: { })), defaultThinkLevel: params.thinkLevel, }); - const systemPromptWithSkills = - systemPrompt + buildWorkspaceSkillsPrompt(resolvedWorkspace); + const systemPromptWithSkills = systemPrompt + skillsSnapshot.prompt; const sessionManager = new SessionManager(false, params.sessionFile); const settingsManager = new SettingsManager(); @@ -576,6 +606,7 @@ export async function runEmbeddedPiAgent(params: { }, }; } finally { + restoreSkillEnv?.(); process.chdir(prevCwd); } }); diff --git a/src/agents/skills.test.ts b/src/agents/skills.test.ts index 2b7c4d463..1913ea4ca 100644 --- a/src/agents/skills.test.ts +++ b/src/agents/skills.test.ts @@ -24,9 +24,42 @@ description: Does demo things "utf-8", ); - const prompt = buildWorkspaceSkillsPrompt(workspaceDir); + const prompt = buildWorkspaceSkillsPrompt(workspaceDir, { + managedSkillsDir: path.join(workspaceDir, ".managed"), + }); expect(prompt).toContain("demo-skill"); expect(prompt).toContain("Does demo things"); expect(prompt).toContain(path.join(skillDir, "SKILL.md")); }); + + it("filters skills based on env/config gates", async () => { + const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdis-")); + const skillDir = path.join(workspaceDir, "skills", "nano-banana-pro"); + await fs.mkdir(skillDir, { recursive: true }); + + await fs.writeFile( + path.join(skillDir, "SKILL.md"), + `--- +name: nano-banana-pro +description: Generates images +metadata: {"clawdis":{"requires":{"env":["GEMINI_API_KEY"]},"primaryEnv":"GEMINI_API_KEY"}} +--- + +# Nano Banana +`, + "utf-8", + ); + + const missingPrompt = buildWorkspaceSkillsPrompt(workspaceDir, { + managedSkillsDir: path.join(workspaceDir, ".managed"), + config: { skills: { "nano-banana-pro": { apiKey: "" } } }, + }); + expect(missingPrompt).not.toContain("nano-banana-pro"); + + const enabledPrompt = buildWorkspaceSkillsPrompt(workspaceDir, { + managedSkillsDir: path.join(workspaceDir, ".managed"), + config: { skills: { "nano-banana-pro": { apiKey: "test-key" } } }, + }); + expect(enabledPrompt).toContain("nano-banana-pro"); + }); }); diff --git a/src/agents/skills.ts b/src/agents/skills.ts index f321d9ec9..7b74b2c67 100644 --- a/src/agents/skills.ts +++ b/src/agents/skills.ts @@ -1,15 +1,395 @@ +import fs from "node:fs"; import path from "node:path"; import { + type Skill, + type SkillFrontmatter, formatSkillsForPrompt, loadSkillsFromDir, } from "@mariozechner/pi-coding-agent"; -export function buildWorkspaceSkillsPrompt(workspaceDir: string): string { - const skillsDir = path.join(workspaceDir, "skills"); - const skills = loadSkillsFromDir({ - dir: skillsDir, - source: "clawdis-workspace", - }); - return formatSkillsForPrompt(skills); +import type { ClawdisConfig, SkillConfig } from "../config/config.js"; +import { CONFIG_DIR } from "../utils.js"; + +type ClawdisSkillMetadata = { + always?: boolean; + skillKey?: string; + primaryEnv?: string; + requires?: { + bins?: string[]; + env?: string[]; + config?: string[]; + }; +}; + +type SkillEntry = { + skill: Skill; + frontmatter: SkillFrontmatter; + clawdis?: ClawdisSkillMetadata; +}; + +export type SkillSnapshot = { + prompt: string; + skills: Array<{ name: string; primaryEnv?: string }>; +}; + +function getFrontmatterValue( + frontmatter: SkillFrontmatter, + key: string, +): string | undefined { + const raw = frontmatter[key]; + return typeof raw === "string" ? raw : undefined; +} + +function stripQuotes(value: string): string { + if ( + (value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'")) + ) { + return value.slice(1, -1); + } + return value; +} + +function parseFrontmatter(content: string): SkillFrontmatter { + const frontmatter: SkillFrontmatter = {}; + const normalized = content.replace(/\r\n/g, "\n").replace(/\r/g, "\n"); + if (!normalized.startsWith("---")) return frontmatter; + const endIndex = normalized.indexOf("\n---", 3); + if (endIndex === -1) return frontmatter; + const block = normalized.slice(4, endIndex); + for (const line of block.split("\n")) { + const match = line.match(/^([\w-]+):\s*(.*)$/); + if (!match) continue; + const key = match[1]; + const value = stripQuotes(match[2].trim()); + if (!key || !value) continue; + frontmatter[key] = value; + } + return frontmatter; +} + +function normalizeStringList(input: unknown): string[] { + if (!input) return []; + if (Array.isArray(input)) { + return input.map((value) => String(value).trim()).filter(Boolean); + } + if (typeof input === "string") { + return input + .split(",") + .map((value) => value.trim()) + .filter(Boolean); + } + return []; +} + +function isTruthy(value: unknown): boolean { + if (value === undefined || value === null) return false; + if (typeof value === "boolean") return value; + if (typeof value === "number") return value !== 0; + if (typeof value === "string") return value.trim().length > 0; + return true; +} + +const DEFAULT_CONFIG_VALUES: Record = { + "browser.enabled": true, +}; + +function resolveConfigPath(config: ClawdisConfig | undefined, pathStr: string) { + const parts = pathStr.split(".").filter(Boolean); + let current: unknown = config; + for (const part of parts) { + if (typeof current !== "object" || current === null) return undefined; + current = (current as Record)[part]; + } + return current; +} + +function isConfigPathTruthy( + config: ClawdisConfig | undefined, + pathStr: string, +): boolean { + const value = resolveConfigPath(config, pathStr); + if (value === undefined && pathStr in DEFAULT_CONFIG_VALUES) { + return DEFAULT_CONFIG_VALUES[pathStr] === true; + } + return isTruthy(value); +} + +function resolveSkillConfig( + config: ClawdisConfig | undefined, + skillKey: string, +): SkillConfig | undefined { + const skills = config?.skills; + if (!skills || typeof skills !== "object") return undefined; + const entry = (skills as Record)[skillKey]; + if (!entry || typeof entry !== "object") return undefined; + return entry; +} + +function hasBinary(bin: string): boolean { + const pathEnv = process.env.PATH ?? ""; + const parts = pathEnv.split(path.delimiter).filter(Boolean); + for (const part of parts) { + const candidate = path.join(part, bin); + try { + fs.accessSync(candidate, fs.constants.X_OK); + return true; + } catch { + // keep scanning + } + } + return false; +} + +function resolveClawdisMetadata( + frontmatter: SkillFrontmatter, +): ClawdisSkillMetadata | undefined { + const raw = getFrontmatterValue(frontmatter, "metadata"); + if (!raw) return undefined; + try { + const parsed = JSON.parse(raw) as { clawdis?: unknown }; + if (!parsed || typeof parsed !== "object") return undefined; + const clawdis = (parsed as { clawdis?: unknown }).clawdis; + if (!clawdis || typeof clawdis !== "object") return undefined; + const clawdisObj = clawdis as Record; + const requiresRaw = + typeof clawdisObj.requires === "object" && clawdisObj.requires !== null + ? (clawdisObj.requires as Record) + : undefined; + return { + always: + typeof clawdisObj.always === "boolean" + ? clawdisObj.always + : undefined, + skillKey: + typeof clawdisObj.skillKey === "string" + ? clawdisObj.skillKey + : undefined, + primaryEnv: + typeof clawdisObj.primaryEnv === "string" + ? clawdisObj.primaryEnv + : undefined, + requires: requiresRaw + ? { + bins: normalizeStringList(requiresRaw.bins), + env: normalizeStringList(requiresRaw.env), + config: normalizeStringList(requiresRaw.config), + } + : undefined, + }; + } catch { + return undefined; + } +} + +function resolveSkillKey(skill: Skill, entry?: SkillEntry): string { + return entry?.clawdis?.skillKey ?? skill.name; +} + +function shouldIncludeSkill(params: { + entry: SkillEntry; + config?: ClawdisConfig; +}): boolean { + const { entry, config } = params; + const skillKey = resolveSkillKey(entry.skill, entry); + const skillConfig = resolveSkillConfig(config, skillKey); + + if (skillConfig?.enabled === false) return false; + if (entry.clawdis?.always === true) { + return true; + } + + const requiredBins = entry.clawdis?.requires?.bins ?? []; + if (requiredBins.length > 0) { + for (const bin of requiredBins) { + if (!hasBinary(bin)) return false; + } + } + + const requiredEnv = entry.clawdis?.requires?.env ?? []; + if (requiredEnv.length > 0) { + for (const envName of requiredEnv) { + if (process.env[envName]) continue; + if (skillConfig?.env?.[envName]) continue; + if ( + skillConfig?.apiKey && + entry.clawdis?.primaryEnv === envName + ) { + continue; + } + return false; + } + } + + const requiredConfig = entry.clawdis?.requires?.config ?? []; + if (requiredConfig.length > 0) { + for (const configPath of requiredConfig) { + if (!isConfigPathTruthy(config, configPath)) return false; + } + } + + return true; +} + +function filterSkillEntries( + entries: SkillEntry[], + config?: ClawdisConfig, +): SkillEntry[] { + return entries.filter((entry) => shouldIncludeSkill({ entry, config })); +} + +export function applySkillEnvOverrides(params: { + skills: SkillEntry[]; + config?: ClawdisConfig; +}) { + const { skills, config } = params; + const updates: Array<{ key: string; prev: string | undefined }> = []; + + for (const entry of skills) { + const skillKey = resolveSkillKey(entry.skill, entry); + const skillConfig = resolveSkillConfig(config, skillKey); + if (!skillConfig) continue; + + if (skillConfig.env) { + for (const [envKey, envValue] of Object.entries(skillConfig.env)) { + if (!envValue || process.env[envKey]) continue; + updates.push({ key: envKey, prev: process.env[envKey] }); + process.env[envKey] = envValue; + } + } + + const primaryEnv = entry.clawdis?.primaryEnv; + if (primaryEnv && skillConfig.apiKey && !process.env[primaryEnv]) { + updates.push({ key: primaryEnv, prev: process.env[primaryEnv] }); + process.env[primaryEnv] = skillConfig.apiKey; + } + } + + return () => { + for (const update of updates) { + if (update.prev === undefined) delete process.env[update.key]; + else process.env[update.key] = update.prev; + } + }; +} + +export function applySkillEnvOverridesFromSnapshot(params: { + snapshot?: SkillSnapshot; + config?: ClawdisConfig; +}) { + const { snapshot, config } = params; + if (!snapshot) return () => {}; + const updates: Array<{ key: string; prev: string | undefined }> = []; + + for (const skill of snapshot.skills) { + const skillConfig = resolveSkillConfig(config, skill.name); + if (!skillConfig) continue; + + if (skillConfig.env) { + for (const [envKey, envValue] of Object.entries(skillConfig.env)) { + if (!envValue || process.env[envKey]) continue; + updates.push({ key: envKey, prev: process.env[envKey] }); + process.env[envKey] = envValue; + } + } + + if (skill.primaryEnv && skillConfig.apiKey && !process.env[skill.primaryEnv]) { + updates.push({ key: skill.primaryEnv, prev: process.env[skill.primaryEnv] }); + process.env[skill.primaryEnv] = skillConfig.apiKey; + } + } + + return () => { + for (const update of updates) { + if (update.prev === undefined) delete process.env[update.key]; + else process.env[update.key] = update.prev; + } + }; +} + +function loadSkillEntries( + workspaceDir: string, + opts?: { + config?: ClawdisConfig; + managedSkillsDir?: string; + }, +): SkillEntry[] { + const managedSkillsDir = + opts?.managedSkillsDir ?? path.join(CONFIG_DIR, "skills"); + const workspaceSkillsDir = path.join(workspaceDir, "skills"); + + const managedSkills = loadSkillsFromDir({ + dir: managedSkillsDir, + source: "clawdis-managed", + }).skills; + const workspaceSkills = loadSkillsFromDir({ + dir: workspaceSkillsDir, + source: "clawdis-workspace", + }).skills; + + const merged = new Map(); + for (const skill of managedSkills) merged.set(skill.name, skill); + for (const skill of workspaceSkills) merged.set(skill.name, skill); + + const skillEntries: SkillEntry[] = Array.from(merged.values()).map((skill) => { + let frontmatter: SkillFrontmatter = {}; + try { + const raw = fs.readFileSync(skill.filePath, "utf-8"); + frontmatter = parseFrontmatter(raw); + } catch { + // ignore malformed skills + } + return { skill, frontmatter, clawdis: resolveClawdisMetadata(frontmatter) }; + }); + return skillEntries; +} + +export function buildWorkspaceSkillSnapshot( + workspaceDir: string, + opts?: { + config?: ClawdisConfig; + managedSkillsDir?: string; + entries?: SkillEntry[]; + }, +): SkillSnapshot { + const skillEntries = opts?.entries ?? loadSkillEntries(workspaceDir, opts); + const eligible = filterSkillEntries(skillEntries, opts?.config); + return { + prompt: formatSkillsForPrompt(eligible.map((entry) => entry.skill)), + skills: eligible.map((entry) => ({ + name: entry.skill.name, + primaryEnv: entry.clawdis?.primaryEnv, + })), + }; +} + +export function buildWorkspaceSkillsPrompt( + workspaceDir: string, + opts?: { + config?: ClawdisConfig; + managedSkillsDir?: string; + entries?: SkillEntry[]; + }, +): string { + const skillEntries = opts?.entries ?? loadSkillEntries(workspaceDir, opts); + const eligible = filterSkillEntries(skillEntries, opts?.config); + return formatSkillsForPrompt(eligible.map((entry) => entry.skill)); +} + +export function loadWorkspaceSkillEntries( + workspaceDir: string, + opts?: { + config?: ClawdisConfig; + managedSkillsDir?: string; + }, +): SkillEntry[] { + return loadSkillEntries(workspaceDir, opts); +} + +export function filterWorkspaceSkillEntries( + entries: SkillEntry[], + config?: ClawdisConfig, +): SkillEntry[] { + return filterSkillEntries(entries, config); } diff --git a/src/auto-reply/reply.ts b/src/auto-reply/reply.ts index cbde0231d..e57c3f8f4 100644 --- a/src/auto-reply/reply.ts +++ b/src/auto-reply/reply.ts @@ -11,6 +11,7 @@ import { DEFAULT_AGENT_WORKSPACE_DIR, ensureAgentWorkspace, } from "../agents/workspace.js"; +import { buildWorkspaceSkillSnapshot } from "../agents/skills.js"; import { type ClawdisConfig, loadConfig } from "../config/config.js"; import { DEFAULT_IDLE_MINUTES, @@ -659,17 +660,48 @@ export async function getReplyFromConfig( sessionId: sessionId ?? crypto.randomUUID(), updatedAt: Date.now(), }; + const skillSnapshot = + isFirstTurnInSession || !current.skillsSnapshot + ? buildWorkspaceSkillSnapshot(workspaceDir, { config: cfg }) + : current.skillsSnapshot; sessionEntry = { ...current, sessionId: sessionId ?? current.sessionId ?? crypto.randomUUID(), updatedAt: Date.now(), systemSent: true, + skillsSnapshot: skillSnapshot, }; sessionStore[sessionKey] = sessionEntry; await saveSessionStore(storePath, sessionStore); systemSent = true; } + const skillsSnapshot = + sessionEntry?.skillsSnapshot ?? + (isFirstTurnInSession + ? undefined + : buildWorkspaceSkillSnapshot(workspaceDir, { config: cfg })); + if ( + skillsSnapshot && + sessionStore && + sessionKey && + !isFirstTurnInSession && + !sessionEntry?.skillsSnapshot + ) { + const current = sessionEntry ?? { + sessionId: sessionId ?? crypto.randomUUID(), + updatedAt: Date.now(), + }; + sessionEntry = { + ...current, + sessionId: sessionId ?? current.sessionId ?? crypto.randomUUID(), + updatedAt: Date.now(), + skillsSnapshot, + }; + sessionStore[sessionKey] = sessionEntry; + await saveSessionStore(storePath, sessionStore); + } + const prefixedBody = transcribedText ? [prefixedBodyBase, `Transcript:\n${transcribedText}`] .filter(Boolean) @@ -709,6 +741,8 @@ export async function getReplyFromConfig( sessionId: sessionIdFinal, sessionFile, workspaceDir, + config: cfg, + skillsSnapshot, prompt: commandBody, provider, model, diff --git a/src/commands/agent.ts b/src/commands/agent.ts index 8278299f6..d99305c0b 100644 --- a/src/commands/agent.ts +++ b/src/commands/agent.ts @@ -10,6 +10,7 @@ import { DEFAULT_AGENT_WORKSPACE_DIR, ensureAgentWorkspace, } from "../agents/workspace.js"; +import { buildWorkspaceSkillSnapshot } from "../agents/skills.js"; import { chunkText } from "../auto-reply/chunk.js"; import type { MsgContext } from "../auto-reply/templating.js"; import { @@ -205,10 +206,31 @@ export async function agentCommand( persistedVerbose ?? (agentCfg?.verboseDefault as VerboseLevel | undefined); + const needsSkillsSnapshot = isNewSession || !sessionEntry?.skillsSnapshot; + const skillsSnapshot = needsSkillsSnapshot + ? buildWorkspaceSkillSnapshot(workspaceDir, { config: cfg }) + : sessionEntry?.skillsSnapshot; + + if (skillsSnapshot && sessionStore && sessionKey && needsSkillsSnapshot) { + const current = sessionEntry ?? { + sessionId, + updatedAt: Date.now(), + }; + const next: SessionEntry = { + ...current, + sessionId, + updatedAt: Date.now(), + skillsSnapshot, + }; + sessionStore[sessionKey] = next; + await saveSessionStore(storePath, sessionStore); + sessionEntry = next; + } + // Persist explicit /command overrides to the session store when we have a key. if (sessionStore && sessionKey) { - const entry = sessionEntry ?? - sessionStore[sessionKey] ?? { sessionId, updatedAt: Date.now() }; + const entry = + sessionStore[sessionKey] ?? sessionEntry ?? { sessionId, updatedAt: Date.now() }; const next: SessionEntry = { ...entry, sessionId, updatedAt: Date.now() }; if (thinkOverride) { if (thinkOverride === "off") delete next.thinkingLevel; @@ -245,6 +267,8 @@ export async function agentCommand( sessionId, sessionFile, workspaceDir, + config: cfg, + skillsSnapshot, prompt: body, provider, model, diff --git a/src/config/config.ts b/src/config/config.ts index 275fb373a..7d577bee3 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -120,6 +120,13 @@ export type GatewayConfig = { controlUi?: GatewayControlUiConfig; }; +export type SkillConfig = { + enabled?: boolean; + apiKey?: string; + env?: Record; + [key: string]: unknown; +}; + export type ClawdisConfig = { identity?: { name?: string; @@ -168,6 +175,7 @@ export type ClawdisConfig = { discovery?: DiscoveryConfig; canvasHost?: CanvasHostConfig; gateway?: GatewayConfig; + skills?: Record; }; // New branding path (preferred) @@ -349,6 +357,17 @@ const ClawdisSchema = z.object({ .optional(), }) .optional(), + skills: z + .record( + z + .object({ + enabled: z.boolean().optional(), + apiKey: z.string().optional(), + env: z.record(z.string()).optional(), + }) + .passthrough(), + ) + .optional(), }); export type ConfigValidationIssue = { diff --git a/src/config/sessions.ts b/src/config/sessions.ts index ba9f5fe51..13bc720e4 100644 --- a/src/config/sessions.ts +++ b/src/config/sessions.ts @@ -25,6 +25,12 @@ export type SessionEntry = { lastTo?: string; // Optional flag to mirror Mac app UI and future sync states. syncing?: boolean | string; + skillsSnapshot?: SessionSkillSnapshot; +}; + +export type SessionSkillSnapshot = { + prompt: string; + skills: Array<{ name: string; primaryEnv?: string }>; }; export function resolveSessionTranscriptsDir(): string { @@ -125,6 +131,7 @@ export async function updateLastRoute(params: { model: existing?.model, contextTokens: existing?.contextTokens, syncing: existing?.syncing, + skillsSnapshot: existing?.skillsSnapshot, lastChannel: channel, lastTo: to?.trim() ? to.trim() : undefined, }; diff --git a/src/cron/isolated-agent.ts b/src/cron/isolated-agent.ts index 40fb8e50d..d587cd491 100644 --- a/src/cron/isolated-agent.ts +++ b/src/cron/isolated-agent.ts @@ -10,6 +10,7 @@ import { DEFAULT_AGENT_WORKSPACE_DIR, ensureAgentWorkspace, } from "../agents/workspace.js"; +import { buildWorkspaceSkillSnapshot } from "../agents/skills.js"; import { chunkText } from "../auto-reply/chunk.js"; import { normalizeThinkLevel } from "../auto-reply/thinking.js"; import type { CliDeps } from "../cli/deps.js"; @@ -204,6 +205,21 @@ export async function runCronIsolatedAgentTurn(params: { const commandBody = base; + const needsSkillsSnapshot = + cronSession.isNewSession || !cronSession.sessionEntry.skillsSnapshot; + const skillsSnapshot = needsSkillsSnapshot + ? buildWorkspaceSkillSnapshot(workspaceDir, { config: params.cfg }) + : cronSession.sessionEntry.skillsSnapshot; + if (needsSkillsSnapshot && skillsSnapshot) { + cronSession.sessionEntry = { + ...cronSession.sessionEntry, + updatedAt: Date.now(), + skillsSnapshot, + }; + cronSession.store[params.sessionKey] = cronSession.sessionEntry; + await saveSessionStore(cronSession.storePath, cronSession.store); + } + // Persist systemSent before the run, mirroring the inbound auto-reply behavior. if (isFirstTurnInSession) { cronSession.sessionEntry.systemSent = true; @@ -223,6 +239,8 @@ export async function runCronIsolatedAgentTurn(params: { sessionId: cronSession.sessionEntry.sessionId, sessionFile, workspaceDir, + config: params.cfg, + skillsSnapshot, prompt: commandBody, provider, model, diff --git a/src/gateway/server.ts b/src/gateway/server.ts index 9ef353ecb..bbd2ccbde 100644 --- a/src/gateway/server.ts +++ b/src/gateway/server.ts @@ -3857,6 +3857,7 @@ export async function startGatewayServer( thinkingLevel: entry?.thinkingLevel, verboseLevel: entry?.verboseLevel, systemSent: entry?.systemSent, + skillsSnapshot: entry?.skillsSnapshot, lastChannel: entry?.lastChannel, lastTo: entry?.lastTo, };