diff --git a/.release-please-manifest.json b/.release-please-manifest.json
index 945d474..4c313f9 100644
--- a/.release-please-manifest.json
+++ b/.release-please-manifest.json
@@ -1,3 +1,3 @@
{
- ".": "1.3.13"
+ ".": "1.4.0"
}
diff --git a/css/app.css b/css/app.css
index 0cc49ab..880b41d 100644
--- a/css/app.css
+++ b/css/app.css
@@ -730,6 +730,15 @@
}
.modal-input:focus { border-color: #007aff; }
body.dark .modal-input { background: #1e1e1e; border-color: #444; color: #e0e0e0; }
+ .modal-error {
+ color: #c00;
+ font-size: 0.82rem;
+ margin: -4px 0 12px;
+ padding: 6px 8px;
+ background: #fff5f5;
+ border-radius: 6px;
+ }
+ body.dark .modal-error { background: #2e1a1a; color: #ff8888; }
.modal-btns { display: flex; gap: 8px; justify-content: flex-end; }
.modal-btn {
padding: 8px 16px;
diff --git a/index.html b/index.html
index 55bfb44..f2d485a 100644
--- a/index.html
+++ b/index.html
@@ -106,6 +106,16 @@
diff --git a/package-lock.json b/package-lock.json
index ea4cdac..e3b12fc 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "mason",
- "version": "1.3.13",
+ "version": "1.4.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "mason",
- "version": "1.3.13",
+ "version": "1.4.0",
"hasInstallScript": true,
"dependencies": {
"electron-window-state": "5.0.3",
diff --git a/package.json b/package.json
index e77f3f1..1ec2ccf 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "mason",
- "version": "1.3.13",
+ "version": "1.4.0",
"description": "Desktop chat app for Databricks AI Gateway with MCP tool calling",
"author": "Databricks",
"main": "build/ts/main.js",
diff --git a/src/app.ts b/src/app.ts
index e6d07b9..a2bb2a8 100644
--- a/src/app.ts
+++ b/src/app.ts
@@ -122,6 +122,22 @@ function initDomRefs(): void {
defaultModelSelect: "defaultModelSelect",
attachmentChips: "attachmentChips",
endpointAdd: "endpointAdd",
+ skillsModal: "skillsModal",
+ skillsModalList: "skillsModalList",
+ skillsModalClose: "skillsModalClose",
+ skillsSettingsList: "skillsSettingsList",
+ skillsNewBtn: "skillsNewBtn",
+ skillEditorModal: "skillEditorModal",
+ skillEditorTitle: "skillEditorTitle",
+ skillEditorName: "skillEditorName",
+ skillEditorDescription: "skillEditorDescription",
+ skillEditorBody: "skillEditorBody",
+ skillEditorError: "skillEditorError",
+ skillEditorSave: "skillEditorSave",
+ skillEditorCancel: "skillEditorCancel",
+ autoLoadSkillsToggle: "autoLoadSkillsToggle",
+ autoLoadSkillsTrack: "autoLoadSkillsTrack",
+ autoLoadSkillsThumb: "autoLoadSkillsThumb",
};
const refs: Record
= {};
for (const [key, id] of Object.entries(lookup)) {
@@ -412,6 +428,221 @@ function renderToolsModal(): void {
}
}
+// --- Skills ---
+
+async function refreshSkillsState(): Promise {
+ try {
+ const [skills, cfg] = await Promise.all([
+ window.api.skillsList(),
+ window.api.skillsConfigLoad(),
+ ]);
+ mason.skills = (skills as MasonSkillSummary[]) || [];
+ mason.disabledSkills = new Set(cfg.disabledSkills || []);
+ mason.autoLoadSkills = cfg.autoLoadSkills !== false;
+ updateSkillsAutoLoadVisual();
+ } catch (e) {
+ console.error("[SKILLS] refresh failed:", (e as Error).message);
+ }
+}
+
+function updateSkillsAutoLoadVisual(): void {
+ const track = mason.el.autoLoadSkillsTrack as HTMLElement | null;
+ const thumb = mason.el.autoLoadSkillsThumb as HTMLElement | null;
+ const toggle = mason.el.autoLoadSkillsToggle as HTMLInputElement | null;
+ if (toggle) toggle.checked = mason.autoLoadSkills;
+ if (track) track.style.background = mason.autoLoadSkills ? "#4caf50" : "#ccc";
+ if (thumb) thumb.style.transform = mason.autoLoadSkills ? "translateX(20px)" : "translateX(0)";
+}
+
+function renderSkillsModal(): void {
+ const list = mason.el.skillsModalList as HTMLElement | null;
+ if (!list) return;
+ list.innerHTML = "";
+
+ if (mason.skills.length === 0) {
+ list.innerHTML =
+ 'No skills available. Create one in Settings → Skills, or install ai-dev-kit for bundled skills.
';
+ return;
+ }
+
+ const enabledCount = mason.skills.filter((s) => !mason.disabledSkills.has(s.slug)).length;
+ const counter = document.createElement("div");
+ counter.style.cssText = "font-size:0.78rem;opacity:0.5;margin-bottom:8px;";
+ counter.textContent = `${enabledCount} of ${mason.skills.length} skills enabled`;
+ list.appendChild(counter);
+
+ const groups: Record = { user: [], "ai-dev-kit": [] };
+ for (const s of mason.skills) groups[s.source].push(s);
+
+ const labels: Record = { user: "User", "ai-dev-kit": "ai-dev-kit" };
+ for (const source of ["user", "ai-dev-kit"] as MasonSkillSource[]) {
+ const items = groups[source];
+ if (items.length === 0) continue;
+ const header = document.createElement("div");
+ header.style.cssText =
+ "font-size:0.72rem;font-weight:700;text-transform:uppercase;letter-spacing:0.04em;opacity:0.45;padding:8px 4px 4px;";
+ header.textContent = `${labels[source]} (${items.length})`;
+ list.appendChild(header);
+
+ for (const s of items) {
+ const enabled = !mason.disabledSkills.has(s.slug);
+ const row = document.createElement("div");
+ row.style.cssText = `display:flex;align-items:flex-start;gap:8px;padding:6px 4px 6px 12px;opacity:${enabled ? "1" : "0.4"};`;
+ row.innerHTML = `
+
+
+
${escapeHtml(s.name)}
+
${escapeHtml(s.description || "")}
+
+ `;
+ const cb = row.querySelector("input") as HTMLInputElement;
+ cb.addEventListener("change", async () => {
+ if (cb.checked) mason.disabledSkills.delete(s.slug);
+ else mason.disabledSkills.add(s.slug);
+ await window.api.skillsConfigSave({ disabledSkills: Array.from(mason.disabledSkills) });
+ renderSkillsModal();
+ });
+ list.appendChild(row);
+ }
+ }
+}
+
+function renderSkillsSettingsList(): void {
+ const list = mason.el.skillsSettingsList as HTMLElement | null;
+ if (!list) return;
+ list.innerHTML = "";
+
+ if (mason.skills.length === 0) {
+ list.innerHTML =
+ 'No skills yet — click + New Skill to create one.
';
+ return;
+ }
+
+ for (const s of mason.skills) {
+ const enabled = !mason.disabledSkills.has(s.slug);
+ const div = document.createElement("div");
+ div.className = "mcp-server-item";
+ const sourceTag = s.source === "ai-dev-kit" ? "ai-dev-kit" : "user";
+ const userActionsHtml =
+ s.source === "user"
+ ? `✎
+ × `
+ : "";
+ div.innerHTML = `
+
+
+ ${escapeHtml(s.name)}
+ ${sourceTag} · ${escapeHtml(s.description || "")}
+
+ ${userActionsHtml}
+ `;
+ const cb = div.querySelector('input[type="checkbox"]') as HTMLInputElement;
+ cb.addEventListener("change", async () => {
+ if (cb.checked) mason.disabledSkills.delete(s.slug);
+ else mason.disabledSkills.add(s.slug);
+ await window.api.skillsConfigSave({ disabledSkills: Array.from(mason.disabledSkills) });
+ renderSkillsSettingsList();
+ });
+ const editBtn = div.querySelector('[data-act="edit"]') as HTMLButtonElement | null;
+ editBtn?.addEventListener("click", () => openSkillEditor(s.slug));
+ const delBtn = div.querySelector('[data-act="delete"]') as HTMLButtonElement | null;
+ delBtn?.addEventListener("click", async () => {
+ if (!confirm(`Delete the skill "${s.name}"? This removes ~/.mason/skills/${s.slug}/.`)) return;
+ await window.api.skillsDelete(s.slug);
+ await refreshSkillsState();
+ renderSkillsSettingsList();
+ });
+ list.appendChild(div);
+ }
+}
+
+let editingSkillSlug: string | null = null;
+
+async function openSkillEditor(slug?: string): Promise {
+ const modal = mason.el.skillEditorModal as HTMLElement | null;
+ const title = mason.el.skillEditorTitle as HTMLElement | null;
+ const nameInput = mason.el.skillEditorName as HTMLInputElement | null;
+ const descInput = mason.el.skillEditorDescription as HTMLInputElement | null;
+ const bodyInput = mason.el.skillEditorBody as HTMLTextAreaElement | null;
+ const errEl = mason.el.skillEditorError as HTMLElement | null;
+ if (!modal || !nameInput || !descInput || !bodyInput) return;
+
+ editingSkillSlug = slug || null;
+ if (errEl) {
+ errEl.style.display = "none";
+ errEl.textContent = "";
+ }
+
+ if (slug) {
+ const skill = (await window.api.skillsLoad(slug)) as
+ | { slug: string; name: string; description: string; body: string }
+ | null;
+ if (!skill) {
+ alert("Could not load skill.");
+ return;
+ }
+ if (title) title.textContent = `Edit Skill — ${skill.name}`;
+ nameInput.value = skill.name;
+ descInput.value = skill.description;
+ bodyInput.value = skill.body;
+ } else {
+ if (title) title.textContent = "New Skill";
+ nameInput.value = "";
+ descInput.value = "";
+ bodyInput.value = "";
+ }
+ modal.classList.add("open");
+ nameInput.focus();
+}
+
+function closeSkillEditor(): void {
+ const modal = mason.el.skillEditorModal as HTMLElement | null;
+ modal?.classList.remove("open");
+ editingSkillSlug = null;
+}
+
+async function saveSkillEditor(): Promise {
+ const nameInput = mason.el.skillEditorName as HTMLInputElement | null;
+ const descInput = mason.el.skillEditorDescription as HTMLInputElement | null;
+ const bodyInput = mason.el.skillEditorBody as HTMLTextAreaElement | null;
+ const errEl = mason.el.skillEditorError as HTMLElement | null;
+ if (!nameInput || !descInput || !bodyInput) return;
+
+ const name = nameInput.value.trim();
+ const description = descInput.value.trim();
+ const body = bodyInput.value;
+ if (!name) {
+ if (errEl) {
+ errEl.style.display = "";
+ errEl.textContent = "Name is required.";
+ }
+ return;
+ }
+ if (!body.trim()) {
+ if (errEl) {
+ errEl.style.display = "";
+ errEl.textContent = "Body is required.";
+ }
+ return;
+ }
+ try {
+ await window.api.skillsSave({
+ name,
+ description,
+ body,
+ slug: editingSkillSlug || undefined,
+ });
+ closeSkillEditor();
+ await refreshSkillsState();
+ renderSkillsSettingsList();
+ } catch (e) {
+ if (errEl) {
+ errEl.style.display = "";
+ errEl.textContent = (e as Error).message || "Save failed.";
+ }
+ }
+}
+
// --- Wire up all event listeners ---
function initEventListeners(): void {
@@ -580,6 +811,38 @@ function initEventListeners(): void {
if (e.target === toolsModal) toolsModal.classList.remove("open");
});
+ // Skills modal — toggle which skills the LLM sees in
+ const skillsModal = el.skillsModal as HTMLElement | null;
+ document.getElementById("menuSkills")?.addEventListener("click", async () => {
+ popup?.classList.remove("open");
+ await refreshSkillsState();
+ renderSkillsModal();
+ skillsModal?.classList.add("open");
+ });
+ (el.skillsModalClose as HTMLElement | null)?.addEventListener("click", () =>
+ skillsModal?.classList.remove("open")
+ );
+ skillsModal?.addEventListener("click", (e) => {
+ if (e.target === skillsModal) skillsModal.classList.remove("open");
+ });
+
+ // Skill editor modal (create/edit)
+ const skillEditorModal = el.skillEditorModal as HTMLElement | null;
+ (el.skillsNewBtn as HTMLElement | null)?.addEventListener("click", () => openSkillEditor());
+ (el.skillEditorCancel as HTMLElement | null)?.addEventListener("click", () => closeSkillEditor());
+ (el.skillEditorSave as HTMLElement | null)?.addEventListener("click", () => saveSkillEditor());
+ skillEditorModal?.addEventListener("click", (e) => {
+ if (e.target === skillEditorModal) closeSkillEditor();
+ });
+
+ // Auto-load skills toggle
+ const autoLoadSkillsToggle = el.autoLoadSkillsToggle as HTMLInputElement | null;
+ autoLoadSkillsToggle?.addEventListener("change", async () => {
+ mason.autoLoadSkills = autoLoadSkillsToggle.checked;
+ updateSkillsAutoLoadVisual();
+ await window.api.skillsConfigSave({ autoLoadSkills: mason.autoLoadSkills });
+ });
+
// Upload Files
document.getElementById("menuUploadFiles")?.addEventListener("click", async () => {
popup?.classList.remove("open");
@@ -1031,6 +1294,8 @@ function initEventListeners(): void {
modelMenu?.classList.remove("open");
toolsModal?.classList.remove("open");
mcpModal?.classList.remove("open");
+ skillsModal?.classList.remove("open");
+ skillEditorModal?.classList.remove("open");
if (mason.currentView === "settings") switchToChatsTab();
}
});
@@ -1075,6 +1340,9 @@ async function initApp(): Promise {
initEventListeners();
initDashboardListener();
+ // Skills are independent of profile/workspace state — load once at startup.
+ await refreshSkillsState();
+
await loadProfiles();
if (!mason.profiles || mason.profiles.length === 0) {
diff --git a/src/chat.ts b/src/chat.ts
index 901e871..f99cbbb 100644
--- a/src/chat.ts
+++ b/src/chat.ts
@@ -50,6 +50,27 @@ const SEND_ICON =
const STOP_ICON =
' ';
+// Build the manifest from enabled skills. Only the name +
+// one-line description go into the system prompt; full bodies load on demand
+// via the load_skill tool.
+function buildSkillsManifest(): string {
+ const enabled = (mason.skills || []).filter((s) => !mason.disabledSkills.has(s.slug));
+ if (enabled.length === 0) return "";
+ const lines: string[] = [
+ "",
+ "The following skills are available. Each is a folder of instructions you can load on-demand by calling the load_skill tool with the skill's slug. Load a skill when the user's request matches its description; then follow the skill's full instructions precisely.",
+ "",
+ ];
+ for (const s of enabled) {
+ lines.push(" ");
+ lines.push(` ${s.slug} `);
+ if (s.description) lines.push(` ${s.description.replace(/[<>]/g, "")} `);
+ lines.push(" ");
+ }
+ lines.push(" ");
+ return lines.join("\n");
+}
+
const MAX_TOOL_RESULT_CHARS = 256 * 1024;
function capToolResult(text: string, toolName: string): string {
if (text.length <= MAX_TOOL_RESULT_CHARS) return text;
@@ -241,11 +262,18 @@ async function chatLoop(_profile: { host?: string }): Promise {
const trimmed = trimHistory(mason.history as any[]);
const sysPrompt = (mason.systemPrompt || "").trim();
+ const skillsManifest = buildSkillsManifest();
const hasUserSystem = trimmed.some((m: any) => m.role === "system");
- const messagesToSend =
- sysPrompt && !hasUserSystem
- ? [{ role: "system", content: sysPrompt }, ...trimmed]
- : trimmed;
+
+ // System messages assembled in order: skills manifest first (cheap, helps
+ // the model discover available skills), then the user-configured system
+ // prompt if any. main.ts adds its tool-aware system prompt on top when
+ // tools are attached.
+ const systemMessages: Array<{ role: "system"; content: string }> = [];
+ if (skillsManifest) systemMessages.push({ role: "system", content: skillsManifest });
+ if (sysPrompt && !hasUserSystem) systemMessages.push({ role: "system", content: sysPrompt });
+
+ const messagesToSend = systemMessages.length > 0 ? [...systemMessages, ...trimmed] : trimmed;
let result: ChatResultPayload;
try {
@@ -334,6 +362,41 @@ async function chatLoop(_profile: { host?: string }): Promise {
// Renderer-handled tools (ask_user, etc.) skip the IPC round-trip and
// also skip the "Calling tool: …" announcement since they render their
// own UI inline.
+ if (toolName === "load_skill") {
+ try {
+ const slug = String(args.slug || "");
+ if (!slug) throw new Error("slug is required");
+ addMessageEl("tool-call", `Loading skill: ${slug}`);
+ const skill = (await window.api.skillsLoad(slug)) as
+ | { slug: string; name: string; description: string; body: string }
+ | null;
+ if (!skill) {
+ (mason.history as any[]).push({
+ role: "tool",
+ tool_call_id: tc.id,
+ name: toolName,
+ content: `Error: skill "${slug}" not found.`,
+ });
+ } else {
+ const content = `# ${skill.name}\n\n${skill.body}`;
+ (mason.history as any[]).push({
+ role: "tool",
+ tool_call_id: tc.id,
+ name: toolName,
+ content: capToolResult(content, toolName),
+ });
+ }
+ } catch (e) {
+ (mason.history as any[]).push({
+ role: "tool",
+ tool_call_id: tc.id,
+ name: toolName,
+ content: `Error: ${(e as Error).message}`,
+ });
+ }
+ continue;
+ }
+
if (toolName === "ask_user") {
try {
// Accept the new batched shape ({ questions: [...] }) but stay
diff --git a/src/dashboards.ts b/src/dashboards.ts
index 474a88f..4270b77 100644
--- a/src/dashboards.ts
+++ b/src/dashboards.ts
@@ -8,6 +8,9 @@ declare function updateToggleVisual(): void;
declare function populateDefaultModelSelect(): void;
declare function renderEndpointsList(): void;
declare function renderProfilesList(): void;
+declare function refreshSkillsState(): Promise;
+declare function renderSkillsSettingsList(): void;
+declare function updateSkillsAutoLoadVisual(): void;
function elAs(key: string): T | null {
return mason.el[key] as T | null;
@@ -80,6 +83,11 @@ function switchToSettingsView(): void {
if (settingsClose) settingsClose.style.display = "inline-block";
elAs("onboardingView")?.classList.remove("visible");
if (typeof renderProfilesList === "function") renderProfilesList();
+ if (typeof refreshSkillsState === "function") {
+ refreshSkillsState().then(() => {
+ if (typeof renderSkillsSettingsList === "function") renderSkillsSettingsList();
+ });
+ }
}
function openDashboard(dashboard: MasonDashboard): void {
diff --git a/src/main.ts b/src/main.ts
index 60dc7f1..328785a 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -22,6 +22,8 @@ const WORKSPACES_FILE = path.join(CONFIG_DIR, "workspaces.json");
const MCP_SERVERS_FILE = path.join(CONFIG_DIR, "mcp_servers.json");
const CLI_PATH_FILE = path.join(CONFIG_DIR, "cli_path.json");
const SETTINGS_FILE = path.join(CONFIG_DIR, "settings.json");
+const SKILLS_FILE = path.join(CONFIG_DIR, "skills.json");
+const USER_SKILLS_DIR = path.join(MASON_HOME, "skills");
const DATABRICKSCFG_PATH = path.join(os.homedir(), ".databrickscfg");
const DEVKIT_DIR = path.join(os.homedir(), ".ai-dev-kit");
const DEVKIT_REPO_DIR = path.join(DEVKIT_DIR, "repo");
@@ -62,6 +64,7 @@ if (!fs.existsSync(MASON_HOME)) fs.mkdirSync(MASON_HOME);
if (!fs.existsSync(CONFIG_DIR)) fs.mkdirSync(CONFIG_DIR);
if (!fs.existsSync(HISTORY_DIR)) fs.mkdirSync(HISTORY_DIR);
if (!fs.existsSync(BIN_DIR)) fs.mkdirSync(BIN_DIR);
+if (!fs.existsSync(USER_SKILLS_DIR)) fs.mkdirSync(USER_SKILLS_DIR);
// --- Databricks CLI resolution + install ---
@@ -753,6 +756,184 @@ ipcMain.handle("settings-save", (_event: IpcMainInvokeEvent, partial: Partial/SKILL.md — user-authored, edited in Settings
+// • ~/.ai-dev-kit/repo/databricks-skills//SKILL.md — read-only
+// Progressive disclosure: chat.ts injects a manifest (name + description)
+// into the system prompt and the renderer's load_skill tool reads the full
+// SKILL.md body on demand. Anthropic-compatible format so user-authored
+// skills are portable.
+
+type SkillSource = "user" | "ai-dev-kit";
+
+interface SkillRecord {
+ name: string;
+ description: string;
+ source: SkillSource;
+ slug: string;
+ path: string;
+ body: string;
+}
+
+interface SkillsConfig {
+ disabledSkills: string[];
+ autoLoadSkills: boolean;
+}
+const DEFAULT_SKILLS_CONFIG: SkillsConfig = { disabledSkills: [], autoLoadSkills: true };
+
+function readSkillsConfig(): SkillsConfig {
+ if (!fs.existsSync(SKILLS_FILE)) return { ...DEFAULT_SKILLS_CONFIG };
+ try {
+ const data = JSON.parse(fs.readFileSync(SKILLS_FILE, "utf-8"));
+ return {
+ disabledSkills: Array.isArray(data.disabledSkills) ? data.disabledSkills : [],
+ autoLoadSkills: data.autoLoadSkills !== false,
+ };
+ } catch (_) {
+ return { ...DEFAULT_SKILLS_CONFIG };
+ }
+}
+
+function writeSkillsConfig(cfg: SkillsConfig): void {
+ if (!fs.existsSync(CONFIG_DIR)) fs.mkdirSync(CONFIG_DIR, { recursive: true });
+ fs.writeFileSync(SKILLS_FILE, JSON.stringify(cfg, null, 2));
+}
+
+// Parse YAML-ish frontmatter — we only care about name + description, so a
+// regex-based parser is plenty (avoids pulling in a YAML lib).
+function parseSkillFile(filePath: string): { name: string; description: string; body: string } | null {
+ let raw: string;
+ try {
+ raw = fs.readFileSync(filePath, "utf-8");
+ } catch (_) {
+ return null;
+ }
+ const m = raw.match(/^---\s*\n([\s\S]*?)\n---\s*\n?([\s\S]*)$/);
+ if (!m) return null;
+ const front = m[1];
+ const body = m[2] || "";
+ const getField = (key: string): string => {
+ const re = new RegExp(`^${key}\\s*:\\s*(.+?)\\s*$`, "m");
+ const mm = front.match(re);
+ if (!mm) return "";
+ // Trim surrounding quotes if present.
+ return mm[1].replace(/^["'](.*)["']$/, "$1").trim();
+ };
+ const name = getField("name");
+ const description = getField("description");
+ if (!name) return null;
+ return { name, description, body };
+}
+
+function scanSkillsDir(root: string, source: SkillSource): SkillRecord[] {
+ if (!fs.existsSync(root)) return [];
+ const out: SkillRecord[] = [];
+ let entries: string[];
+ try {
+ entries = fs.readdirSync(root);
+ } catch (_) {
+ return out;
+ }
+ for (const slug of entries) {
+ const dir = path.join(root, slug);
+ let stat: fs.Stats;
+ try {
+ stat = fs.statSync(dir);
+ } catch (_) {
+ continue;
+ }
+ if (!stat.isDirectory()) continue;
+ const file = path.join(dir, "SKILL.md");
+ const parsed = parseSkillFile(file);
+ if (!parsed) continue;
+ out.push({ ...parsed, source, slug, path: file });
+ }
+ return out;
+}
+
+function listSkills(): SkillRecord[] {
+ // User skills win over ai-dev-kit on slug collision so a user can shadow.
+ const user = scanSkillsDir(USER_SKILLS_DIR, "user");
+ const devkit = scanSkillsDir(path.join(DEVKIT_REPO_DIR, "databricks-skills"), "ai-dev-kit");
+ const seen = new Set(user.map((s) => s.slug));
+ return [...user, ...devkit.filter((s) => !seen.has(s.slug))];
+}
+
+function slugify(name: string): string {
+ return name
+ .toLowerCase()
+ .trim()
+ .replace(/[^a-z0-9]+/g, "-")
+ .replace(/^-+|-+$/g, "")
+ .slice(0, 64) || "skill";
+}
+
+function serializeSkill(name: string, description: string, body: string): string {
+ // Escape any closing-fence sequences in the description (unlikely but safe).
+ const safeDesc = description.replace(/\n/g, " ").trim();
+ return `---\nname: ${name}\ndescription: ${safeDesc}\n---\n${body.trim()}\n`;
+}
+
+ipcMain.handle("skills-list", () => {
+ return listSkills().map((s) => ({
+ name: s.name,
+ description: s.description,
+ source: s.source,
+ slug: s.slug,
+ path: s.path,
+ }));
+});
+
+ipcMain.handle("skills-load", (_event: IpcMainInvokeEvent, slug: string) => {
+ const skills = listSkills();
+ const hit = skills.find((s) => s.slug === slug);
+ if (!hit) return null;
+ return { slug: hit.slug, name: hit.name, description: hit.description, body: hit.body };
+});
+
+ipcMain.handle(
+ "skills-save",
+ (_event: IpcMainInvokeEvent, { name, description, body, slug }: { name: string; description: string; body: string; slug?: string }) => {
+ const cleanName = String(name || "").trim();
+ if (!cleanName) throw new Error("Skill name is required.");
+ const targetSlug = slug || slugify(cleanName);
+ if (!fs.existsSync(USER_SKILLS_DIR)) fs.mkdirSync(USER_SKILLS_DIR, { recursive: true });
+ const skillDir = path.join(USER_SKILLS_DIR, targetSlug);
+ if (!fs.existsSync(skillDir)) fs.mkdirSync(skillDir, { recursive: true });
+ const filePath = path.join(skillDir, "SKILL.md");
+ fs.writeFileSync(filePath, serializeSkill(cleanName, description || "", body || ""));
+ return {
+ name: cleanName,
+ description: description || "",
+ source: "user" as SkillSource,
+ slug: targetSlug,
+ path: filePath,
+ };
+ }
+);
+
+ipcMain.handle("skills-delete", (_event: IpcMainInvokeEvent, slug: string) => {
+ const dir = path.join(USER_SKILLS_DIR, slug);
+ if (!fs.existsSync(dir)) return { ok: false };
+ fs.rmSync(dir, { recursive: true, force: true });
+ return { ok: true };
+});
+
+ipcMain.handle("skills-config-load", () => readSkillsConfig());
+
+ipcMain.handle("skills-config-save", (_event: IpcMainInvokeEvent, partial: Partial) => {
+ const next = { ...readSkillsConfig(), ...partial };
+ if (partial.disabledSkills && Array.isArray(partial.disabledSkills)) {
+ // Dedupe.
+ next.disabledSkills = Array.from(new Set(partial.disabledSkills));
+ }
+ writeSkillsConfig(next);
+ return next;
+});
+
// --- ai-dev-kit ---
function readDevkitVersion(): string | null {
diff --git a/src/preload.ts b/src/preload.ts
index 0402f96..180eb9e 100644
--- a/src/preload.ts
+++ b/src/preload.ts
@@ -70,6 +70,13 @@ const api: MasonApi = {
settingsLoad: () => ipcRenderer.invoke("settings-load"),
settingsSave: (partial) => ipcRenderer.invoke("settings-save", partial),
+ skillsList: () => ipcRenderer.invoke("skills-list"),
+ skillsLoad: (slug) => ipcRenderer.invoke("skills-load", slug),
+ skillsSave: (params) => ipcRenderer.invoke("skills-save", params),
+ skillsDelete: (slug) => ipcRenderer.invoke("skills-delete", slug),
+ skillsConfigLoad: () => ipcRenderer.invoke("skills-config-load"),
+ skillsConfigSave: (partial) => ipcRenderer.invoke("skills-config-save", partial),
+
detectDevkit: () => ipcRenderer.invoke("detect-devkit"),
installDevkit: (params) => ipcRenderer.invoke("install-devkit", params),
uninstallDevkit: () => ipcRenderer.invoke("uninstall-devkit"),
diff --git a/src/shared/api.ts b/src/shared/api.ts
index b018509..11d5c41 100644
--- a/src/shared/api.ts
+++ b/src/shared/api.ts
@@ -178,6 +178,30 @@ export interface InstallDevkitParams {
profile?: string;
}
+export type SkillSource = "user" | "ai-dev-kit";
+
+export interface MasonSkillSummary {
+ name: string;
+ description: string;
+ source: SkillSource;
+ slug: string;
+ path: string;
+}
+
+export interface MasonSkillSaveParams {
+ name: string;
+ description: string;
+ body: string;
+ // If slug is provided it identifies the existing skill being updated.
+ // Otherwise a slug is derived from name.
+ slug?: string;
+}
+
+export interface MasonSkillsConfig {
+ disabledSkills: string[];
+ autoLoadSkills: boolean;
+}
+
export interface MasonApi {
// Streaming chat chunk listener
onChatChunk(callback: (chunk: ChatChunk) => void): void;
@@ -263,4 +287,12 @@ export interface MasonApi {
uninstallDevkit(): Promise<{ ok: boolean; error?: string }>;
onDevkitInstallProgress(callback: (payload: DevkitInstallProgress) => void): void;
removeDevkitInstallListeners(): void;
+
+ // Skills
+ skillsList(): Promise;
+ skillsLoad(slug: string): Promise<{ slug: string; name: string; description: string; body: string } | null>;
+ skillsSave(params: MasonSkillSaveParams): Promise;
+ skillsDelete(slug: string): Promise<{ ok: boolean }>;
+ skillsConfigLoad(): Promise;
+ skillsConfigSave(partial: Partial): Promise;
}
diff --git a/src/state.ts b/src/state.ts
index 505e940..b72b7c6 100644
--- a/src/state.ts
+++ b/src/state.ts
@@ -33,6 +33,11 @@ window.mason = {
dashboardsList: [],
autoConnectDone: false,
+ // Skills
+ skills: [],
+ disabledSkills: new Set(),
+ autoLoadSkills: true,
+
// DOM refs (populated on init)
el: {},
};
diff --git a/src/tools.ts b/src/tools.ts
index 7fde835..7e5c72b 100644
--- a/src/tools.ts
+++ b/src/tools.ts
@@ -84,11 +84,29 @@ const BUILTIN_TOOLS: ToolDef[] = [
},
},
},
+ {
+ type: "function",
+ function: {
+ name: "load_skill",
+ description:
+ "Load the full instructions for a skill named in . Call this when the user's request matches a skill's description — you'll receive the skill's full markdown body, which you should then follow precisely. Pass the skill's slug (the value of in the manifest).",
+ parameters: {
+ type: "object",
+ properties: {
+ slug: {
+ type: "string",
+ description: "The skill slug from (e.g. 'dcf-analysis').",
+ },
+ },
+ required: ["slug"],
+ },
+ },
+ },
];
const BUILTIN_TOOL_NAMES = new Set(BUILTIN_TOOLS.map((t) => t.function.name));
// Tools handled entirely in the renderer (no IPC round-trip). chatLoop dispatches
// these inline so they can render UI and await user input.
-const RENDERER_BUILTIN_TOOL_NAMES = new Set(["ask_user"]);
+const RENDERER_BUILTIN_TOOL_NAMES = new Set(["ask_user", "load_skill"]);
function getAllToolDefs(): ToolDef[] {
const tools: ToolDef[] = BUILTIN_TOOLS.filter(
diff --git a/src/types/state.d.ts b/src/types/state.d.ts
index 1a0a4a5..1668cf2 100644
--- a/src/types/state.d.ts
+++ b/src/types/state.d.ts
@@ -54,6 +54,21 @@ declare global {
name: string;
}
+ type MasonSkillSource = "user" | "ai-dev-kit";
+
+ interface MasonSkillSummary {
+ name: string;
+ description: string;
+ source: MasonSkillSource;
+ slug: string;
+ path: string;
+ }
+
+ interface MasonSkillsConfig {
+ disabledSkills: string[];
+ autoLoadSkills: boolean;
+ }
+
interface MasonSettings {
darkMode: boolean;
systemPrompt: string;
@@ -87,6 +102,10 @@ declare global {
dashboardsLoading?: boolean;
autoConnectDone: boolean;
+ skills: MasonSkillSummary[];
+ disabledSkills: Set;
+ autoLoadSkills: boolean;
+
defaultModel?: { value: string; label: string } | null;
el: Record;