Files
resolutionflow/docs/superpowers/plans/2026-03-13-script-generator-phase2.md
chihlasm d4dbf44781 feat: Script Generator Phase 1+2 — backend, engine, API, frontend, template editor, parameter detector
Complete Script Generator feature including:

Backend:
- ScriptCategory, ScriptTemplate, ScriptGeneration models
- ScriptTemplateEngine with substitution, filters, sanitization
- CRUD + share API endpoints with permission checks
- Integration tests for permissions and sharing
- Migration 057 with AD User Management seed templates

Frontend — Script Library:
- Browse templates with category tabs and search
- Configure pane with parameter form and script generation
- Script preview with live substitution and copy/download
- scriptGeneratorStore Zustand store

Frontend — Template Editor:
- Full CRUD form with metadata, script body (Monaco Editor), parameters
- ParameterSchemaBuilder with visual builder + JSON toggle
- ScriptManagePage with routing and nav link

Frontend — Parameter Detector:
- Client-side PowerShell parameter detection engine
- Detects script-level param() blocks and variable assignments
- Type inference from PS type annotations and value patterns
- ParameterDetectorStepper one-by-one review UI with accept/skip

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-14 20:18:59 -04:00

1603 lines
48 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Script Generator Phase 2 — Frontend Implementation Plan
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Build the Script Library frontend — browse PowerShell templates by category, fill parameters, get a live preview, and generate + copy/download the final script.
**Architecture:** Zustand store owns all browsing/form/generation state; `ScriptLibraryPage` renders a top filter bar above a two-column layout (template list left, generator panel right); client-side `{{key}}` substitution drives the live preview; the real `POST /scripts/generate` is called only on the Generate button.
**Tech Stack:** React 19, TypeScript, Zustand, Axios (`apiClient`), Tailwind CSS v3, Lucide React, existing shared `<Input>` / `<Textarea>` components.
---
## File Structure
| File | Action | Responsibility |
|------|--------|----------------|
| `frontend/src/types/scripts.ts` | Create | All script-domain TypeScript interfaces |
| `frontend/src/types/index.ts` | Modify | Re-export `* from './scripts'` |
| `frontend/src/api/scripts.ts` | Create | `scriptsApi` object — all HTTP calls for script domain |
| `frontend/src/api/index.ts` | Modify | Re-export `{ scriptsApi }` |
| `frontend/src/store/scriptGeneratorStore.ts` | Create | Zustand store — browsing state, form state, generation output |
| `frontend/src/pages/ScriptLibraryPage.tsx` | Create | Page shell — owns `inputValue` lift, bootstraps store, two-column layout |
| `frontend/src/components/scripts/ScriptFilterBar.tsx` | Create | Category tabs + debounced search input |
| `frontend/src/components/scripts/ScriptTemplateList.tsx` | Create | Scrollable template list with loading/empty states |
| `frontend/src/components/scripts/TemplateCard.tsx` | Create | Single template card — name, complexity badge, tags, active state |
| `frontend/src/components/scripts/ScriptGeneratorPanel.tsx` | Create | Right panel — permission check, header, form, preview, action bar |
| `frontend/src/components/scripts/ScriptParameterForm.tsx` | Create | Iterates parameters, groups by `group` field |
| `frontend/src/components/scripts/ScriptParameterField.tsx` | Create | Single parameter input — all 7 field types |
| `frontend/src/components/scripts/ScriptPreview.tsx` | Create | Draft/generated preview, copy button |
| `frontend/src/components/scripts/PowerShellHighlighter.tsx` | Create | Single-pass tokenizer → coloured `<span>` elements |
| `frontend/src/router.tsx` | Modify | Add `/scripts` route |
| `frontend/src/components/layout/Sidebar.tsx` | Modify | Add "Scripts" nav item |
| `frontend/src/components/layout/AppLayout.tsx` | Modify | Add "Scripts" to mobile nav items array |
---
## Chunk 1: Foundation — Types, API Client, Zustand Store
### Task 1: Types
**Files:**
- Create: `frontend/src/types/scripts.ts`
- Modify: `frontend/src/types/index.ts`
- [ ] **Step 1: Create `frontend/src/types/scripts.ts`**
```typescript
export interface ScriptCategoryResponse {
id: string
name: string
slug: string
description: string | null
icon: string | null
sort_order: number
template_count: number
}
export interface ScriptTemplateListItem {
id: string
category_id: string
team_id: string | null
name: string
slug: string
description: string | null
tags: string[]
complexity: 'beginner' | 'intermediate' | 'advanced' // must match backend ScriptComplexity enum exactly
estimated_runtime: string | null
requires_elevation: boolean
requires_modules: string[]
is_verified: boolean
usage_count: number
}
export interface ScriptParameterOption {
value: string
label: string
}
export interface ScriptParameterValidation {
min_value?: number // matches backend field name (not 'min')
max_value?: number // matches backend field name (not 'max')
pattern?: string
min_length?: number
max_length?: number
}
export interface ScriptParameter {
key: string
label: string
type: 'text' | 'password' | 'select' | 'boolean' | 'multi_text' | 'number' | 'textarea'
required: boolean
placeholder: string | null
group: string | null
order: number
help_text: string | null
options: ScriptParameterOption[] | null // for select type
default: string | boolean | number | null
validation: ScriptParameterValidation | null
sensitive: boolean
}
export interface ScriptParametersSchema {
parameters: ScriptParameter[]
}
export interface ScriptTemplateDetail extends ScriptTemplateListItem {
use_case: string | null
script_body: string
// NOTE: backend types this as `dict` — arrives as unknown at runtime.
// Always access via cast: (detail.parameters_schema as ScriptParametersSchema).parameters ?? []
parameters_schema: ScriptParametersSchema
default_values: Record<string, unknown> // template-level metadata; not used in Phase 2
validation_rules: Record<string, unknown> // template-level metadata; not used in Phase 2
version: number
created_at: string
updated_at: string
}
export interface ScriptGenerateRequest {
template_id: string
parameters: Record<string, unknown>
session_id?: string // Phase 3: passed when generating inside a session
}
export interface ScriptGenerateResponse {
id: string // generation UUID
script: string // rendered PowerShell
warnings: string[]
metadata: {
template_name: string
template_version: number
requires_elevation: boolean
[key: string]: unknown
}
}
export interface ScriptGenerationRecord {
id: string
template_id: string
template_name: string
parameters_used: Record<string, unknown> // sensitive values already redacted by backend
created_at: string
}
```
- [ ] **Step 2: Add re-export to `frontend/src/types/index.ts`**
Add this line at the end of the existing export list (after the `kbAccelerator` export block):
```typescript
export * from './scripts'
```
- [ ] **Step 3: Verify TypeScript compiles**
Run from `frontend/`:
```bash
npm run build 2>&1 | tail -20
```
Expected: no type errors related to `scripts.ts`.
- [ ] **Step 4: Commit**
```bash
git add frontend/src/types/scripts.ts frontend/src/types/index.ts
git commit -m "feat: add script generator TypeScript types
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>"
```
---
### Task 2: API Client
**Files:**
- Create: `frontend/src/api/scripts.ts`
- Modify: `frontend/src/api/index.ts`
- [ ] **Step 1: Create `frontend/src/api/scripts.ts`**
```typescript
import apiClient from './client'
import type {
ScriptCategoryResponse,
ScriptTemplateListItem,
ScriptTemplateDetail,
ScriptGenerateRequest,
ScriptGenerateResponse,
ScriptGenerationRecord,
} from '@/types'
export const scriptsApi = {
async getCategories(): Promise<ScriptCategoryResponse[]> {
const response = await apiClient.get<ScriptCategoryResponse[]>('/scripts/categories')
return response.data
},
async getTemplates(params?: {
category_slug?: string
search?: string
tags?: string // Phase 3: comma-separated tag filter
}): Promise<ScriptTemplateListItem[]> {
const response = await apiClient.get<ScriptTemplateListItem[]>('/scripts/templates', { params })
return response.data
},
async getTemplateDetail(id: string): Promise<ScriptTemplateDetail> {
const response = await apiClient.get<ScriptTemplateDetail>(`/scripts/templates/${id}`)
return response.data
},
async generate(req: ScriptGenerateRequest): Promise<ScriptGenerateResponse> {
const response = await apiClient.post<ScriptGenerateResponse>('/scripts/generate', req)
return response.data
},
// Phase 3: fetch generation history for the current user
async getGenerations(): Promise<ScriptGenerationRecord[]> {
const response = await apiClient.get<ScriptGenerationRecord[]>('/scripts/generations')
return response.data
},
}
```
- [ ] **Step 2: Add re-export to `frontend/src/api/index.ts`**
Add after the last existing `export` line:
```typescript
export { scriptsApi } from './scripts'
```
- [ ] **Step 3: Verify build**
```bash
cd frontend && npm run build 2>&1 | tail -20
```
Expected: clean build, no errors.
- [ ] **Step 4: Commit**
```bash
git add frontend/src/api/scripts.ts frontend/src/api/index.ts
git commit -m "feat: add scriptsApi client
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>"
```
---
### Task 3: Zustand Store
**Files:**
- Create: `frontend/src/store/scriptGeneratorStore.ts`
- [ ] **Step 1: Create `frontend/src/store/scriptGeneratorStore.ts`**
```typescript
import { create } from 'zustand'
import { scriptsApi } from '@/api'
import type {
ScriptCategoryResponse,
ScriptTemplateListItem,
ScriptTemplateDetail,
ScriptParametersSchema,
} from '@/types'
interface ScriptGeneratorState {
// Template browsing
categories: ScriptCategoryResponse[]
templates: ScriptTemplateListItem[]
selectedTemplate: ScriptTemplateDetail | null
searchQuery: string
activeCategoryId: string | null // null = "All"
isLoadingTemplates: boolean // drives skeleton in ScriptTemplateList
isLoadingDetail: boolean // drives spinner in ScriptGeneratorPanel
// Form
paramValues: Record<string, string> // keyed by ScriptParameter.key; booleans as 'true'/'false'
formErrors: Record<string, string> // keyed by ScriptParameter.key
// Output
generatedScript: string | null
generationId: string | null
generationWarnings: string[]
isGenerating: boolean
generateError: string | null
// Actions
loadCategories: () => Promise<void>
loadTemplates: () => Promise<void>
selectTemplate: (id: string) => Promise<void>
setCategory: (id: string | null) => void
setSearch: (query: string) => void
setParamValue: (key: string, value: string) => void
validate: () => boolean
generate: (sessionId?: string) => Promise<void>
clearOutput: () => void
reset: () => void
}
export const useScriptGeneratorStore = create<ScriptGeneratorState>()((set, get) => ({
// Initial state
categories: [],
templates: [],
selectedTemplate: null,
searchQuery: '',
activeCategoryId: null,
isLoadingTemplates: false,
isLoadingDetail: false,
paramValues: {},
formErrors: {},
generatedScript: null,
generationId: null,
generationWarnings: [],
isGenerating: false,
generateError: null,
loadCategories: async () => {
const categories = await scriptsApi.getCategories()
set({ categories })
},
loadTemplates: async () => {
set({ isLoadingTemplates: true })
const { activeCategoryId, categories, searchQuery } = get()
const category = categories.find(c => c.id === activeCategoryId)
const params: { category_slug?: string; search?: string } = {}
if (category) params.category_slug = category.slug
if (searchQuery) params.search = searchQuery
const templates = await scriptsApi.getTemplates(params)
set({ templates, isLoadingTemplates: false })
},
selectTemplate: async (id: string) => {
set({ isLoadingDetail: true })
const detail = await scriptsApi.getTemplateDetail(id)
// Populate paramValues from parameter defaults
const schema = detail.parameters_schema as ScriptParametersSchema
const parameters = schema?.parameters ?? []
const paramValues: Record<string, string> = {}
for (const param of parameters) {
const d = param.default
if (d === null || d === undefined) paramValues[param.key] = ''
else if (typeof d === 'boolean') paramValues[param.key] = d ? 'true' : 'false'
else paramValues[param.key] = String(d)
}
set({
selectedTemplate: detail,
paramValues,
formErrors: {},
generatedScript: null,
generationId: null,
generationWarnings: [],
generateError: null,
isLoadingDetail: false,
})
},
setCategory: (id: string | null) => {
set({ activeCategoryId: id })
get().loadTemplates()
},
setSearch: (query: string) => {
set({ searchQuery: query })
get().loadTemplates()
},
setParamValue: (key: string, value: string) => {
set(state => ({
paramValues: { ...state.paramValues, [key]: value },
formErrors: { ...state.formErrors, [key]: '' }, // clear error on change
}))
},
validate: () => {
const { selectedTemplate, paramValues } = get()
if (!selectedTemplate) return true
const schema = selectedTemplate.parameters_schema as ScriptParametersSchema
const parameters = schema?.parameters ?? []
const errors: Record<string, string> = {}
for (const param of parameters) {
if (param.required && !paramValues[param.key]) {
errors[param.key] = `${param.label} is required`
}
}
set({ formErrors: errors })
return Object.keys(errors).length === 0
},
generate: async (sessionId?: string) => {
const { selectedTemplate, paramValues } = get()
if (!selectedTemplate) return
if (!get().validate()) return
set({ isGenerating: true, generateError: null })
try {
const response = await scriptsApi.generate({
template_id: selectedTemplate.id,
parameters: paramValues,
...(sessionId ? { session_id: sessionId } : {}),
})
set({
generatedScript: response.script,
generationId: response.id,
generationWarnings: response.warnings,
isGenerating: false,
})
} catch (error: unknown) {
const axiosErr = error as { response?: { data?: { detail?: string } } }
const message = axiosErr.response?.data?.detail ?? 'Failed to generate script'
set({ generateError: message, isGenerating: false })
}
},
clearOutput: () => {
set({
generatedScript: null,
generationId: null,
generationWarnings: [],
generateError: null,
})
},
// Exposed for Phase 3 callers (session execution context).
// Does NOT clear selectedTemplate, categories, templates, or browsing state.
reset: () => {
set({
paramValues: {},
formErrors: {},
generatedScript: null,
generationId: null,
generationWarnings: [],
generateError: null,
})
},
}))
```
- [ ] **Step 2: Verify TypeScript compiles**
```bash
cd frontend && npm run build 2>&1 | tail -20
```
Expected: no errors.
- [ ] **Step 3: Commit**
```bash
git add frontend/src/store/scriptGeneratorStore.ts
git commit -m "feat: add scriptGeneratorStore Zustand store
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>"
```
---
## Chunk 2: UI — Components, Page, Routing, Navigation
### Task 4: PowerShellHighlighter
**Files:**
- Create: `frontend/src/components/scripts/PowerShellHighlighter.tsx`
- [ ] **Step 1: Create `frontend/src/components/scripts/PowerShellHighlighter.tsx`**
```tsx
/**
* Single-pass PowerShell syntax highlighter.
*
* Uses a combined alternation regex so tokens matched earlier in the list
* cannot be re-coloured by later rules (e.g. a variable inside a string
* is captured by the string rule and won't be re-matched by the variable rule).
*
* Priority order:
* 1. Comments /#[^\r\n]star/
* 2. String literals /"[^"]*"|'[^']*'/
* 3. Unfilled placeholders /\{\{[^}]+\}\}/
* 4. Variables /\$\w+/
* 5. Cmdlets /[A-Z][a-z]+-[A-Z][a-zA-Z]+/
* 6. Parameters /-[A-Za-z]+/
* 7. Keywords /\b(if|else|...)\b/
*
* Note: variables (priority 4) consume $foreach before keywords (priority 7)
* can match — this is intentional PowerShell behaviour.
*/
const TOKEN_REGEX = new RegExp(
[
/#[^\r\n]*/, // 1. comments
/"[^"]*"|'[^']*'/, // 2. string literals
/\{\{[^}]+\}\}/, // 3. unfilled placeholders
/\$\w+/, // 4. variables
/[A-Z][a-z]+-[A-Z][a-zA-Z]+/, // 5. cmdlets (Verb-Noun)
/-[A-Za-z]+/, // 6. parameters
/\b(?:if|else|elseif|foreach|for|while|function|return|try|catch|finally|param|switch)\b/, // 7. keywords
]
.map(r => r.source)
.join('|'),
'g'
)
const TOKEN_CLASSES: Record<string, string> = {
comment: 'text-[#8b949e]',
string: 'text-[#a5d6ff]',
placeholder: 'text-amber-400 underline decoration-dashed',
variable: 'text-[#79c0ff]',
cmdlet: 'text-[#22d3ee]',
parameter: 'text-[#d2a8ff]',
keyword: 'text-[#ff7b72]',
}
const KEYWORDS = new Set([
'if', 'else', 'elseif', 'foreach', 'for', 'while',
'function', 'return', 'try', 'catch', 'finally', 'param', 'switch',
])
function classify(token: string): string {
if (token.startsWith('#')) return 'comment'
if (token.startsWith('"') || token.startsWith("'")) return 'string'
if (token.startsWith('{{')) return 'placeholder'
if (token.startsWith('$')) return 'variable'
if (/^-[A-Za-z]+$/.test(token)) return 'parameter'
if (KEYWORDS.has(token)) return 'keyword'
return 'cmdlet'
}
interface Props {
script: string
}
export function PowerShellHighlighter({ script }: Props) {
const parts: React.ReactNode[] = []
let lastIndex = 0
TOKEN_REGEX.lastIndex = 0
let match: RegExpExecArray | null
while ((match = TOKEN_REGEX.exec(script)) !== null) {
if (match.index > lastIndex) {
parts.push(script.slice(lastIndex, match.index))
}
const token = match[0]
const kind = classify(token)
parts.push(
<span key={match.index} className={TOKEN_CLASSES[kind]}>
{token}
</span>
)
lastIndex = match.index + token.length
}
if (lastIndex < script.length) {
parts.push(script.slice(lastIndex))
}
return (
<pre className="font-label text-sm bg-card rounded-xl p-4 overflow-x-auto">
<code>{parts}</code>
</pre>
)
}
```
- [ ] **Step 2: Verify build**
```bash
cd frontend && npm run build 2>&1 | tail -10
```
Expected: no errors.
- [ ] **Step 3: Commit**
```bash
git add frontend/src/components/scripts/PowerShellHighlighter.tsx
git commit -m "feat: add PowerShellHighlighter syntax highlighter
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>"
```
---
### Task 5: ScriptPreview
**Files:**
- Create: `frontend/src/components/scripts/ScriptPreview.tsx`
- [ ] **Step 1: Create `frontend/src/components/scripts/ScriptPreview.tsx`**
```tsx
import { useState } from 'react'
import { Copy, Check } from 'lucide-react'
import { useScriptGeneratorStore } from '@/store/scriptGeneratorStore'
import { PowerShellHighlighter } from './PowerShellHighlighter'
import type { ScriptParametersSchema } from '@/types'
export function ScriptPreview() {
const selectedTemplate = useScriptGeneratorStore(s => s.selectedTemplate)
const paramValues = useScriptGeneratorStore(s => s.paramValues)
const generatedScript = useScriptGeneratorStore(s => s.generatedScript)
const [copied, setCopied] = useState(false)
if (!selectedTemplate) return null
// Compute the displayed script
let displayScript: string
if (generatedScript !== null) {
displayScript = generatedScript
} else {
// Draft mode: client-side {{key}} substitution
const schema = selectedTemplate.parameters_schema as ScriptParametersSchema
const parameters = schema?.parameters ?? []
displayScript = selectedTemplate.script_body
for (const param of parameters) {
const placeholder = `{{${param.key}}}`
const replacement = param.sensitive
? '****'
: (paramValues[param.key] ?? '')
displayScript = displayScript.replaceAll(placeholder, replacement || placeholder)
}
}
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(displayScript)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
} catch {
// silently fail — no error displayed
}
}
return (
<div className="relative">
<button
onClick={handleCopy}
className="absolute top-3 right-3 z-10 p-1.5 rounded-md text-muted-foreground hover:text-foreground hover:bg-white/5 transition-colors"
title={copied ? 'Copied!' : 'Copy to clipboard'}
aria-label={copied ? 'Copied!' : 'Copy to clipboard'}
>
{copied ? <Check size={14} className="text-emerald-400" /> : <Copy size={14} />}
</button>
<PowerShellHighlighter script={displayScript} />
</div>
)
}
```
- [ ] **Step 2: Verify build**
```bash
cd frontend && npm run build 2>&1 | tail -10
```
Expected: no errors.
- [ ] **Step 3: Commit**
```bash
git add frontend/src/components/scripts/ScriptPreview.tsx
git commit -m "feat: add ScriptPreview with live substitution and copy
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>"
```
---
### Task 6: ScriptParameterField
**Files:**
- Create: `frontend/src/components/scripts/ScriptParameterField.tsx`
- [ ] **Step 1: Create `frontend/src/components/scripts/ScriptParameterField.tsx`**
```tsx
import { useState } from 'react'
import { Eye, EyeOff } from 'lucide-react'
import { Input } from '@/components/ui/Input'
import { Textarea } from '@/components/ui/Textarea'
import { useScriptGeneratorStore } from '@/store/scriptGeneratorStore'
import type { ScriptParameter } from '@/types'
interface Props {
param: ScriptParameter
value: string
error: string | undefined
disabled: boolean
}
export function ScriptParameterField({ param, value, error, disabled }: Props) {
const setParamValue = useScriptGeneratorStore(s => s.setParamValue)
const [showPassword, setShowPassword] = useState(false)
const id = `param-${param.key}`
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => {
setParamValue(param.key, e.target.value)
}
const handleCheckbox = (e: React.ChangeEvent<HTMLInputElement>) => {
setParamValue(param.key, e.target.checked ? 'true' : 'false')
}
let input: React.ReactNode
if (param.type === 'text' || param.type === 'multi_text' || param.type === 'number') {
input = (
<Input
id={id}
type={param.type === 'number' ? 'number' : 'text'}
value={value}
onChange={handleChange}
placeholder={
param.type === 'multi_text'
? 'Comma-separated values'
: (param.placeholder ?? undefined)
}
disabled={disabled}
error={error}
/>
)
} else if (param.type === 'password') {
input = (
<div className="relative">
<Input
id={id}
type={showPassword ? 'text' : 'password'}
value={value}
onChange={handleChange}
placeholder={param.placeholder ?? undefined}
disabled={disabled}
error={error}
/>
<button
type="button"
onClick={() => setShowPassword(v => !v)}
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
tabIndex={-1}
aria-label={showPassword ? 'Hide password' : 'Show password'}
>
{showPassword ? <EyeOff size={14} /> : <Eye size={14} />}
</button>
</div>
)
} else if (param.type === 'textarea') {
input = (
<Textarea
id={id}
value={value}
onChange={handleChange}
placeholder={param.placeholder ?? undefined}
disabled={disabled}
error={error}
rows={4}
/>
)
} else if (param.type === 'select') {
input = (
<>
<select
id={id}
value={value}
onChange={handleChange}
disabled={disabled}
className="w-full rounded-[10px] border border-border bg-card text-foreground px-3 py-2 text-sm focus:outline-none focus:border-[rgba(6,182,212,0.3)] disabled:cursor-not-allowed disabled:opacity-50"
>
<option value="">Select</option>
{(param.options ?? []).map(opt => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
{error && <p className="mt-1.5 text-xs text-red-400">{error}</p>}
</>
)
} else if (param.type === 'boolean') {
input = (
<>
<div className="flex items-center gap-2">
<input
id={id}
type="checkbox"
checked={value === 'true'}
onChange={handleCheckbox}
disabled={disabled}
className="rounded border-border disabled:cursor-not-allowed disabled:opacity-50"
/>
<label htmlFor={id} className="text-sm text-foreground">
{param.label}
</label>
</div>
{error && <p className="mt-1.5 text-xs text-red-400">{error}</p>}
</>
)
} else {
// Fallback for unknown types
input = (
<Input
id={id}
value={value}
onChange={handleChange}
disabled={disabled}
error={error}
/>
)
}
// Boolean renders its own label inline; all others show the label above
const showTopLabel = param.type !== 'boolean'
return (
<div className="flex flex-col gap-1">
{showTopLabel && (
<label htmlFor={id} className="text-sm font-medium text-foreground">
{param.label}
{param.required && <span className="text-red-400 ml-0.5">*</span>}
</label>
)}
{input}
{param.help_text && (
<p className="text-xs text-muted-foreground mt-1">{param.help_text}</p>
)}
</div>
)
}
```
- [ ] **Step 2: Verify build**
```bash
cd frontend && npm run build 2>&1 | tail -10
```
Expected: no errors.
- [ ] **Step 3: Commit**
```bash
git add frontend/src/components/scripts/ScriptParameterField.tsx
git commit -m "feat: add ScriptParameterField — all 7 field types
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>"
```
---
### Task 7: ScriptParameterForm
**Files:**
- Create: `frontend/src/components/scripts/ScriptParameterForm.tsx`
- [ ] **Step 1: Create `frontend/src/components/scripts/ScriptParameterForm.tsx`**
```tsx
import { useScriptGeneratorStore } from '@/store/scriptGeneratorStore'
import { ScriptParameterField } from './ScriptParameterField'
import type { ScriptParametersSchema, ScriptParameter } from '@/types'
interface Props {
canGenerate: boolean
}
export function ScriptParameterForm({ canGenerate }: Props) {
const selectedTemplate = useScriptGeneratorStore(s => s.selectedTemplate)
const paramValues = useScriptGeneratorStore(s => s.paramValues)
const formErrors = useScriptGeneratorStore(s => s.formErrors)
if (!selectedTemplate) return null
const schema = selectedTemplate.parameters_schema as ScriptParametersSchema
const parameters = (schema?.parameters ?? []).slice().sort((a, b) => a.order - b.order)
// Group parameters: null-group first, then named groups in order of first appearance
const ungrouped = parameters.filter(p => p.group === null)
const groupOrder: string[] = []
const grouped: Record<string, ScriptParameter[]> = {}
for (const p of parameters) {
if (p.group !== null) {
if (!grouped[p.group]) {
grouped[p.group] = []
groupOrder.push(p.group)
}
grouped[p.group].push(p)
}
}
const renderParam = (param: ScriptParameter) => (
<ScriptParameterField
key={param.key}
param={param}
value={paramValues[param.key] ?? ''}
error={formErrors[param.key] || undefined}
disabled={!canGenerate}
/>
)
return (
<div className="flex flex-col gap-4">
{ungrouped.map(renderParam)}
{groupOrder.map(group => (
<div key={group}>
<p className="font-label text-[0.625rem] uppercase tracking-[0.1em] text-muted-foreground mb-3">
{group}
</p>
<div className="flex flex-col gap-4">
{grouped[group].map(renderParam)}
</div>
</div>
))}
</div>
)
}
```
- [ ] **Step 2: Verify build**
```bash
cd frontend && npm run build 2>&1 | tail -10
```
Expected: no errors.
- [ ] **Step 3: Commit**
```bash
git add frontend/src/components/scripts/ScriptParameterForm.tsx
git commit -m "feat: add ScriptParameterForm with parameter grouping
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>"
```
---
### Task 8: TemplateCard
**Files:**
- Create: `frontend/src/components/scripts/TemplateCard.tsx`
- [ ] **Step 1: Create `frontend/src/components/scripts/TemplateCard.tsx`**
```tsx
import { ShieldAlert } from 'lucide-react'
import { cn } from '@/lib/utils'
import { useScriptGeneratorStore } from '@/store/scriptGeneratorStore'
import type { ScriptTemplateListItem } from '@/types'
const COMPLEXITY_CLASSES: Record<ScriptTemplateListItem['complexity'], string> = {
beginner: 'text-emerald-400 bg-emerald-400/10',
intermediate: 'text-amber-400 bg-amber-400/10',
advanced: 'text-rose-500 bg-rose-500/10',
}
interface Props {
template: ScriptTemplateListItem
}
export function TemplateCard({ template }: Props) {
const selectedTemplate = useScriptGeneratorStore(s => s.selectedTemplate)
const selectTemplate = useScriptGeneratorStore(s => s.selectTemplate)
const isActive = selectedTemplate?.id === template.id
return (
<button
type="button"
onClick={() => selectTemplate(template.id)}
className={cn(
'w-full text-left px-4 py-3 rounded-xl border transition-all',
'hover:border-white/12 hover:bg-white/4',
isActive
? 'bg-primary/10 border-primary/30 border-l-[3px] border-l-primary'
: 'border-border bg-transparent'
)}
>
<div className="flex items-start justify-between gap-2 mb-1">
<span className="text-sm font-medium text-foreground line-clamp-1">
{template.name}
</span>
<div className="flex items-center gap-1.5 shrink-0">
{template.requires_elevation && (
<span title="Requires administrator elevation">
<ShieldAlert size={13} className="text-amber-400" />
</span>
)}
<span className={cn('font-label text-[0.625rem] uppercase tracking-wide px-1.5 py-0.5 rounded', COMPLEXITY_CLASSES[template.complexity])}>
{template.complexity}
</span>
</div>
</div>
{template.description && (
<p className="text-xs text-muted-foreground line-clamp-2 mb-2">
{template.description}
</p>
)}
<div className="flex items-center gap-3 text-[0.625rem] text-muted-foreground font-label">
<span>{template.usage_count}× used</span>
{template.tags.length > 0 && (
<div className="flex gap-1 flex-wrap">
{template.tags.slice(0, 3).map(tag => (
<span key={tag} className="bg-white/5 border border-border rounded px-1.5 py-0.5">
{tag}
</span>
))}
{template.tags.length > 3 && (
<span className="text-muted-foreground">+{template.tags.length - 3}</span>
)}
</div>
)}
</div>
</button>
)
}
```
- [ ] **Step 2: Verify build**
```bash
cd frontend && npm run build 2>&1 | tail -10
```
Expected: no errors.
- [ ] **Step 3: Commit**
```bash
git add frontend/src/components/scripts/TemplateCard.tsx
git commit -m "feat: add TemplateCard component
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>"
```
---
### Task 9: ScriptTemplateList
**Files:**
- Create: `frontend/src/components/scripts/ScriptTemplateList.tsx`
- [ ] **Step 1: Create `frontend/src/components/scripts/ScriptTemplateList.tsx`**
```tsx
import { FileCode, Search } from 'lucide-react'
import { useScriptGeneratorStore } from '@/store/scriptGeneratorStore'
import { TemplateCard } from './TemplateCard'
interface Props {
inputValue: string
onClearSearch: () => void
}
function TemplateSkeleton() {
return (
<div className="px-4 py-3 rounded-xl border border-border animate-pulse">
<div className="flex justify-between mb-2">
<div className="h-4 w-2/3 bg-white/8 rounded" />
<div className="h-4 w-14 bg-white/8 rounded" />
</div>
<div className="h-3 w-full bg-white/5 rounded mb-1" />
<div className="h-3 w-3/4 bg-white/5 rounded" />
</div>
)
}
export function ScriptTemplateList({ inputValue, onClearSearch }: Props) {
const templates = useScriptGeneratorStore(s => s.templates)
const isLoadingTemplates = useScriptGeneratorStore(s => s.isLoadingTemplates)
if (isLoadingTemplates) {
return (
<div className="flex flex-col gap-2 p-2">
<TemplateSkeleton />
<TemplateSkeleton />
<TemplateSkeleton />
</div>
)
}
if (templates.length === 0) {
if (inputValue !== '') {
return (
<div className="flex flex-col items-center justify-center gap-3 py-12 text-center px-4">
<Search size={32} className="text-muted-foreground/40" />
<p className="text-sm text-muted-foreground">No templates match your search</p>
<button
type="button"
onClick={onClearSearch}
className="text-xs text-primary hover:underline"
>
Clear search
</button>
</div>
)
}
return (
<div className="flex flex-col items-center justify-center gap-3 py-12 text-center px-4">
<FileCode size={32} className="text-muted-foreground/40" />
<p className="text-sm text-muted-foreground">No templates found</p>
</div>
)
}
return (
<div className="flex flex-col gap-2 p-2">
{templates.map(template => (
<TemplateCard key={template.id} template={template} />
))}
</div>
)
}
```
- [ ] **Step 2: Verify build**
```bash
cd frontend && npm run build 2>&1 | tail -10
```
Expected: no errors.
- [ ] **Step 3: Commit**
```bash
git add frontend/src/components/scripts/ScriptTemplateList.tsx
git commit -m "feat: add ScriptTemplateList with skeleton and empty states
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>"
```
---
### Task 10: ScriptFilterBar
**Files:**
- Create: `frontend/src/components/scripts/ScriptFilterBar.tsx`
- [ ] **Step 1: Create `frontend/src/components/scripts/ScriptFilterBar.tsx`**
```tsx
import { useEffect, useRef } from 'react'
import { Search } from 'lucide-react'
import { cn } from '@/lib/utils'
import { useScriptGeneratorStore } from '@/store/scriptGeneratorStore'
interface Props {
inputValue: string
setInputValue: (value: string) => void
}
export function ScriptFilterBar({ inputValue, setInputValue }: Props) {
const categories = useScriptGeneratorStore(s => s.categories)
const activeCategoryId = useScriptGeneratorStore(s => s.activeCategoryId)
const setCategory = useScriptGeneratorStore(s => s.setCategory)
const setSearch = useScriptGeneratorStore(s => s.setSearch)
// Debounce: 300ms after the input value settles, push to store
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
useEffect(() => {
if (debounceRef.current) clearTimeout(debounceRef.current)
debounceRef.current = setTimeout(() => {
setSearch(inputValue)
}, 300)
return () => {
if (debounceRef.current) clearTimeout(debounceRef.current)
}
}, [inputValue, setSearch])
return (
<div className="flex items-center gap-3 flex-wrap">
{/* Category pills */}
<div className="flex items-center gap-1.5 flex-wrap">
<button
type="button"
onClick={() => setCategory(null)}
className={cn(
'font-label text-xs px-3 py-1.5 rounded-full border transition-all',
activeCategoryId === null
? 'bg-primary/10 border-primary/30 text-foreground border-l-[3px] border-l-primary'
: 'border-border text-muted-foreground hover:border-white/12 hover:text-foreground'
)}
>
All
</button>
{categories.map(cat => (
<button
key={cat.id}
type="button"
onClick={() => setCategory(cat.id)}
className={cn(
'font-label text-xs px-3 py-1.5 rounded-full border transition-all',
activeCategoryId === cat.id
? 'bg-primary/10 border-primary/30 text-foreground border-l-[3px] border-l-primary'
: 'border-border text-muted-foreground hover:border-white/12 hover:text-foreground'
)}
>
{cat.name}
</button>
))}
</div>
{/* Search input */}
<div className="relative ml-auto">
<Search size={14} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-muted-foreground pointer-events-none" />
<input
type="text"
value={inputValue}
onChange={e => setInputValue(e.target.value)}
placeholder="Search templates…"
className="pl-8 pr-3 py-1.5 text-sm rounded-md border border-border bg-card text-foreground placeholder:text-muted-foreground focus:outline-none focus:border-[rgba(6,182,212,0.3)] focus:ring-1 focus:ring-[rgba(6,182,212,0.2)] w-52"
/>
</div>
</div>
)
}
```
- [ ] **Step 2: Verify build**
```bash
cd frontend && npm run build 2>&1 | tail -10
```
Expected: no errors.
- [ ] **Step 3: Commit**
```bash
git add frontend/src/components/scripts/ScriptFilterBar.tsx
git commit -m "feat: add ScriptFilterBar with category tabs and debounced search
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>"
```
---
### Task 11: ScriptGeneratorPanel
**Files:**
- Create: `frontend/src/components/scripts/ScriptGeneratorPanel.tsx`
- [ ] **Step 1: Create `frontend/src/components/scripts/ScriptGeneratorPanel.tsx`**
```tsx
import { Terminal, Download, Loader2, AlertTriangle } from 'lucide-react'
import { useScriptGeneratorStore } from '@/store/scriptGeneratorStore'
import { usePermissions } from '@/hooks/usePermissions'
import { ScriptParameterForm } from './ScriptParameterForm'
import { ScriptPreview } from './ScriptPreview'
export function ScriptGeneratorPanel() {
const selectedTemplate = useScriptGeneratorStore(s => s.selectedTemplate)
const isLoadingDetail = useScriptGeneratorStore(s => s.isLoadingDetail)
const generatedScript = useScriptGeneratorStore(s => s.generatedScript)
const generationWarnings = useScriptGeneratorStore(s => s.generationWarnings)
const isGenerating = useScriptGeneratorStore(s => s.isGenerating)
const generateError = useScriptGeneratorStore(s => s.generateError)
const generate = useScriptGeneratorStore(s => s.generate)
const { isEngineer } = usePermissions()
const canGenerate = isEngineer
// No template selected
if (!selectedTemplate && !isLoadingDetail) {
return (
<div className="glass-card-static h-full flex flex-col items-center justify-center gap-3 text-center p-8">
<Terminal size={40} className="text-muted-foreground/40" />
<p className="text-sm text-muted-foreground">Select a template to get started</p>
</div>
)
}
// Loading template detail
if (isLoadingDetail) {
return (
<div className="glass-card-static h-full flex items-center justify-center">
<Loader2 size={28} className="text-primary animate-spin" />
</div>
)
}
if (!selectedTemplate) return null
const handleDownload = () => {
if (!generatedScript) return
const blob = new Blob([generatedScript], { type: 'text/plain' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `${selectedTemplate.slug}.ps1`
a.click()
URL.revokeObjectURL(url)
}
return (
<div className="glass-card-static h-full flex flex-col gap-4 p-4 overflow-y-auto">
{/* Header */}
<div>
<h2 className="text-base font-semibold text-foreground">{selectedTemplate.name}</h2>
{selectedTemplate.description && (
<p className="text-sm text-muted-foreground mt-0.5">{selectedTemplate.description}</p>
)}
</div>
{/* Parameter form */}
<ScriptParameterForm canGenerate={canGenerate} />
{/* Warnings */}
{generationWarnings.length > 0 && (
<div className="flex flex-col gap-1 rounded-lg border border-amber-400/20 bg-amber-400/5 p-3">
<div className="flex items-center gap-1.5 text-amber-400 text-xs font-medium mb-1">
<AlertTriangle size={13} />
Warnings
</div>
{generationWarnings.map((w, i) => (
<p key={i} className="text-xs text-amber-400/80">{w}</p>
))}
</div>
)}
{/* Preview */}
<ScriptPreview />
{/* Action bar */}
<div className="flex items-center gap-2 pt-1">
<span title={!canGenerate ? 'Engineer access required' : undefined}>
<button
type="button"
onClick={() => generate()}
disabled={isGenerating || !canGenerate}
className="flex items-center gap-1.5 bg-gradient-brand text-[#101114] font-semibold text-sm px-4 py-2 rounded-[10px] hover:opacity-90 active:scale-[0.97] transition-all shadow-lg shadow-primary/20 disabled:opacity-50 disabled:cursor-not-allowed disabled:active:scale-100"
>
{isGenerating && <Loader2 size={14} className="animate-spin" />}
Generate
</button>
</span>
<span title={!canGenerate ? 'Engineer access required' : undefined}>
<button
type="button"
onClick={handleDownload}
disabled={!generatedScript || !canGenerate}
className="flex items-center gap-1.5 bg-white/4 border border-border text-foreground text-sm px-4 py-2 rounded-[10px] hover:border-white/12 transition-all disabled:opacity-50 disabled:cursor-not-allowed"
>
<Download size={14} />
Download .ps1
</button>
</span>
</div>
{/* Generate error */}
{generateError && (
<p className="text-xs text-rose-500">{generateError}</p>
)}
</div>
)
}
```
- [ ] **Step 2: Verify build**
```bash
cd frontend && npm run build 2>&1 | tail -10
```
Expected: no errors.
- [ ] **Step 3: Commit**
```bash
git add frontend/src/components/scripts/ScriptGeneratorPanel.tsx
git commit -m "feat: add ScriptGeneratorPanel with permission gating
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>"
```
---
### Task 12: ScriptLibraryPage
**Files:**
- Create: `frontend/src/pages/ScriptLibraryPage.tsx`
- [ ] **Step 1: Create `frontend/src/pages/ScriptLibraryPage.tsx`**
```tsx
import { useState, useEffect } from 'react'
import { useScriptGeneratorStore } from '@/store/scriptGeneratorStore'
import { ScriptFilterBar } from '@/components/scripts/ScriptFilterBar'
import { ScriptTemplateList } from '@/components/scripts/ScriptTemplateList'
import { ScriptGeneratorPanel } from '@/components/scripts/ScriptGeneratorPanel'
export default function ScriptLibraryPage() {
// inputValue is owned here so ScriptFilterBar and ScriptTemplateList
// can coordinate clear-search without direct coupling.
const [inputValue, setInputValue] = useState('')
const loadCategories = useScriptGeneratorStore(s => s.loadCategories)
const loadTemplates = useScriptGeneratorStore(s => s.loadTemplates)
const setSearch = useScriptGeneratorStore(s => s.setSearch)
useEffect(() => {
// loadCategories must complete before loadTemplates can resolve slugs
loadCategories().then(() => loadTemplates())
}, [loadCategories, loadTemplates])
const onClearSearch = () => {
setInputValue('')
setSearch('')
}
return (
<div className="flex flex-col gap-4 p-6 h-full">
{/* Page header */}
<div>
<h1 className="text-2xl font-heading font-bold text-foreground">Script Library</h1>
<p className="text-sm text-muted-foreground mt-1">
Browse PowerShell templates, fill in parameters, and generate ready-to-run scripts.
</p>
</div>
{/* Filter bar */}
<ScriptFilterBar inputValue={inputValue} setInputValue={setInputValue} />
{/* Two-column layout */}
<div className="grid grid-cols-[320px_1fr] gap-4 flex-1 min-h-0">
{/* Template list — scrollable */}
<div className="glass-card-static overflow-y-auto">
<ScriptTemplateList inputValue={inputValue} onClearSearch={onClearSearch} />
</div>
{/* Generator panel */}
<ScriptGeneratorPanel />
</div>
</div>
)
}
```
- [ ] **Step 2: Verify build**
```bash
cd frontend && npm run build 2>&1 | tail -10
```
Expected: no errors.
- [ ] **Step 3: Commit**
```bash
git add frontend/src/pages/ScriptLibraryPage.tsx
git commit -m "feat: add ScriptLibraryPage shell
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>"
```
---
### Task 13: Routing and Navigation
**Files:**
- Modify: `frontend/src/router.tsx`
- Modify: `frontend/src/components/layout/Sidebar.tsx`
- Modify: `frontend/src/components/layout/AppLayout.tsx`
- [ ] **Step 1: Add lazy import to `frontend/src/router.tsx`**
After the existing `StepLibraryPage` import (around line 43), add:
```typescript
const ScriptLibraryPage = lazy(() => import('@/pages/ScriptLibraryPage'))
```
- [ ] **Step 2: Add route to `frontend/src/router.tsx`**
In the protected/AppLayout children array, after the `step-library` route (around line 162), add:
```typescript
{ path: 'scripts', element: page(ScriptLibraryPage) },
```
- [ ] **Step 3: Add nav item to `frontend/src/components/layout/Sidebar.tsx`**
Add `Terminal` to the lucide import line at the top of the file.
The component renders nav items in two places. Search for `href="/step-library"` — it appears twice. After each occurrence, add the Scripts entry:
Collapsed section — find this line and add immediately after:
```tsx
// find:
<NavItem href="/step-library" icon={Bookmark} label="Step Library" collapsed />
// add after:
<NavItem href="/scripts" icon={Terminal} label="Script Library" collapsed />
```
Expanded section — find this line and add immediately after:
```tsx
// find:
<NavItem href="/step-library" icon={Bookmark} label="Step Library" />
// add after:
<NavItem href="/scripts" icon={Terminal} label="Script Library" />
```
- [ ] **Step 4: Add to mobile nav in `frontend/src/components/layout/AppLayout.tsx`**
`Terminal` needs to be added to the lucide import in `AppLayout.tsx`. Find the lucide import and add `Terminal`.
In the `mobileNavItems` array (after the step-library entry), add:
```typescript
{ path: '/scripts', label: 'Script Library', icon: Terminal },
```
- [ ] **Step 5: Verify build**
```bash
cd frontend && npm run build 2>&1 | tail -20
```
Expected: clean build with no errors or type warnings.
- [ ] **Step 6: Commit**
```bash
git add frontend/src/router.tsx frontend/src/components/layout/Sidebar.tsx frontend/src/components/layout/AppLayout.tsx
git commit -m "feat: add /scripts route and Script Library nav entry
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>"
```
---
### Task 14: End-to-End Smoke Test
- [ ] **Step 1: Start dev server**
```bash
# Terminal 1 — backend
cd backend && source venv/bin/activate && uvicorn app.main:app --reload
# Terminal 2 — frontend
cd frontend && npm run dev
```
- [ ] **Step 2: Navigate to Script Library**
Open http://localhost:5173 and log in as `engineer@resolutionflow.example.com` (password: `TestPass123!`).
Click "Script Library" in the sidebar. Verify:
- Page loads at `/scripts`
- Filter bar shows "All" tab (active) + any seeded categories
- Template list shows templates (or empty state if none seeded)
- [ ] **Step 3: Smoke test template selection**
Click any template card. Verify:
- Card gets active highlight (cyan left border + `bg-primary/10`)
- Right panel spinner appears briefly then shows template name + parameters
- [ ] **Step 4: Smoke test live preview**
Fill in a parameter value. Verify:
- Code block updates in real-time to replace `{{key}}` with the typed value
- [ ] **Step 5: Smoke test generate**
Click Generate. Verify:
- Button shows spinner while in flight
- Generated script appears in code block
- Download button becomes enabled
- [ ] **Step 6: Smoke test viewer permission**
Log out and log in as `viewer@resolutionflow.example.com` (if a viewer account exists in seed data) or check with a viewer-role account. Verify:
- Page is accessible and templates are browsable
- Generate and Download buttons show "Engineer access required" tooltip and are disabled
- Parameter fields are disabled (greyed out)
- [ ] **Step 7: Smoke test search**
Type in the search box. Verify:
- ~300ms delay then templates filter
- "Clear search" appears and works when no results
- [ ] **Step 8: Final build check**
```bash
cd frontend && npm run build 2>&1 | tail -20
```
Expected: clean build.
- [ ] **Step 9: Commit smoke test confirmation**
```bash
git add -A
git commit -m "chore: smoke test complete — script library phase 2 working
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>"
```
---
## Done
All tasks complete. Push the branch and update the PR:
```bash
git push origin feat/script-generator
```
Then update `CURRENT-STATE.md` to move "Step Library Frontend UI" from In Progress and add "Script Library Frontend (Phase 2)" as recently completed.