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
1 change: 1 addition & 0 deletions .agents/skills/versioned-tool/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ This project manages external CLI tools through a **single-source-of-truth versi
| OpenCode | `DEFAULT_OPENCODE_VERSION` | `opencode-version` | `github-releases` (`anomalyco/opencode`) |
| oMo | `DEFAULT_OMO_VERSION` | `omo-version` | `npm` (`oh-my-openagent`) |
| Bun | `DEFAULT_BUN_VERSION` | _(internal only)_ | `github-releases` (`oven-sh/bun`, extract `bun-v` prefix) |
| Systematic | `DEFAULT_SYSTEMATIC_VERSION` | `systematic-version` | `npm` (`@fro.bot/systematic`) |

## Quick Start

Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -378,6 +378,7 @@ concurrency:
| `aws-region` | No | — | AWS region for S3 bucket |
| `skip-cache` | No | `false` | Skip cache restore (useful for debugging) |
| `omo-config` | No | — | Custom oMo configuration JSON (deep-merged) |
| `systematic-config` | No | — | Custom Systematic configuration JSON (deep-merged) |
| `opencode-config` | No | — | Custom OpenCode configuration JSON (deep-merged) |

### Action Outputs
Expand Down
3 changes: 3 additions & 0 deletions action.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,9 @@ inputs:
Custom oMo configuration JSON. Written before installer runs.
Deep-merged with existing config.
required: false
systematic-config:
description: Custom Systematic plugin configuration JSON (deep-merged with existing config).
required: false
dedup-window:
description: >-
Deduplication window in milliseconds. Skip execution if the agent already
Expand Down
32 changes: 16 additions & 16 deletions dist/main.js

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions src/features/agent/opencode.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1054,6 +1054,7 @@ describe('ensureOpenCodeAvailable', () => {
kimiForCoding: 'no',
},
opencodeConfig: null,
systematicConfig: null,
})

// #then
Expand Down Expand Up @@ -1087,6 +1088,7 @@ describe('ensureOpenCodeAvailable', () => {
kimiForCoding: 'no',
},
opencodeConfig: null,
systematicConfig: null,
})
} catch {
// Expected to fail since runSetup will fail in test environment
Expand Down
18 changes: 10 additions & 8 deletions src/features/agent/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,14 +64,15 @@ export async function verifyOpenCodeAvailable(
}

export interface EnsureOpenCodeOptions {
logger: Logger
opencodeVersion: string
githubToken: string
authJson: string
omoVersion: string
systematicVersion: string
omoProviders: SetupInputs['omoProviders']
opencodeConfig: string | null
readonly logger: Logger
readonly opencodeVersion: string
readonly githubToken: string
readonly authJson: string
readonly omoVersion: string
readonly systematicVersion: string
readonly omoProviders: SetupInputs['omoProviders']
readonly opencodeConfig: string | null
readonly systematicConfig: string | null
}

export async function ensureOpenCodeAvailable(options: EnsureOpenCodeOptions): Promise<EnsureOpenCodeResult> {
Expand All @@ -91,6 +92,7 @@ export async function ensureOpenCodeAvailable(options: EnsureOpenCodeOptions): P
appId: null,
privateKey: null,
opencodeConfig: options.opencodeConfig,
systematicConfig: options.systematicConfig,
omoConfig: null,
omoVersion: options.omoVersion,
systematicVersion: options.systematicVersion,
Expand Down
36 changes: 36 additions & 0 deletions src/harness/config/inputs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -575,6 +575,42 @@ describe('parseActionInputs', () => {
expect(result.success).toBe(true)
expect(result.success && result.data.opencodeConfig).toBe(null)
})

it('parses systematic-config when provided', () => {
const mockGetInput = core.getInput as ReturnType<typeof vi.fn>

mockGetInput.mockImplementation((name: string) => {
const inputs: Record<string, string> = {
'github-token': 'ghp_test123',
'auth-json': '{"anthropic":{"type":"api","key":"sk-ant-test"}}',
'systematic-config': '{"mode":"strict"}',
}
return inputs[name] ?? ''
})

const result = parseActionInputs()

expect(result.success).toBe(true)
expect(result.success && result.data.systematicConfig).toBe('{"mode":"strict"}')
})

it('sets systematicConfig to null when empty string', () => {
const mockGetInput = core.getInput as ReturnType<typeof vi.fn>

mockGetInput.mockImplementation((name: string) => {
const inputs: Record<string, string> = {
'github-token': 'ghp_test123',
'auth-json': '{"anthropic":{"type":"api","key":"sk-ant-test"}}',
'systematic-config': '',
}
return inputs[name] ?? ''
})

const result = parseActionInputs()

expect(result.success).toBe(true)
expect(result.success && result.data.systematicConfig).toBe(null)
})
})
})

