feat: add skills search and website

This commit is contained in:
Peter Steinberger
2025-12-20 17:31:09 +01:00
parent c4a67b7d02
commit ba0791b896
9 changed files with 125 additions and 23 deletions

View File

@@ -16,6 +16,7 @@ struct SkillStatus: Codable, Identifiable {
let skillKey: String
let primaryEnv: String?
let emoji: String?
let homepage: String?
let always: Bool
let disabled: Bool
let eligible: Bool

View File

@@ -5,11 +5,14 @@ import SwiftUI
struct SkillsSettings: View {
@State private var model = SkillsSettingsModel()
@State private var envEditor: EnvEditorState?
@State private var searchQuery = ""
@State private var filter: SkillsFilter = .all
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 14) {
self.header
self.filterBar
self.statusBanner
self.skillsList
Spacer(minLength: 0)
@@ -62,7 +65,7 @@ struct SkillsSettings: View {
private var skillsList: some View {
VStack(spacing: 10) {
ForEach(self.model.skills) { skill in
ForEach(self.filteredSkills) { skill in
SkillRow(
skill: skill,
isBusy: self.model.isBusy(skill: skill),
@@ -80,6 +83,73 @@ struct SkillsSettings: View {
isPrimary: isPrimary)
})
}
if !self.model.skills.isEmpty && self.filteredSkills.isEmpty {
Text("No skills match this filter.")
.font(.callout)
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.top, 4)
}
}
}
private var filterBar: some View {
VStack(alignment: .leading, spacing: 10) {
TextField("Search skills", text: self.$searchQuery)
.textFieldStyle(.roundedBorder)
.frame(maxWidth: 320)
Picker("Filter", selection: self.$filter) {
ForEach(SkillsFilter.allCases) { filter in
Text(filter.title)
.tag(filter)
}
}
.pickerStyle(.segmented)
.frame(maxWidth: 420)
}
}
private var filteredSkills: [SkillStatus] {
let trimmed = self.searchQuery.trimmingCharacters(in: .whitespacesAndNewlines)
let query = trimmed.lowercased()
return self.model.skills.filter { skill in
if !query.isEmpty {
let matchesName = skill.name.lowercased().contains(query)
let matchesDescription = skill.description.lowercased().contains(query)
if !(matchesName || matchesDescription) { return false }
}
switch self.filter {
case .all:
return true
case .ready:
return !skill.disabled && skill.eligible
case .needsSetup:
return !skill.disabled && !skill.eligible
case .disabled:
return skill.disabled
}
}
}
}
private enum SkillsFilter: String, CaseIterable, Identifiable {
case all
case ready
case needsSetup
case disabled
var id: String { self.rawValue }
var title: String {
switch self {
case .all:
return "All"
case .ready:
return "Ready"
case .needsSetup:
return "Needs Setup"
case .disabled:
return "Disabled"
}
}
}
@@ -171,6 +241,13 @@ private struct SkillRow: View {
private var metaRow: some View {
HStack(spacing: 10) {
SkillTag(text: self.sourceLabel)
if let url = self.homepageUrl {
Link(destination: url) {
Label("Website", systemImage: "link")
.font(.caption2.weight(.semibold))
}
.buttonStyle(.link)
}
HStack(spacing: 6) {
Text(self.enabledLabel)
.font(.caption)
@@ -188,6 +265,14 @@ private struct SkillRow: View {
self.skill.disabled ? "Disabled" : "Enabled"
}
private var homepageUrl: URL? {
guard let raw = self.skill.homepage?.trimmingCharacters(in: .whitespacesAndNewlines) else {
return nil
}
guard !raw.isEmpty else { return nil }
return URL(string: raw)
}
private var enabledBinding: Binding<Bool> {
Binding(
get: { !self.skill.disabled },

View File

@@ -160,14 +160,14 @@ Example:
### `skillsInstall` (installer preference)
Controls which installer is surfaced by the macOS Skills UI when a skill offers
multiple install options (brew vs node). Defaults to **brew when available** and
**npm** for node installs.
multiple install options. Defaults to **brew when available** and **npm** for
node installs.
```json5
{
skillsInstall: {
preferBrew: true,
nodeManager: "npm" // npm | pnpm | bun
nodeManager: "npm" // npm | pnpm | yarn
}
}
```
@@ -231,32 +231,29 @@ Defaults:
Notes:
- `clawdis gateway` refuses to start unless `gateway.mode` is set to `local` (or you pass the override flag).
### `canvasHost` (Gateway Canvas file server + live reload)
### `canvasHost` (LAN/tailnet Canvas file server + live reload)
The Gateway serves a directory of HTML/CSS/JS over HTTP so iOS/Android nodes can `canvas.navigate` to it.
The Gateway serves a directory of HTML/CSS/JS over HTTP so iOS/Android nodes can simply `canvas.navigate` to it.
Default root: `~/clawd/canvas`
Port: **same as the Gateway WebSocket/HTTP port** (default `18789`)
Path: `/__clawdis__/canvas/`
Live-reload WebSocket: `/__clawdis/ws`
Default port: `18793` (chosen to avoid the clawd browser CDP port `18792`)
The server listens on `0.0.0.0` so it works on LAN **and** Tailnet (Tailscale is optional).
The server:
- serves files under `canvasHost.root`
- injects a tiny live-reload client into served HTML
- watches the directory and broadcasts reloads over `/__clawdis/ws`
- watches the directory and broadcasts reloads over a WebSocket endpoint at `/__clawdis/ws`
- auto-creates a starter `index.html` when the directory is empty (so you see something immediately)
```json5
{
canvasHost: {
root: "~/clawd/canvas"
root: "~/clawd/canvas",
port: 18793
}
}
```
Notes:
- The bind host follows `gateway.bind` (loopback/lan/tailnet).
Disable with:
- config: `canvasHost: { enabled: false }`
- env: `CLAWDIS_SKIP_CANVAS_HOST=1`

View File

@@ -15,7 +15,7 @@ The macOS app surfaces Clawdis skills via the gateway; it does not parse skills
## Install actions
- `metadata.clawdis.install` defines install options (brew/node/go/pnpm/shell).
- 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`).
- The gateway surfaces only one preferred installer when multiple are provided (brew when available, otherwise node manager from `skillsInstall`, default npm).
## Env/API keys
- The app stores keys in `~/.clawdis/clawdis.json` under `skills.<skillKey>`.

View File

@@ -39,6 +39,8 @@ Notes:
- 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.
- Optional frontmatter keys:
- `homepage` — URL surfaced as “Website” in the macOS Skills UI (also supported via `metadata.clawdis.homepage`).
## Gating (load-time filters)
@@ -55,6 +57,7 @@ metadata: {"clawdis":{"requires":{"bins":["uv"],"env":["GEMINI_API_KEY"],"config
Fields under `metadata.clawdis`:
- `always: true` — always include the skill (skip other gates).
- `emoji` — optional emoji used by the macOS Skills UI.
- `homepage` — optional URL shown as “Website” in the macOS Skills UI.
- `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.
@@ -73,7 +76,7 @@ metadata: {"clawdis":{"emoji":"♊️","requires":{"bins":["gemini"]},"install":
Notes:
- 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).
- Node installs honor `skillsInstall.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).

View File

@@ -51,8 +51,8 @@ function buildNodeInstallCommand(
switch (prefs.nodeManager) {
case "pnpm":
return ["pnpm", "add", "-g", packageName];
case "bun":
return ["bun", "add", "-g", packageName];
case "yarn":
return ["yarn", "global", "add", packageName];
default:
return ["npm", "install", "-g", packageName];
}

View File

@@ -36,6 +36,7 @@ export type SkillStatusEntry = {
skillKey: string;
primaryEnv?: string;
emoji?: string;
homepage?: string;
always: boolean;
disabled: boolean;
eligible: boolean;
@@ -135,6 +136,12 @@ function buildSkillStatus(
const disabled = skillConfig?.enabled === false;
const always = entry.clawdis?.always === true;
const emoji = entry.clawdis?.emoji ?? entry.frontmatter.emoji;
const homepageRaw =
entry.clawdis?.homepage ??
entry.frontmatter.homepage ??
entry.frontmatter.website ??
entry.frontmatter.url;
const homepage = homepageRaw?.trim() ? homepageRaw.trim() : undefined;
const requiredBins = entry.clawdis?.requires?.bins ?? [];
const requiredEnv = entry.clawdis?.requires?.env ?? [];
@@ -182,6 +189,7 @@ function buildSkillStatus(
skillKey,
primaryEnv: entry.clawdis?.primaryEnv,
emoji,
homepage,
always,
disabled,
eligible,

View File

@@ -29,6 +29,7 @@ export type ClawdisSkillMetadata = {
skillKey?: string;
primaryEnv?: string;
emoji?: string;
homepage?: string;
requires?: {
bins?: string[];
env?: string[];
@@ -39,7 +40,7 @@ export type ClawdisSkillMetadata = {
export type SkillsInstallPreferences = {
preferBrew: boolean;
nodeManager: "npm" | "pnpm" | "bun";
nodeManager: "npm" | "pnpm" | "yarn";
};
type ParsedSkillFrontmatter = Record<string, string>;
@@ -189,7 +190,7 @@ export function resolveSkillsInstallPreferences(
typeof raw?.nodeManager === "string" ? raw.nodeManager.trim() : "";
const manager = managerRaw.toLowerCase();
const nodeManager =
manager === "pnpm" || manager === "bun" || manager === "npm"
manager === "pnpm" || manager === "yarn" || manager === "npm"
? (manager as SkillsInstallPreferences["nodeManager"])
: "npm";
return { preferBrew, nodeManager };
@@ -271,6 +272,10 @@ function resolveClawdisMetadata(
typeof clawdisObj.always === "boolean" ? clawdisObj.always : undefined,
emoji:
typeof clawdisObj.emoji === "string" ? clawdisObj.emoji : undefined,
homepage:
typeof clawdisObj.homepage === "string"
? clawdisObj.homepage
: undefined,
skillKey:
typeof clawdisObj.skillKey === "string"
? clawdisObj.skillKey

View File

@@ -97,6 +97,8 @@ export type CanvasHostConfig = {
enabled?: boolean;
/** Directory to serve (default: ~/clawd/canvas). */
root?: string;
/** HTTP port to listen on (default: 18793). */
port?: number;
};
export type GatewayControlUiConfig = {
@@ -135,7 +137,7 @@ export type SkillsLoadConfig = {
export type SkillsInstallConfig = {
preferBrew?: boolean;
nodeManager?: "npm" | "pnpm" | "bun";
nodeManager?: "npm" | "pnpm" | "yarn";
};
export type ClawdisConfig = {
@@ -147,6 +149,7 @@ export type ClawdisConfig = {
logging?: LoggingConfig;
browser?: BrowserConfig;
skillsLoad?: SkillsLoadConfig;
skillsInstall?: SkillsInstallConfig;
inbound?: {
allowFrom?: string[]; // E.164 numbers allowed to trigger auto-reply (without whatsapp:)
/** Agent working directory (preferred). Used as the default cwd for agent runs. */
@@ -188,7 +191,6 @@ export type ClawdisConfig = {
canvasHost?: CanvasHostConfig;
gateway?: GatewayConfig;
skills?: Record<string, SkillConfig>;
skillsInstall?: SkillsInstallConfig;
};
// New branding path (preferred)
@@ -349,6 +351,7 @@ const ClawdisSchema = z.object({
.object({
enabled: z.boolean().optional(),
root: z.string().optional(),
port: z.number().int().positive().optional(),
})
.optional(),
gateway: z
@@ -378,7 +381,7 @@ const ClawdisSchema = z.object({
.object({
preferBrew: z.boolean().optional(),
nodeManager: z
.union([z.literal("npm"), z.literal("pnpm"), z.literal("bun")])
.union([z.literal("npm"), z.literal("pnpm"), z.literal("yarn")])
.optional(),
})
.optional(),