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: 6 additions & 1 deletion apps/cli/ai/agent.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import path from 'path';
import { query, type Query } from '@anthropic-ai/claude-agent-sdk';
import {
ALLOWED_TOOLS,
Expand Down Expand Up @@ -61,7 +62,10 @@ export function startAiAgent( config: AiAgentConfig ): Query {
questions?: AskUserQuestion[];
answers?: Record< string, string >;
};
const questions = typedInput.questions ?? [];
const questions = ( typedInput.questions ?? [] ).map( ( q ) => ( {
...q,
allowFreeForm: true,
} ) );
const answers = await onAskUser( questions );
return {
behavior: 'allow' as const,
Expand All @@ -77,6 +81,7 @@ export function startAiAgent( config: AiAgentConfig ): Query {
pathApprovalSession,
} );
},
plugins: [ { type: 'local' as const, path: path.resolve( import.meta.dirname, 'plugin' ) } ],
model,
resume,
},
Expand Down
5 changes: 5 additions & 0 deletions apps/cli/ai/plugin/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"name": "studio",
"description": "WordPress Studio AI skills",
"version": "1.0.0"
}
71 changes: 71 additions & 0 deletions apps/cli/ai/plugin/skills/site-spec/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
---
name: site-spec
description: Gather user preferences before building a WordPress site. Asks about purpose, audience, brand personality, aesthetic direction, colors, and content structure. Run this before creating any new site.
user-invokable: true
---

# Site Spec Discovery

Before creating a new WordPress site, gather the user's preferences through a short interactive discovery phase. This produces a **Site Spec** that guides all subsequent design and development decisions.

## How to Run

Gather preferences through 3-4 rounds, building on previous answers. Keep it concise — no more than 6-7 questions total.

**AskUserQuestion constraints**: Each call supports 1-4 questions, each with 2-4 options. An "Other" free-form option is automatically provided by the system — do NOT add one yourself. Keep option labels short (1-5 words). Only use AskUserQuestion for questions that have meaningful predefined options. For open-ended questions (like asking for a name), just ask in your text output — the user will type their answer in the prompt.

### Round 1 — Name

Ask the user for their business/site name in your text output. **Stop here and wait for their reply** — do NOT call any tools or continue to the next round. The user needs a chance to type their answer in the prompt.

### Round 2 — Purpose & Audience

After the user provides the name, use AskUserQuestion for:
- What is this site for? (e.g., business type, portfolio, blog, community, e-commerce, agency, restaurant, etc.)
- Who is the target audience? (e.g., professionals, consumers, creatives, developers, local community, etc.)

### Round 3 — Brand & Aesthetic Direction

Adapt based on previous answers. Ask about:
- What tone or personality should the site convey? (e.g., professional, playful, minimalist, bold, luxurious, raw, editorial, retro, organic, etc.)
- Any reference sites or brands you admire? Or styles you want to avoid?

### Round 4 — Visual & Structural Preferences

Adapt based on previous answers. Ask about:
- Color preferences or existing brand colors? (or let the user say "surprise me")
- One-page site or multi-page site? (e.g., single scrollable page with sections vs. separate pages for each area)
- What pages/sections do you need? (e.g., homepage, about, services, blog, contact, shop, gallery, etc.) — adapt the phrasing based on the one-page vs. multi-page answer

## Synthesize the Site Spec

After gathering answers, produce a concise **Site Spec** document:

```
Site Spec: [Site Name]
- Purpose: [what the site is for]
- Audience: [who it's for]
- Tone: [personality/voice]
- Aesthetic direction: [visual style, references]
- Color palette: [colors or direction]
- Layout: [one-page or multi-page]
- Pages/Structure: [list of pages or sections and key features]
- Key differentiator: [the one thing that makes this site memorable]
```

Show the spec to the user, then use AskUserQuestion to ask for confirmation (e.g., "Ready to build this site?" with options like "Yes, let's go" / "I'd like to adjust something"). Do NOT just print a text question and wait — always use the AskUserQuestion tool for confirmation so the user gets interactive options.