Expand Down
4 changes: 4 additions & 0 deletions src/harness/config/inputs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,9 @@ export function parseActionInputs(): Result<ActionInputs, Error> {
const opencodeConfigRaw = core.getInput('opencode-config').trim()
const opencodeConfig = opencodeConfigRaw.length > 0 ? opencodeConfigRaw : null

const systematicConfigRaw = core.getInput('systematic-config').trim()
const systematicConfig = systematicConfigRaw.length > 0 ? systematicConfigRaw : null

const dedupWindowRaw = core.getInput('dedup-window').trim()
const dedupWindow =
dedupWindowRaw.length > 0 ? parseTimeoutMs(dedupWindowRaw, 'dedup-window') : DEFAULT_DEDUP_WINDOW_MS
Expand Down Expand Up @@ -235,6 +238,7 @@ export function parseActionInputs(): Result<ActionInputs, Error> {
systematicVersion,
omoProviders,
opencodeConfig,
systematicConfig,
dedupWindow,
})
} catch (error) {
Expand Down
1 change: 1 addition & 0 deletions src/harness/phases/bootstrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ export async function runBootstrap(bootstrapLogger: Logger): Promise<BootstrapPh
systematicVersion: inputs.systematicVersion,
omoProviders: inputs.omoProviders,
opencodeConfig: inputs.opencodeConfig,
systematicConfig: inputs.systematicConfig,
})

