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
4 changes: 3 additions & 1 deletion .fallowrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
"src/sdk/metro.ts",
"src/sdk/remote-config.ts",
"src/sdk/install-source.ts",
"src/sdk/plugins.ts",
"src/sdk/android-adb.ts",
"src/sdk/contracts.ts",
"src/sdk/selectors.ts",
Expand Down Expand Up @@ -217,9 +218,10 @@
},
{
"comment": "Dedicated CLI command handlers are reached only through the dynamic `import()` table `dedicatedCliCommandHandlerLoaders` in src/cli/commands/router.ts, which --production analysis cannot follow to a consumer. Same shape as the daemon route-handler entry above; that table is what enumerates this list, so add/remove here whenever a loader is added/removed.",
"file": "src/cli/commands/{auth,connection,daemon,device,proxy,recording,replay,screenshot,takeover}.ts",
"file": "src/cli/commands/{auth,connection,daemon,device,plugins,proxy,recording,replay,screenshot,takeover}.ts",
"exports": [
"authCommand",
"pluginsCommand",
"connectCommand",
"disconnectCommand",
"connectionCommand",
Expand Down
4 changes: 4 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,10 @@
"./ai-sdk": {
"types": "./dist/src/ai-sdk.d.ts",
"import": "./dist/src/ai-sdk.js"
},
"./plugins": {
"types": "./dist/src/plugins.d.ts",
"import": "./dist/src/plugins.js"
}
},
"engines": {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ test('every command that deviates from require-owner is a reviewed, diffable set
'lease_release',
'mcp',
'metro',
'plugins',
'proxy',
'react-devtools',
'release_materialized_paths',
Expand Down
11 changes: 11 additions & 0 deletions packages/command-registry/src/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1691,6 +1691,17 @@ export const RAW_COMMAND_DESCRIPTORS = [
mcpExposed: false,
platformExecution: NO_PLATFORM_EXECUTION,
},
{
name: 'plugins',
deviceClaimPolicy: 'none',
...(ownerFilesEnabled ? { ownerFiles: ['src/cli/commands/plugins.ts'] as const } : {}),
catalog: { group: 'local-cli' },
recordsSessionAction: false,
timeoutPolicy: DEFAULT_TIMEOUT_POLICY,
batchable: false,
mcpExposed: false,
platformExecution: NO_PLATFORM_EXECUTION,
},
{
name: 'connect',
deviceClaimPolicy: 'none',
Expand Down
2 changes: 1 addition & 1 deletion scripts/layering/daemon-platform-runtime-inventory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -223,7 +223,7 @@ export const DAEMON_PLATFORM_RUNTIME_EDGES: readonly DaemonPlatformRuntimeEdge[]
{
file: 'src/daemon/server/daemon-runtime.ts',
target: 'src/provider-device-runtimes.ts',
symbols: ['createDefaultProviderRuntimeComposition', 'DEFAULT_PROVIDER_RUNTIME_REQUIRED_IDS'],
symbols: ['createDaemonProviderRuntimeComposition', 'DEFAULT_PROVIDER_RUNTIME_REQUIRED_IDS'],
classification: 'composition-essential',
rationale:
'process-root assembly of the default provider runtime composition and the ids whose runtime ' +
Expand Down
1 change: 1 addition & 0 deletions scripts/layering/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ const TARGET_DAG_RANK = new Map([
['metro', 4],
['remote', 4],
['sdk', 4],
['plugins', 4],
['daemon-client', 5],
['cli', 6],
]);
Expand Down
35 changes: 34 additions & 1 deletion src/__tests__/provider-device-runtimes.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,39 @@
import assert from 'node:assert/strict';
import { test } from 'vitest';
import { createDefaultProviderRuntimeComposition } from '../provider-device-runtimes.ts';
import {
createDefaultProviderRuntimeComposition,
createDaemonProviderRuntimeComposition,
} from '../provider-device-runtimes.ts';
import { pluginHome, selectPlugin, registrationSource } from '../plugins/plugin.fixtures.ts';

test('daemon composition registers installed provider runtimes and their lazy platform modules', async () => {
const { home, env } = pluginHome();
selectPlugin(home, 'example', 'example', registrationSource('example'));
const composition = await createDaemonProviderRuntimeComposition(env);
const plugin = composition.runtimes.find((runtime) => runtime.provider === 'example');
assert.ok(plugin);
assert.equal(
composition.platformModules.find(({ runtime }) => runtime === plugin)?.module.owner.provider,
'example',
);
await Promise.all(composition.runtimes.map((runtime) => runtime.shutdown()));
});

test('bundled composition stays independent of configured plugins and reserves all builtin IDs', async () => {
const { home, env } = pluginHome();
selectPlugin(home, 'example', 'example', 'throw new Error("must not evaluate");');
const composition = await createDefaultProviderRuntimeComposition(env);
assert.ok(composition.runtimes.every(({ provider }) => provider !== 'example'));
await Promise.all(composition.runtimes.map((runtime) => runtime.shutdown()));
});

test('daemon composition rejects builtin provider IDs before evaluating plugins', async () => {
for (const provider of ['limrun', 'browserstack', 'aws-device-farm']) {
const { home, env } = pluginHome();
selectPlugin(home, 'example', provider, 'throw new Error("must not evaluate");');
await assert.rejects(createDaemonProviderRuntimeComposition(env), { code: 'INVALID_ARGS' });
}
});

test('default provider runtimes skip Limrun when only the removed API key alias is configured', async () => {
const { runtimes, platformModules } = await createDefaultProviderRuntimeComposition({
Expand Down
3 changes: 3 additions & 0 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ const REMOTE_MATERIALIZATION_DEFERRED_COMMANDS = new Set([
'connection',
'close',
'daemon',
'plugins',
'device',
'disconnect',
'metro',
Expand Down Expand Up @@ -724,6 +725,7 @@ function resolveActiveConnectionDefaults(options: {
options.command === 'connect' ||
options.command === 'connection' ||
options.command === 'daemon' ||
options.command === 'plugins' ||
options.command === 'proxy'
) {
return null;
Expand Down Expand Up @@ -751,6 +753,7 @@ function shouldResolveRemoteAuth(command: string): boolean {
command !== 'auth' &&
command !== 'connection' &&
command !== 'daemon' &&
command !== 'plugins' &&
command !== 'device' &&
command !== 'proxy'
);
Expand Down
60 changes: 60 additions & 0 deletions src/cli/commands/__tests__/plugins.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import { afterEach, test, vi } from 'vitest';
import { runCli } from '../../../cli.ts';
import { pluginHome, selectPlugin } from '../../../plugins/plugin.fixtures.ts';
import { parseRawArgs, usageForCommand } from '../../parser/args.ts';

vi.mock('../../../provider-device-runtimes.ts', () => {
throw new Error('provider runtimes must remain unloaded');
});

afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllEnvs();
});

test('plugins list and remove route through the CLI and emits JSON without daemon access or plugin evaluation', async () => {
const { home } = pluginHome();
vi.stubEnv('AGENT_DEVICE_HOME', home);
vi.stubEnv('AGENT_DEVICE_STATE_DIR', path.join(home, 'state'));
vi.stubEnv('AGENT_DEVICE_NO_UPDATE_NOTIFIER', '1');
const marker = path.join(home, 'evaluated');
selectPlugin(
home,
'example',
'example',
`import fs from 'node:fs'; fs.writeFileSync(${JSON.stringify(marker)}, 'yes');`,
);
const sendToDaemon = vi.fn(async () => {
throw new Error('unexpected daemon access');
});
let stdout = '';
vi.spyOn(process.stdout, 'write').mockImplementation((chunk) => {
stdout += String(chunk);
return true;
});
await runCli(['plugins', 'list', '--json'], {
sendToDaemon,
});
const result = JSON.parse(stdout);
assert.equal(result.success, true);
assert.deepEqual(result.data.plugins, [
{ name: 'example', version: '1.2.3', provider: 'example', compatible: true },
]);
assert.equal(sendToDaemon.mock.calls.length, 0);
stdout = '';
await runCli(['plugins', 'remove', 'example', '--json'], { sendToDaemon });
assert.deepEqual(JSON.parse(stdout).data.plugins, []);
assert.ok(!fs.existsSync(marker));
});

test('plugin schema exposes operator help and preserves scoped npm package specs', async () => {
const args = parseRawArgs(['plugins', 'add', '@example/provider@^1.0.0', '--json']);
assert.equal(args.command, 'plugins');
assert.deepEqual(args.positionals, ['add', '@example/provider@^1.0.0']);
const help = await usageForCommand('plugins');
assert.ok(help);
assert.match(help, /plugins list\|add/);
});
40 changes: 40 additions & 0 deletions src/cli/commands/plugins.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { AppError } from '@agent-device/kernel/errors';
import { changePlugin, listPlugins } from '../../plugins/store.ts';
import { writeCommandOutput } from './shared.ts';
import type { ClientCommandHandler } from './router-types.ts';

export const pluginsCommand: ClientCommandHandler = async ({ positionals, flags }) => {
const [action = 'list', name, extra] = positionals;
if (
extra ||
!['add', 'update', 'remove', 'list'].includes(action) ||
(action === 'list' ? name !== undefined : !name)
) {
throw new AppError(
'INVALID_ARGS',
'Use plugins list, add <package[@version]>, update <package>, or remove <package>',
);
}
if (action !== 'list' && name) {
const reserved =
action === 'remove'
? []
: (await import('../../provider-device-runtimes.ts')).DEFAULT_PROVIDER_RUNTIME_REQUIRED_IDS;
await changePlugin(action as 'add' | 'update' | 'remove', name, process.env, reserved);
}
const plugins = listPlugins(process.env);
await writeCommandOutput(flags, { plugins, restartRequired: action !== 'list' }, () =>
[
...plugins.map((plugin) =>
plugin.compatible
? `${plugin.name}@${plugin.version} (${plugin.provider})`
: `${plugin.name}: unavailable; run plugins update ${plugin.name} or plugins remove ${plugin.name}`,
),
...(action !== 'list'
? ['Restart the local daemon after closing active sessions to use the changed plugin set.']
: []),
...(plugins.length === 0 ? ['No provider plugins installed.'] : []),
].join('\n'),
);
return true;
};
1 change: 1 addition & 0 deletions src/cli/commands/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ const dedicatedCliCommandHandlerLoaders = {
disconnect: async () => (await import('./connection.ts')).disconnectCommand,
connection: async () => (await import('./connection.ts')).connectionCommand,
auth: async () => (await import('./auth.ts')).authCommand,
plugins: async () => (await import('./plugins.ts')).pluginsCommand,
daemon: async () => (await import('./daemon.ts')).daemonCommand,
device: async () => (await import('./device.ts')).deviceCommand,
proxy: async () => (await import('./proxy.ts')).proxyCommand,
Expand Down
26 changes: 26 additions & 0 deletions src/commands/schema/cli-config.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import { test } from 'vitest';
import { pluginHome } from '../../plugins/plugin.fixtures.ts';
import { resolveConfigBackedFlagDefaults, resolveUserConfigPath } from './cli-config.ts';

test('AGENT_DEVICE_HOME relocates user defaults; project and explicit config cannot select plugins', () => {
const { home, env } = pluginHome();
const cliFlags = { help: false, version: false, json: false };
fs.writeFileSync(
path.join(home, 'config.json'),
JSON.stringify({ session: 'custom-home', plugins: {} }),
);
const options = { command: 'snapshot', cwd: home, cliFlags, env };
assert.equal(resolveConfigBackedFlagDefaults(options).session, 'custom-home');
fs.writeFileSync(path.join(home, 'agent-device.json'), JSON.stringify({ plugins: {} }));
assert.throws(() => resolveConfigBackedFlagDefaults(options), { code: 'INVALID_ARGS' });
const explicit = {
...options,
cliFlags: { ...cliFlags, config: path.join(home, 'config.json') },
};
assert.throws(() => resolveConfigBackedFlagDefaults(explicit), { code: 'INVALID_ARGS' });
const relative = { AGENT_DEVICE_HOME: './relative' };
assert.throws(() => resolveUserConfigPath(relative), { code: 'INVALID_ARGS' });
});
17 changes: 13 additions & 4 deletions src/commands/schema/cli-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { CliFlags } from '@agent-device/contracts/command';
import fs from 'node:fs';
import path from 'node:path';
import { AppError } from '@agent-device/kernel/errors';
import { isRecord } from '@agent-device/kernel/record';
import { mergeDefinedFlags } from './merge-flags.ts';
import { type FlagKey } from '@agent-device/command-registry/flag-types';
import { projectConfigFlagKeys } from '@agent-device/command-registry/flag-registry';
Expand Down Expand Up @@ -55,8 +56,13 @@ function resolveConfigPaths(
];
}

function resolveUserConfigPath(env: EnvMap): string {
return path.join(expandUserHomePath('~', { env }), '.agent-device', 'config.json');
export function resolveUserConfigPath(env: EnvMap): string {
const home = env.AGENT_DEVICE_HOME
? expandUserHomePath(env.AGENT_DEVICE_HOME, { env })
: path.join(expandUserHomePath('~', { env }), '.agent-device');
if (!path.isAbsolute(home))
throw new AppError('INVALID_ARGS', 'AGENT_DEVICE_HOME must be absolute or ~/...');
return path.join(home, 'config.json');
}

function resolveInputPath(inputPath: string, cwd: string, env: EnvMap): string {
Expand Down Expand Up @@ -99,7 +105,7 @@ function loadSingleConfigFile(entry: ConfigPath): Partial<CliFlags> {
});
}

if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
if (!isRecord(parsed)) {
throw new AppError('INVALID_ARGS', `Config file must contain a JSON object: ${filePath}`);
}

Expand All @@ -114,7 +120,10 @@ function parseConfigObject(
origin: { source: ConfigFileSource; label: string },
): Partial<CliFlags> {
const flags: Partial<CliFlags> = {};
for (const [rawKey, rawValue] of Object.entries(source)) {
const entries = Object.entries(source).filter(
([key]) => key !== 'plugins' || origin.source !== 'user',
);
for (const [rawKey, rawValue] of entries) {
const key = rawKey as FlagKey;
const spec = getOptionSpec(key);
if (!spec) {
Expand Down
2 changes: 1 addition & 1 deletion src/commands/schema/cli-help.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ import { renderCliHelpOverview } from './cli-help-overview.ts';
import { foldableHelpTopic } from '../system/index.ts';

const CONFIGURATION_LINES = [
'Default config files: ~/.agent-device/config.json, ./agent-device.json (project-safe defaults only).',
'Default config files: ~/.agent-device/config.json (or <AGENT_DEVICE_HOME>/config.json), ./agent-device.json (project-safe defaults only).',
'Use --config <path> or AGENT_DEVICE_CONFIG for explicit connection/provider defaults; project config cannot select endpoints or credentials.',
] as const;

Expand Down
11 changes: 11 additions & 0 deletions src/commands/schema/command-overrides.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,17 @@ import {
type SchemaOnlyCliCommandName = Exclude<LocalCliCommandName, CommandName>;

const SCHEMA_ONLY_CLI_COMMAND_SCHEMAS = {
plugins: {
text: {
summary: 'Manage installed provider plugins',
description:
'Install, list, update, or remove npm provider plugins in AGENT_DEVICE_HOME (default ~/.agent-device). Changes take effect after restarting the local daemon. Use --json for structured results.',
},
usageOverride: 'plugins list|add <package[@version]>|update <package>|remove <package>',
listUsageOverride: 'plugins',
positionalArgs: ['list|add|update|remove', 'package?'],
supportedFlags: [],
},
cdp: {
text: {
summary: 'Inspect CDP targets, JS heap, and leaks',
Expand Down
Loading
Loading