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>
25 KiB
Script Library Pane Takeover — 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: Redesign the Script Library left pane to have Browse mode (template list + filter bar) and Configure mode (full-height parameter form + action buttons), with the right pane becoming a read-only ScriptPreview.
Architecture: ScriptLibraryPage owns paneMode local state ('browse' | 'configure'). Clicking "Configure →" on a TemplateCard calls store.selectTemplate(id) then flips the pane. ScriptConfigurePane (new component) owns the configure-mode layout — back button, template header, param form, action bar. ScriptGeneratorPanel is deleted; the right pane becomes ScriptPreview in isolation.
Tech Stack: React 19, TypeScript, Zustand (useScriptGeneratorStore), Tailwind CSS v3, Lucide React. Verification: npx tsc -b --noEmit (NOT npm run build — pre-existing Node 18 incompatibility with Vite).
File Structure
| File | Action | Responsibility |
|---|---|---|
frontend/src/components/scripts/TemplateCard.tsx |
Modify | Non-interactive card with "Configure →" button; no store subscription |
frontend/src/components/scripts/ScriptTemplateList.tsx |
Modify | Thread onConfigure prop to each TemplateCard |
frontend/src/components/scripts/ScriptConfigurePane.tsx |
Create | Configure mode layout: back button, template header, form, action bar |
frontend/src/pages/ScriptLibraryPage.tsx |
Modify | paneMode state, filter-bar moved into left pane, right pane simplified |
frontend/src/components/scripts/ScriptGeneratorPanel.tsx |
Delete | Superseded by ScriptConfigurePane + right-pane simplification |
Chunk 1: All Tasks
Task 1: Modify TemplateCard — remove store subscription, add "Configure →" button
Files:
- Modify:
frontend/src/components/scripts/TemplateCard.tsx
Current state: TemplateCard is a <button> that calls store.selectTemplate() on click and applies active-border styling when selectedTemplate?.id === template.id. It imports useScriptGeneratorStore.
- Step 1: Replace the entire file with the updated implementation
import { ShieldAlert } from 'lucide-react'
import { cn } from '@/lib/utils'
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
onConfigure: (id: string) => void
}
export function TemplateCard({ template, onConfigure }: Props) {
return (
<div
className={cn(
'w-full text-left px-4 py-3 rounded-xl border transition-all',
'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 justify-between gap-3">
<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
type="button"
onClick={() => onConfigure(template.id)}
className="shrink-0 bg-primary/10 border border-primary/20 text-primary text-xs px-2.5 py-1 rounded-md hover:bg-primary/20 transition-colors"
>
Configure →
</button>
</div>
</div>
)
}
- Step 2: Verify TypeScript
cd /home/michaelchihlas/dev/patherly/frontend && npx tsc -b --noEmit
Expected: errors only from ScriptTemplateList (it still passes no onConfigure prop) — that's fine, it's fixed in Task 2.
- Step 3: Commit
git add frontend/src/components/scripts/TemplateCard.tsx
git commit -m "refactor: TemplateCard — remove store subscription, add Configure button"
Task 2: Modify ScriptTemplateList — thread onConfigure prop
Files:
- Modify:
frontend/src/components/scripts/ScriptTemplateList.tsx
Current state: ScriptTemplateList accepts { inputValue, onClearSearch } and renders <TemplateCard template={template} /> with no extra props.
- Step 1: Update the file
import { FileCode, Search } from 'lucide-react'
import { useScriptGeneratorStore } from '@/store/scriptGeneratorStore'
import { TemplateCard } from './TemplateCard'
interface Props {
inputValue: string
onClearSearch: () => void
onConfigure: (id: string) => 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/10 rounded" />
<div className="h-4 w-14 bg-white/10 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, onConfigure }: 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} onConfigure={onConfigure} />
))}
</div>
)
}
- Step 2: Verify TypeScript
cd /home/michaelchihlas/dev/patherly/frontend && npx tsc -b --noEmit
Expected: errors now only from ScriptLibraryPage (it passes no onConfigure to ScriptTemplateList yet) — that's fine.
- Step 3: Commit
git add frontend/src/components/scripts/ScriptTemplateList.tsx
git commit -m "refactor: ScriptTemplateList — add onConfigure prop threading"
Task 3: Create ScriptConfigurePane — configure mode layout
Files:
- Create:
frontend/src/components/scripts/ScriptConfigurePane.tsx
This new component renders the full configure-mode left pane: back button, loading spinner (when isLoadingDetail), first-selection error state, template header with tags, ScriptParameterForm, warnings callout, and the Generate/Download/Copy action bar.
Data comes from useScriptGeneratorStore directly. canGenerate and onBack come from props.
The action-bar Copy button copies store.generatedScript and is disabled when generatedScript === null. The Download button uses selectedTemplate.slug for the filename. Both Generate and Download are disabled when isGenerating || !canGenerate. Copy is disabled when !generatedScript || !canGenerate.
- Step 1: Create the file
import { useState } from 'react'
import { ArrowLeft, Terminal, Download, Loader2, AlertTriangle, Copy, Check } from 'lucide-react'
import { cn } from '@/lib/utils'
import { useScriptGeneratorStore } from '@/store/scriptGeneratorStore'
import { ScriptParameterForm } from './ScriptParameterForm'
const COMPLEXITY_CLASSES = {
beginner: 'text-emerald-400 bg-emerald-400/10 border-emerald-400/20',
intermediate: 'text-amber-400 bg-amber-400/10 border-amber-400/20',
advanced: 'text-rose-500 bg-rose-500/10 border-rose-500/20',
} as const
interface Props {
canGenerate: boolean
onBack: () => void
}
export function ScriptConfigurePane({ canGenerate, onBack }: Props) {
const selectedTemplate = useScriptGeneratorStore(s => s.selectedTemplate)
const isLoadingDetail = useScriptGeneratorStore(s => s.isLoadingDetail)
const categories = useScriptGeneratorStore(s => s.categories)
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 [copied, setCopied] = useState(false)
const handleCopy = async () => {
if (!generatedScript) return
try {
await navigator.clipboard.writeText(generatedScript)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
} catch {
// silently fail
}
}
const handleDownload = () => {
if (!generatedScript || !selectedTemplate) 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`
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
}
// Loading state
if (isLoadingDetail) {
return (
<div className="glass-card-static h-full flex flex-col p-4 overflow-y-auto">
<button
type="button"
onClick={onBack}
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors mb-4 w-fit"
>
<ArrowLeft size={12} />
Back to library
</button>
<div className="flex-1 flex items-center justify-center">
<Loader2 size={28} className="text-primary animate-spin" />
</div>
</div>
)
}
// First-selection failure state
if (!selectedTemplate) {
return (
<div className="glass-card-static h-full flex flex-col p-4 overflow-y-auto">
<button
type="button"
onClick={onBack}
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors mb-4 w-fit"
>
<ArrowLeft size={12} />
Back to library
</button>
<div className="flex-1 flex flex-col items-center justify-center gap-3 text-center">
<Terminal size={32} className="text-muted-foreground/40" />
<p className="text-sm text-muted-foreground">Failed to load template.</p>
</div>
</div>
)
}
const categoryName = categories.find(c => c.id === selectedTemplate.category_id)?.name
const displayTags = selectedTemplate.tags.slice(0, 3)
const extraTagCount = selectedTemplate.tags.length - 3
return (
<div className="glass-card-static h-full flex flex-col p-4 overflow-y-auto">
{/* Back button */}
<button
type="button"
onClick={onBack}
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors mb-4 w-fit"
>
<ArrowLeft size={12} />
Back to library
</button>
{/* Template header */}
<div className="mb-3">
<h2 className="text-base font-semibold font-heading text-foreground">
{selectedTemplate.name}
</h2>
{selectedTemplate.description && (
<p className="text-sm text-muted-foreground mt-0.5">{selectedTemplate.description}</p>
)}
<div className="flex items-center gap-1.5 flex-wrap mt-2">
{selectedTemplate.requires_elevation && (
<span
title="Requires administrator elevation"
className="font-label text-[0.625rem] uppercase tracking-wide px-1.5 py-0.5 rounded border text-amber-400 bg-amber-400/10 border-amber-400/20"
>
⚠ Elevated
</span>
)}
<span className={cn(
'font-label text-[0.625rem] uppercase tracking-wide px-1.5 py-0.5 rounded border',
COMPLEXITY_CLASSES[selectedTemplate.complexity]
)}>
{selectedTemplate.complexity}
</span>
{categoryName && (
<span className="font-label text-[0.625rem] uppercase tracking-wide px-1.5 py-0.5 rounded border border-border text-muted-foreground bg-white/5">
{categoryName}
</span>
)}
{displayTags.map(tag => (
<span key={tag} className="font-label text-[0.625rem] px-1.5 py-0.5 rounded border border-border text-muted-foreground bg-white/5">
{tag}
</span>
))}
{extraTagCount > 0 && (
<span className="font-label text-[0.625rem] text-muted-foreground">+{extraTagCount}</span>
)}
</div>
</div>
<div className="border-t border-border mb-4" />
{/* 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 mt-4">
<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>
)}
{/* Action bar */}
<div className="flex flex-col gap-2 mt-4 pt-1">
<span title={!canGenerate ? 'Engineer access required' : undefined}>
<button
type="button"
onClick={() => generate()}
disabled={isGenerating || !canGenerate}
className="w-full flex items-center justify-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 Script
</button>
</span>
<div className="flex gap-2">
<span className="flex-1" title={!canGenerate ? 'Engineer access required' : undefined}>
<button
type="button"
onClick={handleDownload}
disabled={!generatedScript || !canGenerate}
className="w-full flex items-center justify-center gap-1.5 bg-white/5 border border-border text-foreground text-sm px-4 py-2 rounded-[10px] hover:border-[rgba(255,255,255,0.12)] transition-all disabled:opacity-50 disabled:cursor-not-allowed"
>
<Download size={14} />
Download .ps1
</button>
</span>
<span className="flex-1" title={!canGenerate ? 'Engineer access required' : undefined}>
<button
type="button"
onClick={handleCopy}
disabled={!generatedScript || !canGenerate}
className="w-full flex items-center justify-center gap-1.5 bg-white/5 border border-border text-foreground text-sm px-4 py-2 rounded-[10px] hover:border-[rgba(255,255,255,0.12)] transition-all disabled:opacity-50 disabled:cursor-not-allowed"
>
{copied ? <Check size={14} className="text-emerald-400" /> : <Copy size={14} />}
{copied ? 'Copied!' : 'Copy'}
</button>
</span>
</div>
</div>
{/* Generate error */}
{generateError && (
<p className="text-xs text-rose-500 mt-2">{generateError}</p>
)}
</div>
)
}
- Step 2: Verify TypeScript
cd /home/michaelchihlas/dev/patherly/frontend && npx tsc -b --noEmit
Expected: No new errors from this file. Errors may still exist from ScriptLibraryPage not yet updated — that's fine.
- Step 3: Commit
git add frontend/src/components/scripts/ScriptConfigurePane.tsx
git commit -m "feat: add ScriptConfigurePane — configure mode layout"
Task 4: Rewrite ScriptLibraryPage — pane mode, filter bar in column, right pane simplified
Files:
- Modify:
frontend/src/pages/ScriptLibraryPage.tsx
Changes:
- Add
paneModestate ('browse' | 'configure') - Add
usePermissionsforcanGenerate - Add
selectedTemplatesubscription for right-pane conditional - Move
ScriptFilterBarinto the left pane column (only rendered in browse mode) - Add
onConfigure/onBackcallbacks - Left pane conditionally renders browse content or
ScriptConfigurePane - Right pane: empty state when
selectedTemplate === null, otherwiseScriptPreviewinoverflow-hiddenwrapper
- Step 1: Replace the entire file
import { useState, useEffect } from 'react'
import { Terminal } from 'lucide-react'
import { useScriptGeneratorStore } from '@/store/scriptGeneratorStore'
import { usePermissions } from '@/hooks/usePermissions'
import { ScriptFilterBar } from '@/components/scripts/ScriptFilterBar'
import { ScriptTemplateList } from '@/components/scripts/ScriptTemplateList'
import { ScriptConfigurePane } from '@/components/scripts/ScriptConfigurePane'
import { ScriptPreview } from '@/components/scripts/ScriptPreview'
export default function ScriptLibraryPage() {
const [inputValue, setInputValue] = useState('')
const [paneMode, setPaneMode] = useState<'browse' | 'configure'>('browse')
const loadCategories = useScriptGeneratorStore(s => s.loadCategories)
const loadTemplates = useScriptGeneratorStore(s => s.loadTemplates)
const setSearch = useScriptGeneratorStore(s => s.setSearch)
const selectTemplate = useScriptGeneratorStore(s => s.selectTemplate)
const clearOutput = useScriptGeneratorStore(s => s.clearOutput)
const selectedTemplate = useScriptGeneratorStore(s => s.selectedTemplate)
const { isEngineer } = usePermissions()
const canGenerate = isEngineer
useEffect(() => {
loadCategories().then(() => loadTemplates())
}, [loadCategories, loadTemplates])
const onClearSearch = () => {
setInputValue('')
setSearch('')
}
const onConfigure = (id: string) => {
selectTemplate(id)
setPaneMode('configure')
}
const onBack = () => {
clearOutput()
setPaneMode('browse')
}
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>
{/* Two-column layout */}
<div className="grid grid-cols-[320px_1fr] gap-4 flex-1 min-h-0">
{/* Left pane — Browse or Configure */}
{paneMode === 'browse' ? (
<div className="glass-card-static flex flex-col overflow-hidden">
<div className="p-3 border-b border-border">
<ScriptFilterBar inputValue={inputValue} setInputValue={setInputValue} />
</div>
<div className="flex-1 overflow-y-auto">
<ScriptTemplateList
inputValue={inputValue}
onClearSearch={onClearSearch}
onConfigure={onConfigure}
/>
</div>
</div>
) : (
<ScriptConfigurePane canGenerate={canGenerate} onBack={onBack} />
)}
{/* Right pane — always ScriptPreview */}
{selectedTemplate === null ? (
<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>
) : (
<div className="glass-card-static h-full overflow-hidden p-4">
<ScriptPreview />
</div>
)}
</div>
</div>
)
}
- Step 2: Verify TypeScript — expect clean
cd /home/michaelchihlas/dev/patherly/frontend && npx tsc -b --noEmit
Expected: Zero errors.
- Step 3: Commit
git add frontend/src/pages/ScriptLibraryPage.tsx
git commit -m "feat: ScriptLibraryPage — pane takeover with Browse/Configure modes"
Task 5: Delete ScriptGeneratorPanel
Files:
- Delete:
frontend/src/components/scripts/ScriptGeneratorPanel.tsx
ScriptGeneratorPanel is no longer imported or used anywhere — ScriptLibraryPage now uses ScriptConfigurePane for the left pane and ScriptPreview directly for the right pane.
- Step 1: Verify nothing imports
ScriptGeneratorPanel
grep -r "ScriptGeneratorPanel" /home/michaelchihlas/dev/patherly/frontend/src
Expected: No output (zero matches).
- Step 2: Delete the file
rm /home/michaelchihlas/dev/patherly/frontend/src/components/scripts/ScriptGeneratorPanel.tsx
- Step 3: Verify TypeScript still clean
cd /home/michaelchihlas/dev/patherly/frontend && npx tsc -b --noEmit
Expected: Zero errors.
- Step 4: Commit
git add -u frontend/src/components/scripts/ScriptGeneratorPanel.tsx
git commit -m "chore: delete ScriptGeneratorPanel — superseded by ScriptConfigurePane"
Task 6: Smoke test
- Step 1: Start dev server
cd /home/michaelchihlas/dev/patherly/frontend && npm run dev
- Step 2: Verify browse mode
Open http://localhost:5173/scripts.
Expected:
-
Template list shows with filter bar above it (inside the left pane, no filter bar outside the pane)
-
Each template card has a "Configure →" button at bottom-right
-
Clicking anywhere on the card body (not the button) does nothing
-
Right pane shows Terminal icon + "Select a template to get started"
-
Step 3: Verify configure mode
Click "Configure →" on any template.
Expected:
-
Left pane transitions to configure view (filter bar and list hidden)
-
Loading spinner visible briefly, then full configure view appears
-
Template name, description, complexity badge, category name, tags visible at top
-
Parameter form below (all fields interactive if engineer role)
-
"Generate Script" button full-width, cyan gradient
-
"Download .ps1" and "Copy" buttons disabled (no generated script yet)
-
Right pane still shows empty state (first click) or previous preview
-
Step 4: Verify generate flow
Fill required parameters, click "Generate Script".
Expected:
-
Spinner appears on Generate button during generation
-
Right pane updates to show generated PowerShell (with syntax highlighting)
-
"Download .ps1" and "Copy" buttons become enabled
-
Copy button copies text; shows "Copied!" for 2 seconds
-
Download button triggers
.ps1file download -
Step 5: Verify Back
Click "← Back to library".
Expected:
-
Left pane returns to browse mode (filter bar + template list visible)
-
Search input and category pills restore to previous state
-
Right pane continues showing the previously generated output
-
Step 6: Stop dev server and push
git push origin feat/script-generator