From 29a15ec1b33d97d0b78dce5683428f35c03c5148 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 9 Sep 2026 19:17:09 +1000 Subject: [PATCH 1/2] feat(workspace): add explicit setup commands --- docs/src/content/docs/docs/reference/cli.mdx | 22 ++ .../docs/docs/reference/configuration.mdx | 24 ++ .../engineering/.allagents/workspace.yaml | 7 + src/cli/agent-help.ts | 28 +- src/cli/commands/workspace.ts | 72 +++++ src/cli/metadata/workspace.ts | 19 ++ src/core/workspace-setup.ts | 65 +++++ src/models/workspace-config.ts | 6 + tests/unit/cli/agent-help.test.ts | 13 +- .../unit/cli/workspace-setup-command.test.ts | 260 ++++++++++++++++++ tests/unit/models/workspace-config.test.ts | 25 ++ 11 files changed, 527 insertions(+), 14 deletions(-) create mode 100644 src/core/workspace-setup.ts create mode 100644 tests/unit/cli/workspace-setup-command.test.ts diff --git a/docs/src/content/docs/docs/reference/cli.mdx b/docs/src/content/docs/docs/reference/cli.mdx index eab543f2..8671d042 100644 --- a/docs/src/content/docs/docs/reference/cli.mdx +++ b/docs/src/content/docs/docs/reference/cli.mdx @@ -62,6 +62,7 @@ With `--json`, each entry in the `plugins` array includes a `kind` field (`"plug ```bash allagents workspace init [--from ] +allagents workspace setup allagents workspace status # alias for `allagents status` allagents workspace plugin install [--scope ] allagents workspace plugin remove [--scope ] @@ -82,6 +83,27 @@ Initialize a new workspace from a template: When using a GitHub source, AllAgents fetches `workspace.yaml` from `.allagents/workspace.yaml` or `workspace.yaml` in the target path. +### workspace setup + +Run the top-level `setup` command list from `.allagents/workspace.yaml`: + +```bash +allagents workspace setup +``` + +This explicit action is the only path that executes setup commands. `workspace +init`, `update`, and `workspace sync` never run them because workspace templates +may come from untrusted remote sources. Review every command before running +setup. + +Each command is shown immediately before it runs. Commands execute sequentially +from the workspace root with inherited terminal I/O. Execution stops on the +first nonzero exit or terminating signal and returns exit status 1. + +With `--json`, stdout contains one deterministic result document listing each +attempted command, its nullable exit code, and its nullable terminating signal. +Command announcements and command output are forwarded to stderr. + ### workspace plugin install / remove | Flag | Description | diff --git a/docs/src/content/docs/docs/reference/configuration.mdx b/docs/src/content/docs/docs/reference/configuration.mdx index 3e6cf647..73c75218 100644 --- a/docs/src/content/docs/docs/reference/configuration.mdx +++ b/docs/src/content/docs/docs/reference/configuration.mdx @@ -8,6 +8,10 @@ description: Configuration file reference. The workspace configuration file defines repositories, plugins, workspace file sync, and target clients. ```yaml +setup: + - bun install + - bun run build + # Workspace file sync (optional) workspace: source: ../shared-config # Default base for relative file paths @@ -49,6 +53,26 @@ clients: - cursor ``` +### Setup Commands + +The optional top-level `setup` field is an ordered list of shell command strings: + +```yaml +setup: + - bun install + - bun run build +``` + +Run these commands only with the explicit `allagents workspace setup` action. +AllAgents never runs them during `init`, `update`, or `sync`. This is a trust +boundary: remote workspace templates are untrusted until you review their setup +commands and explicitly choose to execute them. + +Each command is shown immediately before it runs. Commands execute sequentially +from the workspace root, inherit terminal I/O, and stop after the first nonzero +exit or terminating signal. In `--json` mode, command announcements and output +are forwarded to stderr so stdout remains one deterministic JSON document. + ### Plugin Skills Control which skills are synced per plugin using the inline `skills` field on plugin entries: diff --git a/examples/workspaces/engineering/.allagents/workspace.yaml b/examples/workspaces/engineering/.allagents/workspace.yaml index 65ab707f..6d908ef6 100644 --- a/examples/workspaces/engineering/.allagents/workspace.yaml +++ b/examples/workspaces/engineering/.allagents/workspace.yaml @@ -1,5 +1,8 @@ version: 2 +setup: + - curl -fsSL https://herdr.dev/install.sh | sh + repositories: [] plugins: @@ -27,5 +30,9 @@ plugins: skills: - research/llm-wiki + - source: https://github.com/herdrdev/herdr + skills: + - herdr + clients: - universal diff --git a/src/cli/agent-help.ts b/src/cli/agent-help.ts index cdcd728d..d99f1142 100644 --- a/src/cli/agent-help.ts +++ b/src/cli/agent-help.ts @@ -1,29 +1,35 @@ import type { AgentCommandMeta } from './help.js'; import { normalizeSkillHelpArgs } from './skill-arg-normalizer.js'; -import { initMeta, syncMeta, statusMeta } from './metadata/workspace.js'; import { - marketplaceListMeta, + skillsAddMeta, + skillsListMeta, + skillsRemoveMeta, + skillsSearchMeta, + skillsUpdateMeta, +} from './metadata/plugin-skills.js'; +import { marketplaceAddMeta, + marketplaceBrowseMeta, + marketplaceListMeta, marketplaceRemoveMeta, marketplaceUpdateMeta, - marketplaceBrowseMeta, - pluginListMeta, - pluginValidateMeta, pluginInstallMeta, + pluginListMeta, pluginUninstallMeta, + pluginValidateMeta, } from './metadata/plugin.js'; import { updateMeta } from './metadata/self.js'; import { - skillsListMeta, - skillsAddMeta, - skillsRemoveMeta, - skillsSearchMeta, - skillsUpdateMeta, -} from './metadata/plugin-skills.js'; + initMeta, + setupMeta, + statusMeta, + syncMeta, +} from './metadata/workspace.js'; const allCommands: AgentCommandMeta[] = [ initMeta, + setupMeta, syncMeta, statusMeta, pluginInstallMeta, diff --git a/src/cli/commands/workspace.ts b/src/cli/commands/workspace.ts index fe2bb459..5184dc63 100644 --- a/src/cli/commands/workspace.ts +++ b/src/cli/commands/workspace.ts @@ -20,6 +20,7 @@ import { removeRepository, updateAgentFiles, } from '../../core/workspace-repo.js'; +import { runWorkspaceSetup } from '../../core/workspace-setup.js'; import { initWorkspace } from '../../core/workspace.js'; import { type ClientEntry, @@ -48,6 +49,7 @@ import { import { initMeta, pruneMeta, + setupMeta, statusMeta, syncMeta, } from '../metadata/workspace.js'; @@ -194,6 +196,75 @@ const initCmd = command({ }, }); +// ============================================================================= +// workspace setup +// ============================================================================= + +const setupCmd = command({ + name: 'setup', + description: buildDescription(setupMeta), + args: {}, + handler: async () => { + try { + const result = await runWorkspaceSetup(process.cwd(), { + jsonMode: isJsonMode(), + }); + const failed = result.commands.find( + ({ exitCode, signal }) => exitCode !== 0 || signal !== null, + ); + + if (failed) { + const error = + failed.signal !== null + ? `Setup command terminated by signal ${failed.signal}: ${failed.command}` + : `Setup command failed with exit code ${failed.exitCode}: ${failed.command}`; + if (isJsonMode()) { + jsonOutput({ + success: false, + command: 'workspace setup', + data: result, + error, + }); + } else { + console.error(`Error: ${error}`); + } + process.exit(1); + } + + if (isJsonMode()) { + jsonOutput({ + success: true, + command: 'workspace setup', + data: result, + }); + return; + } + + if (result.commands.length === 0) { + console.log('No setup commands configured.'); + } else { + console.log( + `Setup complete. ${result.commands.length} command(s) ran.`, + ); + } + } catch (error) { + if (error instanceof Error) { + if (isJsonMode()) { + jsonOutput({ + success: false, + command: 'workspace setup', + error: error.message, + }); + } else { + console.error(`Error: ${error.message}`); + } + process.exit(1); + } + throw error; + } + }, +}); + // ============================================================================= // workspace sync // ============================================================================= @@ -846,6 +917,7 @@ export const workspaceCmd = conciseSubcommands({ 'Manage AI agent workspaces - initialize, sync, and configure plugins', cmds: { init: initCmd, + setup: setupCmd, sync: syncCmd, status: statusCmd, prune: pruneCmd, diff --git a/src/cli/metadata/workspace.ts b/src/cli/metadata/workspace.ts index 72d4bd21..8af7ceeb 100644 --- a/src/cli/metadata/workspace.ts +++ b/src/cli/metadata/workspace.ts @@ -31,6 +31,25 @@ export const initMeta: AgentCommandMeta = { }, }; +export const setupMeta: AgentCommandMeta = { + command: 'workspace setup', + description: 'Run workspace setup commands', + whenToUse: + 'After reviewing the setup commands in workspace.yaml and explicitly deciding to run them', + examples: ['allagents workspace setup'], + expectedOutput: + 'Shows and runs setup commands sequentially from the workspace root. Exit 0 when every command succeeds, exit 1 on the first nonzero exit or signal.', + outputSchema: { + commands: [ + { + command: 'string', + exitCode: 'number | null', + signal: 'string | null', + }, + ], + }, +}; + export const syncMeta: AgentCommandMeta = { command: 'update', description: 'Update plugins in workspace', diff --git a/src/core/workspace-setup.ts b/src/core/workspace-setup.ts new file mode 100644 index 00000000..3bf4ba88 --- /dev/null +++ b/src/core/workspace-setup.ts @@ -0,0 +1,65 @@ +import { spawn } from 'node:child_process'; +import { once } from 'node:events'; +import { join, resolve } from 'node:path'; +import { CONFIG_DIR, WORKSPACE_CONFIG_FILE } from '../constants.js'; +import { parseWorkspaceConfig } from '../utils/workspace-parser.js'; + +export interface SetupCommandResult { + command: string; + exitCode: number | null; + signal: NodeJS.Signals | null; +} + +export interface WorkspaceSetupResult { + commands: SetupCommandResult[]; +} + +async function runShellCommand( + command: string, + workspaceRoot: string, + jsonMode: boolean, +): Promise> { + const child = spawn(command, { + cwd: workspaceRoot, + shell: true, + stdio: jsonMode ? ['inherit', process.stderr, process.stderr] : 'inherit', + }); + + const [exitCode, signal] = (await once(child, 'close')) as [ + number | null, + NodeJS.Signals | null, + ]; + return { exitCode, signal }; +} + +/** + * Run the configured setup commands in declaration order from the workspace + * root. This function is intentionally called only by the explicit setup CLI + * action; init and update must treat setup commands as inert configuration. + */ +export async function runWorkspaceSetup( + workspacePath: string, + options: { jsonMode?: boolean } = {}, +): Promise { + const workspaceRoot = resolve(workspacePath); + const config = await parseWorkspaceConfig( + join(workspaceRoot, CONFIG_DIR, WORKSPACE_CONFIG_FILE), + ); + const results: SetupCommandResult[] = []; + const jsonMode = options.jsonMode ?? false; + const output = jsonMode ? process.stderr : process.stdout; + + for (const command of config.setup ?? []) { + output.write(`$ ${command}\n`); + + const commandResult = await runShellCommand( + command, + workspaceRoot, + jsonMode, + ); + results.push({ command, ...commandResult }); + if (commandResult.exitCode !== 0 || commandResult.signal !== null) break; + } + + return { commands: results }; +} diff --git a/src/models/workspace-config.ts b/src/models/workspace-config.ts index ba782ca7..6c81ee9a 100644 --- a/src/models/workspace-config.ts +++ b/src/models/workspace-config.ts @@ -396,6 +396,12 @@ export type McpServerConfig = z.infer; */ export const WorkspaceConfigSchema = z.object({ version: z.number().optional(), + /** + * Shell commands run only by the explicit `allagents workspace setup` action. + * Remote workspace templates are untrusted, so sync and init must never run + * these commands automatically. + */ + setup: z.array(z.string()).optional(), workspace: WorkspaceSchema.optional(), repositories: z.array(RepositorySchema), plugins: z.array(PluginEntrySchema), diff --git a/tests/unit/cli/agent-help.test.ts b/tests/unit/cli/agent-help.test.ts index f9ebe32c..3ea9c561 100644 --- a/tests/unit/cli/agent-help.test.ts +++ b/tests/unit/cli/agent-help.test.ts @@ -1,6 +1,11 @@ import { describe, test, expect } from 'bun:test'; import { extractAgentHelpFlag, findMetaByCommand } from '../../../src/cli/agent-help.js'; -import { initMeta, syncMeta, statusMeta } from '../../../src/cli/metadata/workspace.js'; +import { + initMeta, + setupMeta, + syncMeta, + statusMeta, +} from '../../../src/cli/metadata/workspace.js'; import { marketplaceListMeta, marketplaceAddMeta, @@ -24,6 +29,7 @@ import type { AgentCommandMeta } from '../../../src/cli/help.js'; const allCommands: AgentCommandMeta[] = [ initMeta, + setupMeta, syncMeta, statusMeta, pluginInstallMeta, @@ -70,8 +76,8 @@ describe('extractAgentHelpFlag', () => { }); describe('agent command metadata', () => { - test('contains exactly 18 commands', () => { - expect(allCommands.length).toBe(18); + test('contains exactly 19 commands', () => { + expect(allCommands.length).toBe(19); }); test('all expected commands are present', () => { @@ -95,6 +101,7 @@ describe('agent command metadata', () => { 'skill update', 'status', 'update', + 'workspace setup', ]); }); diff --git a/tests/unit/cli/workspace-setup-command.test.ts b/tests/unit/cli/workspace-setup-command.test.ts new file mode 100644 index 00000000..2535a918 --- /dev/null +++ b/tests/unit/cli/workspace-setup-command.test.ts @@ -0,0 +1,260 @@ +import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; +import { + existsSync, + mkdirSync, + readFileSync, + rmSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +const cliEntry = join(import.meta.dir, '..', '..', '..', 'src', 'cli', 'index.ts'); + +function normalizeLines(output: string): string { + return output.replaceAll('\r\n', '\n'); +} + +function fixtureCommand(root: string, name: string, source: string): string { + const fixtureDir = join(root, 'fixtures'); + const fixturePath = join(fixtureDir, `${name}.cjs`); + mkdirSync(fixtureDir, { recursive: true }); + writeFileSync(fixturePath, source); + const executable = process.execPath.replaceAll('\\', '/'); + const script = fixturePath.replaceAll('\\', '/'); + return `"${executable}" "${script}"`; +} + +function runCli( + cwd: string, + args: string[], + testRoot: string, + json = true, +) { + return Bun.spawnSync( + ['bun', 'run', cliEntry, ...(json ? ['--json'] : []), ...args], + { + cwd, + env: { + ...process.env, + ALLAGENTS_TEST_HOME: join(testRoot, 'home'), + }, + stdin: 'ignore', + stdout: 'pipe', + stderr: 'pipe', + }, + ); +} + +function writeWorkspace(root: string, setup: string[]): void { + mkdirSync(join(root, '.allagents'), { recursive: true }); + writeFileSync( + join(root, '.allagents', 'workspace.yaml'), + [ + 'repositories: []', + 'plugins: []', + 'clients: []', + 'setup:', + ...setup.map((command) => ` - ${JSON.stringify(command)}`), + '', + ].join('\n'), + ); +} + +describe('workspace setup command', () => { + let testDir: string; + + beforeEach(() => { + testDir = join( + tmpdir(), + `allagents-workspace-setup-${process.pid}-${Date.now()}`, + ); + mkdirSync(testDir, { recursive: true }); + }); + + afterEach(() => { + rmSync(testDir, { recursive: true, force: true }); + }); + + test('runs commands sequentially from the workspace root', () => { + const commands = [ + fixtureCommand( + testDir, + 'record-cwd', + "require('node:fs').writeFileSync('setup.cwd', process.cwd());", + ), + fixtureCommand( + testDir, + 'write-first', + "const fs = require('node:fs'); if (!fs.existsSync('setup.cwd')) process.exit(2); fs.writeFileSync('setup.log', 'first');", + ), + fixtureCommand( + testDir, + 'append-second', + "const fs = require('node:fs'); if (fs.readFileSync('setup.log', 'utf8') !== 'first') process.exit(3); fs.appendFileSync('setup.log', '-second');", + ), + ]; + writeWorkspace(testDir, commands); + + const proc = runCli(testDir, ['workspace', 'setup'], testDir); + + expect(proc.exitCode).toBe(0); + expect(normalizeLines(proc.stderr.toString())).toBe( + commands.map((command) => `$ ${command}\n`).join(''), + ); + expect(JSON.parse(proc.stdout.toString())).toEqual({ + success: true, + command: 'workspace setup', + data: { + commands: commands.map((command) => ({ + command, + exitCode: 0, + signal: null, + })), + }, + }); + expect(readFileSync(join(testDir, 'setup.cwd'), 'utf8')).toBe(testDir); + expect(readFileSync(join(testDir, 'setup.log'), 'utf8')).toBe( + 'first-second', + ); + }); + + test('shows each command before execution in normal mode', () => { + const command = fixtureCommand( + testDir, + 'write-output', + "process.stdout.write('command-output\\n');", + ); + writeWorkspace(testDir, [command]); + + const proc = runCli(testDir, ['workspace', 'setup'], testDir, false); + + expect(proc.exitCode).toBe(0); + expect(normalizeLines(proc.stdout.toString())).toBe( + `$ ${command}\ncommand-output\nSetup complete. 1 command(s) ran.\n`, + ); + }); + + test('keeps JSON stdout deterministic when a command writes output', () => { + const command = fixtureCommand( + testDir, + 'write-output', + "process.stdout.write('command-output\\n');", + ); + writeWorkspace(testDir, [command]); + + const proc = runCli(testDir, ['workspace', 'setup'], testDir); + + expect(proc.exitCode).toBe(0); + expect(normalizeLines(proc.stderr.toString())).toBe( + `$ ${command}\ncommand-output\n`, + ); + expect(JSON.parse(proc.stdout.toString())).toEqual({ + success: true, + command: 'workspace setup', + data: { + commands: [{ command, exitCode: 0, signal: null }], + }, + }); + }); + + test('stops after the first nonzero exit', () => { + const commands = [ + fixtureCommand( + testDir, + 'write-first', + "require('node:fs').writeFileSync('setup.log', 'first');", + ), + fixtureCommand(testDir, 'fail', 'process.exit(7);'), + fixtureCommand( + testDir, + 'write-third', + "require('node:fs').appendFileSync('setup.log', '-third');", + ), + ]; + writeWorkspace(testDir, commands); + + const proc = runCli(testDir, ['workspace', 'setup'], testDir); + + expect(proc.exitCode).toBe(1); + expect(JSON.parse(proc.stdout.toString())).toEqual({ + success: false, + command: 'workspace setup', + data: { + commands: [ + { command: commands[0], exitCode: 0, signal: null }, + { command: commands[1], exitCode: 7, signal: null }, + ], + }, + error: `Setup command failed with exit code 7: ${commands[1]}`, + }); + expect(readFileSync(join(testDir, 'setup.log'), 'utf8')).toBe('first'); + }); + + test('preserves partial results when a command is terminated by a signal', () => { + const commands = [ + fixtureCommand( + testDir, + 'write-first', + "require('node:fs').writeFileSync('setup.log', 'first');", + ), + fixtureCommand( + testDir, + 'terminate-shell', + "process.kill(process.ppid, 'SIGTERM');", + ), + fixtureCommand( + testDir, + 'write-third', + "require('node:fs').appendFileSync('setup.log', '-third');", + ), + ]; + writeWorkspace(testDir, commands); + + const proc = runCli(testDir, ['workspace', 'setup'], testDir); + + expect(proc.exitCode).toBe(1); + expect(JSON.parse(proc.stdout.toString())).toEqual({ + success: false, + command: 'workspace setup', + data: { + commands: [ + { command: commands[0], exitCode: 0, signal: null }, + { command: commands[1], exitCode: null, signal: 'SIGTERM' }, + ], + }, + error: `Setup command terminated by signal SIGTERM: ${commands[1]}`, + }); + expect(readFileSync(join(testDir, 'setup.log'), 'utf8')).toBe('first'); + }); + + test('does not run setup commands during init or update', () => { + const templateDir = join(testDir, 'template'); + const workspaceDir = join(testDir, 'workspace'); + const markerPath = join(testDir, 'setup-ran'); + const command = fixtureCommand( + testDir, + 'write-marker', + `require('node:fs').writeFileSync(${JSON.stringify(markerPath)}, 'ran');`, + ); + writeWorkspace(templateDir, [command]); + + const init = runCli( + testDir, + [ + 'workspace', + 'init', + workspaceDir, + '--from', + join(templateDir, '.allagents', 'workspace.yaml'), + ], + testDir, + ); + expect(init.exitCode).toBe(0); + expect(existsSync(markerPath)).toBe(false); + + const update = runCli(workspaceDir, ['update'], testDir); + expect(update.exitCode).toBe(0); + expect(existsSync(markerPath)).toBe(false); + }); +}); diff --git a/tests/unit/models/workspace-config.test.ts b/tests/unit/models/workspace-config.test.ts index ec641581..e7ef815b 100644 --- a/tests/unit/models/workspace-config.test.ts +++ b/tests/unit/models/workspace-config.test.ts @@ -24,6 +24,31 @@ describe('WorkspaceConfigSchema', () => { expect(result.success).toBe(true); }); + it('accepts a top-level list of setup shell commands', () => { + const result = WorkspaceConfigSchema.safeParse({ + repositories: [], + plugins: [], + clients: [], + setup: ['bun install', 'bun run build'], + }); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.setup).toEqual(['bun install', 'bun run build']); + } + }); + + it('rejects non-string setup commands', () => { + const result = WorkspaceConfigSchema.safeParse({ + repositories: [], + plugins: [], + clients: [], + setup: ['bun install', { command: 'bun run build' }], + }); + + expect(result.success).toBe(false); + }); + it('should reject invalid client types', () => { const invalidConfig = { repositories: [], From 1e6dbfc799bb96e1970d6c0716ead9ad8d0a4fd4 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 10 Sep 2026 12:10:39 +1000 Subject: [PATCH 2/2] feat(workspace): select setup commands by platform --- docs/src/content/docs/docs/reference/cli.mdx | 21 ++-- .../docs/docs/reference/configuration.mdx | 37 ++++-- .../engineering/.allagents/workspace.yaml | 5 +- src/cli/commands/workspace.ts | 10 +- src/cli/metadata/workspace.ts | 96 ++++++++++---- src/core/workspace-setup.ts | 48 ++++++- src/models/workspace-config.ts | 84 ++++++++++--- .../unit/cli/workspace-setup-command.test.ts | 117 ++++++++++++++++-- tests/unit/models/workspace-config.test.ts | 49 +++++++- 9 files changed, 392 insertions(+), 75 deletions(-) diff --git a/docs/src/content/docs/docs/reference/cli.mdx b/docs/src/content/docs/docs/reference/cli.mdx index 8671d042..fa72f46c 100644 --- a/docs/src/content/docs/docs/reference/cli.mdx +++ b/docs/src/content/docs/docs/reference/cli.mdx @@ -85,7 +85,8 @@ When using a GitHub source, AllAgents fetches `workspace.yaml` from `.allagents/ ### workspace setup -Run the top-level `setup` command list from `.allagents/workspace.yaml`: +Run setup entries from `.allagents/workspace.yaml` that match the current +platform and architecture: ```bash allagents workspace setup @@ -96,13 +97,19 @@ init`, `update`, and `workspace sync` never run them because workspace templates may come from untrusted remote sources. Review every command before running setup. -Each command is shown immediately before it runs. Commands execute sequentially -from the workspace root with inherited terminal I/O. Execution stops on the -first nonzero exit or terminating signal and returns exit status 1. +String entries run everywhere. Object entries may restrict execution with +`platforms` using Node platform names such as `linux`, `darwin`, and `win32`, +and with `architectures` using names such as `x64` and `arm64`. When both are +present, both must match. Nonmatching commands are reported as skipped. -With `--json`, stdout contains one deterministic result document listing each -attempted command, its nullable exit code, and its nullable terminating signal. -Command announcements and command output are forwarded to stderr. +Matching commands are shown immediately before they run, execute sequentially +from the workspace root with inherited terminal I/O, and stop on the first +nonzero exit or terminating signal. + +With `--json`, stdout contains one deterministic result document preserving +declaration order. Each entry reports `succeeded`, `failed`, or `skipped`, its +nullable exit code and signal, and a skip reason when applicable. Command +announcements and command output are forwarded to stderr. ### workspace plugin install / remove diff --git a/docs/src/content/docs/docs/reference/configuration.mdx b/docs/src/content/docs/docs/reference/configuration.mdx index 73c75218..7de6ef4c 100644 --- a/docs/src/content/docs/docs/reference/configuration.mdx +++ b/docs/src/content/docs/docs/reference/configuration.mdx @@ -55,23 +55,38 @@ clients: ### Setup Commands -The optional top-level `setup` field is an ordered list of shell command strings: +The optional top-level `setup` field is an ordered list. A string runs on every +platform. Use an object to select operating systems or CPU architectures: ```yaml setup: - bun install - - bun run build + - run: curl -fsSL https://example.com/install.sh | sh + platforms: [linux, darwin] + architectures: [x64, arm64] + - run: 'powershell -ExecutionPolicy Bypass -c "irm https://example.com/install.ps1 | iex"' + platforms: [win32] ``` -Run these commands only with the explicit `allagents workspace setup` action. -AllAgents never runs them during `init`, `update`, or `sync`. This is a trust -boundary: remote workspace templates are untrusted until you review their setup -commands and explicitly choose to execute them. - -Each command is shown immediately before it runs. Commands execute sequentially -from the workspace root, inherit terminal I/O, and stop after the first nonzero -exit or terminating signal. In `--json` mode, command announcements and output -are forwarded to stderr so stdout remains one deterministic JSON document. +| Field | Required | Description | +|-------|----------|-------------| +| `run` | Yes for object entries | Shell command to execute | +| `platforms` | No | Allowed Node platform names, such as `linux`, `darwin`, or `win32` | +| `architectures` | No | Allowed Node architecture names, such as `x64` or `arm64` | + +When both selectors are present, the current platform and architecture must +match. Nonmatching entries are reported as skipped without executing. + +Run setup only with the explicit `allagents workspace setup` action. AllAgents +never runs it during `init`, `update`, or `sync`. This is a trust boundary: +remote workspace templates are untrusted until you review their setup commands +and explicitly choose to execute them. + +AllAgents reports entries in declaration order. Matching commands are shown +immediately before they run, execute sequentially from the workspace root, +inherit terminal I/O, and stop after the first nonzero exit or terminating +signal. In `--json` mode, command announcements and output are forwarded to +stderr so stdout remains one deterministic JSON document. ### Plugin Skills diff --git a/examples/workspaces/engineering/.allagents/workspace.yaml b/examples/workspaces/engineering/.allagents/workspace.yaml index 6d908ef6..d64c42b7 100644 --- a/examples/workspaces/engineering/.allagents/workspace.yaml +++ b/examples/workspaces/engineering/.allagents/workspace.yaml @@ -1,7 +1,10 @@ version: 2 setup: - - curl -fsSL https://herdr.dev/install.sh | sh + - run: curl -fsSL https://herdr.dev/install.sh | sh + platforms: [linux, darwin] + - run: 'powershell -ExecutionPolicy Bypass -c "irm https://herdr.dev/install.ps1 | iex"' + platforms: [win32] repositories: [] diff --git a/src/cli/commands/workspace.ts b/src/cli/commands/workspace.ts index 5184dc63..666b8149 100644 --- a/src/cli/commands/workspace.ts +++ b/src/cli/commands/workspace.ts @@ -209,9 +209,7 @@ const setupCmd = command({ const result = await runWorkspaceSetup(process.cwd(), { jsonMode: isJsonMode(), }); - const failed = result.commands.find( - ({ exitCode, signal }) => exitCode !== 0 || signal !== null, - ); + const failed = result.commands.find(({ status }) => status === 'failed'); if (failed) { const error = @@ -243,8 +241,12 @@ const setupCmd = command({ if (result.commands.length === 0) { console.log('No setup commands configured.'); } else { + const ran = result.commands.filter( + ({ status }) => status !== 'skipped', + ).length; + const skipped = result.commands.length - ran; console.log( - `Setup complete. ${result.commands.length} command(s) ran.`, + `Setup complete. ${ran} command(s) ran; ${skipped} skipped.`, ); } } catch (error) { diff --git a/src/cli/metadata/workspace.ts b/src/cli/metadata/workspace.ts index 8af7ceeb..c30eecc2 100644 --- a/src/cli/metadata/workspace.ts +++ b/src/cli/metadata/workspace.ts @@ -3,7 +3,8 @@ import type { AgentCommandMeta } from '../help.js'; export const initMeta: AgentCommandMeta = { command: 'init', description: 'Create new workspace and sync plugins', - whenToUse: 'When starting a new project or adding allagents to an existing repo for the first time', + whenToUse: + 'When starting a new project or adding allagents to an existing repo for the first time', examples: [ 'allagents init', 'allagents init ./my-project', @@ -13,11 +14,26 @@ export const initMeta: AgentCommandMeta = { expectedOutput: 'Creates .allagents/workspace.yaml and syncs plugins. Shows sync results per plugin. Exit 0 on success, exit 1 on failure.', positionals: [ - { name: 'path', type: 'string', required: false, description: 'Target directory for the workspace (defaults to current directory)' }, + { + name: 'path', + type: 'string', + required: false, + description: + 'Target directory for the workspace (defaults to current directory)', + }, ], options: [ - { flag: '--from', type: 'string', description: 'Copy workspace.yaml from existing template/workspace' }, - { flag: '--client', type: 'string', description: 'Comma-separated list of clients (e.g., claude,copilot,cursor)' }, + { + flag: '--from', + type: 'string', + description: 'Copy workspace.yaml from existing template/workspace', + }, + { + flag: '--client', + type: 'string', + description: + 'Comma-separated list of clients (e.g., claude,copilot,cursor)', + }, ], outputSchema: { path: 'string', @@ -26,25 +42,35 @@ export const initMeta: AgentCommandMeta = { generated: 'number', failed: 'number', skipped: 'number', - plugins: [{ plugin: 'string', success: 'boolean', copied: 'number', generated: 'number', failed: 'number' }], + plugins: [ + { + plugin: 'string', + success: 'boolean', + copied: 'number', + generated: 'number', + failed: 'number', + }, + ], }, }, }; export const setupMeta: AgentCommandMeta = { command: 'workspace setup', - description: 'Run workspace setup commands', + description: 'Run workspace setup commands for this platform', whenToUse: - 'After reviewing the setup commands in workspace.yaml and explicitly deciding to run them', + 'After reviewing the setup commands in workspace.yaml and explicitly deciding to run commands matching this platform and architecture', examples: ['allagents workspace setup'], expectedOutput: - 'Shows and runs setup commands sequentially from the workspace root. Exit 0 when every command succeeds, exit 1 on the first nonzero exit or signal.', + 'Shows matching and skipped setup commands in declaration order. Exit 0 when every matching command succeeds, exit 1 on the first nonzero exit or signal.', outputSchema: { commands: [ { command: 'string', + status: 'succeeded | failed | skipped', exitCode: 'number | null', signal: 'string | null', + reason: 'string | null', }, ], }, @@ -63,26 +89,47 @@ export const syncMeta: AgentCommandMeta = { expectedOutput: 'Lists synced files with status per plugin. Exit 0 on success, exit 1 if any files failed.', options: [ - { flag: '--offline', type: 'boolean', description: 'Use cached plugins without fetching latest from remote' }, - { flag: '--dry-run', short: '-n', type: 'boolean', description: 'Simulate sync without making changes' }, - { flag: '--verbose', short: '-v', type: 'boolean', description: 'Show informational sync messages' }, + { + flag: '--offline', + type: 'boolean', + description: 'Use cached plugins without fetching latest from remote', + }, + { + flag: '--dry-run', + short: '-n', + type: 'boolean', + description: 'Simulate sync without making changes', + }, + { + flag: '--verbose', + short: '-v', + type: 'boolean', + description: 'Show informational sync messages', + }, ], outputSchema: { copied: 'number', generated: 'number', failed: 'number', skipped: 'number', - plugins: [{ plugin: 'string', success: 'boolean', copied: 'number', generated: 'number', failed: 'number' }], + plugins: [ + { + plugin: 'string', + success: 'boolean', + copied: 'number', + generated: 'number', + failed: 'number', + }, + ], }, }; export const pruneMeta: AgentCommandMeta = { command: 'workspace prune', description: 'Remove orphaned plugin references', - whenToUse: 'After removing a marketplace to clean up stale plugin references in workspace configs', - examples: [ - 'allagents workspace prune', - ], + whenToUse: + 'After removing a marketplace to clean up stale plugin references in workspace configs', + examples: ['allagents workspace prune'], expectedOutput: 'Lists removed orphaned plugins from both project and user scopes. Exit 0 on success, exit 1 on error.', outputSchema: { @@ -94,15 +141,20 @@ export const pruneMeta: AgentCommandMeta = { export const statusMeta: AgentCommandMeta = { command: 'status', description: 'Show sync status of plugins', - whenToUse: 'To check which plugins and skills are configured and whether they are available locally', - examples: [ - 'allagents status', - 'allagents workspace status', - ], + whenToUse: + 'To check which plugins and skills are configured and whether they are available locally', + examples: ['allagents status', 'allagents workspace status'], expectedOutput: 'Lists all configured plugins/skills with availability status and configured clients. Exit 0 on success, exit 1 if workspace is not initialized.', outputSchema: { - plugins: [{ source: 'string', type: 'string', kind: 'string', available: 'boolean' }], + plugins: [ + { + source: 'string', + type: 'string', + kind: 'string', + available: 'boolean', + }, + ], clients: ['string'], }, }; diff --git a/src/core/workspace-setup.ts b/src/core/workspace-setup.ts index 3bf4ba88..0c18a243 100644 --- a/src/core/workspace-setup.ts +++ b/src/core/workspace-setup.ts @@ -3,11 +3,16 @@ import { once } from 'node:events'; import { join, resolve } from 'node:path'; import { CONFIG_DIR, WORKSPACE_CONFIG_FILE } from '../constants.js'; import { parseWorkspaceConfig } from '../utils/workspace-parser.js'; +import type { SetupCommand } from '../models/workspace-config.js'; + +export type SetupCommandStatus = 'succeeded' | 'failed' | 'skipped'; export interface SetupCommandResult { command: string; + status: SetupCommandStatus; exitCode: number | null; signal: NodeJS.Signals | null; + reason: string | null; } export interface WorkspaceSetupResult { @@ -49,17 +54,52 @@ export async function runWorkspaceSetup( const jsonMode = options.jsonMode ?? false; const output = jsonMode ? process.stderr : process.stdout; - for (const command of config.setup ?? []) { - output.write(`$ ${command}\n`); + for (const entry of config.setup ?? []) { + const command = typeof entry === 'string' ? entry : entry.run; + const reason = getSkipReason(entry); + + if (reason !== null) { + output.write(`- Skipped: ${command} (${reason})\n`); + results.push({ + command, + status: 'skipped', + exitCode: null, + signal: null, + reason, + }); + continue; + } + output.write(`$ ${command}\n`); const commandResult = await runShellCommand( command, workspaceRoot, jsonMode, ); - results.push({ command, ...commandResult }); - if (commandResult.exitCode !== 0 || commandResult.signal !== null) break; + const status = + commandResult.exitCode === 0 && commandResult.signal === null + ? 'succeeded' + : 'failed'; + results.push({ + command, + status, + ...commandResult, + reason: null, + }); + if (status === 'failed') break; } return { commands: results }; } + +function getSkipReason(command: SetupCommand): string | null { + if (typeof command === 'string') return null; + + if (command.platforms && !command.platforms.includes(process.platform)) { + return `platform ${process.platform} does not match ${command.platforms.join(', ')}`; + } + if (command.architectures && !command.architectures.includes(process.arch)) { + return `architecture ${process.arch} does not match ${command.architectures.join(', ')}`; + } + return null; +} diff --git a/src/models/workspace-config.ts b/src/models/workspace-config.ts index 6c81ee9a..1963ad96 100644 --- a/src/models/workspace-config.ts +++ b/src/models/workspace-config.ts @@ -182,20 +182,21 @@ export type PluginSkillsConfig = z.infer; */ export const PluginEntrySchema = z.union([ PluginSourceSchema, - z.object({ - source: PluginSourceSchema, - clients: z.array(ClientTypeSchema).optional(), - install: InstallModeSchema.optional(), - exclude: z.array(z.string()).optional(), - skills: PluginSkillsConfigSchema.optional(), - /** - * Optional Git ref (tag or branch). Equivalent to passing the - * `owner/repo@` shorthand on install. When set, every sync resolves - * the plugin at this ref instead of the default branch. - */ - ref: z.string().optional(), - }).strict(), - + z + .object({ + source: PluginSourceSchema, + clients: z.array(ClientTypeSchema).optional(), + install: InstallModeSchema.optional(), + exclude: z.array(z.string()).optional(), + skills: PluginSkillsConfigSchema.optional(), + /** + * Optional Git ref (tag or branch). Equivalent to passing the + * `owner/repo@` shorthand on install. When set, every sync resolves + * the plugin at this ref instead of the default branch. + */ + ref: z.string().optional(), + }) + .strict(), ]); export type PluginEntry = z.infer; @@ -391,6 +392,55 @@ export const McpServerConfigSchema = z.union([ export type McpServerConfig = z.infer; +const SetupCommandTextSchema = z + .string() + .refine( + (command) => command.trim().length > 0, + 'Setup command cannot be blank', + ); + +export const SetupPlatformSchema = z.enum([ + 'aix', + 'android', + 'darwin', + 'freebsd', + 'haiku', + 'linux', + 'openbsd', + 'sunos', + 'win32', + 'cygwin', + 'netbsd', +]); + +export const SetupArchitectureSchema = z.enum([ + 'arm', + 'arm64', + 'ia32', + 'loong64', + 'mips', + 'mipsel', + 'ppc', + 'ppc64', + 'riscv64', + 's390', + 's390x', + 'x64', +]); + +export const SetupCommandSchema = z.union([ + SetupCommandTextSchema, + z + .object({ + run: SetupCommandTextSchema, + platforms: z.array(SetupPlatformSchema).min(1).optional(), + architectures: z.array(SetupArchitectureSchema).min(1).optional(), + }) + .strict(), +]); + +export type SetupCommand = z.infer; + /** * Complete workspace configuration (workspace.yaml) */ @@ -398,10 +448,10 @@ export const WorkspaceConfigSchema = z.object({ version: z.number().optional(), /** * Shell commands run only by the explicit `allagents workspace setup` action. - * Remote workspace templates are untrusted, so sync and init must never run - * these commands automatically. + * String entries run everywhere; object entries can select Node platforms and + * architectures. Sync and init must never run these commands automatically. */ - setup: z.array(z.string()).optional(), + setup: z.array(SetupCommandSchema).optional(), workspace: WorkspaceSchema.optional(), repositories: z.array(RepositorySchema), plugins: z.array(PluginEntrySchema), diff --git a/tests/unit/cli/workspace-setup-command.test.ts b/tests/unit/cli/workspace-setup-command.test.ts index 2535a918..08dd10e0 100644 --- a/tests/unit/cli/workspace-setup-command.test.ts +++ b/tests/unit/cli/workspace-setup-command.test.ts @@ -11,6 +11,14 @@ import { join } from 'node:path'; const cliEntry = join(import.meta.dir, '..', '..', '..', 'src', 'cli', 'index.ts'); +type SetupFixture = + | string + | { + run: string; + platforms?: NodeJS.Platform[]; + architectures?: NodeJS.Architecture[]; + }; + function normalizeLines(output: string): string { return output.replaceAll('\r\n', '\n'); } @@ -46,7 +54,7 @@ function runCli( ); } -function writeWorkspace(root: string, setup: string[]): void { +function writeWorkspace(root: string, setup: SetupFixture[]): void { mkdirSync(join(root, '.allagents'), { recursive: true }); writeFileSync( join(root, '.allagents', 'workspace.yaml'), @@ -108,8 +116,10 @@ describe('workspace setup command', () => { data: { commands: commands.map((command) => ({ command, + status: 'succeeded', exitCode: 0, signal: null, + reason: null, })), }, }); @@ -131,7 +141,7 @@ describe('workspace setup command', () => { expect(proc.exitCode).toBe(0); expect(normalizeLines(proc.stdout.toString())).toBe( - `$ ${command}\ncommand-output\nSetup complete. 1 command(s) ran.\n`, + `$ ${command}\ncommand-output\nSetup complete. 1 command(s) ran; 0 skipped.\n`, ); }); @@ -153,7 +163,15 @@ describe('workspace setup command', () => { success: true, command: 'workspace setup', data: { - commands: [{ command, exitCode: 0, signal: null }], + commands: [ + { + command, + status: 'succeeded', + exitCode: 0, + signal: null, + reason: null, + }, + ], }, }); }); @@ -182,8 +200,20 @@ describe('workspace setup command', () => { command: 'workspace setup', data: { commands: [ - { command: commands[0], exitCode: 0, signal: null }, - { command: commands[1], exitCode: 7, signal: null }, + { + command: commands[0], + status: 'succeeded', + exitCode: 0, + signal: null, + reason: null, + }, + { + command: commands[1], + status: 'failed', + exitCode: 7, + signal: null, + reason: null, + }, ], }, error: `Setup command failed with exit code 7: ${commands[1]}`, @@ -219,8 +249,20 @@ describe('workspace setup command', () => { command: 'workspace setup', data: { commands: [ - { command: commands[0], exitCode: 0, signal: null }, - { command: commands[1], exitCode: null, signal: 'SIGTERM' }, + { + command: commands[0], + status: 'succeeded', + exitCode: 0, + signal: null, + reason: null, + }, + { + command: commands[1], + status: 'failed', + exitCode: null, + signal: 'SIGTERM', + reason: null, + }, ], }, error: `Setup command terminated by signal SIGTERM: ${commands[1]}`, @@ -228,6 +270,67 @@ describe('workspace setup command', () => { expect(readFileSync(join(testDir, 'setup.log'), 'utf8')).toBe('first'); }); + test('runs only commands matching the current platform and architecture', () => { + const otherPlatform: NodeJS.Platform = + process.platform === 'linux' ? 'win32' : 'linux'; + const otherArchitecture: NodeJS.Architecture = + process.arch === 'x64' ? 'arm64' : 'x64'; + const skippedPlatform = fixtureCommand( + testDir, + 'skipped-platform', + "require('node:fs').writeFileSync('skipped-platform', 'ran');", + ); + const matching = fixtureCommand( + testDir, + 'matching', + "require('node:fs').writeFileSync('matching', 'ran');", + ); + const skippedArchitecture = fixtureCommand( + testDir, + 'skipped-architecture', + "require('node:fs').writeFileSync('skipped-architecture', 'ran');", + ); + writeWorkspace(testDir, [ + { run: skippedPlatform, platforms: [otherPlatform] }, + { + run: matching, + platforms: [process.platform], + architectures: [process.arch], + }, + { run: skippedArchitecture, architectures: [otherArchitecture] }, + ]); + + const proc = runCli(testDir, ['workspace', 'setup'], testDir); + + expect(proc.exitCode).toBe(0); + expect(existsSync(join(testDir, 'skipped-platform'))).toBe(false); + expect(readFileSync(join(testDir, 'matching'), 'utf8')).toBe('ran'); + expect(existsSync(join(testDir, 'skipped-architecture'))).toBe(false); + expect(JSON.parse(proc.stdout.toString()).data.commands).toEqual([ + { + command: skippedPlatform, + status: 'skipped', + exitCode: null, + signal: null, + reason: `platform ${process.platform} does not match ${otherPlatform}`, + }, + { + command: matching, + status: 'succeeded', + exitCode: 0, + signal: null, + reason: null, + }, + { + command: skippedArchitecture, + status: 'skipped', + exitCode: null, + signal: null, + reason: `architecture ${process.arch} does not match ${otherArchitecture}`, + }, + ]); + }); + test('does not run setup commands during init or update', () => { const templateDir = join(testDir, 'template'); const workspaceDir = join(testDir, 'workspace'); diff --git a/tests/unit/models/workspace-config.test.ts b/tests/unit/models/workspace-config.test.ts index e7ef815b..7dbc5b20 100644 --- a/tests/unit/models/workspace-config.test.ts +++ b/tests/unit/models/workspace-config.test.ts @@ -24,7 +24,7 @@ describe('WorkspaceConfigSchema', () => { expect(result.success).toBe(true); }); - it('accepts a top-level list of setup shell commands', () => { + it('accepts unconditional setup command shorthand', () => { const result = WorkspaceConfigSchema.safeParse({ repositories: [], plugins: [], @@ -38,7 +38,52 @@ describe('WorkspaceConfigSchema', () => { } }); - it('rejects non-string setup commands', () => { + it('accepts platform and architecture selectors for setup commands', () => { + const result = WorkspaceConfigSchema.safeParse({ + repositories: [], + plugins: [], + clients: [], + setup: [ + { + run: 'install-tool', + platforms: ['linux', 'darwin'], + architectures: ['x64', 'arm64'], + }, + ], + }); + + expect(result.success).toBe(true); + }); + + it('rejects unknown setup platforms and architectures', () => { + const result = WorkspaceConfigSchema.safeParse({ + repositories: [], + plugins: [], + clients: [], + setup: [ + { + run: 'install-tool', + platforms: ['windows'], + architectures: ['amd64'], + }, + ], + }); + + expect(result.success).toBe(false); + }); + + it('rejects blank setup commands', () => { + const result = WorkspaceConfigSchema.safeParse({ + repositories: [], + plugins: [], + clients: [], + setup: [' '], + }); + + expect(result.success).toBe(false); + }); + + it('rejects malformed setup command objects', () => { const result = WorkspaceConfigSchema.safeParse({ repositories: [], plugins: [],