feat: add skills search and website
This commit is contained in:
@@ -16,6 +16,7 @@ struct SkillStatus: Codable, Identifiable {
|
|||||||
let skillKey: String
|
let skillKey: String
|
||||||
let primaryEnv: String?
|
let primaryEnv: String?
|
||||||
let emoji: String?
|
let emoji: String?
|
||||||
|
let homepage: String?
|
||||||
let always: Bool
|
let always: Bool
|
||||||
let disabled: Bool
|
let disabled: Bool
|
||||||
let eligible: Bool
|
let eligible: Bool
|
||||||
|
|||||||
@@ -5,11 +5,14 @@ import SwiftUI
|
|||||||
struct SkillsSettings: View {
|
struct SkillsSettings: View {
|
||||||
@State private var model = SkillsSettingsModel()
|
@State private var model = SkillsSettingsModel()
|
||||||
@State private var envEditor: EnvEditorState?
|
@State private var envEditor: EnvEditorState?
|
||||||
|
@State private var searchQuery = ""
|
||||||
|
@State private var filter: SkillsFilter = .all
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ScrollView {
|
ScrollView {
|
||||||
VStack(alignment: .leading, spacing: 14) {
|
VStack(alignment: .leading, spacing: 14) {
|
||||||
self.header
|
self.header
|
||||||
|
self.filterBar
|
||||||
self.statusBanner
|
self.statusBanner
|
||||||
self.skillsList
|
self.skillsList
|
||||||
Spacer(minLength: 0)
|
Spacer(minLength: 0)
|
||||||
@@ -62,7 +65,7 @@ struct SkillsSettings: View {
|
|||||||
|
|
||||||
private var skillsList: some View {
|
private var skillsList: some View {
|
||||||
VStack(spacing: 10) {
|
VStack(spacing: 10) {
|
||||||
ForEach(self.model.skills) { skill in
|
ForEach(self.filteredSkills) { skill in
|
||||||
SkillRow(
|
SkillRow(
|
||||||
skill: skill,
|
skill: skill,
|
||||||
isBusy: self.model.isBusy(skill: skill),
|
isBusy: self.model.isBusy(skill: skill),
|
||||||
@@ -80,6 +83,73 @@ struct SkillsSettings: View {
|
|||||||
isPrimary: isPrimary)
|
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 {
|
private var metaRow: some View {
|
||||||
HStack(spacing: 10) {
|
HStack(spacing: 10) {
|
||||||
SkillTag(text: self.sourceLabel)
|
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) {
|
HStack(spacing: 6) {
|
||||||
Text(self.enabledLabel)
|
Text(self.enabledLabel)
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
@@ -188,6 +265,14 @@ private struct SkillRow: View {
|
|||||||
self.skill.disabled ? "Disabled" : "Enabled"
|
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> {
|
private var enabledBinding: Binding<Bool> {
|
||||||
Binding(
|
Binding(
|
||||||
get: { !self.skill.disabled },
|
get: { !self.skill.disabled },
|
||||||
|
|||||||
@@ -160,14 +160,14 @@ Example:
|
|||||||
### `skillsInstall` (installer preference)
|
### `skillsInstall` (installer preference)
|
||||||
|
|
||||||
Controls which installer is surfaced by the macOS Skills UI when a skill offers
|
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
|
multiple install options. Defaults to **brew when available** and **npm** for
|
||||||
**npm** for node installs.
|
node installs.
|
||||||
|
|
||||||
```json5
|
```json5
|
||||||
{
|
{
|
||||||
skillsInstall: {
|
skillsInstall: {
|
||||||
preferBrew: true,
|
preferBrew: true,
|
||||||
nodeManager: "npm" // npm | pnpm | bun
|
nodeManager: "npm" // npm | pnpm | yarn
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@@ -231,32 +231,29 @@ Defaults:
|
|||||||
Notes:
|
Notes:
|
||||||
- `clawdis gateway` refuses to start unless `gateway.mode` is set to `local` (or you pass the override flag).
|
- `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`
|
Default root: `~/clawd/canvas`
|
||||||
Port: **same as the Gateway WebSocket/HTTP port** (default `18789`)
|
Default port: `18793` (chosen to avoid the clawd browser CDP port `18792`)
|
||||||
Path: `/__clawdis__/canvas/`
|
The server listens on `0.0.0.0` so it works on LAN **and** Tailnet (Tailscale is optional).
|
||||||
Live-reload WebSocket: `/__clawdis/ws`
|
|
||||||
|
|
||||||
The server:
|
The server:
|
||||||
- serves files under `canvasHost.root`
|
- serves files under `canvasHost.root`
|
||||||
- injects a tiny live-reload client into served HTML
|
- 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)
|
- auto-creates a starter `index.html` when the directory is empty (so you see something immediately)
|
||||||
|
|
||||||
```json5
|
```json5
|
||||||
{
|
{
|
||||||
canvasHost: {
|
canvasHost: {
|
||||||
root: "~/clawd/canvas"
|
root: "~/clawd/canvas",
|
||||||
|
port: 18793
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Notes:
|
|
||||||
- The bind host follows `gateway.bind` (loopback/lan/tailnet).
|
|
||||||
|
|
||||||
Disable with:
|
Disable with:
|
||||||
- config: `canvasHost: { enabled: false }`
|
- config: `canvasHost: { enabled: false }`
|
||||||
- env: `CLAWDIS_SKIP_CANVAS_HOST=1`
|
- 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
|
## Install actions
|
||||||
- `metadata.clawdis.install` defines install options (brew/node/go/pnpm/shell).
|
- `metadata.clawdis.install` defines install options (brew/node/go/pnpm/shell).
|
||||||
- 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`).
|
- The gateway surfaces only one preferred installer when multiple are provided (brew when available, otherwise node manager from `skillsInstall`, 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.<skillKey>`.
|
||||||
|
|||||||
@@ -39,6 +39,8 @@ Notes:
|
|||||||
- The parser used by the embedded agent supports **single-line** frontmatter keys only.
|
- The parser used by the embedded agent supports **single-line** frontmatter keys only.
|
||||||
- `metadata` should be a **single-line JSON object**.
|
- `metadata` should be a **single-line JSON object**.
|
||||||
- Use `{baseDir}` in instructions to reference the skill folder path.
|
- 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)
|
## Gating (load-time filters)
|
||||||
|
|
||||||
@@ -55,6 +57,7 @@ metadata: {"clawdis":{"requires":{"bins":["uv"],"env":["GEMINI_API_KEY"],"config
|
|||||||
Fields under `metadata.clawdis`:
|
Fields under `metadata.clawdis`:
|
||||||
- `always: true` — always include the skill (skip other gates).
|
- `always: true` — always include the skill (skip other gates).
|
||||||
- `emoji` — optional emoji used by the macOS Skills UI.
|
- `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.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.
|
||||||
@@ -73,7 +76,7 @@ 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).
|
- 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).
|
If no `metadata.clawdis` is present, the skill is always eligible (unless disabled in config).
|
||||||
|
|
||||||
|
|||||||
@@ -51,8 +51,8 @@ function buildNodeInstallCommand(
|
|||||||
switch (prefs.nodeManager) {
|
switch (prefs.nodeManager) {
|
||||||
case "pnpm":
|
case "pnpm":
|
||||||
return ["pnpm", "add", "-g", packageName];
|
return ["pnpm", "add", "-g", packageName];
|
||||||
case "bun":
|
case "yarn":
|
||||||
return ["bun", "add", "-g", packageName];
|
return ["yarn", "global", "add", packageName];
|
||||||
default:
|
default:
|
||||||
return ["npm", "install", "-g", packageName];
|
return ["npm", "install", "-g", packageName];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ export type SkillStatusEntry = {
|
|||||||
skillKey: string;
|
skillKey: string;
|
||||||
primaryEnv?: string;
|
primaryEnv?: string;
|
||||||
emoji?: string;
|
emoji?: string;
|
||||||
|
homepage?: string;
|
||||||
always: boolean;
|
always: boolean;
|
||||||
disabled: boolean;
|
disabled: boolean;
|
||||||
eligible: boolean;
|
eligible: boolean;
|
||||||
@@ -135,6 +136,12 @@ function buildSkillStatus(
|
|||||||
const disabled = skillConfig?.enabled === false;
|
const disabled = skillConfig?.enabled === false;
|
||||||
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 =
|
||||||
|
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 requiredBins = entry.clawdis?.requires?.bins ?? [];
|
||||||
const requiredEnv = entry.clawdis?.requires?.env ?? [];
|
const requiredEnv = entry.clawdis?.requires?.env ?? [];
|
||||||
@@ -182,6 +189,7 @@ function buildSkillStatus(
|
|||||||
skillKey,
|
skillKey,
|
||||||
primaryEnv: entry.clawdis?.primaryEnv,
|
primaryEnv: entry.clawdis?.primaryEnv,
|
||||||
emoji,
|
emoji,
|
||||||
|
homepage,
|
||||||
always,
|
always,
|
||||||
disabled,
|
disabled,
|
||||||
eligible,
|
eligible,
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ export type ClawdisSkillMetadata = {
|
|||||||
skillKey?: string;
|
skillKey?: string;
|
||||||
primaryEnv?: string;
|
primaryEnv?: string;
|
||||||
emoji?: string;
|
emoji?: string;
|
||||||
|
homepage?: string;
|
||||||
requires?: {
|
requires?: {
|
||||||
bins?: string[];
|
bins?: string[];
|
||||||
env?: string[];
|
env?: string[];
|
||||||
@@ -39,7 +40,7 @@ export type ClawdisSkillMetadata = {
|
|||||||
|
|
||||||
export type SkillsInstallPreferences = {
|
export type SkillsInstallPreferences = {
|
||||||
preferBrew: boolean;
|
preferBrew: boolean;
|
||||||
nodeManager: "npm" | "pnpm" | "bun";
|
nodeManager: "npm" | "pnpm" | "yarn";
|
||||||
};
|
};
|
||||||
|
|
||||||
type ParsedSkillFrontmatter = Record<string, string>;
|
type ParsedSkillFrontmatter = Record<string, string>;
|
||||||
@@ -189,7 +190,7 @@ export function resolveSkillsInstallPreferences(
|
|||||||
typeof raw?.nodeManager === "string" ? raw.nodeManager.trim() : "";
|
typeof raw?.nodeManager === "string" ? raw.nodeManager.trim() : "";
|
||||||
const manager = managerRaw.toLowerCase();
|
const manager = managerRaw.toLowerCase();
|
||||||
const nodeManager =
|
const nodeManager =
|
||||||
manager === "pnpm" || manager === "bun" || manager === "npm"
|
manager === "pnpm" || manager === "yarn" || manager === "npm"
|
||||||
? (manager as SkillsInstallPreferences["nodeManager"])
|
? (manager as SkillsInstallPreferences["nodeManager"])
|
||||||
: "npm";
|
: "npm";
|
||||||
return { preferBrew, nodeManager };
|
return { preferBrew, nodeManager };
|
||||||
@@ -271,6 +272,10 @@ function resolveClawdisMetadata(
|
|||||||
typeof clawdisObj.always === "boolean" ? clawdisObj.always : undefined,
|
typeof clawdisObj.always === "boolean" ? clawdisObj.always : undefined,
|
||||||
emoji:
|
emoji:
|
||||||
typeof clawdisObj.emoji === "string" ? clawdisObj.emoji : undefined,
|
typeof clawdisObj.emoji === "string" ? clawdisObj.emoji : undefined,
|
||||||
|
homepage:
|
||||||
|
typeof clawdisObj.homepage === "string"
|
||||||
|
? clawdisObj.homepage
|
||||||
|
: undefined,
|
||||||
skillKey:
|
skillKey:
|
||||||
typeof clawdisObj.skillKey === "string"
|
typeof clawdisObj.skillKey === "string"
|
||||||
? clawdisObj.skillKey
|
? clawdisObj.skillKey
|
||||||
|
|||||||
@@ -97,6 +97,8 @@ export type CanvasHostConfig = {
|
|||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
/** Directory to serve (default: ~/clawd/canvas). */
|
/** Directory to serve (default: ~/clawd/canvas). */
|
||||||
root?: string;
|
root?: string;
|
||||||
|
/** HTTP port to listen on (default: 18793). */
|
||||||
|
port?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type GatewayControlUiConfig = {
|
export type GatewayControlUiConfig = {
|
||||||
@@ -135,7 +137,7 @@ export type SkillsLoadConfig = {
|
|||||||
|
|
||||||
export type SkillsInstallConfig = {
|
export type SkillsInstallConfig = {
|
||||||
preferBrew?: boolean;
|
preferBrew?: boolean;
|
||||||
nodeManager?: "npm" | "pnpm" | "bun";
|
nodeManager?: "npm" | "pnpm" | "yarn";
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ClawdisConfig = {
|
export type ClawdisConfig = {
|
||||||
@@ -147,6 +149,7 @@ export type ClawdisConfig = {
|
|||||||
logging?: LoggingConfig;
|
logging?: LoggingConfig;
|
||||||
browser?: BrowserConfig;
|
browser?: BrowserConfig;
|
||||||
skillsLoad?: SkillsLoadConfig;
|
skillsLoad?: SkillsLoadConfig;
|
||||||
|
skillsInstall?: SkillsInstallConfig;
|
||||||
inbound?: {
|
inbound?: {
|
||||||
allowFrom?: string[]; // E.164 numbers allowed to trigger auto-reply (without whatsapp:)
|
allowFrom?: string[]; // E.164 numbers allowed to trigger auto-reply (without whatsapp:)
|
||||||
/** Agent working directory (preferred). Used as the default cwd for agent runs. */
|
/** Agent working directory (preferred). Used as the default cwd for agent runs. */
|
||||||
@@ -188,7 +191,6 @@ export type ClawdisConfig = {
|
|||||||
canvasHost?: CanvasHostConfig;
|
canvasHost?: CanvasHostConfig;
|
||||||
gateway?: GatewayConfig;
|
gateway?: GatewayConfig;
|
||||||
skills?: Record<string, SkillConfig>;
|
skills?: Record<string, SkillConfig>;
|
||||||
skillsInstall?: SkillsInstallConfig;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// New branding path (preferred)
|
// New branding path (preferred)
|
||||||
@@ -349,6 +351,7 @@ const ClawdisSchema = z.object({
|
|||||||
.object({
|
.object({
|
||||||
enabled: z.boolean().optional(),
|
enabled: z.boolean().optional(),
|
||||||
root: z.string().optional(),
|
root: z.string().optional(),
|
||||||
|
port: z.number().int().positive().optional(),
|
||||||
})
|
})
|
||||||
.optional(),
|
.optional(),
|
||||||
gateway: z
|
gateway: z
|
||||||
@@ -378,7 +381,7 @@ const ClawdisSchema = z.object({
|
|||||||
.object({
|
.object({
|
||||||
preferBrew: z.boolean().optional(),
|
preferBrew: z.boolean().optional(),
|
||||||
nodeManager: z
|
nodeManager: z
|
||||||
.union([z.literal("npm"), z.literal("pnpm"), z.literal("bun")])
|
.union([z.literal("npm"), z.literal("pnpm"), z.literal("yarn")])
|
||||||
.optional(),
|
.optional(),
|
||||||
})
|
})
|
||||||
.optional(),
|
.optional(),
|
||||||
|
|||||||
Reference in New Issue
Block a user