diff --git a/.changeset/shared-skill-library.md b/.changeset/shared-skill-library.md new file mode 100644 index 0000000..429f044 --- /dev/null +++ b/.changeset/shared-skill-library.md @@ -0,0 +1,5 @@ +--- +"grok-bot-cli": minor +--- + +Add `gbot skills list|add|remove` for the account-wide Grok Bot skill library over the gateway (`getAgentWorkflows`, `importAgentWorkflowText`, `deleteAgentWorkflow`). Grok Bot has no per-bot skill attach: `add` makes a `SKILL.md` visible to every bot, `remove` deletes a `workflow` skill for every bot and refuses `managed`, `plugin`, and `automation` entries. Skills have no `--files` mode. diff --git a/README.md b/README.md index aee32b3..977fec0 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,9 @@ gbot send Researcher "Summarize the launch status." gbot send Launch "Share your updates." gbot thread Researcher gbot thread Researcher --after --json +gbot skills list +gbot skills add ./skills/poteto-mode +gbot skills remove poteto-mode gbot groups delete Launch gbot bots delete Researcher gbot bots delete Writer @@ -36,6 +39,15 @@ gbot bots delete Writer `update` fields: `--name` `--description` `--title` `--avatar-shape` `--avatar-color` `--notify on|off` `--hidden on|off`. `--description` is the UI Instructions field. +`gbot skills` manages the skill library over the gateway. Grok Bot keeps one +library per account; every bot reads the same list, and there is no per-bot +attach. `add` posts a `SKILL.md` (or a directory holding one) and every bot sees +it. `list` shows each entry's `source`: `workflow` is your library, `managed` is +team-published, `plugin` comes from a marketplace install, `automation` is a +scheduled routine. `remove` deletes a `workflow` skill by id or exact name, for +every bot, and refuses the other sources. Adding the same file twice yields two +same-named skills; remove by id then. There is no `--files` mode for skills. + `gbot thread --after ID` filters the bounded tail locally and returns entries strictly after that opaque entry ID. Its JSON includes `cursor`, `entryCount`, and `gapReset`. An unchanged poll has `entryCount: 0`; an unknown or expired ID returns one bounded diff --git a/src/cli/_shared.ts b/src/cli/_shared.ts index 2beec81..6404a30 100644 --- a/src/cli/_shared.ts +++ b/src/cli/_shared.ts @@ -61,6 +61,15 @@ export const agentSummarySchema = z.object({ title: z.string().optional(), }).strict(); +export const skillSchema = z.object({ + description: z.string(), + id: z.string(), + name: z.string(), + pluginId: z.string().optional(), + source: z.string(), + sourceRef: z.string().optional(), +}).strict(); + export const createFieldsSchema = z.object({ avatarColor: avatarColorSchema.optional(), avatarShape: avatarShapeSchema.optional(), diff --git a/src/cli/skills/add.tsx b/src/cli/skills/add.tsx new file mode 100644 index 0000000..089e00f --- /dev/null +++ b/src/cli/skills/add.tsx @@ -0,0 +1,43 @@ +import { readFile, stat } from 'node:fs/promises'; +import { join, resolve } from 'node:path'; + +import { Agent } from '@agent-bundle/runtime'; +import type { CliRouteConfig, CliRouteProps } from 'agent-bundle'; +import { z } from 'zod'; + +import { backendFlagsSchema, openBackendFromInput, skillSchema } from '../_shared.js'; + +export const config = { + description: 'Add a SKILL.md to the shared skill library. Every bot sees it.', + inputJsonSchema: { + additionalProperties: false, + properties: { + dir: { description: 'Agents directory for --files mode', type: 'string' }, + files: { description: 'Force the on-disk agents store', type: 'boolean' }, + gateway: { description: 'Force the live gateway', type: 'boolean' }, + path: { description: 'SKILL.md file, or a directory holding one', type: 'string' }, + }, + required: ['path'], + type: 'object', + }, + positionals: ['path'], +} satisfies CliRouteConfig; + +export const inputSchema = backendFlagsSchema.extend({ path: z.string().min(1) }).strict(); +export const resultSchema = skillSchema; + +const skillFile = async (path: string): Promise => { + const target = resolve(path); + return (await stat(target)).isDirectory() ? join(target, 'SKILL.md') : target; +}; + +export default async function skillsAdd({ input }: CliRouteProps) { + const markdown = await readFile(await skillFile(input.path), 'utf8'); + const backend = await openBackendFromInput(input); + const skill = resultSchema.parse(await backend.addSkill(markdown)); + return ( + + {`Added skill ${skill.name} (${skill.id}) to the shared library`} + + ); +} diff --git a/src/cli/skills/list.tsx b/src/cli/skills/list.tsx new file mode 100644 index 0000000..98562bc --- /dev/null +++ b/src/cli/skills/list.tsx @@ -0,0 +1,32 @@ +import { Agent } from '@agent-bundle/runtime'; +import type { CliRouteConfig, CliRouteProps } from 'agent-bundle'; +import { z } from 'zod'; + +import { backendFlagsSchema, openBackendFromInput, skillSchema } from '../_shared.js'; + +export const config = { + description: 'List the skill library every bot in this Grok Bot shares.', + inputJsonSchema: { + additionalProperties: false, + properties: { + dir: { description: 'Agents directory for --files mode', type: 'string' }, + files: { description: 'Force the on-disk agents store', type: 'boolean' }, + gateway: { description: 'Force the live gateway', type: 'boolean' }, + }, + type: 'object', + }, +} satisfies CliRouteConfig; + +export const inputSchema = backendFlagsSchema; +export const resultSchema = z.array(skillSchema); + +export default async function skillsList({ input }: CliRouteProps) { + const backend = await openBackendFromInput(input); + const skills = resultSchema.parse(await backend.skills()); + const lines = skills.map((s) => `${s.id} ${s.name} [${s.source}]${s.description ? ` ${s.description}` : ''}`); + return ( + + {lines.length > 0 ? lines.join('\n') : 'No skills.'} + + ); +} diff --git a/src/cli/skills/remove.tsx b/src/cli/skills/remove.tsx new file mode 100644 index 0000000..85f6d45 --- /dev/null +++ b/src/cli/skills/remove.tsx @@ -0,0 +1,34 @@ +import { Agent } from '@agent-bundle/runtime'; +import type { CliRouteConfig, CliRouteProps } from 'agent-bundle'; +import { z } from 'zod'; + +import { backendFlagsSchema, openBackendFromInput, skillSchema } from '../_shared.js'; + +export const config = { + description: 'Remove a library skill by id or name. Every bot loses it.', + inputJsonSchema: { + additionalProperties: false, + properties: { + dir: { description: 'Agents directory for --files mode', type: 'string' }, + files: { description: 'Force the on-disk agents store', type: 'boolean' }, + gateway: { description: 'Force the live gateway', type: 'boolean' }, + skill: { description: 'Skill id or name', type: 'string' }, + }, + required: ['skill'], + type: 'object', + }, + positionals: ['skill'], +} satisfies CliRouteConfig; + +export const inputSchema = backendFlagsSchema.extend({ skill: z.string().min(1) }).strict(); +export const resultSchema = skillSchema; + +export default async function skillsRemove({ input }: CliRouteProps) { + const backend = await openBackendFromInput(input); + const skill = resultSchema.parse(await backend.removeSkill(input.skill)); + return ( + + {`Removed skill ${skill.name} (${skill.id}) from the shared library`} + + ); +} diff --git a/src/core/commands.js b/src/core/commands.js index df22d56..7ff81a1 100644 --- a/src/core/commands.js +++ b/src/core/commands.js @@ -30,6 +30,9 @@ export async function openBackend(opts) { send: (ref, prompt, extra) => gw.sendPrompt(session, ref, prompt, extra), transcript: (ref, limit) => gw.getTranscriptTail(session, ref, limit), thread: (ref, rootId) => gw.getThread(session, ref, rootId), + skills: () => gw.listSkills(session), + addSkill: (markdown) => gw.addSkill(session, markdown), + removeSkill: (skillRef) => gw.removeSkill(session, skillRef), }; } const root = files.resolveAgentsRoot(opts.root); @@ -48,5 +51,8 @@ export async function openBackend(opts) { send: async () => { throw new files.StoreError("send requires the live gateway"); }, transcript: async () => { throw new files.StoreError("thread requires the live gateway"); }, thread: async () => { throw new files.StoreError("thread requires the live gateway"); }, + skills: async () => { throw new files.StoreError("skills requires the live gateway"); }, + addSkill: async () => { throw new files.StoreError("skills requires the live gateway"); }, + removeSkill: async () => { throw new files.StoreError("skills requires the live gateway"); }, }; } diff --git a/src/core/gateway.js b/src/core/gateway.js index f9553c5..dcc9721 100644 --- a/src/core/gateway.js +++ b/src/core/gateway.js @@ -298,6 +298,76 @@ export async function deleteAgent(session, ref) { return rec; } +// Grok Bot keeps one skill library per box. Every bot reads the same list, and the +// workflow RPCs take any bot id only to say whose automations ride along. +const SKILL_MAX_BODY_LENGTH = 100000; +const SKILL_SOURCES = { workflow: "your library", managed: "team-managed", plugin: "a plugin", automation: "a scheduled automation" }; + +function asSkill(skill) { + return { + id: String(skill.id), + name: skill.name || "", + description: skill.description || "", + source: String(skill.source || "workflow"), + ...(skill.sourceRef ? { sourceRef: String(skill.sourceRef) } : {}), + ...(skill.pluginId ? { pluginId: String(skill.pluginId) } : {}), + }; +} + +function asSkills(data) { + const list = Array.isArray(data) ? data : Array.isArray(data?.workflows) ? data.workflows : []; + return list.filter((s) => s && s.id).map(asSkill); +} + +async function anyBotId(session) { + const bot = (await listAgents(session)).find((r) => !r.isGroup); + if (!bot) throw new GatewayError("Skills need at least one bot. Run gbot bots create first."); + return bot.id; +} + +export async function listSkills(session) { + return asSkills(await gatewayCall(session, "getAgentWorkflows", { id: await anyBotId(session) })); +} + +export async function addSkill(session, markdown) { + const text = String(markdown); + if (!text.trim()) throw new GatewayError("Skill markdown is empty."); + if (text.length > SKILL_MAX_BODY_LENGTH) { + throw new GatewayError(`Skill markdown is ${text.length} characters. Grok Bot truncates bodies over ${SKILL_MAX_BODY_LENGTH}; shorten it.`); + } + const id = await anyBotId(session); + let data; + try { + data = await gatewayCall(session, "importAgentWorkflowText", { id, markdown: text }); + } catch (error) { + if (error instanceof GatewayError && /response too large/.test(error.message)) { + throw new GatewayError("Grok Bot echoed a skill list too large to read; the import may still have landed. Run gbot skills list."); + } + throw error; + } + const imported = data.result?.imported?.[0]; + if (!imported) { + const reason = data.result?.skipped?.[0]?.reason || "rejected"; + throw new GatewayError(`Grok Bot did not import the skill (${reason}). It needs a name, a non-empty body, and library room.`); + } + return asSkills(data).find((s) => s.id === String(imported.id)) ?? asSkill(imported); +} + +export async function removeSkill(session, skillRef) { + const id = await anyBotId(session); + const skills = asSkills(await gatewayCall(session, "getAgentWorkflows", { id })); + const needle = String(skillRef).trim().toLowerCase(); + const matches = skills.filter((s) => s.id.toLowerCase() === needle || s.name.toLowerCase() === needle); + if (matches.length === 0) throw new GatewayError(`No skill "${skillRef}".`); + if (matches.length > 1) throw new GatewayError(`Ambiguous skill name "${skillRef}". Use the id.`); + if (matches[0].source !== "workflow") { + const kind = SKILL_SOURCES[matches[0].source] || matches[0].source; + throw new GatewayError(`"${matches[0].name}" is ${kind}, not a library skill. Manage it where it came from.`); + } + await gatewayCall(session, "deleteAgentWorkflow", { id, workflowId: matches[0].id }); + return matches[0]; +} + function normalizeMemberIds(records, memberRefs) { const memberIds = new Set(); for (const ref of memberRefs) { diff --git a/test/gateway-skills.test.js b/test/gateway-skills.test.js new file mode 100644 index 0000000..1a49c33 --- /dev/null +++ b/test/gateway-skills.test.js @@ -0,0 +1,100 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { addSkill, listSkills, removeSkill } from "../src/core/gateway.js"; + +const session = { gatewayUrl: "http://127.0.0.1:1340", gatewayToken: "test-token" }; +const group = { id: "group-1", name: "Launch", isGroup: true, memberIds: ["bot-1"] }; +const bot = { id: "bot-1", name: "Researcher", title: "", isGroup: false }; +// Wire shapes from the harness: library skills are `workflow`; team, plugin, and cron entries ride along. +const librarySkill = { id: "wf-1", name: "poteto-mode", description: "Lazy senior dev", source: "workflow", body: "..." }; +const pluginSkill = { id: "wf-2", name: "talk-to-grok-bot", description: "", source: "plugin", pluginId: "77" }; +const automation = { id: "auto-1", name: "Daily digest", description: "", source: "automation", trigger: { schedule: "0 9 * * *" } }; + +function mockGateway(t, { agents = [group, bot], workflows = [librarySkill, pluginSkill, automation], imported = { id: "wf-3", name: "new-skill" }, importReply } = {}) { + const calls = []; + t.mock.method(globalThis, "fetch", async (url, options) => { + const method = new URL(url).pathname.split("/").pop(); + const body = JSON.parse(options.body); + calls.push({ method, body }); + if (method === "importAgentWorkflowText" && importReply) return importReply(); + const data = { + listAgents: { agents }, + getAgentWorkflows: workflows, + importAgentWorkflowText: imported + ? { workflows: [...workflows, { ...imported, description: "", source: "workflow" }], result: { imported: [imported], skipped: [] } } + : { workflows, result: { imported: [], skipped: [{ source: "pasted skill", reason: "empty or invalid" }] } }, + deleteAgentWorkflow: workflows.filter((w) => w.id !== body.workflowId), + }[method]; + return new Response(JSON.stringify(data), { status: 200 }); + }); + return calls; +} + +test("list uses the first bot (never a group) and strips bodies", async (t) => { + const calls = mockGateway(t); + const skills = await listSkills(session); + assert.deepEqual(calls.at(-1), { method: "getAgentWorkflows", body: { id: bot.id } }); + assert.deepEqual(skills, [ + { id: "wf-1", name: "poteto-mode", description: "Lazy senior dev", source: "workflow" }, + { id: "wf-2", name: "talk-to-grok-bot", description: "", source: "plugin", pluginId: "77" }, + { id: "auto-1", name: "Daily digest", description: "", source: "automation" }, + ]); +}); + +test("list explains when the account has no bot to route through", async (t) => { + mockGateway(t, { agents: [group] }); + await assert.rejects(listSkills(session), { name: "GatewayError", message: /at least one bot/ }); +}); + +test("add imports the markdown and returns the new library record", async (t) => { + const calls = mockGateway(t); + const skill = await addSkill(session, "---\nname: new-skill\n---\nBody"); + assert.deepEqual(calls.find((c) => c.method === "importAgentWorkflowText").body, { id: bot.id, markdown: "---\nname: new-skill\n---\nBody" }); + assert.deepEqual(skill, { id: "wf-3", name: "new-skill", description: "", source: "workflow" }); +}); + +test("add surfaces the gateway skip reason instead of a silent no-op", async (t) => { + mockGateway(t, { imported: null }); + await assert.rejects(addSkill(session, "# no frontmatter"), { name: "GatewayError", message: /empty or invalid/ }); +}); + +for (const [label, markdown, error] of [ + ["empty markdown", " \n", /empty/], + ["markdown Grok Bot would truncate", "x".repeat(100001), /truncates bodies over 100000/], +]) { + test(`add rejects ${label} before touching the gateway`, async (t) => { + const calls = mockGateway(t); + await assert.rejects(addSkill(session, markdown), { name: "GatewayError", message: error }); + assert.equal(calls.length, 0); + }); +} + +test("add says the import may have landed when the echoed list exceeds the response cap", async (t) => { + mockGateway(t, { importReply: () => new Response("[" + "\"x\",".repeat(600000) + "\"x\"]", { status: 200 }) }); + await assert.rejects(addSkill(session, "# Skill\nbody"), { name: "GatewayError", message: /may still have landed. Run gbot skills list/ }); +}); + +test("remove deletes a library skill by name, case-insensitively", async (t) => { + const calls = mockGateway(t); + const skill = await removeSkill(session, "POTETO-MODE"); + assert.deepEqual(calls.at(-1), { method: "deleteAgentWorkflow", body: { id: bot.id, workflowId: "wf-1" } }); + assert.equal(skill.id, "wf-1"); +}); + +for (const [label, skillRef, error] of [ + ["unknown skills", "missing", /No skill "missing"/], + ["plugin skills", "talk-to-grok-bot", /is a plugin, not a library skill/], + ["automations", "auto-1", /is a scheduled automation/], +]) { + test(`remove refuses ${label} without deleting`, async (t) => { + const calls = mockGateway(t); + await assert.rejects(removeSkill(session, skillRef), { name: "GatewayError", message: error }); + assert.ok(calls.every((c) => c.method !== "deleteAgentWorkflow")); + }); +} + +test("remove refuses an ambiguous name", async (t) => { + const calls = mockGateway(t, { workflows: [librarySkill, { ...librarySkill, id: "wf-9" }] }); + await assert.rejects(removeSkill(session, librarySkill.name), { name: "GatewayError", message: /Ambiguous/ }); + assert.ok(calls.every((c) => c.method !== "deleteAgentWorkflow")); +});