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
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,20 @@
# Changelog

## Unreleased

### Added

- Hosted mode can negotiate and run core validation, diagnostics, adapter lifecycle, profile lifecycle, and catalog-list commands advertised by Webcmd Cloud.
- `@agentrhq/webcmd/adapter-analysis` exposes platform-neutral validation and convention-audit rules for trusted hosted command inventories.
- `@agentrhq/webcmd/hosted/core-commands` exposes the `hosted-core-commands-v1` capability contract and canonical command IDs.
- `webcmd profile use` now stores a validated hosted profile preference locally.

### Changed

- Hosted help and completion advertise Cloud-owned core commands only when the authenticated manifest advertises them.
- Hosted command lists retain excluded commands as `LOCAL` rows and return a local-only error instead of plugin-install guidance.
- Local auth commands initialize user CLI compatibility shims, and hosted auth uses the same native grammar, flags, choices, and help as local mode.

## [0.7.8](https://github.com/agentrhq/webcmd/compare/webcmd-v0.7.7...webcmd-v0.7.8) (2026-08-27)

### Highlights
Expand Down
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
},
"exports": {
".": "./dist/src/main.js",
"./adapter-analysis": "./dist/src/adapter-analysis.js",
"./registry": "./dist/src/registry-api.js",
"./errors": "./dist/src/errors.js",
"./types": "./dist/src/types.js",
Expand All @@ -34,6 +35,7 @@
"./pipeline": "./dist/src/pipeline/index.js",
"./plugin-runtime": "./dist/src/plugin-runtime.js",
"./hosted/availability": "./dist/src/hosted/availability.js",
"./hosted/core-commands": "./dist/src/hosted/core-commands.js",
"./hosted/programmatic": "./dist/src/hosted/programmatic.js"
},
"files": [
Expand Down
48 changes: 48 additions & 0 deletions src/adapter-analysis.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { describe, expect, it, vi } from 'vitest';
import {
auditAdapterConventions,
validateAdapterCommands,
type AdapterAnalysisCommand,
} from './adapter-analysis.js';

const command: AdapterAnalysisCommand = {
site: 'demo',
name: 'list',
command: 'demo/list',
description: 'List demos',
access: 'read',
browser: false,
args: [{ name: 'limit' }],
columns: ['item_id'],
pipeline: [{ fetch: { url: 'https://example.com' } }],
runnable: true,
sourceFile: 'plugins/demo/clis/demo/list.js',
};

describe('public adapter analysis', () => {
it('validates supplied commands and step names without registry discovery', () => {
expect(validateAdapterCommands([command], { knownPipelineSteps: ['fetch'] })).toEqual({
ok: true,
results: [{ label: 'demo/list', errors: [], warnings: [] }],
errors: 0,
warnings: 0,
commands: 1,
});
});

it('passes only the logical package-relative path to the source reader', () => {
const readSource = vi.fn(() => 'const row = { item_id: "a", hidden: true };');
const report = auditAdapterConventions([command], { readSource });
expect(readSource).toHaveBeenCalledWith('plugins/demo/clis/demo/list.js');
expect(report.categories.find(category => category.rule === 'silent-column-drop')?.count).toBe(1);
expect(report.categories.flatMap(category => category.violations)[0]?.file)
.toBe('plugins/demo/clis/demo/list.js');
});

it('rejects unknown targets before reading source', () => {
const readSource = vi.fn();
expect(() => auditAdapterConventions([command], { target: 'missing', readSource }))
.toThrow('No command matches "missing"');
expect(readSource).not.toHaveBeenCalled();
});
});
35 changes: 35 additions & 0 deletions src/adapter-analysis.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
export interface AdapterAnalysisArg {
name: string;
positional?: boolean;
required?: boolean;
help?: string;
}

export interface AdapterAnalysisCommand {
site: string;
name: string;
command: string;
description?: string;
access?: string;
browser?: boolean;
domain?: string | null;
args?: readonly AdapterAnalysisArg[];
columns?: readonly string[];
pipeline?: readonly Record<string, unknown>[];
runnable: boolean;
sourceFile?: string;
modulePath?: string;
}

export type AdapterAnalysisSourceReader = (logicalPath: string) => string | undefined;

export {
validateAdapterCommands,
type CommandValidationResult,
type ValidationReport,
} from './validate.js';
export {
auditAdapterConventions,
type ConventionAuditReport,
type ConventionViolation,
} from './convention-audit.js';
6 changes: 5 additions & 1 deletion src/command-presentation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export interface PresentableCommand {
description: string;
access: 'read' | 'write';
strategy: string;
availability?: string;
browser: boolean;
args: readonly Arg[];
columns: readonly string[];
Expand Down Expand Up @@ -223,6 +224,7 @@ export function commandListRows(
description: command.description,
access: command.access,
strategy: command.strategy,
...(command.availability ? { availability: command.availability } : {}),
browser: command.browser,
args: command.args.map(serializePresentableArg),
columns: [...command.columns],
Expand All @@ -243,6 +245,7 @@ export function commandListRows(
description: command.description,
access: command.access,
strategy: command.strategy,
...(command.availability ? { availability: command.availability } : {}),
browser: command.browser,
args: formatArgumentSummary(command.args),
};
Expand Down Expand Up @@ -276,6 +279,7 @@ export function commandListPresentation(
'description',
'access',
'strategy',
...(unique.some((command) => command.availability) ? ['availability'] : []),
'browser',
'args',
...(unique.some((command) => command.origin) ? ['origin'] : []),
Expand Down Expand Up @@ -327,7 +331,7 @@ function formatGroupedCommandList(
for (const command of siteCommands) {
const aliases = command.aliases.length > 0 ? ` (aliases: ${command.aliases.join(', ')})` : '';
lines.push(
` ${command.name} [${command.strategy}]${aliases}`
` ${command.name} [${command.strategy}]${command.availability ? ` [${command.availability}]` : ''}${aliases}`
+ `${command.description ? ` — ${command.description}` : ''}`
+ `${command.origin ? ` [${command.origin}]` : ''}`,
);
Expand Down
24 changes: 23 additions & 1 deletion src/commands/auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ vi.mock('../execution.js', () => ({
executeCommand: executeCommandMock,
}));

import { collectAuthRefresh, collectAuthStatus, registerAuthCommands } from './auth.js';
import { collectAuthRefresh, collectAuthStatus, configureAuthCommandSurface, registerAuthCommands } from './auth.js';
import { AuthRequiredError } from '../errors.js';
import { cli, getRegistry, Strategy } from '../registry.js';

Expand Down Expand Up @@ -308,6 +308,28 @@ describe('auth command format validation', () => {
});
});

describe('auth command surface', () => {
it('exposes the native grammar without attaching local probe actions', () => {
const program = new Command('webcmd');
const { status, refresh } = configureAuthCommandSurface(program);

expect(status.options.map(option => option.flags)).toEqual([
'--site <sites>',
'--full',
'--concurrency <n>',
'--timeout <seconds>',
'--only <status>',
'-v, --verbose',
'-f, --format <fmt>',
'--json',
]);
expect(status.options.find(option => option.long === '--only')?.argChoices).toEqual([
'all', 'logged-in', 'not-logged-in', 'unknown', 'error',
]);
expect(refresh.options.map(option => option.long)).not.toContain('--trace');
});
});

// Both probes drive the browser/daemon stack, whose CDP diagnostics are already
// gated on isVerbose() — so -v has something observable behind it here (#174).
describe('auth verbose flag', () => {
Expand Down
34 changes: 22 additions & 12 deletions src/commands/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,15 +71,19 @@ interface AuthRefreshState {
sites: Record<string, AuthRefreshSiteState>;
}

function parsePositiveInt(raw: string | number | undefined, label: string, fallback: number): number {
if (raw === undefined || raw === null || raw === '') return fallback;
export function parseAuthPositiveInt(raw: string | number | undefined, label: string): number | undefined {
if (raw === undefined || raw === null || raw === '') return undefined;
const parsed = Number(raw);
if (!Number.isInteger(parsed) || parsed <= 0) {
throw new InvalidArgumentError(`${label} must be a positive integer. Received: "${String(raw)}"`);
}
return parsed;
}

function parsePositiveInt(raw: string | number | undefined, label: string, fallback: number): number {
return parseAuthPositiveInt(raw, label) ?? fallback;
}

function parseSiteFilter(raw: string | undefined): Set<string> | null {
if (!raw || !raw.trim()) return null;
const sites = raw.split(',').map(site => site.trim()).filter(Boolean);
Expand Down Expand Up @@ -454,7 +458,7 @@ export async function collectAuthRefresh(options: AuthRefreshOptions): Promise<A
return rows;
}

export function registerAuthCommands(program: Command): Command {
export function configureAuthCommandSurface(program: Command) {
const auth = program
.command('auth')
.description('Inspect website login status');
Expand All @@ -469,6 +473,21 @@ export function registerAuthCommands(program: Command): Command {
.addOption(new Option('--only <status>', 'Filter rows by status').choices(['all', 'logged-in', 'not-logged-in', 'unknown', 'error']).default('all'))
.option('-v, --verbose', 'Debug output', false);
addOutputFormatOption(status);
const refresh = auth
.command('refresh')
.description('Touch logged-in site sessions to keep browser auth fresh')
.option('--site <sites>', 'Comma-separated site names to refresh, e.g. github,claude')
.option('--all', 'Ignore the 24h refresh throttle and force every selected site', false)
.option('--concurrency <n>', 'Maximum sites to refresh at once')
.option('--timeout <seconds>', 'Per-site timeout in seconds')
.option('-v, --verbose', 'Debug output', false);
addOutputFormatOption(refresh);

return { auth, status, refresh };
}

export function registerAuthCommands(program: Command): Command {
const { auth, status, refresh } = configureAuthCommandSurface(program);
status.action(async (opts) => {
// Both auth probes drive the browser/daemon stack, so verbose mode surfaces
// the CDP diagnostics those layers already gate on `isVerbose()` (#174).
Expand All @@ -493,15 +512,6 @@ export function registerAuthCommands(program: Command): Command {
});
});

const refresh = auth
.command('refresh')
.description('Touch logged-in site sessions to keep browser auth fresh')
.option('--site <sites>', 'Comma-separated site names to refresh, e.g. github,claude')
.option('--all', 'Ignore the 24h refresh throttle and force every selected site', false)
.option('--concurrency <n>', 'Maximum sites to refresh at once')
.option('--timeout <seconds>', 'Per-site timeout in seconds')
.option('-v, --verbose', 'Debug output', false);
addOutputFormatOption(refresh);
refresh.action(async (opts) => {
enableVerbose(opts.verbose === true);
const fmt = resolveCommandOutputFormat(refresh, opts.format);
Expand Down
43 changes: 43 additions & 0 deletions src/completion-shared.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import { describe, expect, it } from 'vitest';
import {
getHostedBuiltinCommands,
getHostedBuiltinSubcommands,
getHostedRootHelp,
HOSTED_BUILTIN_COMMANDS,
HOSTED_ROOT_HELP,
LOCAL_ONLY_COMMAND_HELP,
Expand Down Expand Up @@ -37,4 +40,44 @@ describe('hosted root help', () => {
'Run `webcmd setup` and choose local mode to use local-only commands.',
);
});

it('falls back to client-owned roots without a core manifest', () => {
const names = getHostedBuiltinCommands(undefined, true);

expect(names).toEqual(expect.arrayContaining([
'completion',
'external',
'setup',
'skills',
'update',
'web',
]));
expect(names).not.toEqual(expect.arrayContaining([
'validate',
'verify',
'convention-audit',
'doctor',
]));
});

it('adds only advertised Cloud root commands', () => {
expect(getHostedBuiltinCommands(['validate', 'doctor'], true))
.toEqual(expect.arrayContaining(['validate', 'doctor']));
expect(getHostedBuiltinCommands(['validate', 'doctor'], true))
.not.toEqual(expect.arrayContaining(['verify', 'convention-audit']));
});

it('gates nested subcommands by canonical IDs', () => {
expect(getHostedBuiltinSubcommands('adapter', ['adapter/status']))
.toEqual(['override', 'path', 'source', 'status']);
expect(getHostedBuiltinSubcommands('profile', ['profile/create']))
.toEqual(['create', 'delete', 'list', 'use']);
expect(getHostedBuiltinSubcommands('plugin', ['plugin/catalog/list'])).toContain('catalog');
});

it('keeps only daemon in permanent local-only root help', () => {
expect(getHostedRootHelp(undefined, true).localOnlyCommands).toEqual([
{ name: 'daemon', description: 'Manage the local Webcmd daemon' },
]);
});
});
Loading
Loading