diff --git a/packages/cli/package.json b/packages/cli/package.json index 8f55bf9ca..542bf003e 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -76,9 +76,6 @@ "import": { "description": "Import existing resources from your Checkly account to your project." }, - "skills": { - "description": "Explore Checkly AI agent skills, actions and reference documentation." - }, "status-pages": { "description": "List and manage status pages in your Checkly account." } diff --git a/packages/cli/src/ai-context/README.md b/packages/cli/src/ai-context/README.md index 99e5b2528..2f81efae1 100644 --- a/packages/cli/src/ai-context/README.md +++ b/packages/cli/src/ai-context/README.md @@ -26,6 +26,10 @@ Agent Skills are a standardized format for giving AI agents specialized knowledg - Build dashboards and status pages - Follow monitoring-as-code best practices with the Checkly CLI +## Installing This Skill + +Run `npx checkly skills install` to install the skill into your project. The command supports Claude Code, Cursor, Codex and more out of the box, or you can specify a custom path. + ## Using This Skill AI agents can load this skill to gain expertise in Checkly monitoring. The skill follows the [Agent Skills specification](https://agentskills.io) with: diff --git a/packages/cli/src/ai-context/skill.md b/packages/cli/src/ai-context/skill.md index 3fc76b49a..b3801cbab 100644 --- a/packages/cli/src/ai-context/skill.md +++ b/packages/cli/src/ai-context/skill.md @@ -10,6 +10,8 @@ metadata: The Checkly CLI provides all the required information via the `npx checkly skills` command. +Use `npx checkly skills install` to install this skill into your project (supports Claude Code, Cursor, Codex and more). + Use `npx checkly skills` to list all available actions, and `npx checkly skills ` to access up-to-date information on how to use the Checkly CLI for each action. ## Progressive Disclosure via `npx checkly skills` diff --git a/packages/cli/src/commands/__tests__/command-metadata.spec.ts b/packages/cli/src/commands/__tests__/command-metadata.spec.ts index e106ad7a2..23bc7eb9e 100644 --- a/packages/cli/src/commands/__tests__/command-metadata.spec.ts +++ b/packages/cli/src/commands/__tests__/command-metadata.spec.ts @@ -33,6 +33,7 @@ import ImportCommit from '../import/commit' import ImportCancel from '../import/cancel' import PwTest from '../pw-test' import SyncPlaywright from '../sync-playwright' +import SkillsInstall from '../skills/install' const commands: Array<[string, typeof BaseCommand]> = [ ['checks list', ChecksList], @@ -66,6 +67,7 @@ const commands: Array<[string, typeof BaseCommand]> = [ ['import cancel', ImportCancel], ['pw-test', PwTest], ['sync-playwright', SyncPlaywright], + ['skills install', SkillsInstall], ] describe('command metadata', () => { diff --git a/packages/cli/src/commands/skills/__tests__/install.spec.ts b/packages/cli/src/commands/skills/__tests__/install.spec.ts new file mode 100644 index 000000000..d43b35faf --- /dev/null +++ b/packages/cli/src/commands/skills/__tests__/install.spec.ts @@ -0,0 +1,341 @@ +import { join } from 'path' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('fs/promises', () => ({ + readFile: vi.fn(), + writeFile: vi.fn(), + mkdir: vi.fn(), + access: vi.fn(), +})) + +vi.mock('prompts', () => ({ + default: vi.fn(() => Promise.resolve({})), +})) + +vi.mock('../../../helpers/cli-mode', () => ({ + detectCliMode: vi.fn(() => 'interactive'), +})) + +import { access, mkdir, readFile, writeFile } from 'fs/promises' +import prompts from 'prompts' +import { detectCliMode } from '../../../helpers/cli-mode' +import SkillsInstall from '../install' + +const SKILL_CONTENT = '# Test Skill Content\nThis is a test skill.' + +const mockConfig = { + runHook: vi.fn().mockResolvedValue({ successes: [], failures: [] }), +} as any + +function createCommand (...args: string[]) { + const cmd = new SkillsInstall(args, mockConfig) + cmd.log = vi.fn() as any + return cmd +} + +function getLogged (cmd: SkillsInstall): string[] { + return (cmd.log as ReturnType).mock.calls.map( + (call: string[]) => call[0], + ) +} + +describe('skills install', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.mocked(readFile).mockResolvedValue(SKILL_CONTENT) + vi.mocked(writeFile).mockResolvedValue(undefined) + vi.mocked(mkdir).mockResolvedValue(undefined) + // File does not exist by default + vi.mocked(access).mockRejectedValue(new Error('ENOENT')) + vi.mocked(detectCliMode).mockReturnValue('interactive') + }) + + describe('--target flag', () => { + it('installs to claude target', async () => { + const cmd = createCommand('--target', 'claude', '--force') + + await cmd.run() + + const expectedDir = join(process.cwd(), '.claude/skills/checkly') + expect(mkdir).toHaveBeenCalledWith(expectedDir, { recursive: true }) + expect(writeFile).toHaveBeenCalledWith( + join(expectedDir, 'SKILL.md'), + SKILL_CONTENT, + 'utf8', + ) + expect(getLogged(cmd).some(m => m.includes('Installed Checkly agent skill to:'))).toBe(true) + }) + + it('installs to cursor target', async () => { + const cmd = createCommand('--target', 'cursor', '--force') + + await cmd.run() + + const expectedDir = join(process.cwd(), '.cursor/skills/checkly') + expect(writeFile).toHaveBeenCalledWith( + join(expectedDir, 'SKILL.md'), + SKILL_CONTENT, + 'utf8', + ) + }) + + it('installs to windsurf target', async () => { + const cmd = createCommand('--target', 'windsurf', '--force') + + await cmd.run() + + const expectedDir = join(process.cwd(), '.windsurf/skills/checkly') + expect(writeFile).toHaveBeenCalledWith( + join(expectedDir, 'SKILL.md'), + SKILL_CONTENT, + 'utf8', + ) + }) + + it('installs to github-copilot target', async () => { + const cmd = createCommand('--target', 'github-copilot', '--force') + + await cmd.run() + + const expectedDir = join(process.cwd(), '.agents/skills/checkly') + expect(writeFile).toHaveBeenCalledWith( + join(expectedDir, 'SKILL.md'), + SKILL_CONTENT, + 'utf8', + ) + }) + + it('installs to goose target', async () => { + const cmd = createCommand('--target', 'goose', '--force') + + await cmd.run() + + const expectedDir = join(process.cwd(), '.goose/skills/checkly') + expect(writeFile).toHaveBeenCalledWith( + join(expectedDir, 'SKILL.md'), + SKILL_CONTENT, + 'utf8', + ) + }) + + it('installs to continue target', async () => { + const cmd = createCommand('--target', 'continue', '--force') + + await cmd.run() + + const expectedDir = join(process.cwd(), '.continue/skills/checkly') + expect(writeFile).toHaveBeenCalledWith( + join(expectedDir, 'SKILL.md'), + SKILL_CONTENT, + 'utf8', + ) + }) + + it('errors on unknown target', async () => { + const cmd = createCommand('--target', 'unknown', '--force') + + await expect(cmd.run()).rejects.toThrow('Unknown target "unknown"') + }) + }) + + describe('--path flag', () => { + it('installs to custom directory', async () => { + const cmd = createCommand('--path', 'custom/dir', '--force') + + await cmd.run() + + const expectedDir = join(process.cwd(), 'custom/dir') + expect(mkdir).toHaveBeenCalledWith(expectedDir, { recursive: true }) + expect(writeFile).toHaveBeenCalledWith( + join(expectedDir, 'SKILL.md'), + SKILL_CONTENT, + 'utf8', + ) + }) + }) + + describe('error paths', () => { + it('errors when skill source file cannot be read', async () => { + vi.mocked(readFile).mockRejectedValue(new Error('ENOENT')) + const cmd = createCommand('--target', 'claude', '--force') + + await expect(cmd.run()).rejects.toThrow('Failed to read skill file') + }) + + it('errors when target directory cannot be created', async () => { + vi.mocked(mkdir).mockRejectedValue(new Error('EACCES')) + const cmd = createCommand('--target', 'claude', '--force') + + await expect(cmd.run()).rejects.toThrow('Failed to create directory') + }) + + it('errors when skill file cannot be written', async () => { + vi.mocked(writeFile).mockRejectedValue(new Error('EACCES')) + const cmd = createCommand('--target', 'claude', '--force') + + await expect(cmd.run()).rejects.toThrow('Failed to write skill file') + }) + }) + + describe('flag exclusivity', () => { + it('errors when both --target and --path are provided', async () => { + const cmd = createCommand('--target', 'claude', '--path', 'custom/dir') + + await expect(cmd.run()).rejects.toThrow(/cannot also be provided when using/) + }) + }) + + describe('overwrite confirmation', () => { + it('skips writing when user declines overwrite', async () => { + vi.mocked(access).mockResolvedValue(undefined) + vi.mocked(prompts).mockResolvedValueOnce({ overwrite: false }) + + const cmd = createCommand('--target', 'claude') + + await cmd.run() + + expect(writeFile).not.toHaveBeenCalled() + expect(getLogged(cmd).some(m => m.includes('Skipped'))).toBe(true) + }) + + it('overwrites when user confirms', async () => { + vi.mocked(access).mockResolvedValue(undefined) + vi.mocked(prompts).mockResolvedValueOnce({ overwrite: true }) + + const cmd = createCommand('--target', 'claude') + + await cmd.run() + + expect(writeFile).toHaveBeenCalled() + }) + + it('skips confirmation with --force', async () => { + vi.mocked(access).mockResolvedValue(undefined) + + const cmd = createCommand('--target', 'claude', '--force') + + await cmd.run() + + expect(prompts).not.toHaveBeenCalled() + expect(writeFile).toHaveBeenCalled() + }) + }) + + describe('non-interactive mode', () => { + beforeEach(() => { + vi.mocked(detectCliMode).mockReturnValue('agent') + }) + + it('prints usage guidance when no flags provided', async () => { + const cmd = createCommand() + + await cmd.run() + + const logged = getLogged(cmd) + expect(logged.some(m => m.includes('--target claude'))).toBe(true) + expect(logged.some(m => m.includes('--target cursor'))).toBe(true) + expect(logged.some(m => m.includes('--target windsurf'))).toBe(true) + expect(logged.some(m => m.includes('--target github-copilot'))).toBe(true) + expect(logged.some(m => m.includes('--target goose'))).toBe(true) + expect(logged.some(m => m.includes('--path'))).toBe(true) + expect(writeFile).not.toHaveBeenCalled() + }) + + it('also prints usage guidance in ci mode', async () => { + vi.mocked(detectCliMode).mockReturnValue('ci') + + const cmd = createCommand() + + await cmd.run() + + const logged = getLogged(cmd) + expect(logged.some(m => m.includes('--target claude'))).toBe(true) + expect(writeFile).not.toHaveBeenCalled() + }) + + it('installs without prompting when file does not exist', async () => { + const cmd = createCommand('--target', 'claude') + + await cmd.run() + + const expectedDir = join(process.cwd(), '.claude/skills/checkly') + expect(prompts).not.toHaveBeenCalled() + expect(writeFile).toHaveBeenCalledWith( + join(expectedDir, 'SKILL.md'), + SKILL_CONTENT, + 'utf8', + ) + expect(getLogged(cmd).some(m => m.includes('Installed Checkly agent skill to:'))).toBe(true) + }) + + it('skips with warning when file already exists', async () => { + vi.mocked(access).mockResolvedValue(undefined) + + const cmd = createCommand('--target', 'claude') + + await cmd.run() + + expect(prompts).not.toHaveBeenCalled() + expect(writeFile).not.toHaveBeenCalled() + expect(getLogged(cmd).some(m => m.includes('--force'))).toBe(true) + }) + }) + + describe('interactive mode', () => { + it('cancels when user selects nothing', async () => { + vi.mocked(prompts).mockResolvedValueOnce({ target: undefined }) + + const cmd = createCommand() + + await cmd.run() + + expect(getLogged(cmd).some(m => m.includes('Cancelled. No skill file written.'))).toBe(true) + expect(writeFile).not.toHaveBeenCalled() + }) + + it('installs to selected platform directory', async () => { + vi.mocked(prompts).mockResolvedValueOnce({ target: '.claude/skills/checkly' }) + + const cmd = createCommand() + + await cmd.run() + + const expectedDir = join(process.cwd(), '.claude/skills/checkly') + expect(writeFile).toHaveBeenCalledWith( + join(expectedDir, 'SKILL.md'), + SKILL_CONTENT, + 'utf8', + ) + }) + + it('prompts for custom path when selected', async () => { + vi.mocked(prompts) + .mockResolvedValueOnce({ target: '__custom__' }) + .mockResolvedValueOnce({ customPath: 'my/custom/dir' }) + + const cmd = createCommand() + + await cmd.run() + + const expectedDir = join(process.cwd(), 'my/custom/dir') + expect(writeFile).toHaveBeenCalledWith( + join(expectedDir, 'SKILL.md'), + SKILL_CONTENT, + 'utf8', + ) + }) + + it('cancels when custom path is empty', async () => { + vi.mocked(prompts) + .mockResolvedValueOnce({ target: '__custom__' }) + .mockResolvedValueOnce({ customPath: '' }) + + const cmd = createCommand() + + await cmd.run() + + expect(getLogged(cmd).some(m => m.includes('Cancelled. No skill file written.'))).toBe(true) + expect(writeFile).not.toHaveBeenCalled() + }) + }) +}) diff --git a/packages/cli/src/commands/skills.ts b/packages/cli/src/commands/skills/index.ts similarity index 94% rename from packages/cli/src/commands/skills.ts rename to packages/cli/src/commands/skills/index.ts index 8bae2d976..3c4a782d9 100644 --- a/packages/cli/src/commands/skills.ts +++ b/packages/cli/src/commands/skills/index.ts @@ -2,10 +2,10 @@ import { Args } from '@oclif/core' import { readFile, readdir } from 'fs/promises' import { join } from 'path' -import { ACTIONS, SKILL } from '../ai-context/context' -import { BaseCommand } from './baseCommand' +import { ACTIONS, SKILL } from '../../ai-context/context' +import { BaseCommand } from '../baseCommand' -const REFERENCES_DIR = join(__dirname, '../ai-context/skills-command/references') +const REFERENCES_DIR = join(__dirname, '../../ai-context/skills-command/references') export default class Skills extends BaseCommand { static hidden = false diff --git a/packages/cli/src/commands/skills/install.ts b/packages/cli/src/commands/skills/install.ts new file mode 100644 index 000000000..99aeb9df6 --- /dev/null +++ b/packages/cli/src/commands/skills/install.ts @@ -0,0 +1,202 @@ +import { Flags } from '@oclif/core' +import { constants } from 'fs' +import { access, mkdir, readFile, writeFile } from 'fs/promises' +import { join } from 'path' +import prompts from 'prompts' + +import { detectCliMode } from '../../helpers/cli-mode' +import { BaseCommand } from '../baseCommand' + +const SKILL_FILE_PATH = join(__dirname, '../../ai-context/public-skills/checkly/SKILL.md') +const SKILL_FILENAME = 'SKILL.md' + +const PLATFORM_TARGETS: Record = { + 'amp': '.agents/skills/checkly', + 'claude': '.claude/skills/checkly', + 'cline': '.agents/skills/checkly', + 'codex': '.agents/skills/checkly', + 'continue': '.continue/skills/checkly', + 'cursor': '.cursor/skills/checkly', + 'gemini-cli': '.agents/skills/checkly', + 'github-copilot': '.agents/skills/checkly', + 'goose': '.goose/skills/checkly', + 'opencode': '.agents/skills/checkly', + 'roo': '.roo/skills/checkly', + 'windsurf': '.windsurf/skills/checkly', +} + +const VALID_TARGETS = Object.keys(PLATFORM_TARGETS) + +export default class SkillsInstall extends BaseCommand { + static hidden = false + static idempotent = true + static description = 'Install the Checkly agent skill (SKILL.md) into your project.' + + static flags = { + target: Flags.string({ + char: 't', + description: `Platform to install the skill for (${VALID_TARGETS.join(', ')}).`, + exclusive: ['path'], + }), + path: Flags.string({ + char: 'p', + description: 'Custom target directory to install the skill into.', + exclusive: ['target'], + }), + force: Flags.boolean({ + char: 'f', + description: 'Overwrite existing SKILL.md without confirmation.', + default: false, + }), + } + + async run (): Promise { + const { flags } = await this.parse(SkillsInstall) + + const skillContent = await this.readSkillFile() + + const targetDir = this.resolveTarget(flags) + + if (targetDir) { + await this.installSkill(skillContent, targetDir, flags.force) + return + } + + if (detectCliMode() !== 'interactive') { + const flagCol = '--target '.length + const maxLen = Math.max(...VALID_TARGETS.map(t => t.length)) + const descCol = flagCol + maxLen + 2 + const padTo = (flag: string, arg: string) => + ` ${(flag + ' ' + arg).padEnd(descCol)}` + + this.log('Non-interactive mode detected. Use one of the following flags:\n') + for (const [name, dir] of Object.entries(PLATFORM_TARGETS)) { + this.log(`${padTo('--target', name)}Install to ${dir}/`) + } + this.log(`${padTo('--path', '')}Install to a custom directory`) + this.log(`${padTo('--force', '')}Overwrite existing SKILL.md without confirmation`) + this.log('\nExample: npx checkly skills install --target claude --force') + return + } + + const selectedDir = await this.promptForTarget() + if (!selectedDir) { + this.log('Cancelled. No skill file written.') + return + } + + await this.installSkill(skillContent, selectedDir, flags.force) + } + + private async readSkillFile (): Promise { + try { + return await readFile(SKILL_FILE_PATH, 'utf8') + } catch { + this.error(`Failed to read skill file at ${SKILL_FILE_PATH}`) + } + } + + private resolveTarget (flags: { target?: string, path?: string }): string | undefined { + if (flags.path) { + return flags.path + } + + if (flags.target) { + const dir = PLATFORM_TARGETS[flags.target] + if (!dir) { + this.error( + `Unknown target "${flags.target}".` + + `\n\nAvailable targets: ${VALID_TARGETS.join(', ')}`, + ) + } + return dir + } + + return undefined + } + + private async promptForTarget (): Promise { + const choices = [ + ...Object.entries(PLATFORM_TARGETS).map(([platform, dir]) => ({ + title: `${platform.charAt(0).toUpperCase() + platform.slice(1)} (${dir}/)`, + value: dir, + })), + { + title: 'Custom path', + value: '__custom__', + }, + ] + + const { target } = await prompts({ + type: 'select', + name: 'target', + message: 'Where do you want to install the Checkly agent skill?', + choices, + initial: 0, + }) + + if (target === undefined) { + return undefined + } + + if (target === '__custom__') { + const { customPath } = await prompts({ + type: 'text', + name: 'customPath', + message: 'Enter the target directory:', + }) + return customPath || undefined + } + + return target + } + + private async installSkill (content: string, targetDir: string, force: boolean): Promise { + const absoluteDir = join(process.cwd(), targetDir) + const targetPath = join(absoluteDir, SKILL_FILENAME) + + try { + await mkdir(absoluteDir, { recursive: true }) + } catch { + this.error(`Failed to create directory ${absoluteDir}`) + } + + if (!force) { + const shouldOverwrite = await this.confirmOverwrite(targetPath) + if (!shouldOverwrite) { + this.log(`Skipped ${targetPath}`) + return + } + } + + try { + await writeFile(targetPath, content, 'utf8') + } catch { + this.error(`Failed to write skill file to ${targetPath}`) + } + + this.style.shortSuccess(`Installed Checkly agent skill to: ${targetPath}`) + } + + private async confirmOverwrite (targetPath: string): Promise { + try { + await access(targetPath, constants.F_OK) + } catch { + return true + } + + if (detectCliMode() !== 'interactive') { + this.log(`Skill file already exists at ${targetPath}. Use --force to overwrite.`) + return false + } + + const { overwrite } = await prompts({ + type: 'confirm', + name: 'overwrite', + message: `Skill file already exists at ${targetPath}. Overwrite?`, + initial: false, + }) + + return overwrite ?? false + } +} diff --git a/packages/cli/src/help/help-extension.ts b/packages/cli/src/help/help-extension.ts index 824a3e4b1..7d781f173 100644 --- a/packages/cli/src/help/help-extension.ts +++ b/packages/cli/src/help/help-extension.ts @@ -27,7 +27,11 @@ export default class ChecklyHelpClass extends Help { } } - const topicRows = [...topicNames].map(name => [name, explicitTopics.get(name)]) + // Exclude topics that have a matching top-level command (e.g. skills index) + const topLevelIds = new Set(commands.filter(c => !c.id.includes(':')).map(c => c.id)) + const topicRows = [...topicNames] + .filter(name => !topLevelIds.has(name)) + .map(name => [name, explicitTopics.get(name)]) return commands // discard commands with ':' indicating they are under a topic diff --git a/skills/checkly/README.md b/skills/checkly/README.md index 99e5b2528..2f81efae1 100644 --- a/skills/checkly/README.md +++ b/skills/checkly/README.md @@ -26,6 +26,10 @@ Agent Skills are a standardized format for giving AI agents specialized knowledg - Build dashboards and status pages - Follow monitoring-as-code best practices with the Checkly CLI +## Installing This Skill + +Run `npx checkly skills install` to install the skill into your project. The command supports Claude Code, Cursor, Codex and more out of the box, or you can specify a custom path. + ## Using This Skill AI agents can load this skill to gain expertise in Checkly monitoring. The skill follows the [Agent Skills specification](https://agentskills.io) with: diff --git a/skills/checkly/SKILL.md b/skills/checkly/SKILL.md index 74644748d..503879535 100644 --- a/skills/checkly/SKILL.md +++ b/skills/checkly/SKILL.md @@ -10,6 +10,8 @@ metadata: The Checkly CLI provides all the required information via the `npx checkly skills` command. +Use `npx checkly skills install` to install this skill into your project (supports Claude Code, Cursor, Codex and more). + Use `npx checkly skills` to list all available actions, and `npx checkly skills ` to access up-to-date information on how to use the Checkly CLI for each action. ## Progressive Disclosure via `npx checkly skills`