feat: unify skills config
This commit is contained in:
@@ -2,6 +2,13 @@
|
|||||||
|
|
||||||
## 2.0.0-beta5 — Unreleased
|
## 2.0.0-beta5 — Unreleased
|
||||||
|
|
||||||
|
### Breaking
|
||||||
|
- Skills config schema moved under `skills.*`:
|
||||||
|
- `skillsLoad.extraDirs` → `skills.load.extraDirs`
|
||||||
|
- `skillsInstall.*` → `skills.install.*`
|
||||||
|
- per-skill config map moved to `skills.entries` (e.g. `skills.peekaboo.enabled` → `skills.entries.peekaboo.enabled`)
|
||||||
|
- new optional bundled allowlist: `skills.allowBundled` (only affects bundled skills)
|
||||||
|
|
||||||
### Features
|
### Features
|
||||||
- Talk mode: continuous speech conversations (macOS/iOS/Android) with ElevenLabs TTS, reply directives, and optional interrupt-on-speech.
|
- Talk mode: continuous speech conversations (macOS/iOS/Android) with ElevenLabs TTS, reply directives, and optional interrupt-on-speech.
|
||||||
- UI: add optional `ui.seamColor` accent to tint the Talk Mode side bubble (macOS/iOS/Android).
|
- UI: add optional `ui.seamColor` accent to tint the Talk Mode side bubble (macOS/iOS/Android).
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ Clawdis loads skills from three locations (workspace wins on name conflict):
|
|||||||
- Managed/local: `~/.clawdis/skills`
|
- Managed/local: `~/.clawdis/skills`
|
||||||
- Workspace: `<workspace>/skills`
|
- Workspace: `<workspace>/skills`
|
||||||
|
|
||||||
Skills can be gated by config/env (see `skills.*` in `docs/configuration.md`).
|
Skills can be gated by config/env (see `skills` in `docs/configuration.md`).
|
||||||
|
|
||||||
## p-mono integration
|
## p-mono integration
|
||||||
|
|
||||||
|
|||||||
@@ -390,11 +390,21 @@ Controls session scoping, idle expiry, reset triggers, and where the session sto
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### `skills` (skill config/env)
|
### `skills` (skills config)
|
||||||
|
|
||||||
Configure skill toggles and env injection. Applies to **bundled** skills and `~/.clawdis/skills` (workspace skills still win on name conflicts).
|
Controls bundled allowlist, install preferences, extra skill folders, and per-skill
|
||||||
|
overrides. Applies to **bundled** skills and `~/.clawdis/skills` (workspace skills
|
||||||
|
still win on name conflicts).
|
||||||
|
|
||||||
Common fields per skill:
|
Fields:
|
||||||
|
- `allowBundled`: optional allowlist for **bundled** skills only. If set, only those
|
||||||
|
bundled skills are eligible (managed/workspace skills unaffected).
|
||||||
|
- `load.extraDirs`: additional skill directories to scan (lowest precedence).
|
||||||
|
- `install.preferBrew`: prefer brew installers when available (default: true).
|
||||||
|
- `install.nodeManager`: node installer preference (`npm` | `pnpm` | `yarn`, default: npm).
|
||||||
|
- `entries.<skillKey>`: per-skill config overrides.
|
||||||
|
|
||||||
|
Per-skill fields:
|
||||||
- `enabled`: set `false` to disable a skill even if it’s bundled/installed.
|
- `enabled`: set `false` to disable a skill even if it’s bundled/installed.
|
||||||
- `env`: environment variables injected for the agent run (only if not already set).
|
- `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`).
|
- `apiKey`: optional convenience for skills that declare a primary env var (e.g. `nano-banana-pro` → `GEMINI_API_KEY`).
|
||||||
@@ -404,44 +414,27 @@ Example:
|
|||||||
```json5
|
```json5
|
||||||
{
|
{
|
||||||
skills: {
|
skills: {
|
||||||
"nano-banana-pro": {
|
allowBundled: ["brave-search", "gemini"],
|
||||||
apiKey: "GEMINI_KEY_HERE",
|
load: {
|
||||||
env: {
|
extraDirs: [
|
||||||
GEMINI_API_KEY: "GEMINI_KEY_HERE"
|
"~/Projects/agent-scripts/skills",
|
||||||
}
|
"~/Projects/oss/some-skill-pack/skills"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
peekaboo: { enabled: true },
|
install: {
|
||||||
sag: { enabled: false }
|
preferBrew: true,
|
||||||
}
|
nodeManager: "npm"
|
||||||
}
|
},
|
||||||
```
|
entries: {
|
||||||
|
"nano-banana-pro": {
|
||||||
### `skillsInstall` (installer preference)
|
apiKey: "GEMINI_KEY_HERE",
|
||||||
|
env: {
|
||||||
Controls which installer is surfaced by the macOS Skills UI when a skill offers
|
GEMINI_API_KEY: "GEMINI_KEY_HERE"
|
||||||
multiple install options. Defaults to **brew when available** and **npm** for
|
}
|
||||||
node installs.
|
},
|
||||||
|
peekaboo: { enabled: true },
|
||||||
```json5
|
sag: { enabled: false }
|
||||||
{
|
}
|
||||||
skillsInstall: {
|
|
||||||
preferBrew: true,
|
|
||||||
nodeManager: "npm" // npm | pnpm | yarn
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### `skillsLoad`
|
|
||||||
|
|
||||||
Additional skill directories to scan (lowest precedence). This is useful if you keep skills in a separate repo but want Clawdis to pick them up without copying them into the workspace.
|
|
||||||
|
|
||||||
```json5
|
|
||||||
{
|
|
||||||
skillsLoad: {
|
|
||||||
extraDirs: [
|
|
||||||
"~/Projects/agent-scripts/skills",
|
|
||||||
"~/Projects/oss/some-skill-pack/skills"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -119,6 +119,7 @@ Example:
|
|||||||
- [Nix mode](./nix.md)
|
- [Nix mode](./nix.md)
|
||||||
- [Clawd personal assistant setup](./clawd.md)
|
- [Clawd personal assistant setup](./clawd.md)
|
||||||
- [Skills](./skills.md)
|
- [Skills](./skills.md)
|
||||||
|
- [Skills config](./skills-config.md)
|
||||||
- [Workspace templates](./templates/AGENTS.md)
|
- [Workspace templates](./templates/AGENTS.md)
|
||||||
- [Gateway runbook](./gateway.md)
|
- [Gateway runbook](./gateway.md)
|
||||||
- [Nodes (iOS/Android)](./nodes.md)
|
- [Nodes (iOS/Android)](./nodes.md)
|
||||||
|
|||||||
@@ -9,16 +9,18 @@ read_when:
|
|||||||
The macOS app surfaces Clawdis skills via the gateway; it does not parse skills locally.
|
The macOS app surfaces Clawdis skills via the gateway; it does not parse skills locally.
|
||||||
|
|
||||||
## Data source
|
## Data source
|
||||||
- `skills.status` (gateway) returns all skills plus eligibility and missing requirements.
|
- `skills.status` (gateway) returns all skills plus eligibility and missing requirements
|
||||||
|
(including allowlist blocks for bundled skills).
|
||||||
- Requirements are derived from `metadata.clawdis.requires` in each `SKILL.md`.
|
- Requirements are derived from `metadata.clawdis.requires` in each `SKILL.md`.
|
||||||
|
|
||||||
## Install actions
|
## Install actions
|
||||||
- `metadata.clawdis.install` defines install options (brew/node/go/uv).
|
- `metadata.clawdis.install` defines install options (brew/node/go/uv).
|
||||||
- The app calls `skills.install` to run installers on the gateway host.
|
- The app calls `skills.install` to run installers on the gateway host.
|
||||||
- The gateway surfaces only one preferred installer when multiple are provided (brew when available, otherwise node manager from `skillsInstall`, default npm).
|
- The gateway surfaces only one preferred installer when multiple are provided
|
||||||
|
(brew when available, otherwise node manager from `skills.install`, default npm).
|
||||||
|
|
||||||
## Env/API keys
|
## Env/API keys
|
||||||
- The app stores keys in `~/.clawdis/clawdis.json` under `skills.<skillKey>`.
|
- The app stores keys in `~/.clawdis/clawdis.json` under `skills.entries.<skillKey>`.
|
||||||
- `skills.update` patches `enabled`, `apiKey`, and `env`.
|
- `skills.update` patches `enabled`, `apiKey`, and `env`.
|
||||||
|
|
||||||
## Remote mode
|
## Remote mode
|
||||||
|
|||||||
58
docs/skills-config.md
Normal file
58
docs/skills-config.md
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
---
|
||||||
|
summary: "Skills config schema and examples"
|
||||||
|
read_when:
|
||||||
|
- Adding or modifying skills config
|
||||||
|
- Adjusting bundled allowlist or install behavior
|
||||||
|
---
|
||||||
|
# Skills Config
|
||||||
|
|
||||||
|
All skills-related configuration lives under `skills` in `~/.clawdis/clawdis.json`.
|
||||||
|
|
||||||
|
```json5
|
||||||
|
{
|
||||||
|
skills: {
|
||||||
|
allowBundled: ["brave-search", "gemini"],
|
||||||
|
load: {
|
||||||
|
extraDirs: [
|
||||||
|
"~/Projects/agent-scripts/skills",
|
||||||
|
"~/Projects/oss/some-skill-pack/skills"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
install: {
|
||||||
|
preferBrew: true,
|
||||||
|
nodeManager: "npm" // npm | pnpm | yarn
|
||||||
|
},
|
||||||
|
entries: {
|
||||||
|
"nano-banana-pro": {
|
||||||
|
enabled: true,
|
||||||
|
apiKey: "GEMINI_KEY_HERE",
|
||||||
|
env: {
|
||||||
|
GEMINI_API_KEY: "GEMINI_KEY_HERE"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
peekaboo: { enabled: true },
|
||||||
|
sag: { enabled: false }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Fields
|
||||||
|
|
||||||
|
- `allowBundled`: optional allowlist for **bundled** skills only. When set, only
|
||||||
|
bundled skills in the list are eligible (managed/workspace skills unaffected).
|
||||||
|
- `load.extraDirs`: additional skill directories to scan (lowest precedence).
|
||||||
|
- `install.preferBrew`: prefer brew installers when available (default: true).
|
||||||
|
- `install.nodeManager`: node installer preference (`npm` | `pnpm` | `yarn`, default: npm).
|
||||||
|
- `entries.<skillKey>`: per-skill overrides.
|
||||||
|
|
||||||
|
Per-skill fields:
|
||||||
|
- `enabled`: set `false` to disable a skill even if it’s bundled/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.
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Keys under `entries` map to the skill name by default. If a skill defines
|
||||||
|
`metadata.clawdis.skillKey`, use that key instead.
|
||||||
|
- Changes to skills are picked up on the next new session.
|
||||||
@@ -21,7 +21,8 @@ If a skill name conflicts, precedence is:
|
|||||||
|
|
||||||
`<workspace>/skills` (highest) → `~/.clawdis/skills` → bundled skills (lowest)
|
`<workspace>/skills` (highest) → `~/.clawdis/skills` → bundled skills (lowest)
|
||||||
|
|
||||||
Additionally, you can configure extra skill folders (lowest precedence) via `skillsLoad.extraDirs` in `~/.clawdis/clawdis.json`.
|
Additionally, you can configure extra skill folders (lowest precedence) via
|
||||||
|
`skills.load.extraDirs` in `~/.clawdis/clawdis.json`.
|
||||||
|
|
||||||
## Format (AgentSkills + Pi-compatible)
|
## Format (AgentSkills + Pi-compatible)
|
||||||
|
|
||||||
@@ -61,7 +62,7 @@ Fields under `metadata.clawdis`:
|
|||||||
- `requires.bins` — list; each must exist on `PATH`.
|
- `requires.bins` — list; each must exist on `PATH`.
|
||||||
- `requires.env` — list; env var must exist **or** be provided in config.
|
- `requires.env` — list; env var must exist **or** be provided in config.
|
||||||
- `requires.config` — list of `clawdis.json` paths that must be truthy.
|
- `requires.config` — list of `clawdis.json` paths that must be truthy.
|
||||||
- `primaryEnv` — env var name associated with `skills.<name>.apiKey`.
|
- `primaryEnv` — env var name associated with `skills.entries.<name>.apiKey`.
|
||||||
- `install` — optional array of installer specs used by the macOS Skills UI (brew/node/go/uv).
|
- `install` — optional array of installer specs used by the macOS Skills UI (brew/node/go/uv).
|
||||||
|
|
||||||
Installer example:
|
Installer example:
|
||||||
@@ -76,9 +77,10 @@ metadata: {"clawdis":{"emoji":"♊️","requires":{"bins":["gemini"]},"install":
|
|||||||
|
|
||||||
Notes:
|
Notes:
|
||||||
- If multiple installers are listed, the gateway picks a **single** preferred option (brew when available, otherwise node).
|
- If multiple installers are listed, the gateway picks a **single** preferred option (brew when available, otherwise node).
|
||||||
- Node installs honor `skillsInstall.nodeManager` in `clawdis.json` (default: npm; options: npm/pnpm/yarn).
|
- Node installs honor `skills.install.nodeManager` in `clawdis.json` (default: npm; options: npm/pnpm/yarn).
|
||||||
|
|
||||||
If no `metadata.clawdis` is present, the skill is always eligible (unless disabled in config).
|
If no `metadata.clawdis` is present, the skill is always eligible (unless
|
||||||
|
disabled in config or blocked by `skills.allowBundled` for bundled skills).
|
||||||
|
|
||||||
## Config overrides (`~/.clawdis/clawdis.json`)
|
## Config overrides (`~/.clawdis/clawdis.json`)
|
||||||
|
|
||||||
@@ -87,33 +89,39 @@ Bundled/managed skills can be toggled and supplied with env values:
|
|||||||
```json5
|
```json5
|
||||||
{
|
{
|
||||||
skills: {
|
skills: {
|
||||||
"nano-banana-pro": {
|
entries: {
|
||||||
enabled: true,
|
"nano-banana-pro": {
|
||||||
apiKey: "GEMINI_KEY_HERE",
|
enabled: true,
|
||||||
env: {
|
apiKey: "GEMINI_KEY_HERE",
|
||||||
GEMINI_API_KEY: "GEMINI_KEY_HERE"
|
env: {
|
||||||
}
|
GEMINI_API_KEY: "GEMINI_KEY_HERE"
|
||||||
},
|
}
|
||||||
peekaboo: { enabled: true },
|
},
|
||||||
sag: { enabled: false }
|
peekaboo: { enabled: true },
|
||||||
|
sag: { enabled: false }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Note: if the skill name contains hyphens, quote the key (JSON5 allows quoted keys).
|
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`.
|
Config keys match the **skill name** by default. If a skill defines
|
||||||
|
`metadata.clawdis.skillKey`, use that key under `skills.entries`.
|
||||||
|
|
||||||
Rules:
|
Rules:
|
||||||
- `enabled: false` disables the skill even if it’s bundled/installed.
|
- `enabled: false` disables the skill even if it’s bundled/installed.
|
||||||
- `env`: injected **only if** the variable isn’t already set in the process.
|
- `env`: injected **only if** the variable isn’t already set in the process.
|
||||||
- `apiKey`: convenience for skills that declare `metadata.clawdis.primaryEnv`.
|
- `apiKey`: convenience for skills that declare `metadata.clawdis.primaryEnv`.
|
||||||
|
- `allowBundled`: optional allowlist for **bundled** skills only. If set, only
|
||||||
|
bundled skills in the list are eligible (managed/workspace skills unaffected).
|
||||||
|
|
||||||
## Environment injection (per agent run)
|
## Environment injection (per agent run)
|
||||||
|
|
||||||
When an agent run starts, Clawdis:
|
When an agent run starts, Clawdis:
|
||||||
1) Reads skill metadata.
|
1) Reads skill metadata.
|
||||||
2) Applies any `skills.<key>.env` or `skills.<key>.apiKey` to `process.env`.
|
2) Applies any `skills.entries.<key>.env` or `skills.entries.<key>.apiKey` to
|
||||||
|
`process.env`.
|
||||||
3) Builds the system prompt with **eligible** skills.
|
3) Builds the system prompt with **eligible** skills.
|
||||||
4) Restores the original environment after the run ends.
|
4) Restores the original environment after the run ends.
|
||||||
|
|
||||||
@@ -125,7 +133,14 @@ Clawdis snapshots the eligible skills **when a session starts** and reuses that
|
|||||||
|
|
||||||
## Managed skills lifecycle
|
## Managed skills lifecycle
|
||||||
|
|
||||||
Clawdis ships a baseline set of skills as **bundled skills** as part of the install (npm package or Clawdis.app). `~/.clawdis/skills` exists for local overrides (for example, pinning/patching a skill without changing the bundled copy). Workspace skills are user-owned and override both on name conflicts.
|
Clawdis ships a baseline set of skills as **bundled skills** as part of the
|
||||||
|
install (npm package or Clawdis.app). `~/.clawdis/skills` exists for local
|
||||||
|
overrides (for example, pinning/patching a skill without changing the bundled
|
||||||
|
copy). Workspace skills are user-owned and override both on name conflicts.
|
||||||
|
|
||||||
|
## Config reference
|
||||||
|
|
||||||
|
See `docs/skills-config.md` for the full configuration schema.
|
||||||
|
|
||||||
---
|
---
|
||||||
<!-- {% endraw %} -->
|
<!-- {% endraw %} -->
|
||||||
|
|||||||
@@ -6,9 +6,11 @@ import {
|
|||||||
hasBinary,
|
hasBinary,
|
||||||
isConfigPathTruthy,
|
isConfigPathTruthy,
|
||||||
loadWorkspaceSkillEntries,
|
loadWorkspaceSkillEntries,
|
||||||
|
resolveBundledAllowlist,
|
||||||
resolveConfigPath,
|
resolveConfigPath,
|
||||||
resolveSkillConfig,
|
resolveSkillConfig,
|
||||||
resolveSkillsInstallPreferences,
|
resolveSkillsInstallPreferences,
|
||||||
|
isBundledSkillAllowed,
|
||||||
type SkillEntry,
|
type SkillEntry,
|
||||||
type SkillInstallSpec,
|
type SkillInstallSpec,
|
||||||
type SkillsInstallPreferences,
|
type SkillsInstallPreferences,
|
||||||
@@ -39,6 +41,7 @@ export type SkillStatusEntry = {
|
|||||||
homepage?: string;
|
homepage?: string;
|
||||||
always: boolean;
|
always: boolean;
|
||||||
disabled: boolean;
|
disabled: boolean;
|
||||||
|
blockedByAllowlist: boolean;
|
||||||
eligible: boolean;
|
eligible: boolean;
|
||||||
requirements: {
|
requirements: {
|
||||||
bins: string[];
|
bins: string[];
|
||||||
@@ -132,6 +135,8 @@ function buildSkillStatus(
|
|||||||
const skillKey = resolveSkillKey(entry);
|
const skillKey = resolveSkillKey(entry);
|
||||||
const skillConfig = resolveSkillConfig(config, skillKey);
|
const skillConfig = resolveSkillConfig(config, skillKey);
|
||||||
const disabled = skillConfig?.enabled === false;
|
const disabled = skillConfig?.enabled === false;
|
||||||
|
const allowBundled = resolveBundledAllowlist(config);
|
||||||
|
const blockedByAllowlist = !isBundledSkillAllowed(entry, allowBundled);
|
||||||
const always = entry.clawdis?.always === true;
|
const always = entry.clawdis?.always === true;
|
||||||
const emoji = entry.clawdis?.emoji ?? entry.frontmatter.emoji;
|
const emoji = entry.clawdis?.emoji ?? entry.frontmatter.emoji;
|
||||||
const homepageRaw =
|
const homepageRaw =
|
||||||
@@ -173,6 +178,7 @@ function buildSkillStatus(
|
|||||||
: { bins: missingBins, env: missingEnv, config: missingConfig };
|
: { bins: missingBins, env: missingEnv, config: missingConfig };
|
||||||
const eligible =
|
const eligible =
|
||||||
!disabled &&
|
!disabled &&
|
||||||
|
!blockedByAllowlist &&
|
||||||
(always ||
|
(always ||
|
||||||
(missing.bins.length === 0 &&
|
(missing.bins.length === 0 &&
|
||||||
missing.env.length === 0 &&
|
missing.env.length === 0 &&
|
||||||
@@ -190,6 +196,7 @@ function buildSkillStatus(
|
|||||||
homepage,
|
homepage,
|
||||||
always,
|
always,
|
||||||
disabled,
|
disabled,
|
||||||
|
blockedByAllowlist,
|
||||||
eligible,
|
eligible,
|
||||||
requirements: {
|
requirements: {
|
||||||
bins: requiredBins,
|
bins: requiredBins,
|
||||||
|
|||||||
@@ -102,7 +102,7 @@ describe("buildWorkspaceSkillsPrompt", () => {
|
|||||||
const prompt = buildWorkspaceSkillsPrompt(workspaceDir, {
|
const prompt = buildWorkspaceSkillsPrompt(workspaceDir, {
|
||||||
bundledSkillsDir: bundledDir,
|
bundledSkillsDir: bundledDir,
|
||||||
managedSkillsDir: managedDir,
|
managedSkillsDir: managedDir,
|
||||||
config: { skillsLoad: { extraDirs: [extraDir] } },
|
config: { skills: { load: { extraDirs: [extraDir] } } },
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(prompt).toContain("Workspace version");
|
expect(prompt).toContain("Workspace version");
|
||||||
@@ -148,13 +148,15 @@ describe("buildWorkspaceSkillsPrompt", () => {
|
|||||||
|
|
||||||
const missingPrompt = buildWorkspaceSkillsPrompt(workspaceDir, {
|
const missingPrompt = buildWorkspaceSkillsPrompt(workspaceDir, {
|
||||||
managedSkillsDir: path.join(workspaceDir, ".managed"),
|
managedSkillsDir: path.join(workspaceDir, ".managed"),
|
||||||
config: { skills: { "nano-banana-pro": { apiKey: "" } } },
|
config: { skills: { entries: { "nano-banana-pro": { apiKey: "" } } } },
|
||||||
});
|
});
|
||||||
expect(missingPrompt).not.toContain("nano-banana-pro");
|
expect(missingPrompt).not.toContain("nano-banana-pro");
|
||||||
|
|
||||||
const enabledPrompt = buildWorkspaceSkillsPrompt(workspaceDir, {
|
const enabledPrompt = buildWorkspaceSkillsPrompt(workspaceDir, {
|
||||||
managedSkillsDir: path.join(workspaceDir, ".managed"),
|
managedSkillsDir: path.join(workspaceDir, ".managed"),
|
||||||
config: { skills: { "nano-banana-pro": { apiKey: "test-key" } } },
|
config: {
|
||||||
|
skills: { entries: { "nano-banana-pro": { apiKey: "test-key" } } },
|
||||||
|
},
|
||||||
});
|
});
|
||||||
expect(enabledPrompt).toContain("nano-banana-pro");
|
expect(enabledPrompt).toContain("nano-banana-pro");
|
||||||
} finally {
|
} finally {
|
||||||
@@ -252,7 +254,7 @@ describe("buildWorkspaceSkillsPrompt", () => {
|
|||||||
managedSkillsDir: path.join(workspaceDir, ".managed"),
|
managedSkillsDir: path.join(workspaceDir, ".managed"),
|
||||||
config: {
|
config: {
|
||||||
browser: { enabled: false },
|
browser: { enabled: false },
|
||||||
skills: { "env-skill": { apiKey: "ok" } },
|
skills: { entries: { "env-skill": { apiKey: "ok" } } },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
expect(gatedPrompt).toContain("bin-skill");
|
expect(gatedPrompt).toContain("bin-skill");
|
||||||
@@ -276,10 +278,39 @@ describe("buildWorkspaceSkillsPrompt", () => {
|
|||||||
|
|
||||||
const prompt = buildWorkspaceSkillsPrompt(workspaceDir, {
|
const prompt = buildWorkspaceSkillsPrompt(workspaceDir, {
|
||||||
managedSkillsDir: path.join(workspaceDir, ".managed"),
|
managedSkillsDir: path.join(workspaceDir, ".managed"),
|
||||||
config: { skills: { alias: { enabled: false } } },
|
config: { skills: { entries: { alias: { enabled: false } } } },
|
||||||
});
|
});
|
||||||
expect(prompt).not.toContain("alias-skill");
|
expect(prompt).not.toContain("alias-skill");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("applies bundled allowlist without affecting workspace skills", async () => {
|
||||||
|
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdis-"));
|
||||||
|
const bundledDir = path.join(workspaceDir, ".bundled");
|
||||||
|
const bundledSkillDir = path.join(bundledDir, "peekaboo");
|
||||||
|
const workspaceSkillDir = path.join(workspaceDir, "skills", "demo-skill");
|
||||||
|
|
||||||
|
await writeSkill({
|
||||||
|
dir: bundledSkillDir,
|
||||||
|
name: "peekaboo",
|
||||||
|
description: "Capture UI",
|
||||||
|
body: "# Peekaboo\n",
|
||||||
|
});
|
||||||
|
await writeSkill({
|
||||||
|
dir: workspaceSkillDir,
|
||||||
|
name: "demo-skill",
|
||||||
|
description: "Workspace version",
|
||||||
|
body: "# Workspace\n",
|
||||||
|
});
|
||||||
|
|
||||||
|
const prompt = buildWorkspaceSkillsPrompt(workspaceDir, {
|
||||||
|
bundledSkillsDir: bundledDir,
|
||||||
|
managedSkillsDir: path.join(workspaceDir, ".managed"),
|
||||||
|
config: { skills: { allowBundled: ["missing-skill"] } },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(prompt).toContain("Workspace version");
|
||||||
|
expect(prompt).not.toContain("peekaboo");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("loadWorkspaceSkillEntries", () => {
|
describe("loadWorkspaceSkillEntries", () => {
|
||||||
@@ -337,6 +368,39 @@ describe("buildWorkspaceSkillStatus", () => {
|
|||||||
expect(skill?.missing.config).toContain("browser.enabled");
|
expect(skill?.missing.config).toContain("browser.enabled");
|
||||||
expect(skill?.install[0]?.id).toBe("brew");
|
expect(skill?.install[0]?.id).toBe("brew");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("marks bundled skills blocked by allowlist", async () => {
|
||||||
|
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdis-"));
|
||||||
|
const bundledDir = path.join(workspaceDir, ".bundled");
|
||||||
|
const bundledSkillDir = path.join(bundledDir, "peekaboo");
|
||||||
|
const originalBundled = process.env.CLAWDIS_BUNDLED_SKILLS_DIR;
|
||||||
|
|
||||||
|
await writeSkill({
|
||||||
|
dir: bundledSkillDir,
|
||||||
|
name: "peekaboo",
|
||||||
|
description: "Capture UI",
|
||||||
|
body: "# Peekaboo\n",
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
process.env.CLAWDIS_BUNDLED_SKILLS_DIR = bundledDir;
|
||||||
|
const report = buildWorkspaceSkillStatus(workspaceDir, {
|
||||||
|
managedSkillsDir: path.join(workspaceDir, ".managed"),
|
||||||
|
config: { skills: { allowBundled: ["other-skill"] } },
|
||||||
|
});
|
||||||
|
const skill = report.skills.find((entry) => entry.name === "peekaboo");
|
||||||
|
|
||||||
|
expect(skill).toBeDefined();
|
||||||
|
expect(skill?.blockedByAllowlist).toBe(true);
|
||||||
|
expect(skill?.eligible).toBe(false);
|
||||||
|
} finally {
|
||||||
|
if (originalBundled === undefined) {
|
||||||
|
delete process.env.CLAWDIS_BUNDLED_SKILLS_DIR;
|
||||||
|
} else {
|
||||||
|
process.env.CLAWDIS_BUNDLED_SKILLS_DIR = originalBundled;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("applySkillEnvOverrides", () => {
|
describe("applySkillEnvOverrides", () => {
|
||||||
@@ -360,7 +424,7 @@ describe("applySkillEnvOverrides", () => {
|
|||||||
|
|
||||||
const restore = applySkillEnvOverrides({
|
const restore = applySkillEnvOverrides({
|
||||||
skills: entries,
|
skills: entries,
|
||||||
config: { skills: { "env-skill": { apiKey: "injected" } } },
|
config: { skills: { entries: { "env-skill": { apiKey: "injected" } } } },
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -388,7 +452,7 @@ describe("applySkillEnvOverrides", () => {
|
|||||||
|
|
||||||
const snapshot = buildWorkspaceSkillSnapshot(workspaceDir, {
|
const snapshot = buildWorkspaceSkillSnapshot(workspaceDir, {
|
||||||
managedSkillsDir: path.join(workspaceDir, ".managed"),
|
managedSkillsDir: path.join(workspaceDir, ".managed"),
|
||||||
config: { skills: { "env-skill": { apiKey: "snap-key" } } },
|
config: { skills: { entries: { "env-skill": { apiKey: "snap-key" } } } },
|
||||||
});
|
});
|
||||||
|
|
||||||
const originalEnv = process.env.ENV_KEY;
|
const originalEnv = process.env.ENV_KEY;
|
||||||
@@ -396,7 +460,7 @@ describe("applySkillEnvOverrides", () => {
|
|||||||
|
|
||||||
const restore = applySkillEnvOverridesFromSnapshot({
|
const restore = applySkillEnvOverridesFromSnapshot({
|
||||||
snapshot,
|
snapshot,
|
||||||
config: { skills: { "env-skill": { apiKey: "snap-key" } } },
|
config: { skills: { entries: { "env-skill": { apiKey: "snap-key" } } } },
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -173,7 +173,7 @@ const DEFAULT_CONFIG_VALUES: Record<string, boolean> = {
|
|||||||
export function resolveSkillsInstallPreferences(
|
export function resolveSkillsInstallPreferences(
|
||||||
config?: ClawdisConfig,
|
config?: ClawdisConfig,
|
||||||
): SkillsInstallPreferences {
|
): SkillsInstallPreferences {
|
||||||
const raw = config?.skillsInstall;
|
const raw = config?.skills?.install;
|
||||||
const preferBrew = raw?.preferBrew ?? true;
|
const preferBrew = raw?.preferBrew ?? true;
|
||||||
const managerRaw =
|
const managerRaw =
|
||||||
typeof raw?.nodeManager === "string" ? raw.nodeManager.trim() : "";
|
typeof raw?.nodeManager === "string" ? raw.nodeManager.trim() : "";
|
||||||
@@ -213,13 +213,36 @@ export function resolveSkillConfig(
|
|||||||
config: ClawdisConfig | undefined,
|
config: ClawdisConfig | undefined,
|
||||||
skillKey: string,
|
skillKey: string,
|
||||||
): SkillConfig | undefined {
|
): SkillConfig | undefined {
|
||||||
const skills = config?.skills;
|
const skills = config?.skills?.entries;
|
||||||
if (!skills || typeof skills !== "object") return undefined;
|
if (!skills || typeof skills !== "object") return undefined;
|
||||||
const entry = (skills as Record<string, SkillConfig | undefined>)[skillKey];
|
const entry = (skills as Record<string, SkillConfig | undefined>)[skillKey];
|
||||||
if (!entry || typeof entry !== "object") return undefined;
|
if (!entry || typeof entry !== "object") return undefined;
|
||||||
return entry;
|
return entry;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeAllowlist(input: unknown): string[] | undefined {
|
||||||
|
if (!input) return undefined;
|
||||||
|
if (!Array.isArray(input)) return undefined;
|
||||||
|
const normalized = input
|
||||||
|
.map((entry) => String(entry).trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
return normalized.length > 0 ? normalized : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isBundledSkill(entry: SkillEntry): boolean {
|
||||||
|
return entry.skill.source === "clawdis-bundled";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isBundledSkillAllowed(
|
||||||
|
entry: SkillEntry,
|
||||||
|
allowlist?: string[],
|
||||||
|
): boolean {
|
||||||
|
if (!allowlist || allowlist.length === 0) return true;
|
||||||
|
if (!isBundledSkill(entry)) return true;
|
||||||
|
const key = resolveSkillKey(entry.skill, entry);
|
||||||
|
return allowlist.includes(key) || allowlist.includes(entry.skill.name);
|
||||||
|
}
|
||||||
|
|
||||||
export function hasBinary(bin: string): boolean {
|
export function hasBinary(bin: string): boolean {
|
||||||
const pathEnv = process.env.PATH ?? "";
|
const pathEnv = process.env.PATH ?? "";
|
||||||
const parts = pathEnv.split(path.delimiter).filter(Boolean);
|
const parts = pathEnv.split(path.delimiter).filter(Boolean);
|
||||||
@@ -298,8 +321,10 @@ function shouldIncludeSkill(params: {
|
|||||||
const { entry, config } = params;
|
const { entry, config } = params;
|
||||||
const skillKey = resolveSkillKey(entry.skill, entry);
|
const skillKey = resolveSkillKey(entry.skill, entry);
|
||||||
const skillConfig = resolveSkillConfig(config, skillKey);
|
const skillConfig = resolveSkillConfig(config, skillKey);
|
||||||
|
const allowBundled = normalizeAllowlist(config?.skills?.allowBundled);
|
||||||
|
|
||||||
if (skillConfig?.enabled === false) return false;
|
if (skillConfig?.enabled === false) return false;
|
||||||
|
if (!isBundledSkillAllowed(entry, allowBundled)) return false;
|
||||||
if (entry.clawdis?.always === true) {
|
if (entry.clawdis?.always === true) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -442,7 +467,7 @@ function loadSkillEntries(
|
|||||||
opts?.managedSkillsDir ?? path.join(CONFIG_DIR, "skills");
|
opts?.managedSkillsDir ?? path.join(CONFIG_DIR, "skills");
|
||||||
const workspaceSkillsDir = path.join(workspaceDir, "skills");
|
const workspaceSkillsDir = path.join(workspaceDir, "skills");
|
||||||
const bundledSkillsDir = opts?.bundledSkillsDir ?? resolveBundledSkillsDir();
|
const bundledSkillsDir = opts?.bundledSkillsDir ?? resolveBundledSkillsDir();
|
||||||
const extraDirsRaw = opts?.config?.skillsLoad?.extraDirs ?? [];
|
const extraDirsRaw = opts?.config?.skills?.load?.extraDirs ?? [];
|
||||||
const extraDirs = extraDirsRaw
|
const extraDirs = extraDirsRaw
|
||||||
.map((d) => (typeof d === "string" ? d.trim() : ""))
|
.map((d) => (typeof d === "string" ? d.trim() : ""))
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
@@ -548,3 +573,8 @@ export function filterWorkspaceSkillEntries(
|
|||||||
): SkillEntry[] {
|
): SkillEntry[] {
|
||||||
return filterSkillEntries(entries, config);
|
return filterSkillEntries(entries, config);
|
||||||
}
|
}
|
||||||
|
export function resolveBundledAllowlist(
|
||||||
|
config?: ClawdisConfig,
|
||||||
|
): string[] | undefined {
|
||||||
|
return normalizeAllowlist(config?.skills?.allowBundled);
|
||||||
|
}
|
||||||
|
|||||||
@@ -307,6 +307,14 @@ export type SkillsInstallConfig = {
|
|||||||
nodeManager?: "npm" | "pnpm" | "yarn";
|
nodeManager?: "npm" | "pnpm" | "yarn";
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type SkillsConfig = {
|
||||||
|
/** Optional bundled-skill allowlist (only affects bundled skills). */
|
||||||
|
allowBundled?: string[];
|
||||||
|
load?: SkillsLoadConfig;
|
||||||
|
install?: SkillsInstallConfig;
|
||||||
|
entries?: Record<string, SkillConfig>;
|
||||||
|
};
|
||||||
|
|
||||||
export type ModelApi =
|
export type ModelApi =
|
||||||
| "openai-completions"
|
| "openai-completions"
|
||||||
| "openai-responses"
|
| "openai-responses"
|
||||||
@@ -364,8 +372,7 @@ export type ClawdisConfig = {
|
|||||||
/** Accent color for Clawdis UI chrome (hex). */
|
/** Accent color for Clawdis UI chrome (hex). */
|
||||||
seamColor?: string;
|
seamColor?: string;
|
||||||
};
|
};
|
||||||
skillsLoad?: SkillsLoadConfig;
|
skills?: SkillsConfig;
|
||||||
skillsInstall?: SkillsInstallConfig;
|
|
||||||
models?: ModelsConfig;
|
models?: ModelsConfig;
|
||||||
agent?: {
|
agent?: {
|
||||||
/** Model id (provider/model), e.g. "anthropic/claude-opus-4-5". */
|
/** Model id (provider/model), e.g. "anthropic/claude-opus-4-5". */
|
||||||
@@ -424,7 +431,7 @@ export type ClawdisConfig = {
|
|||||||
canvasHost?: CanvasHostConfig;
|
canvasHost?: CanvasHostConfig;
|
||||||
talk?: TalkConfig;
|
talk?: TalkConfig;
|
||||||
gateway?: GatewayConfig;
|
gateway?: GatewayConfig;
|
||||||
skills?: Record<string, SkillConfig>;
|
skills?: SkillsConfig;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -880,30 +887,33 @@ const ClawdisSchema = z.object({
|
|||||||
.optional(),
|
.optional(),
|
||||||
})
|
})
|
||||||
.optional(),
|
.optional(),
|
||||||
skillsLoad: z
|
|
||||||
.object({
|
|
||||||
extraDirs: z.array(z.string()).optional(),
|
|
||||||
})
|
|
||||||
.optional(),
|
|
||||||
skillsInstall: z
|
|
||||||
.object({
|
|
||||||
preferBrew: z.boolean().optional(),
|
|
||||||
nodeManager: z
|
|
||||||
.union([z.literal("npm"), z.literal("pnpm"), z.literal("yarn")])
|
|
||||||
.optional(),
|
|
||||||
})
|
|
||||||
.optional(),
|
|
||||||
skills: z
|
skills: z
|
||||||
.record(
|
.object({
|
||||||
z.string(),
|
allowBundled: z.array(z.string()).optional(),
|
||||||
z
|
load: z
|
||||||
.object({
|
.object({
|
||||||
enabled: z.boolean().optional(),
|
extraDirs: z.array(z.string()).optional(),
|
||||||
apiKey: z.string().optional(),
|
|
||||||
env: z.record(z.string(), z.string()).optional(),
|
|
||||||
})
|
})
|
||||||
.passthrough(),
|
.optional(),
|
||||||
)
|
install: z
|
||||||
|
.object({
|
||||||
|
preferBrew: z.boolean().optional(),
|
||||||
|
nodeManager: z
|
||||||
|
.union([z.literal("npm"), z.literal("pnpm"), z.literal("yarn")])
|
||||||
|
.optional(),
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
|
entries: z.record(
|
||||||
|
z.string(),
|
||||||
|
z
|
||||||
|
.object({
|
||||||
|
enabled: z.boolean().optional(),
|
||||||
|
apiKey: z.string().optional(),
|
||||||
|
env: z.record(z.string(), z.string()).optional(),
|
||||||
|
})
|
||||||
|
.passthrough(),
|
||||||
|
).optional(),
|
||||||
|
})
|
||||||
.optional(),
|
.optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -4821,8 +4821,9 @@ export async function startGatewayServer(
|
|||||||
};
|
};
|
||||||
const cfg = loadConfig();
|
const cfg = loadConfig();
|
||||||
const skills = cfg.skills ? { ...cfg.skills } : {};
|
const skills = cfg.skills ? { ...cfg.skills } : {};
|
||||||
const current = skills[p.skillKey]
|
const entries = skills.entries ? { ...skills.entries } : {};
|
||||||
? { ...skills[p.skillKey] }
|
const current = entries[p.skillKey]
|
||||||
|
? { ...entries[p.skillKey] }
|
||||||
: {};
|
: {};
|
||||||
if (typeof p.enabled === "boolean") {
|
if (typeof p.enabled === "boolean") {
|
||||||
current.enabled = p.enabled;
|
current.enabled = p.enabled;
|
||||||
@@ -4843,7 +4844,8 @@ export async function startGatewayServer(
|
|||||||
}
|
}
|
||||||
current.env = nextEnv;
|
current.env = nextEnv;
|
||||||
}
|
}
|
||||||
skills[p.skillKey] = current;
|
entries[p.skillKey] = current;
|
||||||
|
skills.entries = entries;
|
||||||
const nextConfig: ClawdisConfig = {
|
const nextConfig: ClawdisConfig = {
|
||||||
...cfg,
|
...cfg,
|
||||||
skills,
|
skills,
|
||||||
|
|||||||
Reference in New Issue
Block a user