-
Notifications
You must be signed in to change notification settings - Fork 11
feat: gbot skills list/add/remove for the shared skill library #91
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
3 commits
Select commit
Hold shift + click to select a range
b95dba0
feat: gbot skills list/add/remove attach a SKILL.md to one bot
ScriptedAlchemy 3c768c4
fix(skills): manage the shared library, not a per-bot list
ScriptedAlchemy f6f6ca6
fix(skills): resolve the routing bot before the import's response-cap…
ScriptedAlchemy File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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> | ||
| ); | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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}` : ''}`); | ||
| return ( | ||
| <Agent.Result value={skills}> | ||
| <Agent.Text>{lines.length > 0 ? lines.join('\n') : 'No skills.'}</Agent.Text> | ||
| </Agent.Result> | ||
| ); | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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> | ||
| ); | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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")); | ||
| }); |
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
A skill name, source, or description containing terminal control sequences is written directly to the terminal here. These fields can originate in imported
SKILL.mdfrontmatter 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 👍 / 👎.