feat: add managed skills gating

This commit is contained in:
Peter Steinberger
2025-12-20 12:22:15 +01:00
parent cf21a15e06
commit d1850aaada
36 changed files with 1235 additions and 16 deletions

View File

@@ -29,6 +29,14 @@ If a file is missing, CLAWDIS injects a single “missing file” marker line (a
Pis 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; its guidance for how *you* want them used.
## Skills
Clawdis loads skills from two locations (workspace wins on name conflict):
- Managed: `~/.clawdis/skills`
- Workspace: `<workspace>/skills`
Managed skills can be gated by config/env (see `skills.*` in `docs/configuration.md`).
## Sessions
Session transcripts are stored as JSONL at:

View File

@@ -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 its 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.

View File

@@ -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)

107
docs/skills.md Normal file
View File

@@ -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
---
<!-- {% raw %} -->
# 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**: `<workspace>/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.<name>.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 dont require a custom `skillKey`.
Rules:
- `enabled: false` disables the managed skill even if installed.
- `env`: injected **only if** the variable isnt 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.<key>.env` or `skills.<key>.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.
---
<!-- {% endraw %} -->

View File

@@ -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",

View File

@@ -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.

19
skills/bird/SKILL.md Normal file
View File

@@ -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 <tweet-id> "..."`
- Read: `bird read <tweet-id>`
- Thread: `bird thread <tweet-id>`
- Search: `bird search "query"`
- Mentions: `bird mentions`
- Whoami: `bird whoami`
Confirm before posting publicly.

15
skills/blucli/SKILL.md Normal file
View File

@@ -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.

15
skills/camsnap/SKILL.md Normal file
View File

@@ -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.

View File

@@ -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 <url>|focus <id>|close <id>`
- 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`.

View File

@@ -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 <id>] [--target <path>]`, `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`.

15
skills/eightctl/SKILL.md Normal file
View File

@@ -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.

View File

@@ -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.

15
skills/gog/SKILL.md Normal file
View File

@@ -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.

15
skills/imsg/SKILL.md Normal file
View File

@@ -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.

15
skills/mcporter/SKILL.md Normal file
View File

@@ -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.

View File

@@ -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.

View File

@@ -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()

View File

@@ -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.

15
skills/openhue/SKILL.md Normal file
View File

@@ -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.

15
skills/oracle/SKILL.md Normal file
View File

@@ -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`.

20
skills/peekaboo/SKILL.md Normal file
View File

@@ -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.

15
skills/qmd/SKILL.md Normal file
View File

@@ -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.

15
skills/sag/SKILL.md Normal file
View File

@@ -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.

17
skills/sonoscli/SKILL.md Normal file
View File

@@ -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 <leader> <member>`
If SSDP fails, specify `--ip <speaker-ip>`.

View File

@@ -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.

15
skills/wacli/SKILL.md Normal file
View File

@@ -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.

View File

@@ -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);
}
});

View File

@@ -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");
});
});

View File

@@ -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<string, boolean> = {
"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<string, unknown>)[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<string, SkillConfig | undefined>)[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<string, unknown>;
const requiresRaw =
typeof clawdisObj.requires === "object" && clawdisObj.requires !== null
? (clawdisObj.requires as Record<string, unknown>)
: 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<string, Skill>();
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);
}

View File

@@ -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,

View File

@@ -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,

View File

@@ -120,6 +120,13 @@ export type GatewayConfig = {
controlUi?: GatewayControlUiConfig;
};
export type SkillConfig = {
enabled?: boolean;
apiKey?: string;
env?: Record<string, string>;
[key: string]: unknown;
};
export type ClawdisConfig = {
identity?: {
name?: string;
@@ -168,6 +175,7 @@ export type ClawdisConfig = {
discovery?: DiscoveryConfig;
canvasHost?: CanvasHostConfig;
gateway?: GatewayConfig;
skills?: Record<string, SkillConfig>;
};
// 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 = {

View File

@@ -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,
};

View File

@@ -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,

View File

@@ -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,
};