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

48 KiB
Raw Permalink Blame History

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

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):

export * from './scripts'
  • Step 3: Verify TypeScript compiles

Run from frontend/:

npm run build 2>&1 | tail -20

Expected: no type errors related to scripts.ts.

  • Step 4: Commit
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

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:

export { scriptsApi } from './scripts'
  • Step 3: Verify build
cd frontend && npm run build 2>&1 | tail -20

Expected: clean build, no errors.

  • Step 4: Commit
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

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
cd frontend && npm run build 2>&1 | tail -20

Expected: no errors.

  • Step 3: Commit
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

/**
 * 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
cd frontend && npm run build 2>&1 | tail -10

Expected: no errors.

  • Step 3: Commit
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

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
cd frontend && npm run build 2>&1 | tail -10

Expected: no errors.

  • Step 3: Commit
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

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
cd frontend && npm run build 2>&1 | tail -10

Expected: no errors.

  • Step 3: Commit
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

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
cd frontend && npm run build 2>&1 | tail -10

Expected: no errors.

  • Step 3: Commit
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

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
cd frontend && npm run build 2>&1 | tail -10

Expected: no errors.

  • Step 3: Commit
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

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
cd frontend && npm run build 2>&1 | tail -10

Expected: no errors.

  • Step 3: Commit
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

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
cd frontend && npm run build 2>&1 | tail -10

Expected: no errors.

  • Step 3: Commit
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

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
cd frontend && npm run build 2>&1 | tail -10

Expected: no errors.

  • Step 3: Commit
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

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
cd frontend && npm run build 2>&1 | tail -10

Expected: no errors.

  • Step 3: Commit
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:

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:

{ 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:

// 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:

// 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:

{ path: '/scripts', label: 'Script Library', icon: Terminal },
  • Step 5: Verify build
cd frontend && npm run build 2>&1 | tail -20

Expected: clean build with no errors or type warnings.

  • Step 6: Commit
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
# 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

cd frontend && npm run build 2>&1 | tail -20

Expected: clean build.

  • Step 9: Commit smoke test confirmation
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:

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.