From cfb1bbe883eeea0427777fc0466e9410d22c705d Mon Sep 17 00:00:00 2001 From: "Sakamoto, Kazunori" Date: Wed, 11 Nov 2026 09:52:05 +0900 Subject: [PATCH] fix(wbfy): write an absolute skills directory path in ~/.gemini/config/skills.json Co-authored-by: WillBooster (Antigravity) --- .../wbfy/src/generators/userAgentConfigs.ts | 101 ++++++++------ .../wbfy/test/unit/userAgentConfigs.test.ts | 126 ++++++++++-------- 2 files changed, 124 insertions(+), 103 deletions(-) diff --git a/packages/wbfy/src/generators/userAgentConfigs.ts b/packages/wbfy/src/generators/userAgentConfigs.ts index b60b1283e..5c26559f1 100644 --- a/packages/wbfy/src/generators/userAgentConfigs.ts +++ b/packages/wbfy/src/generators/userAgentConfigs.ts @@ -15,6 +15,7 @@ const userAgentInstructionFilePaths = ['.codex/AGENTS.md', '.claude/CLAUDE.md', const claudeStatusLineScriptPath = path.join(os.homedir(), '.claude/statusline-command.sh'); const antigravityStatusLineScriptPath = path.join(os.homedir(), '.gemini/antigravity-cli/statusline-command.sh'); +const sharedGlobalSkillsDirPath = path.join(os.homedir(), '.agents/skills'); // This content is loaded into every agent session, so it is kept as short as it can be while // staying unambiguous: e.g., the excluded organizations are written as shell brace expansion, which @@ -28,6 +29,37 @@ const userAgentInstructionContent = `- Be honest in your responses. - In anything written to a public repository (e.g., issues, PRs, comments, commits, branch names, code), never reveal a private repository's name, URL, issue/PR numbers, code, logs, or customer names unless already public or explicitly requested; state the need in general terms with a freshly written minimal reproduction instead. If visibility is unknown, run \`gh repo view / --json visibility\`. `; +// Claude Code and Antigravity CLI pipe differently shaped session JSON to the same script, so each +// part reads whichever agent's keys are present. Antigravity CLI meters Gemini and other models +// separately, so it shows a Gemini (G) and a third-party (3rd) pair where Claude Code shows one. +// Every percentage is what is left, followed by the time until it resets. +const statusLineScriptContent = String.raw`#!/bin/sh +input=$(cat) +cwd=$(printf '%s' "$input" | jq -r '.cwd') +git_info="" +if branch=$(git -C "$cwd" symbolic-ref --short HEAD 2>/dev/null || git -C "$cwd" rev-parse --short HEAD 2>/dev/null); then + git_info=" on git:$branch" +fi +printf '%s' "$input" | jq -j --arg home "$HOME" --arg git "$git_info" ' + def duration: floor | if . >= 86400 then "\(. / 86400 | floor)d\(. % 86400 / 3600 | floor)h" + elif . >= 3600 then "\(. / 3600 | floor)h\(. % 3600 / 60 | floor)m" + else "\(. / 60 | floor)m" end; + def claudeLimit: if . == null then null else { left: (100 - .used_percentage), resetsAt: .resets_at } end; + def antigravityLimit: if . == null then null else { left: (.remaining_fraction * 100), resetsAt: (now + .reset_in_seconds) } end; + def limit($label): if . == null then empty + else "\($label) \(.left | round)% (\([.resetsAt - now, 0] | max | duration))" end; + def limits($group; $fiveHour; $weekly): [($fiveHour | limit("5h")), ($weekly | limit("week"))] + | if length == 0 then "" else " [\($group)\(join(" | "))]" end; + (.cwd | if . == $home or startswith($home + "/") then "~" + .[($home | length):] else . end) + + $git + + ((.effort.level // .model.effort | if . == null then "" else " (\(.))" end) as $effort + | .model.display_name | if . == null then "" else " [\(sub(" \\([^)]*\\)$"; ""))\($effort)]" end) + + (.context_window.used_percentage | if . == null then "" else " [ctx \(100 - . | round)%]" end) + + limits(""; .rate_limits.five_hour | claudeLimit; .rate_limits.seven_day | claudeLimit) + + limits("G: "; .quota["gemini-5h"] | antigravityLimit; .quota["gemini-weekly"] | antigravityLimit) + + limits("3rd: "; .quota["3p-5h"] | antigravityLimit; .quota["3p-weekly"] | antigravityLimit)' +`; + /** * The user-level settings merged into each agent's JSON settings file, relative to the home * directory. @@ -38,7 +70,12 @@ const userAgentInstructionContent = `- Be honest in your responses. * instruction files above. Codex needs no entry because its memories are opt-in * (`[features] memories = true` in `~/.codex/config.toml`), so leaving that flag unset is enough. */ -const agentSettings = [ +const agentSettings: readonly { + acceptsComments: boolean | 'full-line'; + obsoleteItems?: readonly unknown[]; + relativePath: string; + settings: object; +}[] = [ { relativePath: '.claude/settings.json', acceptsComments: false, @@ -80,6 +117,9 @@ const agentSettings = [ // Antigravity CLI strips only full-line `//` comments before `JSON.parse`, so inline and block // comments fail to parse. acceptsComments: 'full-line', + // Antigravity CLI rejects `~/...` in `skills.json` as non-absolute despite its documentation; + // drop the `~/.agents/skills` entry written by an earlier wbfy version when writing the resolved path. + obsoleteItems: [{ path: '~/.agents/skills' }], // The `skills` CLI installs global skills into `~/.agents/skills` for Codex and the agents that // share that global directory; for Antigravity CLI itself it installs into // `~/.gemini/antigravity-cli/skills/`, which Antigravity CLI already loads, so registering @@ -87,7 +127,7 @@ const agentSettings = [ settings: { entries: [ { - path: '~/.agents/skills', + path: sharedGlobalSkillsDirPath, }, ], }, @@ -103,12 +143,11 @@ const agentSettings = [ }, }, }, -] as const; +]; /** - * Overwrites the user-level instruction files and status line scripts of every supported agent with - * the fixed content and merges the fixed settings into each agent's user settings. Returns false - * when any file was skipped (e.g. a symlinked or unparsable file), so the command can fail loudly. + * Returns false when any file was skipped (e.g. a symlinked or unparsable file), so the command can + * fail loudly. */ export async function generateUserAgentConfigs(): Promise { let hasWrittenAll = true; @@ -120,49 +159,19 @@ export async function generateUserAgentConfigs(): Promise { const hasWritten = await fsUtil.generateFile(scriptPath, statusLineScriptContent); hasWrittenAll &&= hasWritten; } - for (const { acceptsComments, relativePath, settings } of agentSettings) { + for (const { acceptsComments, obsoleteItems, relativePath, settings } of agentSettings) { // Awaited separately: `&&=` would skip the merge once an earlier file was skipped. - const hasMerged = await mergeAgentSettings(relativePath, settings, acceptsComments); + const hasMerged = await mergeAgentSettings(relativePath, settings, acceptsComments, obsoleteItems); hasWrittenAll &&= hasMerged; } return hasWrittenAll; } -// Claude Code and Antigravity CLI pipe differently shaped session JSON to the same script, so each -// part reads whichever agent's keys are present. Antigravity CLI meters Gemini and other models -// separately, so it shows a Gemini (G) and a third-party (3rd) pair where Claude Code shows one. -// Every percentage is what is left, followed by the time until it resets. -const statusLineScriptContent = String.raw`#!/bin/sh -input=$(cat) -cwd=$(printf '%s' "$input" | jq -r '.cwd') -git_info="" -if branch=$(git -C "$cwd" symbolic-ref --short HEAD 2>/dev/null || git -C "$cwd" rev-parse --short HEAD 2>/dev/null); then - git_info=" on git:$branch" -fi -printf '%s' "$input" | jq -j --arg home "$HOME" --arg git "$git_info" ' - def duration: floor | if . >= 86400 then "\(. / 86400 | floor)d\(. % 86400 / 3600 | floor)h" - elif . >= 3600 then "\(. / 3600 | floor)h\(. % 3600 / 60 | floor)m" - else "\(. / 60 | floor)m" end; - def claudeLimit: if . == null then null else { left: (100 - .used_percentage), resetsAt: .resets_at } end; - def antigravityLimit: if . == null then null else { left: (.remaining_fraction * 100), resetsAt: (now + .reset_in_seconds) } end; - def limit($label): if . == null then empty - else "\($label) \(.left | round)% (\([.resetsAt - now, 0] | max | duration))" end; - def limits($group; $fiveHour; $weekly): [($fiveHour | limit("5h")), ($weekly | limit("week"))] - | if length == 0 then "" else " [\($group)\(join(" | "))]" end; - (.cwd | if . == $home or startswith($home + "/") then "~" + .[($home | length):] else . end) - + $git - + ((.effort.level // .model.effort | if . == null then "" else " (\(.))" end) as $effort - | .model.display_name | if . == null then "" else " [\(sub(" \\([^)]*\\)$"; ""))\($effort)]" end) - + (.context_window.used_percentage | if . == null then "" else " [ctx \(100 - . | round)%]" end) - + limits(""; .rate_limits.five_hour | claudeLimit; .rate_limits.seven_day | claudeLimit) - + limits("G: "; .quota["gemini-5h"] | antigravityLimit; .quota["gemini-weekly"] | antigravityLimit) - + limits("3rd: "; .quota["3p-5h"] | antigravityLimit; .quota["3p-weekly"] | antigravityLimit)' -`; - async function mergeAgentSettings( relativePath: string, newSettings: object, - acceptsComments: boolean | 'full-line' + acceptsComments: boolean | 'full-line', + obsoleteItems: readonly unknown[] = [] ): Promise { const filePath = path.join(os.homedir(), relativePath); const existingContent = await fsUtil.readFileIfExists(filePath); @@ -189,10 +198,10 @@ async function mergeAgentSettings( } } const settings = merge(existingSettings, newSettings, { - arrayMerge: (target: unknown[], source: unknown[]) => [ - ...target, - ...source.filter((item) => !target.some((existing) => JSON.stringify(existing) === JSON.stringify(item))), - ], + arrayMerge: (target: unknown[], source: unknown[]) => { + const kept = target.filter((item) => !obsoleteItems.some((obsolete) => isSameItem(item, obsolete))); + return [...kept, ...source.filter((item) => !kept.some((existing) => isSameItem(existing, item)))]; + }, }); // Only the properties whose value changes are rewritten, so the file keeps its own formatting // and, where the agent allows them, its comments. @@ -204,3 +213,7 @@ async function mergeAgentSettings( } return true; } + +function isSameItem(left: unknown, right: unknown): boolean { + return JSON.stringify(left) === JSON.stringify(right); +} diff --git a/packages/wbfy/test/unit/userAgentConfigs.test.ts b/packages/wbfy/test/unit/userAgentConfigs.test.ts index f188ae3e2..da16bc505 100644 --- a/packages/wbfy/test/unit/userAgentConfigs.test.ts +++ b/packages/wbfy/test/unit/userAgentConfigs.test.ts @@ -2,92 +2,52 @@ import { expect, test } from 'bun:test'; import fs from 'node:fs'; import path from 'node:path'; -/** - * Runs the command over the given home-directory files and returns its exit code together with the - * same files as it left them. The command resolves every path from os.homedir(), which Bun fixes at - * process start, so it runs in a subprocess whose HOME is the scratch directory holding the files. - */ -async function generateUserAgentConfigs(files: Record): Promise<{ - exitCode: number; - files: Record; -}> { - const tempRootPath = path.join(process.cwd(), '.tmp'); - await fs.promises.mkdir(tempRootPath, { recursive: true }); - const homePath = await fs.promises.mkdtemp(path.join(tempRootPath, 'user-agent-configs-')); - try { - for (const [relativePath, content] of Object.entries(files)) { - const filePath = path.join(homePath, relativePath); - await fs.promises.mkdir(path.dirname(filePath), { recursive: true }); - await fs.promises.writeFile(filePath, content); - } - const commandPath = path.join(import.meta.dir, '../../src/index.ts'); - const spawned = Bun.spawn(['bun', commandPath, 'generate-user-agent-configs'], { - env: { ...Bun.env, HOME: homePath }, - stderr: 'pipe', - stdout: 'pipe', - }); - const exitCode = await spawned.exited; - const generatedFiles: Record = {}; - for (const relativePath of Object.keys(files)) { - generatedFiles[relativePath] = await fs.promises.readFile(path.join(homePath, relativePath), 'utf8'); - } - return { exitCode, files: generatedFiles }; - } finally { - await fs.promises.rm(homePath, { force: true, recursive: true }); - } -} - test("turns off memory, attribution, and telemetry, sets the status line, and registers global skills idempotently while keeping the developer's own Claude and Antigravity CLI settings", async () => { - const first = await generateUserAgentConfigs({ - '.claude/settings.json': '{\n "effortLevel": "medium",\n "attribution": { "commit": "keep me" }\n}\n', - '.gemini/antigravity-cli/settings.json': '{\n "trustedWorkspaces": ["/repo"],\n "enableTelemetry": true\n}\n', - '.gemini/config/skills.json': '{\n "entries": [\n { "path": "~/personal-skills" }\n ]\n}\n', - }); - const { exitCode, files } = await generateUserAgentConfigs(first.files); + const { exitCode, files, homePath } = await generateUserAgentConfigs( + { + '.claude/settings.json': '{\n "effortLevel": "medium",\n "attribution": { "commit": "keep me" }\n}\n', + '.gemini/antigravity-cli/settings.json': '{\n "trustedWorkspaces": ["/repo"],\n "enableTelemetry": true\n}\n', + '.gemini/config/skills.json': + '{\n "entries": [\n { "path": "/personal-skills" },\n { "path": "~/.agents/skills" }\n ]\n}\n', + }, + 2 + ); - expect(first.exitCode).toBe(0); expect(exitCode).toBe(0); expect(JSON.parse(files['.claude/settings.json'] ?? '')).toEqual({ attribution: { commit: '', pr: '', sessionUrl: false }, autoMemoryEnabled: false, effortLevel: 'medium', - statusLine: { type: 'command', command: expect.stringMatching(/^sh '.+\/\.claude\/statusline-command\.sh'$/u) }, + statusLine: { type: 'command', command: `sh '${path.join(homePath, '.claude/statusline-command.sh')}'` }, }); expect(JSON.parse(files['.gemini/antigravity-cli/settings.json'] ?? '')).toEqual({ enableTelemetry: false, statusLine: { type: 'command', - command: expect.stringMatching(/^sh '.+\/\.gemini\/antigravity-cli\/statusline-command\.sh'$/u), + command: `sh '${path.join(homePath, '.gemini/antigravity-cli/statusline-command.sh')}'`, enabled: true, stack_with_default: false, }, trustedWorkspaces: ['/repo'], }); expect(JSON.parse(files['.gemini/config/skills.json'] ?? '')).toEqual({ - entries: [{ path: '~/personal-skills' }, { path: '~/.agents/skills' }], + entries: [{ path: '/personal-skills' }, { path: path.join(homePath, '.agents/skills') }], }); }); test('renders the Claude Code and Antigravity CLI status lines in the same format', async () => { const { files } = await generateUserAgentConfigs({ '.claude/statusline-command.sh': '' }); + const script = files['.claude/statusline-command.sh'] ?? ''; const nowInSeconds = Math.floor(Date.now() / 1000); const tempRootPath = path.join(process.cwd(), '.tmp'); await fs.promises.mkdir(tempRootPath, { recursive: true }); const homePath = await fs.promises.mkdtemp(path.join(tempRootPath, 'status-line-home-')); const repoPath = path.join(homePath, 'repo'); - const render = async (payload: object): Promise => { - const spawned = Bun.spawn(['sh', '-c', files['.claude/statusline-command.sh'] ?? ''], { - env: { ...Bun.env, HOME: homePath }, - stdin: new TextEncoder().encode(JSON.stringify(payload)), - stdout: 'pipe', - }); - return await new Response(spawned.stdout).text(); - }; try { await Bun.spawn(['git', 'init', '-q', '-b', 'status-line', repoPath]).exited; expect( - await render({ + await renderStatusLine(script, homePath, { cwd: repoPath, model: { display_name: 'Opus 5.5' }, effort: { level: 'medium' }, @@ -99,7 +59,7 @@ test('renders the Claude Code and Antigravity CLI status lines in the same forma }) ).toBe('~/repo on git:status-line [Opus 5.5 (medium)] [ctx 94%] [5h 98% (2h30m) | week 88% (5d1h)]'); expect( - await render({ + await renderStatusLine(script, homePath, { cwd: `${homePath}-sibling`, model: { display_name: 'Gemini 4 Argon (Medium)', effort: 'medium' }, context_window: { used_percentage: 0 }, @@ -114,7 +74,7 @@ test('renders the Claude Code and Antigravity CLI status lines in the same forma `${homePath}-sibling [Gemini 4 Argon (medium)] [ctx 100%] [G: 5h 85% (2h54m) | week 89% (5d1h)] [3rd: 5h 100% (4h59m) | week 100% (6d23h)]` ); expect( - await render({ + await renderStatusLine(script, homePath, { cwd: repoPath, rate_limits: { five_hour: { used_percentage: 2, resets_at: nowInSeconds + 2 * 3600 + 30 * 60 + 30 } }, }) @@ -125,16 +85,16 @@ test('renders the Claude Code and Antigravity CLI status lines in the same forma }); test('merges the memory setting into a commented Gemini settings file and global skills into a full-line commented Antigravity skills file', async () => { - const { exitCode, files } = await generateUserAgentConfigs({ + const { exitCode, files, homePath } = await generateUserAgentConfigs({ '.gemini/config/skills.json': - '// user skills config\n{\n // personal skills\n "entries": [\n { "path": "~/personal-skills" }\n ]\n}\n', + '// user skills config\n{\n // personal skills\n "entries": [\n { "path": "/personal-skills" }\n ]\n}\n', '.gemini/settings.json': '{\n // chosen when signing in\n "security": { "auth": {} }\n}\n', }); expect(exitCode).toBe(0); const skillsContent = files['.gemini/config/skills.json'] ?? ''; expect(JSON.parse(skillsContent.replaceAll(/^\s*\/\/.*$/gmu, ''))).toEqual({ - entries: [{ path: '~/personal-skills' }, { path: '~/.agents/skills' }], + entries: [{ path: '/personal-skills' }, { path: path.join(homePath, '.agents/skills') }], }); expect(skillsContent).toContain('// user skills config'); expect(skillsContent).toContain('// personal skills'); @@ -181,3 +141,51 @@ test('leaves a settings file with a trailing comma to the developer', async () = expect(exitCode).toBe(1); expect(files['.gemini/settings.json']).toBe(original); }); + +// Bun fixes os.homedir() at process start, so the command must run in a subprocess to observe the +// scratch HOME directory. +async function generateUserAgentConfigs( + files: Record, + runs = 1 +): Promise<{ + exitCode: number; + files: Record; + homePath: string; +}> { + const tempRootPath = path.join(process.cwd(), '.tmp'); + await fs.promises.mkdir(tempRootPath, { recursive: true }); + const homePath = await fs.promises.mkdtemp(path.join(tempRootPath, 'user-agent-configs-')); + try { + for (const [relativePath, content] of Object.entries(files)) { + const filePath = path.join(homePath, relativePath); + await fs.promises.mkdir(path.dirname(filePath), { recursive: true }); + await fs.promises.writeFile(filePath, content); + } + const commandPath = path.join(import.meta.dir, '../../src/index.ts'); + let exitCode = 0; + for (let i = 0; i < runs; i++) { + const spawned = Bun.spawn(['bun', commandPath, 'generate-user-agent-configs'], { + env: { ...Bun.env, HOME: homePath }, + stderr: 'pipe', + stdout: 'pipe', + }); + exitCode ||= await spawned.exited; + } + const generatedFiles: Record = {}; + for (const relativePath of Object.keys(files)) { + generatedFiles[relativePath] = await fs.promises.readFile(path.join(homePath, relativePath), 'utf8'); + } + return { exitCode, files: generatedFiles, homePath }; + } finally { + await fs.promises.rm(homePath, { force: true, recursive: true }); + } +} + +async function renderStatusLine(script: string, homePath: string, payload: object): Promise { + const spawned = Bun.spawn(['sh', '-c', script], { + env: { ...Bun.env, HOME: homePath }, + stdin: new TextEncoder().encode(JSON.stringify(payload)), + stdout: 'pipe', + }); + return await new Response(spawned.stdout).text(); +}