feat: add skills search and website
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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>`.
|
||||
|
||||
@@ -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).
|
||||
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user