diff --git a/README.md b/README.md index 54575d1..42b9c1d 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,13 @@ pnpm install -g @vizzly-testing/cli vizzly init ``` +For agent-friendly repos, install the Vizzly skill and add a short project +`AGENTS.md` note: + +```bash +vizzly init --agent-guidance +``` + ### Start Local TDD Start the TDD server, run your tests, and open the dashboard at @@ -140,6 +147,13 @@ Generate a config file: vizzly init ``` +To teach project agents about Vizzly screenshot memory and the local visual TDD +loop, add the repo-local skill and AGENTS.md guidance: + +```bash +vizzly init --agent-guidance +``` + Or create `vizzly.config.js` manually: ```javascript diff --git a/docs/json-output.md b/docs/json-output.md index 354e63a..06c8930 100644 --- a/docs/json-output.md +++ b/docs/json-output.md @@ -737,6 +737,29 @@ vizzly init --json } ``` +With project agent setup: + +```bash +vizzly init --agent-guidance --json +``` + +```json +{ + "status": "created", + "configPath": "/path/to/vizzly.config.js", + "plugins": [], + "agentSkill": { + "status": "installed", + "sourcePath": "/path/to/cli/skills/vizzly", + "targetPath": "/path/to/project/.agents/skills/vizzly" + }, + "agentGuidance": { + "status": "created", + "agentsPath": "/path/to/project/AGENTS.md" + } +} +``` + ### `vizzly project link` ```bash diff --git a/package.json b/package.json index e28ddfe..310f77b 100644 --- a/package.json +++ b/package.json @@ -55,6 +55,7 @@ "bin", "dist", "docs/assets", + "skills", "README.md", "LICENSE" ], diff --git a/skills/vizzly/SKILL.md b/skills/vizzly/SKILL.md new file mode 100644 index 0000000..a48dd4d --- /dev/null +++ b/skills/vizzly/SKILL.md @@ -0,0 +1,99 @@ +--- +name: vizzly +description: "Use when a repo has Vizzly configured and you need screenshot memory or visual history: before/after UI changes, visual regression review, screenshot history, approved baselines, diffs, dynamic regions, docs/manual images, public screenshots, or Vizzly builds. Teaches agents to use Vizzly CLI/context for local TDD, cloud builds, review state, comments, hotspots, previews, and SDK capture patterns." +--- + +# Vizzly + +Vizzly is the project's screenshot memory database. It stores approved baselines, current screenshots, diffs, review state, comments, hotspots, previews, and public screenshot URLs. + +Use Vizzly when you need to understand what the UI looked like before, what changed, what humans already reviewed, or which screenshots already exist. Browser automation is still useful for interacting with a live app; Vizzly is usually the faster first stop for visual history. + +## First Instinct + +Before changing UI, check whether Vizzly already has local screenshot context: + +```bash +vizzly context build current --source local --agent +``` + +If a task names a screen, component, or screenshot, inspect that screenshot's history before changing thresholds or re-capturing blindly: + +```bash +vizzly context screenshot "" --source local --json +``` + +When you make or verify UI changes, run the focused user workflow that owns the surface: + +```bash +vizzly tdd run "" --no-open +``` + +Then inspect what changed: + +```bash +vizzly context build current --source local --agent --json +``` + +For specific evidence, drill in: + +```bash +vizzly context comparison --json +vizzly context screenshot "" --json +vizzly context review-queue --json +``` + +If local context is unavailable and the project uses cloud builds, use the build id from CI or CLI output: + +```bash +vizzly context build --agent --json --include diffs,comments +``` + +## What Vizzly Knows + +- **Approved baselines**: expected UI. +- **Current screenshots**: what the latest run rendered. +- **Diffs**: where pixels/layout/content changed. +- **Review state and comments**: human context attached to builds and screenshots. +- **Hotspots and confirmed regions**: known dynamic areas. +- **Preview links**: static or deployed UI context for a build. +- **Public screenshots**: stable URLs for documentation and manuals. + +## Acting On Visual Context + +- Treat approved baselines as visual truth. +- Treat diffs as evidence, not as approval instructions. +- Do not approve or reject visual changes unless the user explicitly asks. +- Prefer existing E2E or user journeys over narrow screenshot-only specs. +- For dynamic content, inspect screenshot context before changing thresholds. +- Prefer deterministic test data, per-screenshot `threshold`, per-screenshot `minClusterSize`, hotspots, or confirmed regions over global tolerance changes. +- Report visual findings with screenshot names, build/comparison links when available, and the command you ran. + +## Capturing Screenshots + +Use the existing integration when one is present. For direct JavaScript capture: + +```javascript +import { vizzlyScreenshot } from '@vizzly-testing/cli/client'; + +let screenshot = await page.screenshot(); +await vizzlyScreenshot('checkout-form', screenshot, { + properties: { + browser: 'chromium', + viewport: 'desktop', + state: 'valid-card' + }, + threshold: 2, + minClusterSize: 4 +}); +``` + +Use `properties` to separate variants such as theme, locale, viewport, role, state, component, page, or docs/manual grouping. + +## When You Need More Detail + +- For SDK examples and capture patterns, read `references/sdks.md`. +- For CLI/context commands and JSON output, read `references/cli-context.md`. +- For dynamic content, thresholds, and hotspots, read `references/dynamic-content.md`. +- For public screenshot URLs and docs/manual images, read `references/public-screenshots.md`. +- For project setup and CI, read `references/setup-ci.md`. diff --git a/skills/vizzly/agents/openai.yaml b/skills/vizzly/agents/openai.yaml new file mode 100644 index 0000000..70b30c8 --- /dev/null +++ b/skills/vizzly/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Vizzly" + short_description: "Use Vizzly visual context while changing UI." + default_prompt: "Use Vizzly to inspect visual context before and after UI changes." diff --git a/skills/vizzly/references/cli-context.md b/skills/vizzly/references/cli-context.md new file mode 100644 index 0000000..6a8ee69 --- /dev/null +++ b/skills/vizzly/references/cli-context.md @@ -0,0 +1,42 @@ +# CLI And Context + +Use these commands to gather Vizzly evidence before making UI assumptions. + +## Local TDD + +```bash +vizzly tdd start +vizzly tdd run "" --no-open +vizzly context build current --source local --agent +vizzly context build current --source local --agent --json +``` + +Local context reads `.vizzly` state and does not require cloud auth. + +## Cloud Builds + +```bash +export VIZZLY_TOKEN="project-token" +vizzly run "" --wait --json +vizzly context build --agent --json --include diffs,comments +``` + +`vizzly run --wait --json` returns a `contextCommand` when a cloud build is created. Prefer that command over constructing URLs by hand. + +## Drilldowns + +```bash +vizzly context comparison --json +vizzly context screenshot "" --json +vizzly context similar --json +vizzly context review-queue --json +``` + +Use comparison context for one diff. Use screenshot context for history and recurring dynamic areas. Use review queue context when triaging unresolved visual work. + +## Good Agent Behavior + +- Use `--json` for automation and summaries. +- Use `--agent` when building prompt context or asking another agent to continue. +- Keep human-readable command output in final summaries only when it changes the user's next action. +- Do not approve/reject unless explicitly asked. diff --git a/skills/vizzly/references/dynamic-content.md b/skills/vizzly/references/dynamic-content.md new file mode 100644 index 0000000..e9a3975 --- /dev/null +++ b/skills/vizzly/references/dynamic-content.md @@ -0,0 +1,44 @@ +# Dynamic Content + +Dynamic content is common: dates, calendars, generated images, avatars, timers, randomized data, responsive text, and API-backed content. + +## Order Of Operations + +1. Make test data deterministic when the changing content is not what you are testing. +2. Inspect screenshot context before changing code: + +```bash +vizzly context screenshot "" --json +``` + +3. Use per-screenshot tolerances for local noise: + +```javascript +await vizzlyScreenshot('plant-calendar', screenshot, { + properties: { surface: 'calendar' }, + threshold: 4, + minClusterSize: 12 +}); +``` + +4. Use hotspots or confirmed regions for recurring valid dynamic regions. + +## Avoid + +- Raising global project thresholds for one dynamic area. +- Masking a whole page when a small region changes. +- Treating every daily diff as product breakage. +- Ignoring structural changes because a region is known to be dynamic. + +## How To Explain Findings + +Say whether the diff looks like: + +- expected dynamic content +- deterministic fixture drift +- real content disappearance +- layout shift +- screenshot timing/capture instability +- baseline mismatch + +Then name the screenshot and include the relevant context command or link. diff --git a/skills/vizzly/references/public-screenshots.md b/skills/vizzly/references/public-screenshots.md new file mode 100644 index 0000000..039a19e --- /dev/null +++ b/skills/vizzly/references/public-screenshots.md @@ -0,0 +1,44 @@ +# Public Screenshots + +Public screenshots are Vizzly's path for stable image URLs in docs and manuals. + +Use this when a user asks for hotlinked UI screenshots, generated manual images, docs that stay current with UI, or CDN-like screenshot URLs. + +## Mental Model + +Do not use arbitrary build screenshot URLs as stable docs assets. Use Public Properties. + +The flow: + +1. Capture screenshots with stable metadata. +2. Configure project Public Properties in Vizzly. +3. Approve/publish the baseline build. +4. Use the Public URLs tab to copy the stable URL. + +Example capture: + +```javascript +await vizzlyScreenshot('watering-widget', screenshot, { + properties: { + manual: 'plant-care', + component: 'watering-widget', + viewport: 'desktop' + } +}); +``` + +Configure a Public Property like `manual=plant-care`. Vizzly publishes matching approved baseline screenshots and keeps stable URLs for each screenshot/property identity. + +## Good Uses + +- Product manuals +- User docs +- Component catalogs +- In-app UI examples +- Screenshots that should update when approved UI changes + +## Cautions + +- Public screenshots are public. Do not publish private user data, secrets, or internal-only UI. +- A new unapproved build does not automatically become the public docs image. +- Use stable names and properties so URLs do not churn. diff --git a/skills/vizzly/references/sdks.md b/skills/vizzly/references/sdks.md new file mode 100644 index 0000000..03ba2bf --- /dev/null +++ b/skills/vizzly/references/sdks.md @@ -0,0 +1,62 @@ +# SDK Capture Patterns + +Vizzly works best when screenshots are attached to real user journeys and stable names. + +## JavaScript Client + +```javascript +import { vizzlyScreenshot } from '@vizzly-testing/cli/client'; + +let screenshot = await page.screenshot(); +await vizzlyScreenshot('settings-profile-edit-mode', screenshot, { + properties: { + browser: 'chromium', + viewport: 'desktop', + state: 'edit' + }, + threshold: 2, + minClusterSize: 4 +}); +``` + +`vizzlyScreenshot(name, image, options)` accepts a PNG buffer or file path. + +Important options: + +- `properties`: metadata used for baseline identity and filtering. +- `threshold`: per-screenshot CIEDE2000 Delta E tolerance. +- `minClusterSize`: minimum changed-pixel cluster size. +- `fullPage`: marks full-page captures. + +## Vitest + +Use the Vizzly Vitest plugin when present. It keeps the native `toMatchScreenshot` style: + +```javascript +await expect(page.getByRole('heading')).toMatchScreenshot('hero-section.png', { + properties: { + theme: 'dark', + viewport: 'desktop' + }, + threshold: 2, + fullPage: true +}); +``` + +## Storybook And Static Sites + +If the repo uses Vizzly Storybook or static-site clients, prefer those existing flows over adding custom Playwright screenshots. They already know how to crawl stories/pages, name screenshots, and attach viewport metadata. + +## Swift + +Swift/XCTest projects use `VizzlyXCTest` helpers such as `app.vizzlyScreenshot(name:properties:threshold:minClusterSize:)`. For iOS work, verify against the Swift package docs in the repo before changing examples. + +## Naming + +Use stable, descriptive screenshot names: + +- Good: `checkout-payment-form-valid-card` +- Good: `settings-profile-edit-mode` +- Avoid: `screenshot1`, `test`, names with slashes + +Use properties for variants instead of stuffing every variant into the name. diff --git a/skills/vizzly/references/setup-ci.md b/skills/vizzly/references/setup-ci.md new file mode 100644 index 0000000..d356021 --- /dev/null +++ b/skills/vizzly/references/setup-ci.md @@ -0,0 +1,42 @@ +# Setup And CI + +Use Vizzly locally for fast feedback and in CI for shared review. + +## Local Setup + +```bash +vizzly init +vizzly tdd start +vizzly tdd run "" --no-open +``` + +The local SDK discovers `.vizzly/server.json` and sends screenshots to the TDD server when it is running. + +## Cloud Setup + +```bash +export VIZZLY_TOKEN="project-token" +vizzly run "" --wait +``` + +Use project tokens in CI. Avoid committing tokens. + +## Parallel CI + +Use `--parallel-id` when jobs are sharded: + +```bash +vizzly run "npm test -- --shard=1/4" --parallel-id shard-1 --wait +``` + +Keep fast PR checks and broad scheduled checks separate: + +- PR builds: critical flows and changed surfaces. +- Nightly builds: full-page sweeps, generated image suites, docs/manual captures. + +## Troubleshooting + +- Run `vizzly doctor` for local configuration checks. +- Run `vizzly status ` for cloud build status. +- Run `vizzly context build --agent --json` for review evidence. +- If no screenshots appear, verify the SDK/import is present and the TDD server or `vizzly run` wrapper is active. diff --git a/src/cli.js b/src/cli.js index f82c0d9..01c773e 100644 --- a/src/cli.js +++ b/src/cli.js @@ -410,8 +410,14 @@ program .command('init') .description('Initialize Vizzly in your project') .option('--force', 'Overwrite existing configuration') + .option('--agent-skill', 'Install the repo-local Vizzly agent skill') + .option( + '--agent-guidance', + 'Add Vizzly guidance to this project AGENTS.md and install the agent skill' + ) + .option('--skip-agent-skill', 'Skip the Vizzly agent skill prompt') .action(async options => { - const globalOptions = program.opts(); + let globalOptions = program.opts(); await init({ ...globalOptions, ...options, plugins }); }); diff --git a/src/commands/init.js b/src/commands/init.js index ef26a64..f3df3ae 100644 --- a/src/commands/init.js +++ b/src/commands/init.js @@ -1,12 +1,16 @@ #!/usr/bin/env node import fs from 'node:fs/promises'; import path from 'node:path'; +import { createInterface } from 'node:readline/promises'; +import { fileURLToPath } from 'node:url'; import { z } from 'zod'; import { VizzlyError } from '../errors/vizzly-error.js'; import { loadPlugins } from '../plugin-loader.js'; import { loadConfig } from '../utils/config-loader.js'; import * as output from '../utils/output.js'; +let commandDir = path.dirname(fileURLToPath(import.meta.url)); + let configValueSchema = z.lazy(() => z.union([ z.string(), @@ -22,10 +26,18 @@ let configSchemaValidator = z.record(z.string(), configValueSchema); function createInitDeps(deps = {}) { return { access: deps.access || fs.access, + copy: deps.copy || fs.cp, cwd: deps.cwd || (() => process.cwd()), + isInteractive: + deps.isInteractive || + (() => Boolean(process.stdin.isTTY && process.stdout.isTTY)), loadConfig: deps.loadConfig || loadConfig, loadPlugins: deps.loadPlugins || loadPlugins, + mkdir: deps.mkdir || fs.mkdir, output: deps.output || output, + promptAgentSkill: deps.promptAgentSkill || promptAgentSkill, + readFile: deps.readFile || fs.readFile, + skillSourcePath: deps.skillSourcePath || getPackagedAgentSkillPath(), writeFile: deps.writeFile || fs.writeFile, }; } @@ -42,6 +54,18 @@ export function getInitConfigPath(cwd = process.cwd()) { return path.join(cwd, 'vizzly.config.js'); } +export function getPackagedAgentSkillPath(baseDir = commandDir) { + return path.resolve(baseDir, '..', '..', 'skills', 'vizzly'); +} + +export function getProjectAgentSkillPath(cwd = process.cwd()) { + return path.join(cwd, '.agents', 'skills', 'vizzly'); +} + +export function getProjectAgentsPath(cwd = process.cwd()) { + return path.join(cwd, 'AGENTS.md'); +} + export async function fileExists(filePath, access = fs.access) { try { await access(filePath); @@ -219,6 +243,196 @@ function showNextSteps(output) { ]); } +async function promptAgentSkill({ + stdin = process.stdin, + stdout = process.stdout, +} = {}) { + let rl = createInterface({ input: stdin, output: stdout }); + + try { + let answer = await rl.question( + 'Add the Vizzly agent skill to this repo? [Y/n] ' + ); + return !['n', 'no'].includes(answer.trim().toLowerCase()); + } finally { + rl.close(); + } +} + +async function shouldInstallAgentSkill(options, deps) { + if (options.agentGuidance) { + return true; + } + + if (options.skipAgentSkill) { + return false; + } + + if (options.agentSkill) { + return true; + } + + if (options.json || !deps.isInteractive()) { + return false; + } + + return await deps.promptAgentSkill(); +} + +let agentGuidanceStart = ''; +let agentGuidanceEnd = ''; + +export function createAgentGuidanceContent() { + return `${agentGuidanceStart} +## Visual Testing With Vizzly + +Use Vizzly for user-facing UI changes when this repo has Vizzly configured. + +- Read visual history before changing UI: \`npx vizzly context build current --source local --agent\` +- Inspect named screenshot history when useful: \`npx vizzly context screenshot "" --source local --json\` +- Verify visual changes through the existing user workflow that owns the surface: \`npx vizzly tdd run "" --no-open\` + +Prefer existing E2E/user journeys over narrow screenshot-only specs. Treat Vizzly diffs as review evidence; do not approve or reject changes unless asked. +${agentGuidanceEnd} +`; +} + +function appendSection(content, section) { + if (!content.trim()) { + return section; + } + + return `${content.replace(/\s*$/, '')}\n\n${section}`; +} + +export async function upsertProjectAgentGuidance({ + cwd, + access = fs.access, + readFile = fs.readFile, + writeFile = fs.writeFile, +}) { + let agentsPath = getProjectAgentsPath(cwd); + let guidance = createAgentGuidanceContent(); + + if (!(await fileExists(agentsPath, access))) { + await writeFile(agentsPath, guidance, 'utf8'); + return { + status: 'created', + agentsPath, + }; + } + + let existingContent = await readFile(agentsPath, 'utf8'); + if (existingContent.includes(agentGuidanceStart)) { + return { + status: 'exists', + agentsPath, + }; + } + + await writeFile(agentsPath, appendSection(existingContent, guidance), 'utf8'); + return { + status: 'updated', + agentsPath, + }; +} + +export async function installProjectAgentSkill({ + cwd, + sourcePath, + access = fs.access, + copy = fs.cp, + mkdir = fs.mkdir, +}) { + let targetPath = getProjectAgentSkillPath(cwd); + + if (!(await fileExists(path.join(sourcePath, 'SKILL.md'), access))) { + return { + status: 'missing-source', + sourcePath, + targetPath, + }; + } + + if (await fileExists(targetPath, access)) { + return { + status: 'exists', + sourcePath, + targetPath, + }; + } + + await mkdir(path.dirname(targetPath), { recursive: true }); + await copy(sourcePath, targetPath, { + recursive: true, + errorOnExist: true, + force: false, + }); + + return { + status: 'installed', + sourcePath, + targetPath, + }; +} + +function writeAgentSkillOutput(output, result) { + if (!result) { + return; + } + + if (result.status === 'installed') { + output.complete('Added Vizzly agent skill'); + output.hint(`Installed at ${result.targetPath}`); + return; + } + + if (result.status === 'exists') { + output.hint('Vizzly agent skill already exists in this repo'); + return; + } + + if (result.status === 'missing-source') { + output.warn('Vizzly agent skill was not found in this CLI package'); + } +} + +function writeAgentGuidanceOutput(output, result) { + if (!result) { + return; + } + + if (result.status === 'created') { + output.complete('Created AGENTS.md with Vizzly guidance'); + output.hint(`Wrote ${result.agentsPath}`); + return; + } + + if (result.status === 'updated') { + output.complete('Added Vizzly guidance to AGENTS.md'); + output.hint(`Updated ${result.agentsPath}`); + return; + } + + if (result.status === 'exists') { + output.hint('Vizzly guidance already exists in AGENTS.md'); + } +} + +function withAgentSetupResults( + payload, + { agentSkillResult, agentGuidanceResult } +) { + if (agentSkillResult) { + payload.agentSkill = agentSkillResult; + } + if (agentGuidanceResult) { + payload.agentGuidance = agentGuidanceResult; + } + + return payload; +} + async function writeConfigFile({ configPath, plugins, @@ -261,22 +475,49 @@ export async function init(options = {}, deps = {}) { let plugins = await loadInitPlugins(options, resolvedDeps); let configPath = getInitConfigPath(resolvedDeps.cwd()); let hasConfig = await fileExists(configPath, resolvedDeps.access); + let agentSkillResult = null; + let agentGuidanceResult = null; + + if (await shouldInstallAgentSkill(options, resolvedDeps)) { + agentSkillResult = await installProjectAgentSkill({ + cwd: resolvedDeps.cwd(), + sourcePath: resolvedDeps.skillSourcePath, + access: resolvedDeps.access, + copy: resolvedDeps.copy, + mkdir: resolvedDeps.mkdir, + }); + } + + if (options.agentGuidance) { + agentGuidanceResult = await upsertProjectAgentGuidance({ + cwd: resolvedDeps.cwd(), + access: resolvedDeps.access, + readFile: resolvedDeps.readFile, + writeFile: resolvedDeps.writeFile, + }); + } if (hasConfig && !options.force) { if (options.json) { - resolvedDeps.output.data({ - status: 'skipped', - reason: 'config_exists', - configPath, - message: - 'A vizzly.config.js file already exists. Use --force to overwrite.', - }); + let payload = withAgentSetupResults( + { + status: 'skipped', + reason: 'config_exists', + configPath, + message: + 'A vizzly.config.js file already exists. Use --force to overwrite.', + }, + { agentSkillResult, agentGuidanceResult } + ); + resolvedDeps.output.data(payload); return { status: 'skipped', configPath }; } resolvedDeps.output.header('init'); resolvedDeps.output.warn('A vizzly.config.js file already exists'); resolvedDeps.output.hint('Use --force to overwrite'); + writeAgentSkillOutput(resolvedDeps.output, agentSkillResult); + writeAgentGuidanceOutput(resolvedDeps.output, agentGuidanceResult); return { status: 'skipped', configPath }; } @@ -292,15 +533,21 @@ export async function init(options = {}, deps = {}) { let pluginNames = getPluginConfigNames(plugins); if (options.json) { - resolvedDeps.output.data({ - status: 'created', - configPath, - plugins: pluginNames, - }); + let payload = withAgentSetupResults( + { + status: 'created', + configPath, + plugins: pluginNames, + }, + { agentSkillResult, agentGuidanceResult } + ); + resolvedDeps.output.data(payload); return { status: 'created', configPath, plugins: pluginNames }; } showNextSteps(resolvedDeps.output); + writeAgentSkillOutput(resolvedDeps.output, agentSkillResult); + writeAgentGuidanceOutput(resolvedDeps.output, agentGuidanceResult); resolvedDeps.output.blank(); resolvedDeps.output.complete('Vizzly CLI setup complete'); diff --git a/tests/commands/init.test.js b/tests/commands/init.test.js index ea9ea78..49f8888 100644 --- a/tests/commands/init.test.js +++ b/tests/commands/init.test.js @@ -1,6 +1,6 @@ import assert from 'node:assert/strict'; import { execFile } from 'node:child_process'; -import { mkdtemp, readFile, writeFile } from 'node:fs/promises'; +import { mkdir, mkdtemp, readFile, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import path from 'node:path'; import { describe, it } from 'node:test'; @@ -10,7 +10,10 @@ import { formatPluginConfig, formatValue, getInitConfigPath, + getProjectAgentSkillPath, + getProjectAgentsPath, init, + installProjectAgentSkill, } from '../../src/commands/init.js'; let execFileAsync = promisify(execFile); @@ -207,6 +210,49 @@ describe('commands/init', () => { ); }); + it('can add the agent skill when config already exists', async () => { + let cwd = await createTempProject(); + let configPath = getInitConfigPath(cwd); + let skillSourcePath = path.join(await createTempProject(), 'vizzly'); + let output = createMockOutput(); + + await writeFile(configPath, 'export default { existing: true };\n'); + await mkdir(skillSourcePath, { recursive: true }); + await writeFile( + path.join(skillSourcePath, 'SKILL.md'), + '---\nname: vizzly\ndescription: Use Vizzly.\n---\n' + ); + + let result = await init( + { + agentSkill: true, + plugins: [], + }, + { + cwd: () => cwd, + output, + skillSourcePath, + } + ); + + let config = await readFile(configPath, 'utf8'); + let installedSkill = await readFile( + path.join(getProjectAgentSkillPath(cwd), 'SKILL.md'), + 'utf8' + ); + + assert.deepStrictEqual(result, { status: 'skipped', configPath }); + assert.strictEqual(config, 'export default { existing: true };\n'); + assert.match(installedSkill, /name: vizzly/); + assert.ok( + output.calls.some( + call => + call.method === 'complete' && + call.args[0] === 'Added Vizzly agent skill' + ) + ); + }); + it('overwrites an existing config when forced', async () => { let cwd = await createTempProject(); let configPath = getInitConfigPath(cwd); @@ -264,5 +310,239 @@ describe('commands/init', () => { }); assert.deepStrictEqual(dataCall.args[0].plugins, ['loaded']); }); + + it('installs the repo-local agent skill when requested', async () => { + let cwd = await createTempProject(); + let skillSourcePath = path.join(await createTempProject(), 'vizzly'); + let output = createMockOutput(); + + await mkdir(skillSourcePath, { recursive: true }); + await writeFile( + path.join(skillSourcePath, 'SKILL.md'), + '---\nname: vizzly\ndescription: Use Vizzly.\n---\n' + ); + + let result = await init( + { + agentSkill: true, + plugins: [], + }, + { + cwd: () => cwd, + output, + skillSourcePath, + } + ); + + let installedSkill = await readFile( + path.join(getProjectAgentSkillPath(cwd), 'SKILL.md'), + 'utf8' + ); + + assert.strictEqual(result.status, 'created'); + assert.match(installedSkill, /name: vizzly/); + assert.ok( + output.calls.some( + call => + call.method === 'complete' && + call.args[0] === 'Added Vizzly agent skill' + ) + ); + }); + + it('adds project AGENTS.md guidance when requested', async () => { + let cwd = await createTempProject(); + let skillSourcePath = path.join(await createTempProject(), 'vizzly'); + let output = createMockOutput(); + + await mkdir(skillSourcePath, { recursive: true }); + await writeFile( + path.join(skillSourcePath, 'SKILL.md'), + '---\nname: vizzly\ndescription: Use Vizzly.\n---\n' + ); + + let result = await init( + { + agentGuidance: true, + plugins: [], + }, + { + cwd: () => cwd, + output, + skillSourcePath, + } + ); + + let installedSkill = await readFile( + path.join(getProjectAgentSkillPath(cwd), 'SKILL.md'), + 'utf8' + ); + let agentsContent = await readFile(getProjectAgentsPath(cwd), 'utf8'); + + assert.strictEqual(result.status, 'created'); + assert.match(installedSkill, /name: vizzly/); + assert.match(agentsContent, /Visual Testing With Vizzly/); + assert.match( + agentsContent, + /npx vizzly context build current --source local --agent/ + ); + assert.match( + agentsContent, + /npx vizzly tdd run "" --no-open/ + ); + assert.ok( + output.calls.some( + call => + call.method === 'complete' && + call.args[0] === 'Created AGENTS.md with Vizzly guidance' + ) + ); + }); + + it('appends project AGENTS.md guidance without duplicating it', async () => { + let cwd = await createTempProject(); + let skillSourcePath = path.join(await createTempProject(), 'vizzly'); + let agentsPath = getProjectAgentsPath(cwd); + + await mkdir(skillSourcePath, { recursive: true }); + await writeFile( + path.join(skillSourcePath, 'SKILL.md'), + '---\nname: vizzly\ndescription: Use Vizzly.\n---\n' + ); + await writeFile(agentsPath, '# Repo Guidance\n\nKeep existing notes.\n'); + + await init( + { + agentGuidance: true, + plugins: [], + }, + { + cwd: () => cwd, + output: createMockOutput(), + skillSourcePath, + } + ); + await init( + { + agentGuidance: true, + force: true, + plugins: [], + }, + { + cwd: () => cwd, + output: createMockOutput(), + skillSourcePath, + } + ); + + let agentsContent = await readFile(agentsPath, 'utf8'); + let guidanceCount = + agentsContent.split('Visual Testing With Vizzly').length - 1; + + assert.match(agentsContent, /# Repo Guidance/); + assert.match(agentsContent, /Keep existing notes/); + assert.strictEqual(guidanceCount, 1); + }); + + it('can add AGENTS.md guidance when config already exists', async () => { + let cwd = await createTempProject(); + let configPath = getInitConfigPath(cwd); + let skillSourcePath = path.join(await createTempProject(), 'vizzly'); + let output = createMockOutput(); + + await writeFile(configPath, 'export default { existing: true };\n'); + await mkdir(skillSourcePath, { recursive: true }); + await writeFile( + path.join(skillSourcePath, 'SKILL.md'), + '---\nname: vizzly\ndescription: Use Vizzly.\n---\n' + ); + + let result = await init( + { + agentGuidance: true, + plugins: [], + }, + { + cwd: () => cwd, + output, + skillSourcePath, + } + ); + + let config = await readFile(configPath, 'utf8'); + let agentsContent = await readFile(getProjectAgentsPath(cwd), 'utf8'); + + assert.deepStrictEqual(result, { status: 'skipped', configPath }); + assert.strictEqual(config, 'export default { existing: true };\n'); + assert.match(agentsContent, /Visual Testing With Vizzly/); + assert.ok( + output.calls.some( + call => + call.method === 'complete' && + call.args[0] === 'Created AGENTS.md with Vizzly guidance' + ) + ); + }); + + it('prompts for the agent skill in interactive init', async () => { + let cwd = await createTempProject(); + let skillSourcePath = path.join(await createTempProject(), 'vizzly'); + let prompted = false; + + await mkdir(skillSourcePath, { recursive: true }); + await writeFile( + path.join(skillSourcePath, 'SKILL.md'), + '---\nname: vizzly\ndescription: Use Vizzly.\n---\n' + ); + + await init( + { + plugins: [], + }, + { + cwd: () => cwd, + output: createMockOutput(), + skillSourcePath, + isInteractive: () => true, + promptAgentSkill: async () => { + prompted = true; + return true; + }, + } + ); + + assert.strictEqual(prompted, true); + let installedSkill = await readFile( + path.join(getProjectAgentSkillPath(cwd), 'SKILL.md'), + 'utf8' + ); + assert.match(installedSkill, /name: vizzly/); + }); + + it('does not overwrite an existing repo-local agent skill', async () => { + let cwd = await createTempProject(); + let skillSourcePath = path.join(await createTempProject(), 'vizzly'); + let targetPath = getProjectAgentSkillPath(cwd); + + await mkdir(skillSourcePath, { recursive: true }); + await mkdir(targetPath, { recursive: true }); + await writeFile( + path.join(skillSourcePath, 'SKILL.md'), + '---\nname: vizzly\ndescription: Packaged.\n---\n' + ); + await writeFile(path.join(targetPath, 'SKILL.md'), 'local skill\n'); + + let result = await installProjectAgentSkill({ + cwd, + sourcePath: skillSourcePath, + }); + let existingSkill = await readFile( + path.join(targetPath, 'SKILL.md'), + 'utf8' + ); + + assert.strictEqual(result.status, 'exists'); + assert.strictEqual(existingSkill, 'local skill\n'); + }); }); });