mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-27 22:15:33 +00:00
v0.41.36.0 feat(mcp): publish agent skills (list_skills / get_skill) for thin clients (#1661)
* feat(skill-catalog): host-repo skill catalog core + mcp config keys New src/core/skill-catalog.ts resolves the agent repo's skills dir, builds a flat catalog, fetches one skill's prose, and gates publishing. Path confinement (manifest-vetted lookup + realpath + SKILL.md file-type check), 256KB response cap, frontmatter allowlist, and D7 tool cross-reference live here. config.ts gains the mcp.publish_skills / mcp.skills_dir keys (+ KNOWN_CONFIG entries). * feat(mcp): list_skills + get_skill read ops for thin-client skill discovery Two read-scope, non-localOnly ops (dynamic-import skill-catalog to avoid the cycle) let Codex/Claude Code/Perplexity discover and follow the agent's skills over gbrain serve. Descriptions + the instructional envelope constants are pinned in operations-descriptions.ts. * feat(mcp): default new installs to publish skills; consent prompt on upgrade gbrain init writes mcp.publish_skills:true (file plane) so new installs publish by default. gbrain upgrade prompts existing installs once (DB plane), strongly recommending opt-in, showing the resolved skills dir + that SKILL.md contents become readable by authorized remote MCP callers. * test(skill-catalog): catalog, security-confinement, transport-gate, description pins 40 cases: buildSkillCatalog/getSkillDetail/D7 split (skill-catalog.test.ts), path traversal + symlink + poisoned-manifest + oversize + non-SKILL.md + gate (skill-catalog-security.test.ts), and real dispatchToolCall gate+scope+plane coverage (skill-catalog-transports.test.ts). Plus list_skills/get_skill description + envelope pins. * chore: bump version and changelog (v0.41.36.0) MCP skill catalog (list_skills / get_skill) — thin clients can discover and follow the agent repo's skills over gbrain serve. PR2 (tarball/install) filed in TODOS. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs: document skill-catalog (list_skills/get_skill + mcp config keys) for v0.41.36.0 Add the src/core/skill-catalog.ts Key-files annotation to CLAUDE.md covering the two new read-scope MCP ops, the mcp.publish_skills / mcp.skills_dir config keys, the full trust-boundary mitigation stack, and the init/upgrade wiring. Regenerate llms-full.txt to match (CI build-llms drift gate). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
0b2a26a31d
commit
ca13f40820
@@ -0,0 +1,5 @@
|
||||
# Skill routing
|
||||
|
||||
| Trigger | Skill |
|
||||
|---------|-------|
|
||||
| where is my note | `skills/query-helper/SKILL.md` |
|
||||
@@ -0,0 +1,3 @@
|
||||
# conventions (must be excluded from the catalog)
|
||||
|
||||
Underscore-prefixed dirs are conventions/shared rules, never skills.
|
||||
@@ -0,0 +1,25 @@
|
||||
---
|
||||
name: brain-ops
|
||||
description: Read, enrich, and write brain pages with source attribution.
|
||||
triggers:
|
||||
- any brain read or write
|
||||
- look something up in the brain
|
||||
tools:
|
||||
- search
|
||||
- query
|
||||
- put_page
|
||||
- web_search
|
||||
writes_pages: true
|
||||
writes_to:
|
||||
- people/
|
||||
- companies/
|
||||
mutating: true
|
||||
sources:
|
||||
- /abs/path/should/be/dropped.ts
|
||||
---
|
||||
|
||||
# brain-ops
|
||||
|
||||
Brain-first lookup, read-enrich-write loop, source attribution.
|
||||
|
||||
Phase 1: search the brain before reaching for any external tool.
|
||||
@@ -0,0 +1,4 @@
|
||||
# broken
|
||||
|
||||
This SKILL.md has no YAML frontmatter fence. It must still be listed (with
|
||||
the directory name as its name and empty triggers), never throw.
|
||||
@@ -0,0 +1,14 @@
|
||||
---
|
||||
name: query-helper
|
||||
triggers:
|
||||
- find a page
|
||||
tools:
|
||||
- search
|
||||
- query
|
||||
writes_pages: false
|
||||
---
|
||||
|
||||
# query-helper
|
||||
|
||||
This skill helps you query the brain. The first prose line becomes the
|
||||
description when no `description:` frontmatter is present.
|
||||
@@ -6,6 +6,10 @@ import {
|
||||
LIST_PAGES_DESCRIPTION,
|
||||
QUERY_DESCRIPTION,
|
||||
SEARCH_DESCRIPTION,
|
||||
LIST_SKILLS_DESCRIPTION,
|
||||
GET_SKILL_DESCRIPTION,
|
||||
SKILL_CATALOG_INSTRUCTIONS,
|
||||
SKILL_CLIENT_GUIDANCE,
|
||||
} from '../src/core/operations-descriptions.ts';
|
||||
import { operations, operationsByName } from '../src/core/operations.ts';
|
||||
import { BRAIN_TOOL_ALLOWLIST } from '../src/core/minions/tools/brain-allowlist.ts';
|
||||
@@ -141,3 +145,45 @@ describe('v0.29 — operations array carries the three new ops', () => {
|
||||
expect(names).toContain('get_recent_transcripts');
|
||||
});
|
||||
});
|
||||
|
||||
describe('PR1 — skill catalog descriptions', () => {
|
||||
test('list_skills / get_skill match the operation registration', () => {
|
||||
expect(operationsByName['list_skills'].description).toBe(LIST_SKILLS_DESCRIPTION);
|
||||
expect(operationsByName['get_skill'].description).toBe(GET_SKILL_DESCRIPTION);
|
||||
});
|
||||
|
||||
test('descriptions teach "prose, not executable code" + the follow-the-prose protocol', () => {
|
||||
expect(LIST_SKILLS_DESCRIPTION).toContain('NOT executable code');
|
||||
expect(LIST_SKILLS_DESCRIPTION).toContain('get_skill');
|
||||
expect(GET_SKILL_DESCRIPTION).toContain('nothing to');
|
||||
expect(GET_SKILL_DESCRIPTION).toContain('same-named MCP tool');
|
||||
});
|
||||
|
||||
test('descriptions surface usable/unavailable tool honesty', () => {
|
||||
expect(LIST_SKILLS_DESCRIPTION).toContain('usable_tools');
|
||||
expect(LIST_SKILLS_DESCRIPTION).toContain('unavailable_tools');
|
||||
expect(GET_SKILL_DESCRIPTION).toContain('unavailable_tools');
|
||||
});
|
||||
|
||||
test('both ops are read-scope, non-localOnly (thin clients reach them over HTTP)', () => {
|
||||
for (const n of ['list_skills', 'get_skill']) {
|
||||
expect(operationsByName[n].scope).toBe('read');
|
||||
expect(operationsByName[n].localOnly).toBeFalsy();
|
||||
expect(operationsByName[n].mutating).toBeFalsy();
|
||||
}
|
||||
});
|
||||
|
||||
test('instructions envelope is shaped + load-bearing', () => {
|
||||
expect(SKILL_CATALOG_INSTRUCTIONS.summary).toContain('not executable tools');
|
||||
expect(SKILL_CATALOG_INSTRUCTIONS.how_to_use.length).toBeGreaterThanOrEqual(3);
|
||||
expect(SKILL_CATALOG_INSTRUCTIONS.how_to_use.join(' ')).toContain('get_skill');
|
||||
expect(SKILL_CLIENT_GUIDANCE.nature).toContain('not code to execute');
|
||||
expect(SKILL_CLIENT_GUIDANCE.protocol.length).toBeGreaterThanOrEqual(3);
|
||||
});
|
||||
|
||||
test('operations array carries both new ops', () => {
|
||||
const names = operations.map(o => o.name);
|
||||
expect(names).toContain('list_skills');
|
||||
expect(names).toContain('get_skill');
|
||||
});
|
||||
});
|
||||
|
||||
Binary file not shown.
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* Transport + gate integration for the skill-catalog ops (codex P2-d).
|
||||
*
|
||||
* Drives the REAL dispatch path (`dispatchToolCall` → buildOperationContext →
|
||||
* op.handler) so we prove `ctx.remote` + `ctx.auth.scopes` + the publish gate
|
||||
* behave per transport: HTTP-remote enforces the gate, local CLI bypasses it,
|
||||
* scope shapes the D7 tool split, and the frontmatter allowlist holds end to end.
|
||||
*
|
||||
* Config is driven via the DB plane (engine.setConfig) — the plane `gbrain
|
||||
* config set` writes and the plane the gate reads first. GBRAIN_HOME is pinned
|
||||
* to a tmp dir so loadConfig()'s file plane can't leak the dev's real config in.
|
||||
*/
|
||||
import { describe, test, expect, beforeAll, afterAll, beforeEach } from 'bun:test';
|
||||
import { join } from 'path';
|
||||
import { mkdtempSync, rmSync } from 'fs';
|
||||
import { tmpdir } from 'os';
|
||||
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
|
||||
import { resetPgliteState } from './helpers/reset-pglite.ts';
|
||||
import { withEnv } from './helpers/with-env.ts';
|
||||
import { dispatchToolCall } from '../src/mcp/dispatch.ts';
|
||||
import type { AuthInfo } from '../src/core/operations.ts';
|
||||
|
||||
const FIXTURE = join(import.meta.dir, 'fixtures', 'skill-catalog', 'skills');
|
||||
|
||||
let engine: PGLiteEngine;
|
||||
let home: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
engine = new PGLiteEngine();
|
||||
await engine.connect({});
|
||||
await engine.initSchema();
|
||||
home = mkdtempSync(join(tmpdir(), 'skill-tx-home-'));
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await engine.disconnect();
|
||||
try { rmSync(home, { recursive: true, force: true }); } catch { /* best-effort */ }
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await resetPgliteState(engine);
|
||||
// Point the catalog at the fixture via the DB plane on every test.
|
||||
await engine.setConfig('mcp.skills_dir', FIXTURE);
|
||||
});
|
||||
|
||||
/** Parse the JSON envelope dispatchToolCall wraps every result/error in. */
|
||||
function unpack(res: { content: { text: string }[]; isError?: boolean }): {
|
||||
isError: boolean;
|
||||
body: any;
|
||||
} {
|
||||
return { isError: !!res.isError, body: JSON.parse(res.content[0].text) };
|
||||
}
|
||||
|
||||
async function call(
|
||||
name: string,
|
||||
params: Record<string, unknown>,
|
||||
opts: { remote: boolean; auth?: AuthInfo },
|
||||
) {
|
||||
return unpack(await dispatchToolCall(engine, name, params, opts));
|
||||
}
|
||||
|
||||
describe('list_skills over dispatch', () => {
|
||||
test('HTTP-remote + gate OFF → permission_denied', async () => {
|
||||
await withEnv({ GBRAIN_HOME: home }, async () => {
|
||||
const r = await call('list_skills', {}, { remote: true, auth: { token: 't', clientId: 'c', scopes: ['read'] } });
|
||||
expect(r.isError).toBe(true);
|
||||
expect(r.body.error).toBe('permission_denied');
|
||||
});
|
||||
});
|
||||
|
||||
test('local (remote=false) bypasses the gate even when OFF', async () => {
|
||||
await withEnv({ GBRAIN_HOME: home }, async () => {
|
||||
const r = await call('list_skills', {}, { remote: false });
|
||||
expect(r.isError).toBe(false);
|
||||
expect(r.body.schema_version).toBe(1);
|
||||
expect(r.body.skills.map((s: any) => s.name)).toContain('brain-ops');
|
||||
});
|
||||
});
|
||||
|
||||
test('HTTP-remote + gate ON → catalog with scope-aware D7 split', async () => {
|
||||
await withEnv({ GBRAIN_HOME: home }, async () => {
|
||||
await engine.setConfig('mcp.publish_skills', 'true');
|
||||
const r = await call('list_skills', {}, { remote: true, auth: { token: 't', clientId: 'c', scopes: ['read'] } });
|
||||
expect(r.isError).toBe(false);
|
||||
const bo = r.body.skills.find((s: any) => s.name === 'brain-ops');
|
||||
expect(bo.usable_tools.sort()).toEqual(['query', 'search']);
|
||||
expect(bo.unavailable_tools).toContain('put_page'); // write op, read token can't call
|
||||
expect(r.body.instructions.available_brain_tools).not.toContain('put_page');
|
||||
});
|
||||
});
|
||||
|
||||
test('admin scope widens usable_tools', async () => {
|
||||
await withEnv({ GBRAIN_HOME: home }, async () => {
|
||||
await engine.setConfig('mcp.publish_skills', 'true');
|
||||
const r = await call('list_skills', {}, { remote: true, auth: { token: 't', clientId: 'c', scopes: ['admin'] } });
|
||||
const bo = r.body.skills.find((s: any) => s.name === 'brain-ops');
|
||||
expect(bo.usable_tools).toContain('put_page');
|
||||
});
|
||||
});
|
||||
|
||||
test('DB-plane disable wins (gbrain config set mcp.publish_skills false)', async () => {
|
||||
await withEnv({ GBRAIN_HOME: home }, async () => {
|
||||
await engine.setConfig('mcp.publish_skills', 'false');
|
||||
const r = await call('list_skills', {}, { remote: true, auth: { token: 't', clientId: 'c', scopes: ['read'] } });
|
||||
expect(r.isError).toBe(true);
|
||||
expect(r.body.error).toBe('permission_denied');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('get_skill over dispatch', () => {
|
||||
test('remote + ON returns prose body, frontmatter allowlisted (no writes_to/sources)', async () => {
|
||||
await withEnv({ GBRAIN_HOME: home }, async () => {
|
||||
await engine.setConfig('mcp.publish_skills', 'true');
|
||||
const r = await call('get_skill', { name: 'brain-ops' }, { remote: true, auth: { token: 't', clientId: 'c', scopes: ['read'] } });
|
||||
expect(r.isError).toBe(false);
|
||||
expect(r.body.body).toContain('Brain-first lookup');
|
||||
const blob = JSON.stringify(r.body);
|
||||
expect(blob).not.toContain('/abs/path/should/be/dropped.ts');
|
||||
expect(blob).not.toContain('people/');
|
||||
expect(r.body.usable_tools.sort()).toEqual(['query', 'search']);
|
||||
});
|
||||
});
|
||||
|
||||
test('remote + OFF → permission_denied (gate before fetch)', async () => {
|
||||
await withEnv({ GBRAIN_HOME: home }, async () => {
|
||||
const r = await call('get_skill', { name: 'brain-ops' }, { remote: true, auth: { token: 't', clientId: 'c', scopes: ['read'] } });
|
||||
expect(r.isError).toBe(true);
|
||||
expect(r.body.error).toBe('permission_denied');
|
||||
});
|
||||
});
|
||||
|
||||
test('unknown skill → page_not_found', async () => {
|
||||
await withEnv({ GBRAIN_HOME: home }, async () => {
|
||||
await engine.setConfig('mcp.publish_skills', 'true');
|
||||
const r = await call('get_skill', { name: 'nope' }, { remote: false });
|
||||
expect(r.isError).toBe(true);
|
||||
expect(r.body.error).toBe('page_not_found');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,163 @@
|
||||
import { describe, test, expect } from 'bun:test';
|
||||
import { join } from 'path';
|
||||
import type { OperationContext } from '../src/core/operations.ts';
|
||||
import {
|
||||
buildSkillCatalog,
|
||||
getSkillDetail,
|
||||
crossReferenceTools,
|
||||
oneLineDescription,
|
||||
resolveSkillMdPath,
|
||||
} from '../src/core/skill-catalog.ts';
|
||||
|
||||
const FIXTURE = join(import.meta.dir, 'fixtures', 'skill-catalog', 'skills');
|
||||
|
||||
/** Minimal ctx stub — the pure catalog functions only read remote/auth. */
|
||||
function ctx(remote: boolean, scopes?: string[]): OperationContext {
|
||||
return {
|
||||
remote,
|
||||
auth: scopes ? { token: 't', clientId: 'c', scopes } : undefined,
|
||||
config: {} as OperationContext['config'],
|
||||
engine: null as unknown as OperationContext['engine'],
|
||||
logger: { info() {}, warn() {}, error() {} },
|
||||
dryRun: false,
|
||||
sourceId: 'default',
|
||||
} as OperationContext;
|
||||
}
|
||||
|
||||
describe('buildSkillCatalog', () => {
|
||||
test('lists real skills, excludes _conventions, derives broken', () => {
|
||||
const res = buildSkillCatalog(ctx(true, ['read']), FIXTURE, 'config');
|
||||
const names = res.skills.map(s => s.name).sort();
|
||||
expect(names).toContain('brain-ops');
|
||||
expect(names).toContain('query-helper');
|
||||
expect(names).toContain('broken'); // malformed frontmatter → derived name, listed
|
||||
expect(names).not.toContain('_conventions');
|
||||
expect(res.count).toBe(res.skills.length);
|
||||
expect(res.schema_version).toBe(1);
|
||||
expect(res.skills_dir_source).toBe('config');
|
||||
});
|
||||
|
||||
test('malformed-frontmatter skill is listed with empty triggers, no throw', () => {
|
||||
const res = buildSkillCatalog(ctx(true, ['read']), FIXTURE, 'config');
|
||||
const broken = res.skills.find(s => s.name === 'broken')!;
|
||||
expect(broken).toBeDefined();
|
||||
expect(broken.triggers).toEqual([]);
|
||||
expect(broken.tools).toEqual([]);
|
||||
expect(broken.writes_pages).toBe(false);
|
||||
});
|
||||
|
||||
test('triggers union frontmatter + RESOLVER.md, deduped', () => {
|
||||
const res = buildSkillCatalog(ctx(true, ['read']), FIXTURE, 'config');
|
||||
const qh = res.skills.find(s => s.name === 'query-helper')!;
|
||||
// frontmatter trigger + the RESOLVER.md row both present
|
||||
expect(qh.triggers).toContain('find a page');
|
||||
expect(qh.triggers).toContain('where is my note');
|
||||
});
|
||||
|
||||
test('D7 usable/unavailable split honors caller scope', () => {
|
||||
const read = buildSkillCatalog(ctx(true, ['read']), FIXTURE, 'config')
|
||||
.skills.find(s => s.name === 'brain-ops')!;
|
||||
expect(read.usable_tools.sort()).toEqual(['query', 'search']);
|
||||
// put_page is write-scope (blocked for read), web_search isn't an op at all
|
||||
expect(read.unavailable_tools.sort()).toEqual(['put_page', 'web_search']);
|
||||
|
||||
const admin = buildSkillCatalog(ctx(true, ['admin']), FIXTURE, 'config')
|
||||
.skills.find(s => s.name === 'brain-ops')!;
|
||||
expect(admin.usable_tools.sort()).toEqual(['put_page', 'query', 'search']);
|
||||
expect(admin.unavailable_tools).toEqual(['web_search']);
|
||||
});
|
||||
|
||||
test('local caller (remote=false) can use any real op', () => {
|
||||
const local = buildSkillCatalog(ctx(false), FIXTURE, 'config')
|
||||
.skills.find(s => s.name === 'brain-ops')!;
|
||||
expect(local.usable_tools.sort()).toEqual(['put_page', 'query', 'search']);
|
||||
expect(local.unavailable_tools).toEqual(['web_search']); // still not an op
|
||||
});
|
||||
|
||||
test('section filter narrows the result set', () => {
|
||||
const all = buildSkillCatalog(ctx(true, ['read']), FIXTURE, 'config');
|
||||
const someSection = all.skills[0].section;
|
||||
const filtered = buildSkillCatalog(ctx(true, ['read']), FIXTURE, 'config', {
|
||||
section: someSection,
|
||||
});
|
||||
expect(filtered.skills.every(s => s.section === someSection)).toBe(true);
|
||||
const nomatch = buildSkillCatalog(ctx(true, ['read']), FIXTURE, 'config', {
|
||||
section: 'no-such-section-xyz',
|
||||
});
|
||||
expect(nomatch.skills).toEqual([]);
|
||||
});
|
||||
|
||||
test('instructions envelope present and shaped', () => {
|
||||
const res = buildSkillCatalog(ctx(true, ['read']), FIXTURE, 'config');
|
||||
expect(res.instructions.fetch_op).toBe('get_skill');
|
||||
expect(res.instructions.how_to_use.length).toBeGreaterThan(0);
|
||||
expect(res.instructions.available_brain_tools).toContain('search');
|
||||
// read scope must NOT advertise a write op as available
|
||||
expect(res.instructions.available_brain_tools).not.toContain('put_page');
|
||||
});
|
||||
|
||||
test('empty dir → count 0 but instructions still present', () => {
|
||||
const empty = join(import.meta.dir, 'fixtures', 'skill-catalog', 'does-not-exist');
|
||||
const res = buildSkillCatalog(ctx(true, ['read']), empty, 'config');
|
||||
expect(res.count).toBe(0);
|
||||
expect(res.skills).toEqual([]);
|
||||
expect(res.instructions.fetch_op).toBe('get_skill');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSkillDetail', () => {
|
||||
test('returns prose body + allowlisted frontmatter (drops writes_to/sources)', () => {
|
||||
const res = getSkillDetail(ctx(true, ['read']), FIXTURE, 'brain-ops');
|
||||
expect(res.name).toBe('brain-ops');
|
||||
expect(res.body).toContain('Brain-first lookup');
|
||||
expect(res.body).not.toContain('---'); // fence stripped
|
||||
// allowlist: name/description/triggers/tools/writes_pages/mutating only
|
||||
const fmKeys = Object.keys(res.frontmatter).sort();
|
||||
expect(fmKeys).not.toContain('writes_to');
|
||||
expect(fmKeys).not.toContain('sources');
|
||||
expect(fmKeys).not.toContain('raw');
|
||||
expect(res.frontmatter.writes_pages).toBe(true);
|
||||
// the dropped fields must not leak anywhere in the response
|
||||
const blob = JSON.stringify(res);
|
||||
expect(blob).not.toContain('/abs/path/should/be/dropped.ts');
|
||||
expect(blob).not.toContain('people/');
|
||||
});
|
||||
|
||||
test('mirrors the D7 tool split + client_guidance', () => {
|
||||
const res = getSkillDetail(ctx(true, ['read']), FIXTURE, 'brain-ops');
|
||||
expect(res.usable_tools.sort()).toEqual(['query', 'search']);
|
||||
expect(res.unavailable_tools.sort()).toEqual(['put_page', 'web_search']);
|
||||
expect(res.client_guidance.mutating).toBe(true);
|
||||
expect(res.client_guidance.protocol.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('oneLineDescription', () => {
|
||||
test('prefers frontmatter description', () => {
|
||||
expect(oneLineDescription('description: hello there', '# h\nbody')).toBe('hello there');
|
||||
});
|
||||
test('falls back to first prose line, skipping headings', () => {
|
||||
expect(oneLineDescription('', '# Title\n\nFirst real line.')).toBe('First real line.');
|
||||
});
|
||||
test('empty when nothing usable', () => {
|
||||
expect(oneLineDescription('', '# only a heading')).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('crossReferenceTools', () => {
|
||||
test('unknown tool is unavailable; read op usable for read scope', () => {
|
||||
const { usable_tools, unavailable_tools } = crossReferenceTools(
|
||||
['search', 'put_page', 'totally_not_an_op'],
|
||||
ctx(true, ['read']),
|
||||
);
|
||||
expect(usable_tools).toEqual(['search']);
|
||||
expect(unavailable_tools.sort()).toEqual(['put_page', 'totally_not_an_op']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveSkillMdPath (happy path)', () => {
|
||||
test('resolves a real skill to its SKILL.md', () => {
|
||||
const p = resolveSkillMdPath(FIXTURE, 'brain-ops');
|
||||
expect(p.endsWith('/brain-ops/SKILL.md')).toBe(true);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user