Skip to content
Open
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
97 changes: 97 additions & 0 deletions cli/src/__tests__/helpers/plugin-fixtures.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { mkdtempSync, readFileSync, rmSync } from 'node:fs'
import os from 'node:os'
import path from 'node:path'

import { expect } from 'bun:test'

import type {
PluginInstallOptions,
PluginInstallResult,
} from '../../commands/plugin-install'

/**
* Fixtures shared by the plugin CLI test files — the install pipeline, the
* command runner, and the two registry walks — which otherwise each rebuilt
* the same temp-root registry and served the same plugin files. Each test
* file registers `cleanUpPluginTestDirs` once with `afterEach`.
*/

/** Temp directories created by the running test file, drained on cleanup. */
const tempDirs: string[] = []

/**
* A registered temp directory the running test may fill. The prefix names the
* test file it came from, so a leftover on disk says where to look.
*/
export function makeTempDir(prefix: string): string {
const dir = mkdtempSync(path.join(os.tmpdir(), prefix))
tempDirs.push(dir)
return dir
}

/**
* Discards every temp directory the running test created, so no fixture state
* crosses into the next test and nothing survives the run.
*/
export function cleanUpPluginTestDirs(): void {
for (const dir of tempDirs.splice(0)) {
rmSync(dir, { recursive: true, force: true })
}
}

/** The §5.2 minimal manifest the install rows serve and expect back. */
export const PLUGIN_JSON = JSON.stringify({
$schema: 'https://agent-plugins.org/schemas/1.0.0/plugin.schema.json',
name: 'test-plugin',
version: '1.0.0',
description: 'a test plugin',
})

/** A skill document the SDK reader accepts, for a skill the manifest lacks. */
export const SKILL_MD = [
'---',
'name: gcloud',
'description: A test skill.',
'---',
'',
'Skill body.',
'',
].join('\n')

/** A conforming §7.2 mcp.json carrying one streamable-http server. */
export const MCP_JSON = JSON.stringify({
$schema: 'https://agent-plugins.org/schemas/1.0.0/mcp.schema.json',
mcpServers: {
'test-server': { type: 'streamable-http', url: 'https://example.com/mcp' },
},
})

/** A gzipped tarball shaped like a codeload download: `<repo>-<ref>/...`. */
export async function makeTarball(
entries: Record<string, string>,
): Promise<Blob> {
const tarPath = path.join(
makeTempDir('plugin-tarball-test-'),
'bundle.tar.gz',
)
await Bun.Archive.write(tarPath, entries, { compress: 'gzip' })
return new Blob([readFileSync(tarPath)])
}

/**
* Asserts the install succeeded, failing with the pipeline's reason
* otherwise, and returns the result so a row asserts on the installed
* values rather than guarding its way to them.
*/
export function expectInstallOk(result: PluginInstallResult) {
expect(result.success).toBe(true)
if (!result.success) throw new Error(`expected install, got: ${result.error}`)
return result
}

/** The fetch seam answering every request with the given tarball, status 200. */
export function fetchReturning(
blob: Blob,
): NonNullable<PluginInstallOptions['fetchImpl']> {
return () => Promise.resolve(new Response(blob, { status: 200 }))
}
74 changes: 72 additions & 2 deletions cli/src/__tests__/unit/create-run-config.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,14 @@
import { describe, test, expect } from 'bun:test'
import { describe, test, expect, afterEach } from 'bun:test'

import { isSensitiveFile } from '../../utils/create-run-config'
import { createRunConfig, isSensitiveFile } from '../../utils/create-run-config'
import {
__setSkillsForTests,
__resetSkillRegistryForTests,
} from '../../utils/skill-registry'

import type { EventHandlerState } from '../../utils/sdk-event-handlers'
import type { SkillDefinition } from '@codebuff/common/types/skill'
import type { Logger } from '@codebuff/common/types/contracts/logger'

