diff --git a/README.md b/README.md index e56dab6..fd52f06 100644 --- a/README.md +++ b/README.md @@ -120,6 +120,13 @@ Recommended sequence: The public skill lives under `skills/agent-terminal/` and ships in the npm package. You can install it directly for Mux-style skill loaders, or let TanStack Intent discover and map it for compatible coding agents. +For coding agents that can ingest instructions on demand, `agent-terminal skill` prints the packaged `SKILL.md` directly to stdout after installation. + +```bash +npm install -g agent-terminal +agent-terminal skill +``` + ### TanStack Intent integration If your agent supports Intent-compatible skill mappings, install `agent-terminal` in the project and let Intent wire the mapping into `AGENTS.md`, `CLAUDE.md`, or another supported agent config file. diff --git a/src/cli/commands/skill.ts b/src/cli/commands/skill.ts new file mode 100644 index 0000000..baf52a4 --- /dev/null +++ b/src/cli/commands/skill.ts @@ -0,0 +1,91 @@ +import { readFile } from 'node:fs/promises'; +import process from 'node:process'; +import { fileURLToPath } from 'node:url'; + +import { emitSuccess } from '../output.js'; + +import { ERROR_CODES, makeCliError } from '../../protocol/errors.js'; +import { assertString, invariant } from '../../util/assert.js'; + +const COMMAND_NAME = 'skill'; +const SKILL_NAME = 'agent-terminal'; +const SKILL_SOURCE = 'packaged-file'; + +export interface SkillResult { + name: string; + source: typeof SKILL_SOURCE; + content: string; +} + +export interface SkillDependencies { + readFile: (path: URL, encoding: 'utf8') => Promise; + skillFileUrl: URL; +} + +const DEFAULT_SKILL_DEPENDENCIES: SkillDependencies = { + readFile: (path, encoding) => readFile(path, encoding), + skillFileUrl: new URL( + '../../../skills/agent-terminal/SKILL.md', + import.meta.url, + ), +}; + +export async function loadPackagedSkillContent( + dependencies: Partial = {}, +): Promise { + const resolvedDependencies: SkillDependencies = { + ...DEFAULT_SKILL_DEPENDENCIES, + ...dependencies, + }; + const skillPath = fileURLToPath(resolvedDependencies.skillFileUrl); + let content: string; + + try { + content = await resolvedDependencies.readFile( + resolvedDependencies.skillFileUrl, + 'utf8', + ); + } catch (error: unknown) { + throw makeCliError(ERROR_CODES.STORAGE_READ_ERROR, { + message: `Failed to read packaged skill at ${skillPath}.`, + details: { + skillPath, + }, + cause: error, + }); + } + + assertString(content, 'packaged skill content must be a string'); + invariant(content.length > 0, 'packaged skill content must not be empty'); + return content; +} + +export async function buildSkillResult( + dependencies: Partial = {}, +): Promise { + const content = await loadPackagedSkillContent(dependencies); + + return { + name: SKILL_NAME, + source: SKILL_SOURCE, + content, + }; +} + +export async function runSkillCommand(options: { + json: boolean; +}): Promise { + const result = await buildSkillResult(); + + if (!options.json) { + process.stdout.write(result.content); + return; + } + + emitSuccess({ + command: COMMAND_NAME, + json: options.json, + result, + lines: [result.content], + }); +} diff --git a/src/cli/main.ts b/src/cli/main.ts index 865088d..1afbdd8 100644 --- a/src/cli/main.ts +++ b/src/cli/main.ts @@ -20,6 +20,7 @@ import { runResizeCommand } from './commands/resize.js'; import { runScreenshotCommand } from './commands/screenshot.js'; import { runSendKeysCommand } from './commands/send-keys.js'; import { runSignalCommand } from './commands/signal.js'; +import { runSkillCommand } from './commands/skill.js'; import { runSnapshotCommand } from './commands/snapshot.js'; import { runTypeCommand } from './commands/type.js'; import { runVersionCommand } from './commands/version.js'; @@ -106,6 +107,13 @@ function wrapAction( }; } +const CODING_AGENT_HELP_INTRO = + 'MANDATORY FOR CODING AGENTS: read the `agent-terminal` skill first. If your agent already loaded that skill, follow it; otherwise run `agent-terminal skill` before any other agent-terminal command.'; +const CODING_AGENT_HELP_OUTRO = + 'Coding agents: use the preloaded `agent-terminal` skill when available; otherwise call `agent-terminal skill` before using session commands.'; +const SKILL_COMMAND_DESCRIPTION = + 'Fallback first step for coding agents: print the packaged skill if it is not already loaded'; + async function main(): Promise { const program = new Command() .name('agent-terminal') @@ -145,6 +153,23 @@ async function main(): Promise { }); }); + program.addHelpText('beforeAll', `${CODING_AGENT_HELP_INTRO}\n\n`); + program.addHelpText('afterAll', `\n${CODING_AGENT_HELP_OUTRO}\n`); + + program + .command('skill') + .description(SKILL_COMMAND_DESCRIPTION) + .option('--json', 'Emit a JSON command envelope', false) + .action( + wrapAction( + 'skill', + async (options: { json: boolean }, context: CommandContext) => { + void context; + await runSkillCommand(options); + }, + ), + ); + program .command('version') .description('Print version') diff --git a/test/integration/cli.test.ts b/test/integration/cli.test.ts index 4ecacbd..c55a4c9 100644 --- a/test/integration/cli.test.ts +++ b/test/integration/cli.test.ts @@ -1,6 +1,7 @@ import { existsSync, mkdtempSync, + readFileSync, realpathSync, rmSync, writeFileSync, @@ -35,6 +36,10 @@ function parseErrorEnvelope(output: string): CommandErrorEnvelope { return JSON.parse(output) as CommandErrorEnvelope; } +function readPackagedSkill(): string { + return readFileSync('skills/agent-terminal/SKILL.md', 'utf8'); +} + describe('CLI integration', () => { beforeEach(() => { // prettier-ignore @@ -62,6 +67,53 @@ describe('CLI integration', () => { expect(parsed.result.rendererBackends).toEqual(['ghostty-web']); }); + it('prints the packaged skill verbatim', () => { + const result = runCli(['skill'], testEnv()); + + expect(result.status).toBe(0); + expect(result.stderr).toBe(''); + expect(result.stdout).toBe(readPackagedSkill()); + }); + + it('prints a JSON envelope for skill', () => { + const result = runCli(['skill', '--json'], testEnv()); + + expect(result.status).toBe(0); + expect(result.stderr).toBe(''); + + const parsed = JSON.parse(result.stdout) as SuccessEnvelope<{ + name: string; + source: string; + content: string; + }>; + + expect(parsed.ok).toBe(true); + expect(parsed.command).toBe('skill'); + expect(parsed.result).toEqual({ + name: 'agent-terminal', + source: 'packaged-file', + content: readPackagedSkill(), + }); + }); + + it('makes the packaged skill guidance prominent in top-level help', () => { + const result = runCli(['--help'], testEnv()); + + expect(result.status).toBe(0); + expect(result.stderr).toBe(''); + expect(result.stdout.startsWith('MANDATORY FOR CODING AGENTS:')).toBe(true); + expect(result.stdout).toContain( + 'If your agent already loaded that skill, follow it; otherwise run `agent-terminal skill` before any other agent-terminal command.', + ); + expect(result.stdout).toContain('skill [options]'); + expect(result.stdout).toContain( + 'Fallback first step for coding agents: print the packaged skill if it is not already loaded', + ); + expect(result.stdout).toContain( + 'Coding agents: use the preloaded `agent-terminal` skill when available; otherwise call `agent-terminal skill` before using session commands.', + ); + }); + it('accepts --append-newline for type', () => { const result = runCli( ['type', 'session-01', 'hello', '--append-newline', '--json'], diff --git a/test/unit/commands/golden-envelopes.test.ts b/test/unit/commands/golden-envelopes.test.ts index 83f697e..22ba32a 100644 --- a/test/unit/commands/golden-envelopes.test.ts +++ b/test/unit/commands/golden-envelopes.test.ts @@ -1,6 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { z } from 'zod'; +import { buildSkillResult } from '../../../src/cli/commands/skill.js'; import { buildVersionResult } from '../../../src/cli/commands/version.js'; import { createErrorEnvelope, @@ -41,6 +42,14 @@ const VersionResultSchema = z }) .strict(); +const SkillResultSchema = z + .object({ + name: z.literal('agent-terminal'), + source: z.literal('packaged-file'), + content: z.string().min(1), + }) + .strict(); + // CreateResultSchema is defined locally because create does not go through // the RPC layer — it constructs the result from the session manifest. // This schema acts as the golden contract lock for the create result shape. @@ -980,6 +989,13 @@ describe('JSON envelope contracts', () => { expect(InspectResultSchema.safeParse(result).success).toBe(true); }); + it('locks the skill success envelope shape', async () => { + const result = await buildSkillResult(); + + expectLockedSuccessEnvelope('skill', result); + expect(SkillResultSchema.safeParse(result).success).toBe(true); + }); + it('locks the version success envelope shape', async () => { const result = await buildVersionResult(); diff --git a/test/unit/commands/skill.test.ts b/test/unit/commands/skill.test.ts new file mode 100644 index 0000000..ad94fa7 --- /dev/null +++ b/test/unit/commands/skill.test.ts @@ -0,0 +1,61 @@ +import { readFile } from 'node:fs/promises'; + +import { describe, expect, it } from 'vitest'; + +import { CliError } from '../../../src/cli/errors.js'; +import { ERROR_CODES } from '../../../src/protocol/errors.js'; +import { + buildSkillResult, + loadPackagedSkillContent, +} from '../../../src/cli/commands/skill.js'; + +describe('skill command', () => { + it('loads the packaged skill content', async () => { + const expectedContent = await readFile( + 'skills/agent-terminal/SKILL.md', + 'utf8', + ); + const content = await loadPackagedSkillContent(); + + expect(content).toBe(expectedContent); + expect(content.length).toBeGreaterThan(0); + }); + + it('builds the skill result payload', async () => { + const result = await buildSkillResult(); + + expect(result.name).toBe('agent-terminal'); + expect(result.source).toBe('packaged-file'); + expect(result.content.length).toBeGreaterThan(0); + }); + + it('maps packaged skill read failures to STORAGE_READ_ERROR', async () => { + const readFailure = Object.assign(new Error('missing skill'), { + code: 'ENOENT', + }); + + try { + await loadPackagedSkillContent({ + readFile: () => Promise.reject(readFailure), + }); + throw new Error('expected loadPackagedSkillContent to reject'); + } catch (error: unknown) { + expect(error).toBeInstanceOf(CliError); + if (!(error instanceof CliError)) { + throw error; + } + + const skillPath = error.details.skillPath; + expect(error.code).toBe(ERROR_CODES.STORAGE_READ_ERROR); + expect(error.message).toContain('Failed to read packaged skill'); + expect(error.cause).toBe(readFailure); + expect(typeof skillPath).toBe('string'); + if (typeof skillPath !== 'string') { + throw new Error('skillPath detail must be a string', { + cause: error, + }); + } + expect(skillPath).toContain('skills/agent-terminal/SKILL.md'); + } + }); +});