## After Confirmation

1. Call `site_create` with an appropriate name derived from the spec.
2. Save the site spec to `{site_path}/.agents/site-spec.md` using the Write tool, so it persists for future sessions.
3. Use the spec to guide ALL subsequent design decisions — theme, typography, colors, layout, content tone, and page structure.

## When to Skip Discovery

Do NOT ask questions if:
- The user already provided an answer for that specific question in the initial prompt. (e.g., "build me a dark minimalist portfolio for a photographer with these 5 pages using navy and gold colors"). Instead, synthesize the spec directly from their request and confirm.
- The user says "just build something" or "surprise me". Pick a bold creative direction yourself and proceed.
- The user explicitly asks to skip the setup or says they don't want questions.

In all skip cases, still synthesize and show a Site Spec before creating the site.
1 change: 1 addition & 0 deletions apps/cli/ai/security.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import type {
export interface AskUserQuestion {
question: string;
options: { label: string; description: string }[];
allowFreeForm?: boolean;
}

export type AskUserHandler = (
Expand Down
5 changes: 3 additions & 2 deletions apps/cli/ai/system-prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,15 @@ IMPORTANT: For any generated content for the site, these three principles are ma

For any request that involves a WordPress site, you MUST first determine which site to use:

- **"Create" / "build" / "make" a site**: Call site_create with a name as your FIRST tool call. Do NOT call site_list first. Do NOT reuse or repurpose any existing site. Every new project gets a fresh site.
- **"Create" / "build" / "make" a site**: Run the \`site-spec\` skill to gather the user's preferences FIRST. Only call site_create AFTER you have a confirmed site spec. Do NOT call site_list first. Do NOT reuse or repurpose any existing site. Every new project gets a fresh site.
- **User names a specific existing site**: Call site_list to find it.
- **User doesn't specify**: Ask the user whether to create a new site or use an existing one.
- **Resuming work on an existing site**: After getting site info, check if \`{site_path}/.agents/site-spec.md\` exists. If it does, read it and use it to guide your design decisions.

Then continue with:

1. **Get site details**: Use site_info to get the site path, URL, and credentials.
2. **Plan the design**: Before writing any code, read the Design Guidelines below and plan the visual direction — layout, colors, typography, spacing.
2. **Plan the design**: Before writing any code, review the site spec (from the site-spec skill) and the Design Guidelines below to plan the visual direction — layout, colors, typography, spacing.
3. **Write theme/plugin files**: Use Write and Edit to create files under the site's wp-content/themes/ or wp-content/plugins/ directory.
4. **Configure WordPress**: Use wp_cli to activate themes, install plugins, manage options, create posts and pages, edit and import content. The site must be running. Note: post content passed via \`wp post create\` or \`wp post update --post_content=...\` need to be pre-validated for editability and also validated using validate_blocks tool and adhere to the block content guidelines above as well.
5. **Check the misuse of HTML blocks**: Verify if HTML blocks were used as sections or not. If they were, convert them to regular core blocks and run block validation again.
Expand Down
156 changes: 138 additions & 18 deletions apps/cli/ai/ui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,21 +2,22 @@ import {
TUI,
ProcessTerminal,
Editor,
Input,
SelectList,
type SelectItem,
type SelectListTheme,
Markdown,
Text,
Loader,
Container,
CombinedAutocompleteProvider,
SelectList,
matchesKey,
isKeyRelease,
type Component,
type Focusable,
type EditorTheme,
type EditorOptions,
type MarkdownTheme,
type SelectItem,
type SelectListTheme,
visibleWidth,
} from '@mariozechner/pi-tui';
import chalk from 'chalk';
Expand Down Expand Up @@ -534,10 +535,22 @@ export class AiChatUI {
private randomThinkingMessage(): string {
return this.thinkingMessages[ Math.floor( Math.random() * this.thinkingMessages.length ) ];
}
private optionPickerVisible = false;
private optionPickerContainer: Container | null = null;
private optionPickerSelectList: SelectList | null = null;
private optionPickerVisible = false;
private optionPickerResolve: ( ( label: string ) => void ) | null = null;
private optionPickerOtherActive = false;
private optionPickerHasFreeForm = false;
private optionPickerItemCount = 0;
private optionPickerInput: Input | null = null;
private static readonly OTHER_VALUE = '__other__';
private static readonly OPTION_PICKER_THEME: SelectListTheme = {
selectedPrefix: ( text: string ) => chalk.blue( text ),
selectedText: ( text: string ) => chalk.blue( text ),
description: ( text: string ) => chalk.dim( text ),
scrollInfo: ( text: string ) => chalk.dim( text ),
noMatch: ( text: string ) => chalk.dim( text ),
};
private sitePickerVisible = false;
private sitePickerContainer: Container | null = null;
private sitePickerItems: SiteInfo[] = [];
Expand Down Expand Up @@ -618,9 +631,52 @@ export class AiChatUI {
process.exit( 0 );
}
// Option picker navigation (must be checked before site picker)
if ( this.optionPickerVisible && this.optionPickerSelectList ) {
if ( this.optionPickerSelectList ) {
// When "Other" is active, let the inline input handle most keys
if ( this.optionPickerOtherActive && this.optionPickerInput ) {
if ( matchesKey( data, 'up' ) ) {
this.deactivateOptionPickerOther();
this.optionPickerSelectList.handleInput( data );
this.renderOptionPicker();
return { consume: true };
}
// Forward everything else to the inline input
this.optionPickerInput.handleInput( data );
this.renderOptionPicker();
return { consume: true };
}

// If user starts typing while on a regular option, jump to "Other" (only if free-form is enabled)
if (
this.optionPickerHasFreeForm &&
! matchesKey( data, 'up' ) &&
! matchesKey( data, 'down' ) &&
! matchesKey( data, 'enter' ) &&
! matchesKey( data, 'escape' ) &&
data.length === 1 &&
data >= ' '
) {
this.optionPickerSelectList.setSelectedIndex( this.optionPickerItemCount - 1 );
this.activateOptionPickerOther();
this.optionPickerInput?.handleInput( data );
this.renderOptionPicker();
return { consume: true };
}

// Let SelectList handle up/down/enter/escape
this.optionPickerSelectList.handleInput( data );
this.tui.requestRender();
// onSelect may have closed the picker — bail if so
if ( ! this.optionPickerSelectList ) {
return { consume: true };
}
// Check if we landed on "Other" after navigation
if ( this.optionPickerHasFreeForm ) {
const selected = this.optionPickerSelectList.getSelectedItem();
if ( selected?.value === AiChatUI.OTHER_VALUE ) {
this.activateOptionPickerOther();
}
}
this.renderOptionPicker();
return { consume: true };
}
// Slash command menu navigation
Expand Down Expand Up @@ -1103,13 +1159,61 @@ export class AiChatUI {
this.tui.requestRender();
}

private renderOptionPicker(): void {
if ( ! this.optionPickerContainer || ! this.optionPickerSelectList ) {
return;
}
this.optionPickerContainer.clear();

const width = ( process.stdout.columns ?? 80 ) - 1;
const lines = this.optionPickerSelectList.render( width );

// When "Other" is active, replace the last line with the inline input
if ( this.optionPickerOtherActive && this.optionPickerInput && lines.length > 0 ) {
const inputText = this.optionPickerInput.getValue();
const cursor = chalk.inverse( ' ' );
const display = inputText
? chalk.blue( inputText ) + cursor
: chalk.dim( 'Type your answer...' ) + cursor;
lines[ lines.length - 1 ] = `${ chalk.blue( '→' ) } ${ display }`;
}

this.optionPickerContainer.addChild( new Text( lines.join( '\n' ), 1, 0 ) );
this.tui.requestRender();
}

private activateOptionPickerOther(): void {
if ( this.optionPickerOtherActive ) {
return;
}
this.optionPickerOtherActive = true;
this.optionPickerInput = new Input();
this.optionPickerInput.onSubmit = ( value: string ) => {
const trimmed = value.trim();
if ( trimmed && this.optionPickerResolve ) {
const resolve = this.optionPickerResolve;
this.optionPickerResolve = null;
this.closeOptionPicker();
resolve( trimmed );
}
};
}

private deactivateOptionPickerOther(): void {
this.optionPickerOtherActive = false;
this.optionPickerInput = null;
}

private closeOptionPicker(): void {
if ( this.optionPickerContainer ) {
this.tui.removeChild( this.optionPickerContainer );
this.optionPickerContainer = null;
}
this.optionPickerVisible = false;
this.optionPickerSelectList = null;
this.optionPickerHasFreeForm = false;
this.optionPickerItemCount = 0;
this.deactivateOptionPickerOther();
this.tui.requestRender();
}

Expand Down Expand Up @@ -1627,29 +1731,34 @@ export class AiChatUI {

for ( const q of questions ) {
// Display the question
this.messages.addChild( new Text( '\n' + chalk.bold( q.question ), 0, 0 ) );
this.messages.addChild( new Text( '\n' + chalk.bold( q.question ), 1, 0 ) );
this.tui.requestRender();

if ( q.options.length > 0 ) {
// Use arrow-key option picker
// Use SelectList for option-based questions.
// When allowFreeForm is true, append an "Other" option with inline input.
this.hideEditor();
const selectItems = q.options.map( ( opt, i ) => ( {
const selectItems: SelectItem[] = q.options.map( ( opt, i ) => ( {
value: opt.label,
label: `${ i + 1 }. ${ opt.label }`,
description: opt.description,
} ) );
this.optionPickerSelectList = new SelectList(
this.optionPickerHasFreeForm = q.allowFreeForm === true;
if ( this.optionPickerHasFreeForm ) {
selectItems.push( {
value: AiChatUI.OTHER_VALUE,
label: 'Other (type my own)',
} );
}

this.optionPickerItemCount = selectItems.length;
const selectList = new SelectList(
selectItems,
selectItems.length,
sitePickerTheme
AiChatUI.OPTION_PICKER_THEME
);
this.optionPickerSelectList.onSelect = ( item ) => {
this.closeOptionPicker();
if ( this.optionPickerResolve ) {
this.optionPickerResolve( item.value );
this.optionPickerResolve = null;
}
};

this.optionPickerSelectList = selectList;
this.optionPickerVisible = true;
this.optionPickerContainer = new Container();
this.tui.addChild( this.optionPickerContainer );
Expand All @@ -1658,6 +1767,17 @@ export class AiChatUI {

const selected = await new Promise< string >( ( resolve ) => {
this.optionPickerResolve = resolve;
selectList.onSelect = ( item: SelectItem ) => {
if ( item.value === AiChatUI.OTHER_VALUE ) {
// "Other" selected via enter without typing — activate input
this.activateOptionPickerOther();
this.renderOptionPicker();
return;
}
this.optionPickerResolve = null;
this.closeOptionPicker();
resolve( item.value );
};
} );

answers[ q.question ] = selected;
Expand Down
11 changes: 11 additions & 0 deletions apps/cli/vite.config.dev.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,20 @@
import { defineConfig, mergeConfig } from 'vite';
import { viteStaticCopy } from 'vite-plugin-static-copy';
import { baseConfig } from './vite.config.base';

export default mergeConfig(
baseConfig,
defineConfig( {
plugins: [
viteStaticCopy( {
targets: [
{
src: 'ai/plugin',
dest: '.',
},
],
} ),
],
define: {
__IS_PACKAGED_FOR_NPM__: false,
__ENABLE_CLI_TELEMETRY__: false,
Expand Down
Loading