Skip to content
Draft
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ hosted-contract.json
.DS_Store

# Local-only research, examples, and agent planning artifacts
docs/
autoresearch/
cases/
designs/
Expand Down
4 changes: 4 additions & 0 deletions src/browser/command-catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ const BROWSER_OPTION_VALUE_NAMES: Readonly<Record<string, string>> = {
depth: 'n',
detail: 'key',
filter: 'fields',
format: 'fmt',
frame: 'index',
fromLabel: 'text',
fromName: 'text',
Expand Down Expand Up @@ -112,6 +113,7 @@ export function browserOptionFlags(option: HostedArgumentContract): string {
if (option.type === 'boolean') return option.name === 'fixture' ? '--no-fixture' : `--${longName}`;
const valueName = BROWSER_OPTION_VALUE_NAMES[option.name];
if (!valueName) throw new Error(`Browser option --${longName} is missing its Commander value name`);
if (option.name === 'format') return `-f, --${longName} <${valueName}>`;
return `--${longName} <${valueName}>`;
}

Expand Down Expand Up @@ -279,6 +281,7 @@ export const browserCommandCatalog: readonly HostedBrowserCommandContract[] = [
'extract',
[],
[
option('format', 'Output format: json', { choices: ['json'] }),
option('selector', 'CSS selector scope; defaults to <main>/<article>/<body>'),
option('chunkSize', 'Target chunk size in chars', { default: '20000' }),
option('start', 'Start offset (use next_start_char from a previous extract)', { default: '0' }),
Expand Down Expand Up @@ -437,6 +440,7 @@ export const browserCommandCatalog: readonly HostedBrowserCommandContract[] = [
'snapshot',
[],
[
option('format', 'Output format: json', { choices: ['json'] }),
option('source', 'Snapshot backend: dom (default) or ax prototype', { default: 'dom' }),
flag('compareSources', 'Print DOM vs AX snapshot metrics for observation promotion decisions', false),
tabOption,
Expand Down
44 changes: 44 additions & 0 deletions src/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1325,6 +1325,50 @@ describe('browser tab targeting commands', () => {
expect(browserState.page?.snapshot).toHaveBeenCalled();
});

it('preserves the default browser state text output', async () => {
const program = createProgram('', '');

await program.parseAsync(['node', 'webcmd', 'browser', '--session', 'test', 'state']);

expect(consoleLogSpy.mock.calls).toEqual([
['URL: https://one.example\n'],
['snapshot'],
]);
});

it('emits a JSON envelope for browser state with -f json', async () => {
const program = createProgram('', '');

await program.parseAsync([
'node', 'webcmd', 'browser', '--session', 'test', 'state', '-f', 'json',
]);

expect(lastJsonLog()).toEqual({
url: 'https://one.example',
snapshot: 'snapshot',
});
});

it('accepts -f json for the existing browser extract JSON envelope', async () => {
browserState.page!.evaluate = vi.fn().mockResolvedValue({
ok: true,
url: 'https://one.example',
title: 'Example',
html: '<main><p>Hello</p></main>',
});
const program = createProgram('', '');

await program.parseAsync([
'node', 'webcmd', 'browser', '--session', 'test', 'extract', '-f', 'json',
]);

expect(lastJsonLog()).toMatchObject({
url: 'https://one.example',
title: 'Example',
content: expect.stringContaining('Hello'),
});
});

it('passes browser --window through Commander options without relying on env pre-processing', async () => {
const program = createProgram('', '');

Expand Down
85 changes: 74 additions & 11 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -789,6 +789,34 @@ export function createProgram(BUILTIN_CLIS: string, USER_CLIS: string): Command
.description('Make any website your CLI. Zero setup. AI-powered.');
configureRootCommandSurface(program);

program.arguments('[args...]').action(async (args: string[]) => {
if (args && args.length > 0) {
// Replicate the previous command:* logic, or let commander handle it.
// Wait, if I do this, it intercepts EVERYTHING that is an unknown command.
const binary = args[0];
console.error(`error: unknown command '${binary}'`);
const { isBinaryInstalled } = await import('./external.js');
if (isBinaryInstalled(binary)) {
console.error(` Tip: '${binary}' exists on your PATH. Use 'webcmd external register ${binary}' to add it as an external CLI.`);
}
program.outputHelp();
process.exitCode = 2; // USAGE_ERROR
return;
}

process.stdout.write(`bin: ${process.argv[1] || process.execPath}\n`);
process.stdout.write(`description: Make any website your CLI. Zero setup. AI-powered.\n`);
process.stdout.write('---\n');
try {
const { daemonStatus } = await import('./commands/daemon.js');
// daemonStatus still uses console.log internally... we might need to intercept it, but for now we just want some stdout
await daemonStatus();
} catch (e) {
process.stdout.write(`Daemon: status unavailable (${String(e)})\n`);
}
process.stdout.write('\nRun "webcmd --help" for all commands, or "webcmd list" to see all sites.\n');
});

// ── Built-in: list ────────────────────────────────────────────────────────

configureListCommandSurface(program.command('list'))
Expand Down Expand Up @@ -1167,7 +1195,21 @@ Examples:
.description('List tabs in the browser session with target IDs')
.action(browserAction(async (page) => {
const tabs = await page.tabs();
console.log(JSON.stringify(tabs, null, 2));
if (!process.argv.includes('--full') && Array.isArray(tabs)) {
const minimal = tabs.map((t: any) => ({
...(t.page ? { page: t.page } : {}),
...(t.targetId ? { targetId: t.targetId } : {}),
title: t.title,
url: t.url,
}));
console.log(JSON.stringify({
count: tabs.length,
tabs: minimal,
help: "Run with --full to see all tab properties (dimensions, lifecycle state)."
}, null, 2));
} else {
console.log(JSON.stringify(tabs, null, 2));
}
}));

browserTab.command('new')
Expand Down Expand Up @@ -1266,6 +1308,7 @@ Examples:
// ── Inspect ──

addBrowserTabOption(browser.command('state').description('Page state: URL, title, interactive elements with [N] indices')
.addOption(new Option('-f, --format <fmt>', 'Output format: json').choices(['json']))
.option('--source <source>', 'Snapshot backend: dom (default) or ax prototype', 'dom')
.option('--compare-sources', 'Print DOM vs AX snapshot metrics for observation promotion decisions', false))
.action(browserAction(async (page, opts) => {
Expand Down Expand Up @@ -1293,14 +1336,32 @@ Examples:
}
const snapshot = await page.snapshot({ viewportExpand: 2000, source: source as 'dom' | 'ax' });
const url = await page.getCurrentUrl?.() ?? '';
if (opts.format === 'json') {
console.log(JSON.stringify({ url, snapshot }, null, 2));
return;
}
console.log(`URL: ${url}\n`);
console.log(typeof snapshot === 'string' ? snapshot : JSON.stringify(snapshot, null, 2));
}));

addBrowserTabOption(browser.command('frames').description('List cross-origin iframe targets in snapshot order'))
.action(browserAction(async (page) => {
const frames = await page.frames?.() ?? [];
console.log(JSON.stringify(frames, null, 2));
if (!process.argv.includes('--full') && Array.isArray(frames)) {
const minimal = frames.map((f: any) => ({
index: f.index,
...(f.frameId ? { frameId: f.frameId } : {}),
url: f.url,
name: f.name
}));
console.log(JSON.stringify({
count: frames.length,
frames: minimal,
help: "Run with --full to see all frame properties."
}, null, 2));
} else {
console.log(JSON.stringify(frames, null, 2));
}
}));

addBrowserTabOption(browser.command('screenshot').argument('[path]', 'Save to file (base64 if omitted)'))
Expand Down Expand Up @@ -2511,6 +2572,7 @@ Examples:

addBrowserTabOption(
browser.command('extract')
.addOption(new Option('-f, --format <fmt>', 'Output format: json').choices(['json']))
.option('--selector <css>', 'CSS selector scope; defaults to <main>/<article>/<body>')
.option('--chunk-size <chars>', 'Target chunk size in chars', '20000')
.option('--start <char>', 'Start offset (use next_start_char from a previous extract)', '0')
Expand Down Expand Up @@ -3747,6 +3809,14 @@ cli({
for (const sub of cmd.commands) applyAncestorAwareUsage(sub);
}
applyAncestorAwareUsage(browser);
program
.command('init-hooks')
.description('Install session hooks for AI agents (Claude Code, Codex, OpenCode)')
.action(async () => {
const { initHooks } = await import('./commands/agent-hooks.js');
await initHooks();
});

installRootPresentationHelp(
program,
() => rootHelpData(program, adapterGroups),
Expand All @@ -3757,15 +3827,8 @@ cli({
// Security: do NOT auto-discover and register arbitrary system binaries.
// Only explicitly registered external CLIs are allowed.

program.on('command:*', (operands: string[]) => {
const binary = operands[0];
console.error(`error: unknown command '${binary}'`);
if (isBinaryInstalled(binary)) {
console.error(` Tip: '${binary}' exists on your PATH. Use 'webcmd external register ${binary}' to add it as an external CLI.`);
}
program.outputHelp();
process.exitCode = EXIT_CODES.USAGE_ERROR;
});
// Program actions handles fallback for root command and unknown commands


return program;
}
Expand Down
111 changes: 111 additions & 0 deletions src/commands/agent-hooks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import { execSync } from 'node:child_process';
import { log } from '../logger.js';
import { ArgumentError } from '../errors.js';

function getWebcmdBinaryCommand(): string {
try {
const which = execSync('which webcmd', { stdio: 'pipe' }).toString().trim();
if (which) return 'webcmd';
} catch {
// Ignore error
}
return path.resolve(process.argv[1] || process.execPath);
}

export async function initHooks(): Promise<void> {
const binary = getWebcmdBinaryCommand();
const command = `${binary}`;
const home = os.homedir();
let installed = 0;

// 1. Claude Code
const claudeSettingsDir = path.join(home, '.claude');
if (fs.existsSync(claudeSettingsDir)) {
const settingsPath = path.join(claudeSettingsDir, 'settings.json');
let settings: any = {};
if (fs.existsSync(settingsPath)) {
try {
settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
} catch {
settings = {};
}
}
settings.hooks = settings.hooks || {};
settings.hooks.SessionStart = command;
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2));
log.success(`Injected SessionStart hook into Claude Code (settings.json)`);
installed++;
}

// 2. Codex
const codexDir = path.join(home, '.codex');
if (fs.existsSync(codexDir)) {
// hooks.json
const hooksPath = path.join(codexDir, 'hooks.json');
let hooks: any = {};
if (fs.existsSync(hooksPath)) {
try {
hooks = JSON.parse(fs.readFileSync(hooksPath, 'utf8'));
} catch {
hooks = {};
}
}
hooks.SessionStart = hooks.SessionStart || [];
if (typeof hooks.SessionStart === 'string') {
hooks.SessionStart = [hooks.SessionStart];
}
if (!hooks.SessionStart.includes(command)) {
hooks.SessionStart.push(command);
}
fs.writeFileSync(hooksPath, JSON.stringify(hooks, null, 2));

// config.toml features.hooks = true
const tomlPath = path.join(codexDir, 'config.toml');
if (fs.existsSync(tomlPath)) {
let toml = fs.readFileSync(tomlPath, 'utf8');
if (!toml.includes('hooks = true') && !toml.includes('hooks=true')) {
if (toml.includes('[features]')) {
toml = toml.replace('[features]', '[features]\nhooks = true');
} else {
toml += '\n[features]\nhooks = true\n';
}
fs.writeFileSync(tomlPath, toml);
}
}
log.success(`Injected SessionStart hook into Codex (hooks.json)`);
installed++;
}

// 3. OpenCode (assuming ~/.opencode/plugins or similar, will use ~/.opencode/hooks.json for now)
const openCodeDir = path.join(home, '.opencode');
if (fs.existsSync(openCodeDir)) {
const hooksPath = path.join(openCodeDir, 'hooks.json');
let hooks: any = {};
if (fs.existsSync(hooksPath)) {
try {
hooks = JSON.parse(fs.readFileSync(hooksPath, 'utf8'));
} catch {
hooks = {};
}
}
hooks.SessionStart = hooks.SessionStart || [];
if (typeof hooks.SessionStart === 'string') {
hooks.SessionStart = [hooks.SessionStart];
}
if (!hooks.SessionStart.includes(command)) {
hooks.SessionStart.push(command);
}
fs.writeFileSync(hooksPath, JSON.stringify(hooks, null, 2));
log.success(`Injected SessionStart hook into OpenCode (hooks.json)`);
installed++;
}

if (installed === 0) {
log.info(`No compatible agent configurations found (.claude, .codex, .opencode) in ${home}`);
} else {
log.success(`Successfully initialized hooks for ${installed} agent(s).`);
}
}
4 changes: 2 additions & 2 deletions src/discovery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -294,7 +294,7 @@ async function discoverPluginDir(dir: string, site: string): Promise<void> {
if (file.endsWith('.js') && !file.endsWith('.d.js')) {
if (!(await isCliModule(filePath))) return;
await import(pathToFileURL(filePath).href).catch((err) => {
log.warn(`Plugin ${site}/${file}: ${getErrorMessage(err)}`);
log.verbose(`Plugin ${site}/${file}: ${getErrorMessage(err)}`);
});
} else if (
file.endsWith('.ts') && !file.endsWith('.d.ts') && !file.endsWith('.test.ts')
Expand All @@ -304,7 +304,7 @@ async function discoverPluginDir(dir: string, site: string): Promise<void> {
if (fileSet.has(jsFile)) return;
// No compiled .js found β€” cannot import raw .ts in production Node.js.
// This typically means esbuild transpilation failed during plugin install.
log.warn(
log.verbose(
`Plugin ${site}/${file}: no compiled .js found. ` +
`Run "webcmd plugin update ${site}" to re-transpile, or install esbuild.`
);
Expand Down
Loading
Loading