From b95dba0ba6c22c2db0afdba0cacbe09bfe152d64 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Thu, 17 Sep 2026 03:18:00 +0000 Subject: [PATCH 1/3] feat: gbot skills list/add/remove attach a SKILL.md to one bot Grok Bot keeps skills per bot behind the gateway, not in a plugin directory. These commands drive getAgentWorkflows, importAgentWorkflowText, and deleteAgentWorkflow so a skill lands on exactly one bot id. --- .changeset/per-bot-skills.md | 5 ++ README.md | 9 ++++ src/cli/_shared.ts | 9 ++++ src/cli/skills/add.tsx | 49 +++++++++++++++++++ src/cli/skills/list.tsx | 35 ++++++++++++++ src/cli/skills/remove.tsx | 36 ++++++++++++++ src/core/commands.js | 6 +++ src/core/gateway.js | 57 ++++++++++++++++++++++ test/gateway-skills.test.js | 91 ++++++++++++++++++++++++++++++++++++ 9 files changed, 297 insertions(+) create mode 100644 .changeset/per-bot-skills.md create mode 100644 src/cli/skills/add.tsx create mode 100644 src/cli/skills/list.tsx create mode 100644 src/cli/skills/remove.tsx create mode 100644 test/gateway-skills.test.js diff --git a/.changeset/per-bot-skills.md b/.changeset/per-bot-skills.md new file mode 100644 index 0000000..746f368 --- /dev/null +++ b/.changeset/per-bot-skills.md @@ -0,0 +1,5 @@ +--- +"grok-bot-cli": minor +--- + +Add `gbot skills list|add|remove ` to attach a `SKILL.md` to one Grok Bot over the gateway (`getAgentWorkflows`, `importAgentWorkflowText`, `deleteAgentWorkflow`). `add` posts to that bot only; `remove` refuses plugin and team-managed skills. Skills have no `--files` mode. diff --git a/README.md b/README.md index aee32b3..529731f 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 Researcher +gbot skills add Researcher ./skills/poteto-mode +gbot skills remove Researcher poteto-mode gbot groups delete Launch gbot bots delete Researcher gbot bots delete Writer @@ -36,6 +39,12 @@ 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 one bot's private skills over the gateway. Grok Bot stores +skills per bot, not in a plugin directory, so `add` posts a `SKILL.md` (or a +directory holding one) to that bot only; other bots keep their own list. `remove` +detaches by id or exact name and refuses plugin and team-managed skills, which +belong to their marketplace install. 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..377d030 --- /dev/null +++ b/src/cli/skills/add.tsx @@ -0,0 +1,49 @@ +import { readFile, stat } from 'node:fs/promises'; +import { basename, dirname, join, resolve } from 'node:path'; + +import { Agent } from '@agent-bundle/runtime'; +import type { CliRouteConfig, CliRouteProps } from 'agent-bundle'; +import { z } from 'zod'; + +import { agentSummarySchema, backendFlagsSchema, openBackendFromInput, skillSchema } from '../_shared.js'; +import { summarize } from '../../core/format.js'; + +export const config = { + description: 'Attach a SKILL.md to one bot. Other bots are not changed.', + 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' }, + name: { description: 'Skill name when the markdown has no frontmatter name', type: 'string' }, + path: { description: 'SKILL.md file, or a directory holding one', type: 'string' }, + ref: { description: 'Bot id or name', type: 'string' }, + }, + required: ['ref', 'path'], + type: 'object', + }, + positionals: ['ref', 'path'], +} satisfies CliRouteConfig; + +export const inputSchema = backendFlagsSchema + .extend({ name: z.string().min(1).optional(), path: z.string().min(1), ref: z.string().min(1) }) + .strict(); +export const resultSchema = z.object({ bot: agentSummarySchema, skill: skillSchema }).strict(); + +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 file = await skillFile(input.path); + const markdown = await readFile(file, 'utf8'); + const backend = await openBackendFromInput(input); + const { bot, skill } = await backend.addSkill(input.ref, markdown, input.name ?? basename(dirname(file))); + return ( + + {`Attached skill ${skill.name} (${skill.id}) to ${bot.name} (${bot.id})`} + + ); +} diff --git a/src/cli/skills/list.tsx b/src/cli/skills/list.tsx new file mode 100644 index 0000000..89d8c18 --- /dev/null +++ b/src/cli/skills/list.tsx @@ -0,0 +1,35 @@ +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 skills attached to one bot.', + 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' }, + ref: { description: 'Bot id or name', type: 'string' }, + }, + required: ['ref'], + type: 'object', + }, + positionals: ['ref'], +} satisfies CliRouteConfig; + +export const inputSchema = backendFlagsSchema.extend({ ref: z.string().min(1) }).strict(); +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(input.ref)); + const lines = skills.map((s) => `${s.id} ${s.name} [${s.source}]${s.description ? ` ${s.description}` : ''}`); + return ( + + {lines.length > 0 ? lines.join('\n') : `No skills on ${input.ref}.`} + + ); +} diff --git a/src/cli/skills/remove.tsx b/src/cli/skills/remove.tsx new file mode 100644 index 0000000..bd7be42 --- /dev/null +++ b/src/cli/skills/remove.tsx @@ -0,0 +1,36 @@ +import { Agent } from '@agent-bundle/runtime'; +import type { CliRouteConfig, CliRouteProps } from 'agent-bundle'; +import { z } from 'zod'; + +import { agentSummarySchema, backendFlagsSchema, openBackendFromInput, skillSchema } from '../_shared.js'; +import { summarize } from '../../core/format.js'; + +export const config = { + description: 'Detach a user skill from one bot by id or name.', + 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' }, + ref: { description: 'Bot id or name', type: 'string' }, + skill: { description: 'Skill id or name', type: 'string' }, + }, + required: ['ref', 'skill'], + type: 'object', + }, + positionals: ['ref', 'skill'], +} satisfies CliRouteConfig; + +export const inputSchema = backendFlagsSchema.extend({ ref: z.string().min(1), skill: z.string().min(1) }).strict(); +export const resultSchema = z.object({ bot: agentSummarySchema, skill: skillSchema }).strict(); + +export default async function skillsRemove({ input }: CliRouteProps) { + const backend = await openBackendFromInput(input); + const { bot, skill } = await backend.removeSkill(input.ref, input.skill); + return ( + + {`Detached skill ${skill.name} (${skill.id}) from ${bot.name} (${bot.id})`} + + ); +} diff --git a/src/core/commands.js b/src/core/commands.js index df22d56..ef731d3 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: (ref) => gw.listAgentSkills(session, ref), + addSkill: (ref, markdown, name) => gw.addAgentSkill(session, ref, markdown, name), + removeSkill: (ref, skillRef) => gw.removeAgentSkill(session, ref, 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..dff9abf 100644 --- a/src/core/gateway.js +++ b/src/core/gateway.js @@ -298,6 +298,63 @@ export async function deleteAgent(session, ref) { return rec; } +function asSkill(skill) { + return { + id: String(skill.id), + name: skill.name || "", + description: skill.description || "", + source: skill.source || "user", + ...(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); +} + +function requireBot(rec) { + if (rec.isGroup) throw new GatewayError(`Skills attach to one bot. "${rec.name}" is a group.`); + return rec; +} + +const skillsOf = async (session, rec) => asSkills(await gatewayCall(session, "getAgentWorkflows", { id: rec.id })); + +export async function listAgentSkills(session, ref) { + return skillsOf(session, requireBot(await resolveRef(session, ref))); +} + +export async function addAgentSkill(session, ref, markdown, name) { + if (!String(markdown).trim()) throw new GatewayError("Skill markdown is empty."); + const rec = requireBot(await resolveRef(session, ref)); + const data = await gatewayCall(session, "importAgentWorkflowText", { + id: rec.id, + markdown: String(markdown), + ...(name ? { name: String(name) } : {}), + }); + 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 and a non-empty body.`); + } + return { bot: rec, skill: asSkills(data).find((s) => s.id === String(imported.id)) ?? asSkill(imported) }; +} + +export async function removeAgentSkill(session, ref, skillRef) { + const rec = requireBot(await resolveRef(session, ref)); + const skills = await skillsOf(session, rec); + 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}" on ${rec.name}.`); + if (matches.length > 1) throw new GatewayError(`Ambiguous skill name "${skillRef}" on ${rec.name}. Use the id.`); + if (matches[0].source !== "user") { + throw new GatewayError(`"${matches[0].name}" is a ${matches[0].source} skill. Manage it from its plugin or team.`); + } + await gatewayCall(session, "deleteAgentWorkflow", { id: rec.id, workflowId: matches[0].id }); + return { bot: rec, skill: 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..10c3405 --- /dev/null +++ b/test/gateway-skills.test.js @@ -0,0 +1,91 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { addAgentSkill, listAgentSkills, removeAgentSkill } from "../src/core/gateway.js"; + +const session = { gatewayUrl: "http://127.0.0.1:1340", gatewayToken: "test-token" }; +const bot = { id: "bot-1", name: "Researcher", title: "", isGroup: false }; +const other = { id: "bot-2", name: "Writer", title: "", isGroup: false }; +const group = { id: "group-1", name: "Launch", isGroup: true, memberIds: [bot.id] }; +const userSkill = { id: "wf-1", name: "poteto-mode", description: "Lazy senior dev", source: "user", body: "..." }; +const pluginSkill = { id: "wf-2", name: "talk-to-grok-bot", description: "", source: "plugin", pluginId: "77" }; + +function mockGateway(t, { workflows = [userSkill, pluginSkill], imported = { id: "wf-3", name: "new-skill" } } = {}) { + 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 }); + const data = { + listAgents: { agents: [bot, other, group] }, + getAgentWorkflows: workflows, + importAgentWorkflowText: imported + ? { workflows: [...workflows, { ...imported, description: "", source: "user" }], 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 resolves the bot by name and strips skill bodies", async (t) => { + const calls = mockGateway(t); + const skills = await listAgentSkills(session, "researcher"); + 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: "user" }, + { id: "wf-2", name: "talk-to-grok-bot", description: "", source: "plugin", pluginId: "77" }, + ]); +}); + +test("add imports markdown for exactly one bot id", async (t) => { + const calls = mockGateway(t); + const { bot: rec, skill } = await addAgentSkill(session, bot.name, "---\nname: new-skill\n---\nBody", "fallback"); + const call = calls.find((c) => c.method === "importAgentWorkflowText"); + assert.deepEqual(call.body, { id: bot.id, markdown: "---\nname: new-skill\n---\nBody", name: "fallback" }); + assert.equal(rec.id, bot.id); + assert.deepEqual(skill, { id: "wf-3", name: "new-skill", description: "", source: "user" }); + assert.ok(calls.every((c) => c.body.id === undefined || c.body.id === bot.id)); +}); + +test("add surfaces the gateway skip reason instead of a silent no-op", async (t) => { + mockGateway(t, { imported: null }); + await assert.rejects(addAgentSkill(session, bot.id, "# no frontmatter"), { name: "GatewayError", message: /empty or invalid/ }); +}); + +test("add rejects empty markdown before touching the gateway", async (t) => { + const calls = mockGateway(t); + await assert.rejects(addAgentSkill(session, bot.id, " \n"), { name: "GatewayError", message: /empty/ }); + assert.equal(calls.length, 0); +}); + +test("skills refuse groups", async (t) => { + const calls = mockGateway(t); + await assert.rejects(listAgentSkills(session, group.name), { name: "GatewayError", message: /is a group/ }); + await assert.rejects(addAgentSkill(session, group.id, "x"), { name: "GatewayError", message: /is a group/ }); + assert.ok(calls.every((c) => c.method === "listAgents")); +}); + +test("remove deletes a user skill by name and returns it", async (t) => { + const calls = mockGateway(t); + const { skill } = await removeAgentSkill(session, bot.id, "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 skill/], +]) { + test(`remove refuses ${label} without deleting`, async (t) => { + const calls = mockGateway(t); + await assert.rejects(removeAgentSkill(session, bot.id, 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: [userSkill, { ...userSkill, id: "wf-9" }] }); + await assert.rejects(removeAgentSkill(session, bot.id, userSkill.name), { name: "GatewayError", message: /Ambiguous/ }); + assert.ok(calls.every((c) => c.method !== "deleteAgentWorkflow")); +}); From 3c768c4c0846c58b3ad8c457a0ee0bef54e530a5 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Thu, 17 Sep 2026 03:49:28 +0000 Subject: [PATCH 2/3] fix(skills): manage the shared library, not a per-bot list The harness keeps one GlobalSkillLibrary per box and reports library skills as source "workflow". Drop the bot positional, accept "workflow" on remove, refuse bodies over 100k before posting, and say the import may have landed when the echoed list exceeds the response cap. --- .changeset/per-bot-skills.md | 5 -- .changeset/shared-skill-library.md | 5 ++ README.md | 19 ++++---- src/cli/skills/add.tsx | 28 +++++------ src/cli/skills/list.tsx | 11 ++--- src/cli/skills/remove.tsx | 20 ++++---- src/core/commands.js | 6 +-- src/core/gateway.js | 68 +++++++++++++++----------- test/gateway-skills.test.js | 77 +++++++++++++++++------------- 9 files changed, 126 insertions(+), 113 deletions(-) delete mode 100644 .changeset/per-bot-skills.md create mode 100644 .changeset/shared-skill-library.md diff --git a/.changeset/per-bot-skills.md b/.changeset/per-bot-skills.md deleted file mode 100644 index 746f368..0000000 --- a/.changeset/per-bot-skills.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"grok-bot-cli": minor ---- - -Add `gbot skills list|add|remove ` to attach a `SKILL.md` to one Grok Bot over the gateway (`getAgentWorkflows`, `importAgentWorkflowText`, `deleteAgentWorkflow`). `add` posts to that bot only; `remove` refuses plugin and team-managed skills. Skills have no `--files` mode. 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 529731f..977fec0 100644 --- a/README.md +++ b/README.md @@ -29,9 +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 Researcher -gbot skills add Researcher ./skills/poteto-mode -gbot skills remove Researcher poteto-mode +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 @@ -39,11 +39,14 @@ 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 one bot's private skills over the gateway. Grok Bot stores -skills per bot, not in a plugin directory, so `add` posts a `SKILL.md` (or a -directory holding one) to that bot only; other bots keep their own list. `remove` -detaches by id or exact name and refuses plugin and team-managed skills, which -belong to their marketplace install. There is no `--files` mode for skills. +`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`. diff --git a/src/cli/skills/add.tsx b/src/cli/skills/add.tsx index 377d030..089e00f 100644 --- a/src/cli/skills/add.tsx +++ b/src/cli/skills/add.tsx @@ -1,35 +1,30 @@ import { readFile, stat } from 'node:fs/promises'; -import { basename, dirname, join, resolve } from 'node:path'; +import { join, resolve } from 'node:path'; import { Agent } from '@agent-bundle/runtime'; import type { CliRouteConfig, CliRouteProps } from 'agent-bundle'; import { z } from 'zod'; -import { agentSummarySchema, backendFlagsSchema, openBackendFromInput, skillSchema } from '../_shared.js'; -import { summarize } from '../../core/format.js'; +import { backendFlagsSchema, openBackendFromInput, skillSchema } from '../_shared.js'; export const config = { - description: 'Attach a SKILL.md to one bot. Other bots are not changed.', + 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' }, - name: { description: 'Skill name when the markdown has no frontmatter name', type: 'string' }, path: { description: 'SKILL.md file, or a directory holding one', type: 'string' }, - ref: { description: 'Bot id or name', type: 'string' }, }, - required: ['ref', 'path'], + required: ['path'], type: 'object', }, - positionals: ['ref', 'path'], + positionals: ['path'], } satisfies CliRouteConfig; -export const inputSchema = backendFlagsSchema - .extend({ name: z.string().min(1).optional(), path: z.string().min(1), ref: z.string().min(1) }) - .strict(); -export const resultSchema = z.object({ bot: agentSummarySchema, skill: skillSchema }).strict(); +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); @@ -37,13 +32,12 @@ const skillFile = async (path: string): Promise => { }; export default async function skillsAdd({ input }: CliRouteProps) { - const file = await skillFile(input.path); - const markdown = await readFile(file, 'utf8'); + const markdown = await readFile(await skillFile(input.path), 'utf8'); const backend = await openBackendFromInput(input); - const { bot, skill } = await backend.addSkill(input.ref, markdown, input.name ?? basename(dirname(file))); + const skill = resultSchema.parse(await backend.addSkill(markdown)); return ( - - {`Attached skill ${skill.name} (${skill.id}) to ${bot.name} (${bot.id})`} + + {`Added skill ${skill.name} (${skill.id}) to the shared library`} ); } diff --git a/src/cli/skills/list.tsx b/src/cli/skills/list.tsx index 89d8c18..98562bc 100644 --- a/src/cli/skills/list.tsx +++ b/src/cli/skills/list.tsx @@ -5,31 +5,28 @@ import { z } from 'zod'; import { backendFlagsSchema, openBackendFromInput, skillSchema } from '../_shared.js'; export const config = { - description: 'List the skills attached to one bot.', + 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' }, - ref: { description: 'Bot id or name', type: 'string' }, }, - required: ['ref'], type: 'object', }, - positionals: ['ref'], } satisfies CliRouteConfig; -export const inputSchema = backendFlagsSchema.extend({ ref: z.string().min(1) }).strict(); +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(input.ref)); + 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 on ${input.ref}.`} + {lines.length > 0 ? lines.join('\n') : 'No skills.'} ); } diff --git a/src/cli/skills/remove.tsx b/src/cli/skills/remove.tsx index bd7be42..85f6d45 100644 --- a/src/cli/skills/remove.tsx +++ b/src/cli/skills/remove.tsx @@ -2,35 +2,33 @@ import { Agent } from '@agent-bundle/runtime'; import type { CliRouteConfig, CliRouteProps } from 'agent-bundle'; import { z } from 'zod'; -import { agentSummarySchema, backendFlagsSchema, openBackendFromInput, skillSchema } from '../_shared.js'; -import { summarize } from '../../core/format.js'; +import { backendFlagsSchema, openBackendFromInput, skillSchema } from '../_shared.js'; export const config = { - description: 'Detach a user skill from one bot by id or name.', + 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' }, - ref: { description: 'Bot id or name', type: 'string' }, skill: { description: 'Skill id or name', type: 'string' }, }, - required: ['ref', 'skill'], + required: ['skill'], type: 'object', }, - positionals: ['ref', 'skill'], + positionals: ['skill'], } satisfies CliRouteConfig; -export const inputSchema = backendFlagsSchema.extend({ ref: z.string().min(1), skill: z.string().min(1) }).strict(); -export const resultSchema = z.object({ bot: agentSummarySchema, skill: skillSchema }).strict(); +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 { bot, skill } = await backend.removeSkill(input.ref, input.skill); + const skill = resultSchema.parse(await backend.removeSkill(input.skill)); return ( - - {`Detached skill ${skill.name} (${skill.id}) from ${bot.name} (${bot.id})`} + + {`Removed skill ${skill.name} (${skill.id}) from the shared library`} ); } diff --git a/src/core/commands.js b/src/core/commands.js index ef731d3..7ff81a1 100644 --- a/src/core/commands.js +++ b/src/core/commands.js @@ -30,9 +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: (ref) => gw.listAgentSkills(session, ref), - addSkill: (ref, markdown, name) => gw.addAgentSkill(session, ref, markdown, name), - removeSkill: (ref, skillRef) => gw.removeAgentSkill(session, ref, skillRef), + skills: () => gw.listSkills(session), + addSkill: (markdown) => gw.addSkill(session, markdown), + removeSkill: (skillRef) => gw.removeSkill(session, skillRef), }; } const root = files.resolveAgentsRoot(opts.root); diff --git a/src/core/gateway.js b/src/core/gateway.js index dff9abf..f72b93b 100644 --- a/src/core/gateway.js +++ b/src/core/gateway.js @@ -298,61 +298,73 @@ 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: skill.source || "user", + 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 : []; + const list = Array.isArray(data) ? data : Array.isArray(data?.workflows) ? data.workflows : []; return list.filter((s) => s && s.id).map(asSkill); } -function requireBot(rec) { - if (rec.isGroup) throw new GatewayError(`Skills attach to one bot. "${rec.name}" is a group.`); - return rec; +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; } -const skillsOf = async (session, rec) => asSkills(await gatewayCall(session, "getAgentWorkflows", { id: rec.id })); - -export async function listAgentSkills(session, ref) { - return skillsOf(session, requireBot(await resolveRef(session, ref))); +export async function listSkills(session) { + return asSkills(await gatewayCall(session, "getAgentWorkflows", { id: await anyBotId(session) })); } -export async function addAgentSkill(session, ref, markdown, name) { - if (!String(markdown).trim()) throw new GatewayError("Skill markdown is empty."); - const rec = requireBot(await resolveRef(session, ref)); - const data = await gatewayCall(session, "importAgentWorkflowText", { - id: rec.id, - markdown: String(markdown), - ...(name ? { name: String(name) } : {}), - }); +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.`); + } + let data; + try { + data = await gatewayCall(session, "importAgentWorkflowText", { id: await anyBotId(session), 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 and a non-empty body.`); + throw new GatewayError(`Grok Bot did not import the skill (${reason}). It needs a name, a non-empty body, and library room.`); } - return { bot: rec, skill: asSkills(data).find((s) => s.id === String(imported.id)) ?? asSkill(imported) }; + return asSkills(data).find((s) => s.id === String(imported.id)) ?? asSkill(imported); } -export async function removeAgentSkill(session, ref, skillRef) { - const rec = requireBot(await resolveRef(session, ref)); - const skills = await skillsOf(session, rec); +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}" on ${rec.name}.`); - if (matches.length > 1) throw new GatewayError(`Ambiguous skill name "${skillRef}" on ${rec.name}. Use the id.`); - if (matches[0].source !== "user") { - throw new GatewayError(`"${matches[0].name}" is a ${matches[0].source} skill. Manage it from its plugin or team.`); + 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: rec.id, workflowId: matches[0].id }); - return { bot: rec, skill: matches[0] }; + await gatewayCall(session, "deleteAgentWorkflow", { id, workflowId: matches[0].id }); + return matches[0]; } function normalizeMemberIds(records, memberRefs) { diff --git a/test/gateway-skills.test.js b/test/gateway-skills.test.js index 10c3405..1a49c33 100644 --- a/test/gateway-skills.test.js +++ b/test/gateway-skills.test.js @@ -1,25 +1,27 @@ import test from "node:test"; import assert from "node:assert/strict"; -import { addAgentSkill, listAgentSkills, removeAgentSkill } from "../src/core/gateway.js"; +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 }; -const other = { id: "bot-2", name: "Writer", title: "", isGroup: false }; -const group = { id: "group-1", name: "Launch", isGroup: true, memberIds: [bot.id] }; -const userSkill = { id: "wf-1", name: "poteto-mode", description: "Lazy senior dev", source: "user", body: "..." }; +// 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, { workflows = [userSkill, pluginSkill], imported = { id: "wf-3", name: "new-skill" } } = {}) { +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: [bot, other, group] }, + listAgents: { agents }, getAgentWorkflows: workflows, importAgentWorkflowText: imported - ? { workflows: [...workflows, { ...imported, description: "", source: "user" }], result: { imported: [imported], skipped: [] } } + ? { 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]; @@ -28,64 +30,71 @@ function mockGateway(t, { workflows = [userSkill, pluginSkill], imported = { id: return calls; } -test("list resolves the bot by name and strips skill bodies", async (t) => { +test("list uses the first bot (never a group) and strips bodies", async (t) => { const calls = mockGateway(t); - const skills = await listAgentSkills(session, "researcher"); + 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: "user" }, + { 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("add imports markdown for exactly one bot id", async (t) => { +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 { bot: rec, skill } = await addAgentSkill(session, bot.name, "---\nname: new-skill\n---\nBody", "fallback"); - const call = calls.find((c) => c.method === "importAgentWorkflowText"); - assert.deepEqual(call.body, { id: bot.id, markdown: "---\nname: new-skill\n---\nBody", name: "fallback" }); - assert.equal(rec.id, bot.id); - assert.deepEqual(skill, { id: "wf-3", name: "new-skill", description: "", source: "user" }); - assert.ok(calls.every((c) => c.body.id === undefined || c.body.id === bot.id)); + 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(addAgentSkill(session, bot.id, "# no frontmatter"), { name: "GatewayError", message: /empty or invalid/ }); + await assert.rejects(addSkill(session, "# no frontmatter"), { name: "GatewayError", message: /empty or invalid/ }); }); -test("add rejects empty markdown before touching the gateway", async (t) => { - const calls = mockGateway(t); - await assert.rejects(addAgentSkill(session, bot.id, " \n"), { name: "GatewayError", message: /empty/ }); - assert.equal(calls.length, 0); -}); +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("skills refuse groups", async (t) => { - const calls = mockGateway(t); - await assert.rejects(listAgentSkills(session, group.name), { name: "GatewayError", message: /is a group/ }); - await assert.rejects(addAgentSkill(session, group.id, "x"), { name: "GatewayError", message: /is a group/ }); - assert.ok(calls.every((c) => c.method === "listAgents")); +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 user skill by name and returns it", async (t) => { +test("remove deletes a library skill by name, case-insensitively", async (t) => { const calls = mockGateway(t); - const { skill } = await removeAgentSkill(session, bot.id, "POTETO-MODE"); + 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 skill/], + ["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(removeAgentSkill(session, bot.id, skillRef), { name: "GatewayError", message: error }); + 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: [userSkill, { ...userSkill, id: "wf-9" }] }); - await assert.rejects(removeAgentSkill(session, bot.id, userSkill.name), { name: "GatewayError", message: /Ambiguous/ }); + 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")); }); From f6f6ca6dfec2d2a20522bcb76e0f6c46638e12f8 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Thu, 17 Sep 2026 03:52:32 +0000 Subject: [PATCH 3/3] fix(skills): resolve the routing bot before the import's response-cap guard --- src/core/gateway.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/core/gateway.js b/src/core/gateway.js index f72b93b..dcc9721 100644 --- a/src/core/gateway.js +++ b/src/core/gateway.js @@ -335,9 +335,10 @@ export async function addSkill(session, markdown) { 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: await anyBotId(session), markdown: text }); + 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.");