From ca8f740b6a6a1a9589782268bf868625b625a50f Mon Sep 17 00:00:00 2001 From: stefan judis Date: Sat, 14 Mar 2026 18:05:30 +0100 Subject: [PATCH 1/3] feat(cli): add `skills install` command Add `npx checkly skills install` to install the Checkly agent skill (SKILL.md) into a project for any supported AI coding tool. Supports --target for known platforms (Claude, Cursor, Windsurf, etc.), --path for custom directories, --force to skip overwrite confirmation, and interactive prompts when run in a TTY. --- packages/cli/package.json | 3 - packages/cli/src/ai-context/README.md | 4 + packages/cli/src/ai-context/skill.md | 2 + .../__tests__/command-metadata.spec.ts | 2 + .../commands/skills/__tests__/install.spec.ts | 380 ++++++++++++++++++ .../commands/{skills.ts => skills/index.ts} | 6 +- packages/cli/src/commands/skills/install.ts | 205 ++++++++++ packages/cli/src/help/help-extension.ts | 6 +- skills/checkly/README.md | 4 + skills/checkly/SKILL.md | 2 + 10 files changed, 607 insertions(+), 7 deletions(-) create mode 100644 packages/cli/src/commands/skills/__tests__/install.spec.ts rename packages/cli/src/commands/{skills.ts => skills/index.ts} (94%) create mode 100644 packages/cli/src/commands/skills/install.ts 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..efd7b7cff 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, and Windsurf 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..e3421c512 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, and Windsurf). + 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..c5eecbff7 --- /dev/null +++ b/packages/cli/src/commands/skills/__tests__/install.spec.ts @@ -0,0 +1,380 @@ +import { join } from 'path' +import { afterEach, 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({})), +})) + +import { access, mkdir, readFile, writeFile } from 'fs/promises' +import prompts from 'prompts' +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')) + }) + + 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', () => { + let originalStdinTTY: boolean | undefined + let originalStdoutTTY: boolean | undefined + let originalCI: string | undefined + let originalNonInteractive: string | undefined + + beforeEach(() => { + originalStdinTTY = process.stdin.isTTY + originalStdoutTTY = process.stdout.isTTY + originalCI = process.env.CI + originalNonInteractive = process.env.CHECKLY_NON_INTERACTIVE + process.stdin.isTTY = true as any + process.stdout.isTTY = true as any + delete process.env.CI + delete process.env.CHECKLY_NON_INTERACTIVE + }) + + afterEach(() => { + process.stdin.isTTY = originalStdinTTY as any + process.stdout.isTTY = originalStdoutTTY as any + process.env.CI = originalCI + process.env.CHECKLY_NON_INTERACTIVE = originalNonInteractive + }) + + 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', () => { + let originalStdinTTY: boolean | undefined + let originalStdoutTTY: boolean | undefined + + beforeEach(() => { + originalStdinTTY = process.stdin.isTTY + originalStdoutTTY = process.stdout.isTTY + process.stdin.isTTY = false as any + process.stdout.isTTY = false as any + }) + + afterEach(() => { + process.stdin.isTTY = originalStdinTTY as any + process.stdout.isTTY = originalStdoutTTY as any + }) + + 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('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', () => { + let originalStdinTTY: boolean | undefined + let originalStdoutTTY: boolean | undefined + let originalCI: string | undefined + let originalNonInteractive: string | undefined + + beforeEach(() => { + originalStdinTTY = process.stdin.isTTY + originalStdoutTTY = process.stdout.isTTY + originalCI = process.env.CI + originalNonInteractive = process.env.CHECKLY_NON_INTERACTIVE + process.stdin.isTTY = true as any + process.stdout.isTTY = true as any + delete process.env.CI + delete process.env.CHECKLY_NON_INTERACTIVE + }) + + afterEach(() => { + process.stdin.isTTY = originalStdinTTY as any + process.stdout.isTTY = originalStdoutTTY as any + process.env.CI = originalCI + process.env.CHECKLY_NON_INTERACTIVE = originalNonInteractive + }) + + 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..d71a92921 --- /dev/null +++ b/packages/cli/src/commands/skills/install.ts @@ -0,0 +1,205 @@ +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 { 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 (this.isNonInteractive()) { + const maxLen = Math.max(...VALID_TARGETS.map(t => t.length)) + const pad = (s: string) => s.padEnd(maxLen + 2) + + this.log('Non-interactive mode detected. Use one of the following flags:\n') + for (const [name, dir] of Object.entries(PLATFORM_TARGETS)) { + this.log(` --target ${pad(name)}Install to ${dir}/`) + } + this.log(` --path ${pad('')}Install to a custom directory`) + this.log(` --force ${pad('')}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 isNonInteractive (): boolean { + return !process.stdin.isTTY + || !process.stdout.isTTY + || !!process.env.CI + || !!process.env.CHECKLY_NON_INTERACTIVE + } + + private async confirmOverwrite (targetPath: string): Promise { + try { + await access(targetPath, constants.F_OK) + } catch { + return true + } + + if (this.isNonInteractive()) { + 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..efd7b7cff 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, and Windsurf 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..d7e0ab9a8 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, and Windsurf). + 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` From 508e1d98d67a8dd331534fac8acfe9a455d85351 Mon Sep 17 00:00:00 2001 From: stefan judis Date: Sat, 14 Mar 2026 18:14:11 +0100 Subject: [PATCH 2/3] fix(cli): align --path and --force columns in skills install help output --- packages/cli/src/commands/skills/install.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/packages/cli/src/commands/skills/install.ts b/packages/cli/src/commands/skills/install.ts index d71a92921..7f4b832df 100644 --- a/packages/cli/src/commands/skills/install.ts +++ b/packages/cli/src/commands/skills/install.ts @@ -62,15 +62,18 @@ export default class SkillsInstall extends BaseCommand { } if (this.isNonInteractive()) { + const flagCol = '--target '.length const maxLen = Math.max(...VALID_TARGETS.map(t => t.length)) - const pad = (s: string) => s.padEnd(maxLen + 2) + 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(` --target ${pad(name)}Install to ${dir}/`) + this.log(`${padTo('--target', name)}Install to ${dir}/`) } - this.log(` --path ${pad('')}Install to a custom directory`) - this.log(` --force ${pad('')}Overwrite existing SKILL.md without confirmation`) + 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 } From 75f2bfcebcbee9a6cbf6169cdbc43cccd75532ff Mon Sep 17 00:00:00 2001 From: Herve Labas Date: Mon, 16 Mar 2026 13:17:47 +0100 Subject: [PATCH 3/3] fix(cli): address PR review for skills install command Use existing detectCliMode() from helpers/cli-mode instead of custom isNonInteractive() method, and update platform mentions to include Codex. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/cli/src/ai-context/README.md | 2 +- packages/cli/src/ai-context/skill.md | 2 +- .../commands/skills/__tests__/install.spec.ts | 79 +++++-------------- packages/cli/src/commands/skills/install.ts | 12 +-- skills/checkly/README.md | 2 +- skills/checkly/SKILL.md | 2 +- 6 files changed, 27 insertions(+), 72 deletions(-) diff --git a/packages/cli/src/ai-context/README.md b/packages/cli/src/ai-context/README.md index efd7b7cff..2f81efae1 100644 --- a/packages/cli/src/ai-context/README.md +++ b/packages/cli/src/ai-context/README.md @@ -28,7 +28,7 @@ Agent Skills are a standardized format for giving AI agents specialized knowledg ## Installing This Skill -Run `npx checkly skills install` to install the skill into your project. The command supports Claude Code, Cursor, and Windsurf out of the box, or you can specify a custom path. +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 diff --git a/packages/cli/src/ai-context/skill.md b/packages/cli/src/ai-context/skill.md index e3421c512..b3801cbab 100644 --- a/packages/cli/src/ai-context/skill.md +++ b/packages/cli/src/ai-context/skill.md @@ -10,7 +10,7 @@ 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, and Windsurf). +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. diff --git a/packages/cli/src/commands/skills/__tests__/install.spec.ts b/packages/cli/src/commands/skills/__tests__/install.spec.ts index c5eecbff7..d43b35faf 100644 --- a/packages/cli/src/commands/skills/__tests__/install.spec.ts +++ b/packages/cli/src/commands/skills/__tests__/install.spec.ts @@ -1,5 +1,5 @@ import { join } from 'path' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { beforeEach, describe, expect, it, vi } from 'vitest' vi.mock('fs/promises', () => ({ readFile: vi.fn(), @@ -12,8 +12,13 @@ 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.' @@ -42,6 +47,7 @@ describe('skills install', () => { 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', () => { @@ -180,29 +186,6 @@ describe('skills install', () => { }) describe('overwrite confirmation', () => { - let originalStdinTTY: boolean | undefined - let originalStdoutTTY: boolean | undefined - let originalCI: string | undefined - let originalNonInteractive: string | undefined - - beforeEach(() => { - originalStdinTTY = process.stdin.isTTY - originalStdoutTTY = process.stdout.isTTY - originalCI = process.env.CI - originalNonInteractive = process.env.CHECKLY_NON_INTERACTIVE - process.stdin.isTTY = true as any - process.stdout.isTTY = true as any - delete process.env.CI - delete process.env.CHECKLY_NON_INTERACTIVE - }) - - afterEach(() => { - process.stdin.isTTY = originalStdinTTY as any - process.stdout.isTTY = originalStdoutTTY as any - process.env.CI = originalCI - process.env.CHECKLY_NON_INTERACTIVE = originalNonInteractive - }) - it('skips writing when user declines overwrite', async () => { vi.mocked(access).mockResolvedValue(undefined) vi.mocked(prompts).mockResolvedValueOnce({ overwrite: false }) @@ -239,19 +222,8 @@ describe('skills install', () => { }) describe('non-interactive mode', () => { - let originalStdinTTY: boolean | undefined - let originalStdoutTTY: boolean | undefined - beforeEach(() => { - originalStdinTTY = process.stdin.isTTY - originalStdoutTTY = process.stdout.isTTY - process.stdin.isTTY = false as any - process.stdout.isTTY = false as any - }) - - afterEach(() => { - process.stdin.isTTY = originalStdinTTY as any - process.stdout.isTTY = originalStdoutTTY as any + vi.mocked(detectCliMode).mockReturnValue('agent') }) it('prints usage guidance when no flags provided', async () => { @@ -269,6 +241,18 @@ describe('skills install', () => { 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') @@ -298,29 +282,6 @@ describe('skills install', () => { }) describe('interactive mode', () => { - let originalStdinTTY: boolean | undefined - let originalStdoutTTY: boolean | undefined - let originalCI: string | undefined - let originalNonInteractive: string | undefined - - beforeEach(() => { - originalStdinTTY = process.stdin.isTTY - originalStdoutTTY = process.stdout.isTTY - originalCI = process.env.CI - originalNonInteractive = process.env.CHECKLY_NON_INTERACTIVE - process.stdin.isTTY = true as any - process.stdout.isTTY = true as any - delete process.env.CI - delete process.env.CHECKLY_NON_INTERACTIVE - }) - - afterEach(() => { - process.stdin.isTTY = originalStdinTTY as any - process.stdout.isTTY = originalStdoutTTY as any - process.env.CI = originalCI - process.env.CHECKLY_NON_INTERACTIVE = originalNonInteractive - }) - it('cancels when user selects nothing', async () => { vi.mocked(prompts).mockResolvedValueOnce({ target: undefined }) diff --git a/packages/cli/src/commands/skills/install.ts b/packages/cli/src/commands/skills/install.ts index 7f4b832df..99aeb9df6 100644 --- a/packages/cli/src/commands/skills/install.ts +++ b/packages/cli/src/commands/skills/install.ts @@ -4,6 +4,7 @@ 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') @@ -61,7 +62,7 @@ export default class SkillsInstall extends BaseCommand { return } - if (this.isNonInteractive()) { + if (detectCliMode() !== 'interactive') { const flagCol = '--target '.length const maxLen = Math.max(...VALID_TARGETS.map(t => t.length)) const descCol = flagCol + maxLen + 2 @@ -177,13 +178,6 @@ export default class SkillsInstall extends BaseCommand { this.style.shortSuccess(`Installed Checkly agent skill to: ${targetPath}`) } - private isNonInteractive (): boolean { - return !process.stdin.isTTY - || !process.stdout.isTTY - || !!process.env.CI - || !!process.env.CHECKLY_NON_INTERACTIVE - } - private async confirmOverwrite (targetPath: string): Promise { try { await access(targetPath, constants.F_OK) @@ -191,7 +185,7 @@ export default class SkillsInstall extends BaseCommand { return true } - if (this.isNonInteractive()) { + if (detectCliMode() !== 'interactive') { this.log(`Skill file already exists at ${targetPath}. Use --force to overwrite.`) return false } diff --git a/skills/checkly/README.md b/skills/checkly/README.md index efd7b7cff..2f81efae1 100644 --- a/skills/checkly/README.md +++ b/skills/checkly/README.md @@ -28,7 +28,7 @@ Agent Skills are a standardized format for giving AI agents specialized knowledg ## Installing This Skill -Run `npx checkly skills install` to install the skill into your project. The command supports Claude Code, Cursor, and Windsurf out of the box, or you can specify a custom path. +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 diff --git a/skills/checkly/SKILL.md b/skills/checkly/SKILL.md index d7e0ab9a8..503879535 100644 --- a/skills/checkly/SKILL.md +++ b/skills/checkly/SKILL.md @@ -10,7 +10,7 @@ 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, and Windsurf). +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.