Compare commits

..
Author SHA1 Message Date
22022182f8 fix(cli): honor search --json output (#2042)
`gbrain search <q> --json` silently printed human text: the search/query
ops declare no json param, parseOpArgs dropped the undeclared trailing
flag, and formatResult only emitted human text or --explain output.

Treat --json as a CLI-local boolean formatter flag in parseOpArgs (never
added to the operation contract; MCP validateParams ignores the extra
key on the routed path) and have formatResult's search/query case emit
the raw result array as parseable JSON when set.

Takeover of #2531, rebased onto master (keeps bigintToStringReplacer at
the local-path formatResult call site).

Co-authored-by: javieraldape <javieraldape@users.noreply.github.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 14:20:25 -07:00
8 changed files with 63 additions and 121 deletions
+1 -5
View File
@@ -206,11 +206,7 @@ jobs:
needs: cache-check
if: needs.cache-check.outputs.hit != 'true'
runs-on: ubuntu-latest
# 20 (was 15): shard 4 runs ~14.5 min on master (dream.test.ts ~29s/test
# dominates it) and hits the 15-min ceiling on slower runners, cancelling
# mid-run with 0 test failures. Rebalancing via
# scripts/mine-shard-weights.ts is the real fix; this stops the bleeding.
timeout-minutes: 20
timeout-minutes: 15
strategy:
fail-fast: false
matrix:
+12 -3
View File
@@ -464,7 +464,7 @@ async function main() {
// routed path. Date → ISO string; bigint → string (postgres.js shape);
// Buffer → object. Microsecond-cost; eliminates a whole drift bug class.
const result = JSON.parse(JSON.stringify(rawResult, bigintToStringReplacer));
const output = formatResult(op.name, result);
const output = formatResult(op.name, result, params);
if (output) process.stdout.write(output);
} catch (e: unknown) {
// v0.42.20.0 (codex D4): on error, set exitCode + return so the `finally`
@@ -547,7 +547,7 @@ async function runThinClientRouted(
signal: sigintController.signal,
});
const result = unpackToolResult(raw);
const output = formatResult(op.name, result);
const output = formatResult(op.name, result, params);
if (output) process.stdout.write(output);
} catch (e: unknown) {
if (e instanceof RemoteMcpError) {
@@ -777,6 +777,10 @@ export function parseOpArgs(op: Operation, args: string[]): Record<string, unkno
const paramDef = op.params[key];
if (paramDef?.type === 'boolean') {
params[key] = true;
} else if (key === 'json') {
// Generic operation formatter flag. It is intentionally CLI-local:
// do not add it to the operation contract exposed over MCP/tools.
params[key] = true;
} else if (i + 1 < args.length) {
params[key] = args[++i];
if (paramDef?.type === 'number') params[key] = Number(params[key]);
@@ -838,7 +842,11 @@ async function makeContext(engine: BrainEngine, params: Record<string, unknown>)
}
// Exported for tests (same import-safety contract as cliAliases/printOpHelp).
export function formatResult(opName: string, result: unknown): string {
export function formatResult(
opName: string,
result: unknown,
params: Record<string, unknown> = {},
): string {
switch (opName) {
case 'volunteer_context': {
const r = result as any;
@@ -877,6 +885,7 @@ export function formatResult(opName: string, result: unknown): string {
case 'search':
case 'query': {
const results = result as any[];
if (params.json === true) return JSON.stringify(results, null, 2) + '\n';
if (results.length === 0) return 'No results.\n';
// v0.40.4 — --explain switches to per-stage attribution formatter.
// Reads CliOptions.explain via the module-level singleton.
+1 -34
View File
@@ -23,15 +23,6 @@
* hold conventions and shared rule files, not skills. Files like
* `_brain-filing-rules.md` live at the root and are not considered
* skills by either loader.
*
* ClawHub-installed workspace skills (#1767): a skill dir carrying
* `.clawhub/origin.json` is an externally-managed runtime integration
* (e.g. an email or catalog skill), not a gbrain-routable skill. The
* derive path SKIPS those so `gbrain doctor` resolver_health doesn't
* hard-fail on them — UNLESS the skill's SKILL.md frontmatter declares
* `triggers:`, which is the explicit opt-in to gbrain routing (and the
* same surface that makes it reachable). An explicit manifest.json that
* lists a ClawHub skill also keeps strict checking (verbatim path).
*/
import { existsSync, readFileSync, readdirSync, statSync } from 'fs';
@@ -69,27 +60,9 @@ function parseSkillName(skillMdPath: string): string | null {
}
}
/**
* Does the SKILL.md frontmatter declare a `triggers:` key? A ClawHub-
* installed skill that ships gbrain `triggers:` has explicitly opted in
* to gbrain routing and gets full resolver checks (#1767).
*/
function declaresTriggers(skillMdPath: string): boolean {
try {
const content = readFileSync(skillMdPath, 'utf-8');
const fmMatch = content.match(/^---\n([\s\S]*?)\n---/);
if (!fmMatch) return false;
return /^triggers:/m.test(fmMatch[1]);
} catch {
return false;
}
}
/**
* Walk skillsDir, return every `<skillsDir>/<dir>/SKILL.md` as a
* ManifestEntry. Dotfile and underscore-prefixed dirs are skipped, as
* are ClawHub-installed external skills that haven't opted in to gbrain
* routing via `triggers:` frontmatter (#1767).
* ManifestEntry. Dotfile and underscore-prefixed dirs are skipped.
*/
function deriveManifest(skillsDir: string): ManifestEntry[] {
const out: ManifestEntry[] = [];
@@ -120,12 +93,6 @@ function deriveManifest(skillsDir: string): ManifestEntry[] {
const skillMd = join(subdirAbs, 'SKILL.md');
if (!existsSync(skillMd)) continue;
// ClawHub-installed external skill (#1767): skip unless it opts in
// to gbrain routing by declaring `triggers:` in its frontmatter.
if (existsSync(join(subdirAbs, '.clawhub', 'origin.json')) && !declaresTriggers(skillMd)) {
continue;
}
const frontmatterName = parseSkillName(skillMd);
const name = frontmatterName && frontmatterName !== '' ? frontmatterName : entry;
out.push({ name, path: `${entry}/SKILL.md` });
-29
View File
@@ -382,35 +382,6 @@ describe("DRY detection — checkResolvable", () => {
});
});
describe("#1767 — ClawHub workspace skills are not resolver-required", () => {
let dir: string;
afterEachCleanup(() => dir && rmSync(dir, { recursive: true, force: true }));
test("ClawHub skill without gbrain metadata produces no unreachable/mece_gap", () => {
dir = mkdtempSync(join(tmpdir(), "gbrain-clawhub-"));
// Native gbrain skill: routable via frontmatter triggers. No manifest.json
// (the OpenClaw derive path from the issue repro).
mkdirSync(join(dir, "query"), { recursive: true });
writeFileSync(
join(dir, "query", "SKILL.md"),
`---\nname: query\ndescription: test\ntriggers:\n - "what do we know"\n---\n\n# query\n`
);
// ClawHub-installed integration: no triggers, no resolver row.
mkdirSync(join(dir, "agentmail", ".clawhub"), { recursive: true });
writeFileSync(
join(dir, "agentmail", ".clawhub", "origin.json"),
JSON.stringify({ registry: "https://clawhub.ai", slug: "agentmail" })
);
writeFileSync(join(dir, "agentmail", "SKILL.md"), `---\nname: agentmail\ndescription: email integration\n---\n\n# agentmail\n`);
const report = checkResolvable(dir);
const agentmailIssues = report.issues.filter(i => i.skill === "agentmail");
expect(agentmailIssues).toEqual([]);
expect(report.ok).toBe(true);
expect(report.summary.total_skills).toBe(1);
});
});
describe("v0.22.4 regression — actual repo skills/ has 0 errors", () => {
test("repo skills/ pass check-resolvable cleanly (zero errors AND zero warnings)", () => {
// The v0.22.4 (Part A) contract was zero warnings AND zero errors.
+12 -1
View File
@@ -20,5 +20,16 @@ describe('parseOpArgs', () => {
source_id: 'gstack-code-repo-0e4763c9',
});
});
});
test('--json is a CLI-local formatter flag for shared operations', () => {
const params = parseOpArgs(operationsByName.search, [
'needle',
'--json',
]);
expect(params).toEqual({
query: 'needle',
json: true,
});
});
});
+28
View File
@@ -0,0 +1,28 @@
import { describe, expect, test } from 'bun:test';
import { formatResult } from '../src/cli.ts';
describe('formatResult - search/query --json', () => {
test('search --json renders the raw result array as parseable JSON', () => {
const out = formatResult('search', [
{
slug: 'docs/example',
score: 0.42,
chunk_text: 'Example result text',
},
], { json: true });
expect(JSON.parse(out)).toEqual([
{
slug: 'docs/example',
score: 0.42,
chunk_text: 'Example result text',
},
]);
});
test('query --json keeps empty results machine-readable', () => {
const out = formatResult('query', [], { json: true });
expect(JSON.parse(out)).toEqual([]);
});
});
+9
View File
@@ -49,6 +49,15 @@ describe('T5 — gbrain search dispatch', () => {
});
});
test('`search "<freetext>" --json` emits a parseable result array', () => {
withHome((home) => {
const { stdout, stderr, status } = run(['search', 'zzz-no-such-page-xyz', '--json'], home);
expect(status).toBe(0);
expect(stderr).not.toContain('Unknown subcommand');
expect(JSON.parse(stdout)).toEqual([]);
});
});
test('`search stats --json` routes to the dashboard', () => {
withHome((home) => {
const { stdout, status } = run(['search', 'stats', '--json'], home);
-49
View File
@@ -166,55 +166,6 @@ describe('loadOrDeriveManifest', () => {
expect(r.skills.map(s => s.name)).toEqual(['apple', 'mango', 'zebra']);
});
// #1767 — ClawHub-installed workspace skills are external integrations,
// not gbrain-routable skills. The derive path skips them unless they
// opt in via `triggers:` frontmatter.
it('skips ClawHub-origin skills without triggers frontmatter (#1767)', () => {
const dir = scratch();
writeSkill(dir, 'query', 'query');
writeSkill(dir, 'agentmail', 'agentmail');
mkdirSync(join(dir, 'agentmail', '.clawhub'), { recursive: true });
writeFileSync(
join(dir, 'agentmail', '.clawhub', 'origin.json'),
JSON.stringify({ registry: 'https://clawhub.ai', slug: 'agentmail' })
);
const r = loadOrDeriveManifest(dir);
expect(r.derived).toBe(true);
expect(r.skills.map(s => s.name)).toEqual(['query']);
});
it('includes ClawHub-origin skills that opt in via triggers frontmatter (#1767)', () => {
const dir = scratch();
writeSkill(dir, 'agentmail', 'agentmail');
mkdirSync(join(dir, 'agentmail', '.clawhub'), { recursive: true });
writeFileSync(
join(dir, 'agentmail', '.clawhub', 'origin.json'),
JSON.stringify({ registry: 'https://clawhub.ai', slug: 'agentmail' })
);
writeFileSync(
join(dir, 'agentmail', 'SKILL.md'),
`---\nname: agentmail\ndescription: test\ntriggers:\n - "send email"\n---\n\n# agentmail\n`
);
const r = loadOrDeriveManifest(dir);
expect(r.derived).toBe(true);
expect(r.skills.map(s => s.name)).toEqual(['agentmail']);
});
it('keeps ClawHub-origin skills listed in an explicit manifest.json (#1767)', () => {
// Explicit manifest.json is a deliberate declaration — strict checking stays.
const dir = scratch();
writeSkill(dir, 'agentmail', 'agentmail');
mkdirSync(join(dir, 'agentmail', '.clawhub'), { recursive: true });
writeFileSync(
join(dir, 'agentmail', '.clawhub', 'origin.json'),
JSON.stringify({ registry: 'https://clawhub.ai', slug: 'agentmail' })
);
writeManifest(dir, { skills: [{ name: 'agentmail', path: 'agentmail/SKILL.md' }] });
const r = loadOrDeriveManifest(dir);
expect(r.derived).toBe(false);
expect(r.skills.map(s => s.name)).toEqual(['agentmail']);
});
it('treats dirs without SKILL.md as not-a-skill', () => {
const dir = scratch();
writeSkill(dir, 'query', 'query');