Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/shared-skill-library.md
Original file line number Diff line number Diff line change
@@ -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.
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,13 +29,25 @@ gbot send Researcher "Summarize the launch status."
gbot send Launch "Share your updates."
gbot thread Researcher
gbot thread Researcher --after <last-entry-id> --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
```

`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
Expand Down
9 changes: 9 additions & 0 deletions src/cli/_shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
43 changes: 43 additions & 0 deletions src/cli/skills/add.tsx
Original file line number Diff line number Diff line change
@@ -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<string> => {
const target = resolve(path);
return (await stat(target)).isDirectory() ? join(target, 'SKILL.md') : target;
};

export default async function skillsAdd({ input }: CliRouteProps<typeof inputSchema>) {
const markdown = await readFile(await skillFile(input.path), 'utf8');
const backend = await openBackendFromInput(input);
const skill = resultSchema.parse(await backend.addSkill(markdown));
return (
<Agent.Result value={skill}>
<Agent.Text>{`Added skill ${skill.name} (${skill.id}) to the shared library`}</Agent.Text>
</Agent.Result>
);
}
32 changes: 32 additions & 0 deletions src/cli/skills/list.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof inputSchema>) {
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}` : ''}`);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Sanitize skill metadata before terminal rendering

A skill name, source, or description containing terminal control sequences is written directly to the terminal here. These fields can originate in imported SKILL.md frontmatter or marketplace/team skills, so listing such a skill can execute ANSI/OSC effects rather than display literal metadata; strip terminal controls before constructing the text output, as other externally supplied text renderers in this repository do.

Useful? React with 👍 / 👎.

return (
<Agent.Result value={skills}>
<Agent.Text>{lines.length > 0 ? lines.join('\n') : 'No skills.'}</Agent.Text>
</Agent.Result>
);
}
34 changes: 34 additions & 0 deletions src/cli/skills/remove.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof inputSchema>) {
const backend = await openBackendFromInput(input);
const skill = resultSchema.parse(await backend.removeSkill(input.skill));
return (
<Agent.Result value={skill}>
<Agent.Text>{`Removed skill ${skill.name} (${skill.id}) from the shared library`}</Agent.Text>
</Agent.Result>
);
}
6 changes: 6 additions & 0 deletions src/core/commands.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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"); },
};
}
70 changes: 70 additions & 0 deletions src/core/gateway.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
100 changes: 100 additions & 0 deletions test/gateway-skills.test.js
Original file line number Diff line number Diff line change
@@ -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"));
});