describe('isSensitiveFile', () => {
test.each([
Expand Down Expand Up @@ -64,3 +72,65 @@ describe('isSensitiveFile', () => {
expect(isSensitiveFile(file)).toBe(expected)
})
})

describe('createRunConfig', () => {
afterEach(() => {
__resetSkillRegistryForTests()
})

/** The parts of an event-handler state a run config creation touches
* (the handlers themselves are never invoked in these tests). */
const eventHandlerState: EventHandlerState = {
streaming: {
streamRefs: undefined as never,
setStreamingAgents: () => {},
setStreamStatus: () => {},
},
message: {
aiMessageId: 'ai-1',
updater: undefined as never,
hasReceivedContentRef: { current: false },
},
subagents: {
addActiveSubagent: () => {},
removeActiveSubagent: () => {},
},
mode: { agentMode: 'DEFAULT', setHasReceivedPlanResponse: () => {} },
logger: {
info: () => {},
warn: () => {},
error: () => {},
debug: () => {},
} as Logger,
setIsRetrying: () => {},
}

test("every run hands the session the registry's skill cache", async () => {
/** Given the registry cache holds a skill (the way an installed
* plugin's skill does after startup), when a run config is built
* and its skill loader runs, the session's skills include that
* skill — the SDK's own loader reads only project/home dirs, so
* this loader is what carries plugin skills into the session. */
const seeded: SkillDefinition = {
name: 'gcloud-setup',
description: 'sets up gcloud',
content: '# gcloud setup',
filePath: '/skills/gcloud-setup/SKILL.md',
}
__setSkillsForTests({ 'gcloud-setup': seeded })

const config = createRunConfig({
logger: eventHandlerState.logger,
agent: 'base',
prompt: 'hello',
content: undefined,
previousRunState: null,
agentDefinitions: [],
eventHandlerState,
signal: new AbortController().signal,
})

const skills = await config.skillsLoader()
expect(skills['gcloud-setup']).toEqual(seeded)
})
})
6 changes: 5 additions & 1 deletion cli/src/cli-args.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,8 +62,12 @@ export function parseArgs({
'Set the working directory (default: current directory)',
)
.addArgument(
new Argument('[command]', 'Command to run').choices(['login']),
new Argument('[command]', 'Command to run').choices([
'login',
'plugin',
]),
)
.allowExcessArguments(true)
.helpOption('-h, --help', 'Show this help message')
} else {
// Codebuff: full CLI with all options
Expand Down
149 changes: 149 additions & 0 deletions cli/src/commands/__tests__/plugin-install-command.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
import path from 'node:path'

import { afterEach, describe, expect, test } from 'bun:test'

import {
runPluginCommand,
runPluginInstallCommand,
} from '../plugin-install-command'

import {
cleanUpPluginTestDirs,
fetchReturning,
makeTarball,
makeTempDir,
MCP_JSON,
PLUGIN_JSON,
SKILL_MD,
} from '../../__tests__/helpers/plugin-fixtures'

afterEach(cleanUpPluginTestDirs)

/**
* Runs the command and reports what a shell would see: the console output,
* and the status the process would end with. Both are restored afterwards,
* so no row inherits the previous row's capture or exit code. The status
* reset writes 0 rather than `undefined`, which bun keeps as the last value
* it was given.
*/
async function runCommand(run: () => Promise<void>) {
const lines: string[] = []
const originalLog = console.log
const previousExitCode = process.exitCode
let exitCode = 0
process.exitCode = 0
console.log = (...parts: unknown[]) => {
lines.push(parts.map(String).join(' '))
}

try {
await run()
exitCode = Number(process.exitCode ?? 0)
} finally {
console.log = originalLog
process.exitCode = previousExitCode ?? 0
}

return { output: lines.join('\n'), exitCode }
}

describe('the plugin command entry', () => {
/**
* Given a subcommand that is not `install`, when the command runs, it
* prints the usage line and exits nonzero.
*/
test('an unknown subcommand prints usage and exits nonzero', async () => {
const { output, exitCode } = await runCommand(() =>
runPluginCommand(['update', 'https://github.com/google/skills']),
)

expect(output).toContain('Usage: freebuff plugin install <url>')
expect(exitCode).toBe(1)
})
})

describe('the plugin install command runner', () => {
/**
* Given an argument count that is not exactly one URL, when the
* command runs, it prints the usage line and exits nonzero before
* any install work begins.
*/
test('not exactly one URL prints usage and exits nonzero', async () => {
const { output, exitCode } = await runCommand(() =>
runPluginInstallCommand([], {}),
)

expect(output).toContain('Usage: freebuff plugin install <url>')
expect(output).toContain('https://github.com/owner/repo')
expect(exitCode).toBe(1)
})

/**
* Given a valid tarball served by the injected fetch, when the command
* runs, it prints the success render — the check, name, version,
* source, counts, and the data dir — and exits zero.
*/
test('a successful install prints the success render', async () => {
const pluginsRoot = makeTempDir('plugin-cmd-test-')
const blob = await makeTarball({
'skills-main/plugin.json': PLUGIN_JSON,
'skills-main/skills/gcloud/SKILL.md': SKILL_MD,
'skills-main/mcp.json': MCP_JSON,
})

const { output, exitCode } = await runCommand(() =>
runPluginInstallCommand(['https://github.com/google/skills'], {
fetchImpl: fetchReturning(blob),
pluginsRoot,
}),
)

expect(exitCode).toBe(0)
expect(output).toContain('✔')
expect(output).toContain('test-plugin 1.0.0')
expect(output).toContain('github.com/google/skills')
expect(output).toContain('skills 1 registered')
expect(output).toContain('mcp 1 server: test-server')
expect(output).toContain(path.join(pluginsRoot, '.data', 'test-plugin'))
})

/**
* Given a failing install (404), when the command runs, it prints the
* reason and exits nonzero.
*/
test('a failed install prints the reason and exits nonzero', async () => {
const { output, exitCode } = await runCommand(() =>
runPluginInstallCommand(['https://github.com/google/skills'], {
fetchImpl: () =>
Promise.resolve(new Response('not found', { status: 404 })),
pluginsRoot: makeTempDir('plugin-cmd-test-'),
}),
)

expect(output).toContain('could not download')
expect(exitCode).toBe(1)
})

/**
* Given a plugin whose skill name collides with an existing skill,
* when the command runs, it prints the conflict and exits nonzero.
*/
test('a name conflict prints the collision and exits nonzero', async () => {
const pluginsRoot = makeTempDir('plugin-cmd-test-')
const blob = await makeTarball({
'skills-main/plugin.json': PLUGIN_JSON,
'skills-main/skills/gcloud/SKILL.md': SKILL_MD,
})

const { output, exitCode } = await runCommand(() =>
runPluginInstallCommand(['https://github.com/google/skills'], {
fetchImpl: fetchReturning(blob),
pluginsRoot,
existingSkillNames: () => new Set(['gcloud']),
}),
)

expect(output).toContain('gcloud')
expect(exitCode).toBe(1)
})
})
Loading
Loading