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
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
91 changes: 91 additions & 0 deletions src/cli/commands/skill.ts
Original file line number Diff line number Diff line change
@@ -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<string>;
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<SkillDependencies> = {},
): Promise<string> {
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<SkillDependencies> = {},
): Promise<SkillResult> {
const content = await loadPackagedSkillContent(dependencies);

return {
name: SKILL_NAME,
source: SKILL_SOURCE,
content,
};
}

export async function runSkillCommand(options: {
json: boolean;
}): Promise<void> {
const result = await buildSkillResult();

if (!options.json) {
process.stdout.write(result.content);
return;
}

emitSuccess({
command: COMMAND_NAME,
json: options.json,
result,
lines: [result.content],
});
}
25 changes: 25 additions & 0 deletions src/cli/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -106,6 +107,13 @@ function wrapAction<Args extends unknown[]>(
};
}

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<void> {
const program = new Command()
.name('agent-terminal')
Expand Down Expand Up @@ -145,6 +153,23 @@ async function main(): Promise<void> {
});
});

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')
Expand Down
52 changes: 52 additions & 0 deletions test/integration/cli.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import {
existsSync,
mkdtempSync,
readFileSync,
realpathSync,
rmSync,
writeFileSync,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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'],
Expand Down
16 changes: 16 additions & 0 deletions test/unit/commands/golden-envelopes.test.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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();

Expand Down
61 changes: 61 additions & 0 deletions test/unit/commands/skill.test.ts
Original file line number Diff line number Diff line change
@@ -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');
}
});
});
Loading