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
101 changes: 57 additions & 44 deletions packages/wbfy/src/generators/userAgentConfigs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 <owner>/<repo> --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.
Expand All @@ -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,
Expand Down Expand Up @@ -80,14 +117,17 @@ 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
// `~/.agents/skills` makes those shared global skills visible to Antigravity CLI too.
settings: {
entries: [
{
path: '~/.agents/skills',
path: sharedGlobalSkillsDirPath,
},
],
},
Expand All @@ -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<boolean> {
let hasWrittenAll = true;
Expand All @@ -120,49 +159,19 @@ export async function generateUserAgentConfigs(): Promise<boolean> {
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<boolean> {
const filePath = path.join(os.homedir(), relativePath);
const existingContent = await fsUtil.readFileIfExists(filePath);
Expand All @@ -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.
Expand All @@ -204,3 +213,7 @@ async function mergeAgentSettings(
}
return true;
}

function isSameItem(left: unknown, right: unknown): boolean {
return JSON.stringify(left) === JSON.stringify(right);
}
126 changes: 67 additions & 59 deletions packages/wbfy/test/unit/userAgentConfigs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>): Promise<{
exitCode: number;
files: Record<string, 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');
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<string, string> = {};
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<string> => {
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' },
Expand All @@ -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 },
Expand All @@ -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 } },
})
Expand All @@ -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');
Expand Down Expand Up @@ -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<string, string>,
runs = 1
): Promise<{
exitCode: number;
files: Record<string, string>;
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<string, string> = {};
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<string> {
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();
}
Loading