if (opencodeResult.didSetup) {
Expand Down
20 changes: 20 additions & 0 deletions src/services/setup/adapters.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import type {ExecAdapter, ToolCacheAdapter} from './types.js'
import * as exec from '@actions/exec'
import * as tc from '@actions/tool-cache'

export function createToolCacheAdapter(): ToolCacheAdapter {
return {
find: tc.find,
downloadTool: tc.downloadTool,
extractTar: tc.extractTar,
extractZip: tc.extractZip,
cacheDir: tc.cacheDir,
}
}

export function createExecAdapter(): ExecAdapter {
return {
exec: exec.exec,
getExecOutput: exec.getExecOutput,
}
}
108 changes: 108 additions & 0 deletions src/services/setup/ci-config.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import type {Logger} from './types.js'
import {describe, expect, it} from 'vitest'
import {createMockLogger} from '../../shared/test-helpers.js'
import {buildCIConfig} from './ci-config.js'

function createLogger(): Logger {
return createMockLogger()
}

describe('buildCIConfig', () => {
it('returns autoupdate baseline with systematic plugin when no user config', () => {
// #given
const logger = createLogger()

// #when
const result = buildCIConfig({opencodeConfig: null, systematicVersion: '2.1.0'}, logger)

// #then
expect(result.error).toBeNull()
expect(result.config).toEqual({autoupdate: false, plugins: ['@fro.bot/systematic@2.1.0']})
})

it('merges user config keys and appends systematic plugin', () => {
// #given
const logger = createLogger()

// #when
const result = buildCIConfig(
{opencodeConfig: '{"model":"claude-opus-4-5","autoupdate":true}', systematicVersion: '2.1.0'},
logger,
)

// #then
expect(result.error).toBeNull()
expect(result.config).toEqual({
autoupdate: true,
model: 'claude-opus-4-5',
plugins: ['@fro.bot/systematic@2.1.0'],
})
})

it('appends systematic plugin to existing plugins array', () => {
// #given
const logger = createLogger()

// #when
const result = buildCIConfig(
{opencodeConfig: '{"plugins":["custom-plugin@1.0.0"]}', systematicVersion: '2.1.0'},
logger,
)

// #then
expect(result.error).toBeNull()
expect(result.config).toEqual({
autoupdate: false,
plugins: ['custom-plugin@1.0.0', '@fro.bot/systematic@2.1.0'],
})
})

it('does not duplicate systematic plugin when already present', () => {
// #given
const logger = createLogger()

// #when
const result = buildCIConfig(
{
opencodeConfig: '{"plugins":["custom-plugin@1.0.0","@fro.bot/systematic@9.9.9"]}',
systematicVersion: '2.1.0',
},
logger,
)

// #then
expect(result.error).toBeNull()
expect(result.config).toEqual({
autoupdate: false,
plugins: ['custom-plugin@1.0.0', '@fro.bot/systematic@9.9.9'],
})
})

it('returns error for invalid JSON', () => {
// #given
const logger = createLogger()

// #when
const result = buildCIConfig({opencodeConfig: '{invalid-json}', systematicVersion: '2.1.0'}, logger)

// #then
expect(result.error).toBe('opencode-config must be valid JSON')
})

it('returns error for non-object JSON values', () => {
// #given
const logger = createLogger()

// #when
const nullResult = buildCIConfig({opencodeConfig: 'null', systematicVersion: '2.1.0'}, logger)
const arrayResult = buildCIConfig({opencodeConfig: '[1,2,3]', systematicVersion: '2.1.0'}, logger)
const numberResult = buildCIConfig({opencodeConfig: '42', systematicVersion: '2.1.0'}, logger)
const stringResult = buildCIConfig({opencodeConfig: '"hello"', systematicVersion: '2.1.0'}, logger)

// #then
expect(nullResult.error).toBe('opencode-config must be a JSON object')
expect(arrayResult.error).toBe('opencode-config must be a JSON object')
expect(numberResult.error).toBe('opencode-config must be a JSON object')
expect(stringResult.error).toBe('opencode-config must be a JSON object')
})
})
43 changes: 43 additions & 0 deletions src/services/setup/ci-config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import type {Logger, SetupInputs} from './types.js'

export interface CIConfigResult {
readonly config: Record<string, unknown>
readonly error: string | null
}

export function buildCIConfig(
inputs: Pick<SetupInputs, 'opencodeConfig' | 'systematicVersion'>,
logger: Logger,
): CIConfigResult {
const ciConfig: Record<string, unknown> = {autoupdate: false}

if (inputs.opencodeConfig != null) {
let parsed: unknown
try {
parsed = JSON.parse(inputs.opencodeConfig)
} catch {
return {config: ciConfig, error: 'opencode-config must be valid JSON'}
}

if (parsed == null || typeof parsed !== 'object' || Array.isArray(parsed)) {
return {config: ciConfig, error: 'opencode-config must be a JSON object'}
}
Object.assign(ciConfig, parsed)
}

const systematicPlugin = `@fro.bot/systematic@${inputs.systematicVersion}`
const rawPlugins: unknown[] = Array.isArray(ciConfig.plugins) ? (ciConfig.plugins as unknown[]) : []
const hasSystematic = rawPlugins.some(
(plugin): plugin is string => typeof plugin === 'string' && plugin.startsWith('@fro.bot/systematic'),
)
if (!hasSystematic) {
ciConfig.plugins = [...rawPlugins, systematicPlugin]
}

logger.debug('Built CI OpenCode config', {
hasUserConfig: inputs.opencodeConfig != null,
pluginCount: Array.isArray(ciConfig.plugins) ? ciConfig.plugins.length : 0,
})

return {config: ciConfig, error: null}
}
4 changes: 4 additions & 0 deletions src/services/setup/index.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
// Setup module public exports
export {createExecAdapter, createToolCacheAdapter} from './adapters.js'
export {parseAuthJsonInput, populateAuthJson} from './auth-json.js'
export {buildBunDownloadUrl, getBunPlatformInfo, installBun, isBunAvailable} from './bun.js'
export type {BunInstallResult, BunPlatformInfo} from './bun.js'
export {buildCIConfig} from './ci-config.js'
export type {CIConfigResult} from './ci-config.js'
export {configureGhAuth, configureGitIdentity, getBotUserId} from './gh-auth.js'
export {installOmo, verifyOmoInstallation} from './omo.js'
export type {OmoInstallDeps, OmoInstallOptions} from './omo.js'
export {getLatestVersion, installOpenCode} from './opencode.js'
export {writeSystematicConfig} from './systematic-config.js'
export {restoreToolsCache, saveToolsCache} from './tools-cache.js'
export type {ToolsCacheAdapter, ToolsCacheResult} from './tools-cache.js'

Expand Down
Loading
Loading