Skip to content
Open
22 changes: 19 additions & 3 deletions apps/cli/ai/agent.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import path from 'path';
import { query, type Query } from '@anthropic-ai/claude-agent-sdk';
import { getClaudeCodePlugins, isClaudeCodePluginProvider } from 'cli/ai/claude-code-plugins';
import {
ALLOWED_TOOLS,
STUDIO_ROOT,
Expand All @@ -9,11 +10,14 @@ import {
} from 'cli/ai/security';
import { buildSystemPrompt } from 'cli/ai/system-prompt';
import { createStudioTools } from 'cli/ai/tools';
import type { AiProviderId } from 'cli/ai/providers';

export type { AskUserQuestion } from 'cli/ai/security';

export interface AiAgentConfig {
prompt: string;
provider?: AiProviderId;
siteContext?: string;
env?: Record< string, string >;
model?: AiModelId;
maxTurns?: number;
Expand All @@ -36,7 +40,16 @@ const pathApprovalSession = createPathApprovalSession();
* Caller can iterate messages with `for await` and call `interrupt()` to stop.
*/
export function startAiAgent( config: AiAgentConfig ): Query {
const { prompt, env, model = DEFAULT_MODEL, maxTurns = 50, resume, onAskUser } = config;
const {
prompt,
provider,
siteContext,
env,
model = DEFAULT_MODEL,
maxTurns = 50,
resume,
onAskUser,
} = config;
const resolvedEnv = env ?? { ...( process.env as Record< string, string > ) };

return query( {
Expand All @@ -46,7 +59,7 @@ export function startAiAgent( config: AiAgentConfig ): Query {
systemPrompt: {
type: 'preset',
preset: 'claude_code',
append: buildSystemPrompt(),
append: buildSystemPrompt( siteContext ),
},
mcpServers: {
studio: createStudioTools(),
Expand Down Expand Up @@ -81,7 +94,10 @@ export function startAiAgent( config: AiAgentConfig ): Query {
pathApprovalSession,
} );
},
plugins: [ { type: 'local' as const, path: path.resolve( import.meta.dirname, 'plugin' ) } ],
plugins: [
{ type: 'local' as const, path: path.resolve( import.meta.dirname, 'plugin' ) },
...( isClaudeCodePluginProvider( provider ) ? getClaudeCodePlugins() : [] ),
],
model,
resume,
},
Expand Down
161 changes: 161 additions & 0 deletions apps/cli/ai/claude-code-plugins.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
import fs from 'fs';
import os from 'os';
import path from 'path';
import type { AutocompleteItem } from '@mariozechner/pi-tui';
import type { AiProviderId } from 'cli/ai/providers';

const CLAUDE_CODE_PLUGIN_PROVIDERS: AiProviderId[] = [ 'anthropic-claude', 'anthropic-api-key' ];

export function isClaudeCodePluginProvider( provider?: AiProviderId ): boolean {
return provider !== undefined && CLAUDE_CODE_PLUGIN_PROVIDERS.includes( provider );
}

interface InstalledPluginEntry {
scope: string;
installPath: string;
}

interface InstalledPluginsManifest {
version: number;
plugins: Record< string, InstalledPluginEntry[] >;
}

function readInstalledPlugins(): InstalledPluginEntry[] {
const manifestPath = path.join( os.homedir(), '.claude', 'plugins', 'installed_plugins.json' );
try {
const content = fs.readFileSync( manifestPath, 'utf8' );
const manifest: InstalledPluginsManifest = JSON.parse( content );
const entries: InstalledPluginEntry[] = [];

for ( const pluginEntries of Object.values( manifest.plugins ) ) {
for ( const entry of pluginEntries ) {
if ( entry.installPath && fs.existsSync( entry.installPath ) ) {
entries.push( entry );
}
}
}

return entries;
} catch {
return [];
}
}

export function getClaudeCodePlugins(): Array< { type: 'local'; path: string } > {
return readInstalledPlugins().map( ( entry ) => ( {
type: 'local' as const,
path: entry.installPath,
} ) );
}

function parseFrontmatter( content: string ): { name?: string; description?: string } {
const match = content.match( /^---\s*\n([\s\S]*?)\n---/ );
if ( ! match ) {
return {};
}

const frontmatter = match[ 1 ];
const nameMatch = frontmatter.match( /^name:\s*["']?([^"'\n]+)["']?\s*$/m );
const descMatch = frontmatter.match( /^description:\s*["']?([^"'\n]+)["']?\s*$/m );

return {
name: nameMatch?.[ 1 ]?.trim(),
description: descMatch?.[ 1 ]?.trim(),
};
}

function readPluginName( pluginPath: string ): string | undefined {
const pluginJsonPath = path.join( pluginPath, '.claude-plugin', 'plugin.json' );
try {
const content = fs.readFileSync( pluginJsonPath, 'utf8' );
const json = JSON.parse( content );
return typeof json.name === 'string' ? json.name : undefined;
} catch {
return undefined;
}
}

function scanSkills( pluginPath: string, pluginName: string ): AutocompleteItem[] {
const skillsDir = path.join( pluginPath, 'skills' );
const items: AutocompleteItem[] = [];

let skillDirs: string[];
try {
skillDirs = fs.readdirSync( skillsDir );
} catch {
return items;
}

for ( const skillDir of skillDirs ) {
const skillPath = path.join( skillsDir, skillDir, 'SKILL.md' );
try {
const content = fs.readFileSync( skillPath, 'utf8' );
const { name, description } = parseFrontmatter( content );
if ( name ) {
items.push( {
value: `${ pluginName }:${ name }`,
label: name,
description: `(${ pluginName }) ${ description ?? '' }`.trim(),
} );
}
} catch {
// Skip unreadable skills
}
}

return items;
}

function scanMarkdownDir(
pluginPath: string,
dirName: string,
pluginName: string
): AutocompleteItem[] {
const dir = path.join( pluginPath, dirName );
const items: AutocompleteItem[] = [];

let files: string[];
try {
files = fs.readdirSync( dir ).filter( ( f ) => f.endsWith( '.md' ) );
} catch {
return items;
}

for ( const file of files ) {
const filePath = path.join( dir, file );
try {
const content = fs.readFileSync( filePath, 'utf8' );
const { name, description } = parseFrontmatter( content );
const baseName = name ?? path.basename( file, '.md' );
items.push( {
value: `${ pluginName }:${ baseName }`,
label: `${ pluginName }:${ baseName }`,
description: description ?? '',
} );
} catch {
// Skip unreadable files
}
}

return items;
}

export function getClaudeCodeSkillCommands(): AutocompleteItem[] {
const entries = readInstalledPlugins();
const items: AutocompleteItem[] = [];

for ( const entry of entries ) {
const pluginName = readPluginName( entry.installPath );
if ( ! pluginName ) {
continue;
}

items.push(
...scanSkills( entry.installPath, pluginName ),
...scanMarkdownDir( entry.installPath, 'commands', pluginName ),
...scanMarkdownDir( entry.installPath, 'agents', pluginName )
);
}

return items;
}
3 changes: 3 additions & 0 deletions apps/cli/ai/security.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,9 @@ export const ALLOWED_TOOLS = [
'TodoRead',
'NotebookRead',
'AskUserQuestion',
'Skill',
'Agent',
'Task',
] as const;

// Tools that should not manipulate files outside trusted roots without permission (write access)
Expand Down
6 changes: 4 additions & 2 deletions apps/cli/ai/system-prompt.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
export function buildSystemPrompt(): string {
export function buildSystemPrompt( siteContext?: string ): string {
const siteSection = siteContext ? `\n## Active Site\n\n${ siteContext }\n` : '';

return `You are WordPress Studio AI, the AI assistant built into WordPress Studio CLI. Your name is "WordPress Studio AI". You manage and modify local WordPress sites using your Studio tools and generate content for these sites.

IMPORTANT: You MUST use your mcp__studio__ tools to manage WordPress sites. Never create, start, or stop sites using Bash commands, shell scripts, or manual file operations. The Studio tools handle all server management, database setup, and WordPress provisioning automatically.
Expand Down Expand Up @@ -99,6 +101,6 @@ Interpret creatively and make unexpected choices that feel genuinely designed fo
**IMPORTANT**: Match implementation complexity to the aesthetic vision. Maximalist designs need elaborate code with extensive animations and effects. Minimalist or refined designs need restraint, precision, and careful attention to spacing, typography, and subtle details. Elegance comes from executing the vision well.

Remember: You are capable of extraordinary creative work. Don't hold back, show what can truly be created when thinking outside the box and committing fully to a distinctive vision.

${ siteSection }
`;
}
Loading
Loading