diff --git a/apps/macos/Sources/Clawdis/SkillsModels.swift b/apps/macos/Sources/Clawdis/SkillsModels.swift index 6f07cffa1..7c510c4e4 100644 --- a/apps/macos/Sources/Clawdis/SkillsModels.swift +++ b/apps/macos/Sources/Clawdis/SkillsModels.swift @@ -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 diff --git a/apps/macos/Sources/Clawdis/SkillsSettings.swift b/apps/macos/Sources/Clawdis/SkillsSettings.swift index 5472b831b..6cb332d81 100644 --- a/apps/macos/Sources/Clawdis/SkillsSettings.swift +++ b/apps/macos/Sources/Clawdis/SkillsSettings.swift @@ -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 { Binding( get: { !self.skill.disabled }, diff --git a/docs/configuration.md b/docs/configuration.md index 6b90c083a..15b2e1eb1 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -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` diff --git a/docs/mac/skills.md b/docs/mac/skills.md index 968205ae0..ee117716e 100644 --- a/docs/mac/skills.md +++ b/docs/mac/skills.md @@ -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.`. diff --git a/docs/skills.md b/docs/skills.md index 761b514df..d3346fa30 100644 --- a/docs/skills.md +++ b/docs/skills.md @@ -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). diff --git a/src/agents/skills-install.ts b/src/agents/skills-install.ts index c44c3bf4d..c69944d12 100644 --- a/src/agents/skills-install.ts +++ b/src/agents/skills-install.ts @@ -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]; } diff --git a/src/agents/skills-status.ts b/src/agents/skills-status.ts index c78191a8c..e11feae29 100644 --- a/src/agents/skills-status.ts +++ b/src/agents/skills-status.ts @@ -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, diff --git a/src/agents/skills.ts b/src/agents/skills.ts index 5c6e0b2d9..647a2afea 100644 --- a/src/agents/skills.ts +++ b/src/agents/skills.ts @@ -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; @@ -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 diff --git a/src/config/config.ts b/src/config/config.ts index e3accd805..302806c68 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -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; - 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(),