# Parameter Detector Implementation Plan > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. **Goal:** Add a client-side PowerShell parameter detection tool to the Script Template Editor that scans script bodies for hardcoded values and walks users through converting them to template parameters via a stepper UI. **Architecture:** Pure frontend feature. A detection engine (`lib/scriptParameterDetector.ts`) parses PowerShell script bodies using regex to find script-level `param()` block entries and variable assignments. A stepper component (`ParameterDetectorStepper`) presents candidates one-by-one for review. Accepted candidates update both `form.script_body` (value → `{{key}}`) and `form.parameters_schema` (new `ScriptParameter` appended). **Tech Stack:** TypeScript, React, Lucide icons, Tailwind CSS, existing ScriptParameter types **Design doc:** `docs/plans/2026-03-14-parameter-detector-design.md` --- ### Task 1: Add ParameterCandidate type **Files:** - Modify: `frontend/src/types/scripts.ts:124` (append after `ScriptTemplateUpdateRequest`) **Step 1: Add the interface** Add to the end of `frontend/src/types/scripts.ts`: ```typescript export interface ParameterCandidate { variableName: string suggestedKey: string suggestedLabel: string suggestedType: ScriptParameter['type'] sensitive: boolean defaultValue: string | boolean | number | null source: 'param_block' | 'assignment' lineNumber: number matchedLine: string inferenceReason: string } ``` **Step 2: Export from types index** Verify `ParameterCandidate` is exported from `frontend/src/types/index.ts`. If scripts types are re-exported with `export * from './scripts'`, it's automatic. Otherwise add the export. **Step 3: Run build to verify** Run: `cd frontend && npm run build` Expected: SUCCESS **Step 4: Commit** ```bash git add frontend/src/types/scripts.ts git commit -m "feat: add ParameterCandidate type for script parameter detection" ``` --- ### Task 2: Build detection engine **Files:** - Create: `frontend/src/lib/scriptParameterDetector.ts` **Step 1: Create the detection utility** Create `frontend/src/lib/scriptParameterDetector.ts` with the following: ```typescript import type { ScriptParameter, ParameterCandidate } from '@/types' /** * PowerShell variable names to skip — these are PS internals, not user inputs. */ const SKIP_VARIABLES = new Set([ '$ErrorActionPreference', '$WarningPreference', '$VerbosePreference', '$DebugPreference', '$InformationPreference', '$ConfirmPreference', '$ProgressPreference', '$PSDefaultParameterValues', '$PSModuleAutoLoadingPreference', '$OFS', '$FormatEnumerationLimit', '$MaximumHistoryCount', '$_', '$PSItem', '$args', '$input', '$this', '$null', '$true', '$false', ]) /** * Sensitive variable name patterns — if the variable name contains any of these, * suggest password type and mark sensitive. */ const SENSITIVE_PATTERNS = /password|secret|key|credential|token|apikey|api_key/i /** * Convert a PowerShell variable name to a snake_case key. * "$OUPath" → "ou_path", "$ServerName" → "server_name" */ function toSnakeCase(varName: string): string { // Strip leading $ const name = varName.replace(/^\$/, '') // Insert underscore before uppercase letters, then lowercase everything return name .replace(/([a-z0-9])([A-Z])/g, '$1_$2') .replace(/([A-Z]+)([A-Z][a-z])/g, '$1_$2') .toLowerCase() } /** * Convert a snake_case key to a human-readable label. * "ou_path" → "OU Path", "server_name" → "Server Name" */ function toLabel(key: string): string { return key .split('_') .map(w => w.charAt(0).toUpperCase() + w.slice(1)) .join(' ') } /** * Infer the ScriptParameter type from a PowerShell type annotation and/or value. */ function inferType( typeAnnotation: string | null, value: string | null, varName: string ): { type: ScriptParameter['type']; sensitive: boolean; reason: string } { // Check type annotation first if (typeAnnotation) { const t = typeAnnotation.toLowerCase() if (t === 'switch') { return { type: 'boolean', sensitive: false, reason: 'Detected [switch] type declaration' } } if (t === 'securestring') { return { type: 'password', sensitive: true, reason: 'Detected [SecureString] type — marked as sensitive' } } if (t === 'int' || t === 'int32' || t === 'int64' || t === 'double' || t === 'float' || t === 'decimal') { return { type: 'number', sensitive: false, reason: `Detected [${typeAnnotation}] type declaration` } } if (t === 'bool' || t === 'boolean') { return { type: 'boolean', sensitive: false, reason: `Detected [${typeAnnotation}] type declaration` } } // [string] or other → fall through to value/name checks } // Check variable name for sensitive patterns if (SENSITIVE_PATTERNS.test(varName)) { return { type: 'password', sensitive: true, reason: `Variable name suggests sensitive data — marked as sensitive` } } // Check value patterns if (value !== null) { const trimmed = value.trim() if (trimmed === '$true' || trimmed === '$false') { return { type: 'boolean', sensitive: false, reason: 'Detected boolean value ($true/$false)' } } if (/^-?\d+(\.\d+)?$/.test(trimmed)) { return { type: 'number', sensitive: false, reason: 'Detected numeric value' } } } // Default const reason = typeAnnotation ? `Detected [${typeAnnotation}] type declaration` : 'Defaulting to text (no type annotation detected)' return { type: 'text', sensitive: false, reason } } /** * Parse the default value into the correct JS type. */ function parseDefault(value: string | null, type: ScriptParameter['type']): string | boolean | number | null { if (value === null) return null const trimmed = value.trim() if (type === 'boolean') { if (trimmed === '$true') return true if (trimmed === '$false') return false return null } if (type === 'number') { const n = Number(trimmed) return isNaN(n) ? null : n } // Strip surrounding quotes for string values if ((trimmed.startsWith("'") && trimmed.endsWith("'")) || (trimmed.startsWith('"') && trimmed.endsWith('"'))) { return trimmed.slice(1, -1) } return trimmed } /** * Find the end index of a script-level param() block. * Returns -1 if no script-level param block is found. * Skips param() blocks inside function declarations. */ function findScriptLevelParamBlock(script: string): { start: number; end: number } | null { const lines = script.split('\n') let inFunction = false let paramStart = -1 let parenDepth = 0 for (let i = 0; i < lines.length; i++) { const trimmed = lines[i].trim() // Track function blocks — skip param() inside functions if (/^function\s+/i.test(trimmed)) { inFunction = true continue } // Find script-level param keyword if (!inFunction && /^param\s*\(/i.test(trimmed) && paramStart === -1) { paramStart = i // Count parens to find the closing ) for (let j = i; j < lines.length; j++) { for (const ch of lines[j]) { if (ch === '(') parenDepth++ if (ch === ')') parenDepth-- if (parenDepth === 0 && paramStart !== -1) { return { start: paramStart, end: j } } } } } // Reset function tracking at closing brace (simplified) if (inFunction && trimmed === '}') { inFunction = false } } return null } /** * Extract parameter candidates from a script-level param() block. */ function extractParamBlockCandidates( script: string, block: { start: number; end: number } ): ParameterCandidate[] { const lines = script.split('\n') const blockText = lines.slice(block.start, block.end + 1).join('\n') const candidates: ParameterCandidate[] = [] // Match patterns like: [string]$VarName = "default" or $VarName or [switch]$VarName // Supports [Parameter(Mandatory=$true)] attributes on preceding lines const paramRegex = /(?:\[(\w+)\])?\s*\$(\w+)(?:\s*=\s*(.+?))?(?:\s*,\s*$|\s*$|\s*\))/gm let match: RegExpExecArray | null while ((match = paramRegex.exec(blockText)) !== null) { const typeAnnotation = match[1] || null const varName = match[2] const rawDefault = match[3]?.trim() ?? null // Skip Parameter() attributes — they look like [Parameter(...)] if (typeAnnotation && /^Parameter$/i.test(typeAnnotation)) continue const key = toSnakeCase(varName) const { type, sensitive, reason } = inferType(typeAnnotation, rawDefault, varName) const defaultValue = parseDefault(rawDefault, type) // Find the actual line number in the original script const lineIndex = lines.findIndex((line, idx) => idx >= block.start && idx <= block.end && line.includes(`$${varName}`) ) candidates.push({ variableName: `$${varName}`, suggestedKey: key, suggestedLabel: toLabel(key), suggestedType: type, sensitive, defaultValue, source: 'param_block', lineNumber: lineIndex !== -1 ? lineIndex + 1 : block.start + 1, matchedLine: lineIndex !== -1 ? lines[lineIndex].trim() : `$${varName}`, inferenceReason: reason, }) } return candidates } /** * Extract parameter candidates from variable assignments ($Var = 'value'). */ function extractAssignmentCandidates( script: string, existingVarNames: Set ): ParameterCandidate[] { const lines = script.split('\n') const candidates: ParameterCandidate[] = [] const seenVars = new Set() // Match: $VarName = 'value' | "value" | 123 | $true | $false const assignRegex = /^\s*(\$\w+)\s*=\s*(.+)$/ for (let i = 0; i < lines.length; i++) { const match = lines[i].match(assignRegex) if (!match) continue const fullVar = match[1] const rawValue = match[2].trim() // Skip PS internals if (SKIP_VARIABLES.has(fullVar)) continue // Skip if already found in param block if (existingVarNames.has(fullVar)) continue // Skip if already seen (take first assignment only) if (seenVars.has(fullVar)) continue // Skip if value is a complex expression (function call, pipeline, etc.) // Only match: quoted strings, numbers, $true/$false if (!/^['"].*['"]$/.test(rawValue) && !/^-?\d+(\.\d+)?$/.test(rawValue) && !/^\$(true|false)$/i.test(rawValue)) { continue } // Skip if the value already contains a {{placeholder}} if (/\{\{.*?\}\}/.test(rawValue)) continue seenVars.add(fullVar) const varName = fullVar.replace(/^\$/, '') const key = toSnakeCase(varName) const { type, sensitive, reason } = inferType(null, rawValue, varName) const defaultValue = parseDefault(rawValue, type) candidates.push({ variableName: fullVar, suggestedKey: key, suggestedLabel: toLabel(key), suggestedType: type, sensitive, defaultValue, source: 'assignment', lineNumber: i + 1, matchedLine: lines[i].trim(), inferenceReason: reason, }) } return candidates } /** * Detect parameter candidates in a PowerShell script body. * Returns candidates from script-level param() block first, then variable assignments. */ export function detectParameterCandidates(script: string): ParameterCandidate[] { if (!script.trim()) return [] // 1. Find and extract script-level param block const paramBlock = findScriptLevelParamBlock(script) const paramCandidates = paramBlock ? extractParamBlockCandidates(script, paramBlock) : [] // Track param block var names to avoid duplicates in assignment scan const paramVarNames = new Set(paramCandidates.map(c => c.variableName)) // 2. Extract variable assignments const assignmentCandidates = extractAssignmentCandidates(script, paramVarNames) return [...paramCandidates, ...assignmentCandidates] } ``` **Step 2: Run build to verify** Run: `cd frontend && npm run build` Expected: SUCCESS **Step 3: Commit** ```bash git add frontend/src/lib/scriptParameterDetector.ts git commit -m "feat: add PowerShell parameter detection engine" ``` --- ### Task 3: Build ParameterDetectorStepper component **Files:** - Create: `frontend/src/components/script-editor/ParameterDetectorStepper.tsx` **Step 1: Create the stepper component** Create `frontend/src/components/script-editor/ParameterDetectorStepper.tsx`: ```tsx import { useState } from 'react' import { ChevronRight, SkipForward, Info, Check } from 'lucide-react' import { cn } from '@/lib/utils' import { Input } from '@/components/ui/Input' import type { ParameterCandidate, ScriptParameter } from '@/types' const PARAM_TYPES: { value: ScriptParameter['type']; label: string }[] = [ { value: 'text', label: 'Text' }, { value: 'password', label: 'Password' }, { value: 'textarea', label: 'Textarea' }, { value: 'number', label: 'Number' }, { value: 'boolean', label: 'Boolean' }, { value: 'select', label: 'Select' }, { value: 'multi_text', label: 'Multi-text' }, ] interface Props { candidates: ParameterCandidate[] existingKeys: string[] onAccept: (candidate: ParameterCandidate, overrides: { key: string label: string type: ScriptParameter['type'] sensitive: boolean required: boolean defaultValue: string | boolean | number | null }) => void onSkip: (candidate: ParameterCandidate) => void onFinish: (acceptedCount: number, totalCount: number) => void } export function ParameterDetectorStepper({ candidates, existingKeys, onAccept, onSkip, onFinish, }: Props) { const [currentIndex, setCurrentIndex] = useState(0) const [acceptedCount, setAcceptedCount] = useState(0) const [showInferenceInfo, setShowInferenceInfo] = useState(false) // Editable overrides for the current candidate const current = candidates[currentIndex] const [key, setKey] = useState(current.suggestedKey) const [label, setLabel] = useState(current.suggestedLabel) const [type, setType] = useState(current.suggestedType) const [sensitive, setSensitive] = useState(current.sensitive) const [required, setRequired] = useState(true) const [defaultValue, setDefaultValue] = useState( current.defaultValue !== null ? String(current.defaultValue) : '' ) const isLast = currentIndex === candidates.length - 1 const keyConflict = existingKeys.includes(key) || candidates.slice(0, currentIndex).some((_, i) => { // This is a simplification — actual conflict check happens against // the running list of accepted keys which is managed by the parent return false }) const resetFieldsForIndex = (index: number) => { const c = candidates[index] setKey(c.suggestedKey) setLabel(c.suggestedLabel) setType(c.suggestedType) setSensitive(c.sensitive) setRequired(true) setDefaultValue(c.defaultValue !== null ? String(c.defaultValue) : '') setShowInferenceInfo(false) } const handleAccept = () => { const parsedDefault = type === 'boolean' ? defaultValue === 'true' : type === 'number' ? (defaultValue ? Number(defaultValue) : null) : (defaultValue || null) onAccept(current, { key, label, type, sensitive, required, defaultValue: parsedDefault, }) const newAccepted = acceptedCount + 1 setAcceptedCount(newAccepted) if (isLast) { onFinish(newAccepted, candidates.length) } else { const nextIndex = currentIndex + 1 setCurrentIndex(nextIndex) resetFieldsForIndex(nextIndex) } } const handleSkip = () => { onSkip(current) if (isLast) { onFinish(acceptedCount, candidates.length) } else { const nextIndex = currentIndex + 1 setCurrentIndex(nextIndex) resetFieldsForIndex(nextIndex) } } return (
{/* Progress */}

Candidate {currentIndex + 1} of {candidates.length}

{candidates.map((_, i) => (
))}
{/* Matched line */}

{current.matchedLine}

Line {current.lineNumber} · {current.source === 'param_block' ? 'param() block' : 'variable assignment'}

{/* Editable fields */}
setKey(e.target.value.replace(/[^a-zA-Z0-9_]/g, ''))} placeholder="param_key" /> {existingKeys.includes(key) && (

Key already exists — consider a different name

)}
setLabel(e.target.value)} placeholder="Display Label" />
{showInferenceInfo && (

{current.inferenceReason}

)}
setDefaultValue(e.target.value)} placeholder="Original value preserved" />
{/* Actions */}
) } ``` **Step 2: Run build to verify** Run: `cd frontend && npm run build` Expected: SUCCESS **Step 3: Commit** ```bash git add frontend/src/components/script-editor/ParameterDetectorStepper.tsx git commit -m "feat: add ParameterDetectorStepper component" ``` --- ### Task 4: Wire detection into ScriptTemplateEditor **Files:** - Modify: `frontend/src/components/script-editor/ScriptTemplateEditor.tsx` **Step 1: Add imports** At the top of `ScriptTemplateEditor.tsx`, add: ```typescript import { Scan } from 'lucide-react' import { detectParameterCandidates } from '@/lib/scriptParameterDetector' import { ParameterDetectorStepper } from './ParameterDetectorStepper' import type { ParameterCandidate, ScriptParameter } from '@/types' ``` Update the existing `lucide-react` import to include `Scan` alongside the existing icons. **Step 2: Add detection state** Inside the `ScriptTemplateEditor` component, after the existing `useState` declarations (around line 59), add: ```typescript const [detectedCandidates, setDetectedCandidates] = useState([]) const [showStepper, setShowStepper] = useState(false) const [detectionSummary, setDetectionSummary] = useState(null) ``` **Step 3: Add detection handler** After `handleBack` (around line 188), add: ```typescript const handleDetectParameters = () => { const candidates = detectParameterCandidates(form.script_body) if (candidates.length === 0) { setDetectionSummary('No parameter candidates detected in the script body.') setShowStepper(false) setTimeout(() => setDetectionSummary(null), 4000) return } setDetectedCandidates(candidates) setDetectionSummary(null) setShowStepper(true) } const handleAcceptCandidate = ( candidate: ParameterCandidate, overrides: { key: string label: string type: ScriptParameter['type'] sensitive: boolean required: boolean defaultValue: string | boolean | number | null } ) => { // 1. Replace the value in the script body with {{key}} let updatedScript = form.script_body if (candidate.source === 'param_block') { // For param block: replace the default value portion // e.g., $VarName = "default" → $VarName = "{{key}}" const defaultMatch = candidate.matchedLine.match(/=\s*(.+?)(?:\s*,?\s*$)/) if (defaultMatch) { updatedScript = updatedScript.replace( candidate.matchedLine, candidate.matchedLine.replace(defaultMatch[1], `'{{${overrides.key}}}'`) ) } } else { // For assignment: replace the right-hand side value const assignMatch = candidate.matchedLine.match(/=\s*(.+)$/) if (assignMatch) { updatedScript = updatedScript.replace( candidate.matchedLine, candidate.matchedLine.replace(assignMatch[1], `'{{${overrides.key}}}'`) ) } } // 2. Append new parameter to the schema const existingParams = form.parameters_schema.parameters const newParam: ScriptParameter = { key: overrides.key, label: overrides.label, type: overrides.type, required: overrides.required, placeholder: null, group: null, order: existingParams.length + 1, help_text: null, options: null, default: overrides.defaultValue, validation: null, sensitive: overrides.sensitive, } // Update both fields setForm(f => ({ ...f, script_body: updatedScript, parameters_schema: { parameters: [...f.parameters_schema.parameters, newParam], }, })) setIsDirty(true) } const handleSkipCandidate = () => { // Nothing to do — stepper advances internally } const handleDetectionFinish = (acceptedCount: number, totalCount: number) => { setShowStepper(false) setDetectedCandidates([]) setDetectionSummary( acceptedCount === 0 ? 'No parameters were added.' : `Added ${acceptedCount} of ${totalCount} detected parameter${totalCount !== 1 ? 's' : ''}.` ) setTimeout(() => setDetectionSummary(null), 5000) } ``` **Step 4: Add UI elements to the Script Body section** In the JSX, find the Script Body section (around line 334-348). After the `` and before ``, add the detect button and stepper: ```tsx updateField('script_body', v)} /> {/* Detect Parameters button + stepper */} {form.script_body.trim() && !showStepper && ( )} {detectionSummary && (

{detectionSummary}

)} {showStepper && detectedCandidates.length > 0 && ( p.key)} onAccept={handleAcceptCandidate} onSkip={handleSkipCandidate} onFinish={handleDetectionFinish} /> )} ``` **Step 5: Run build to verify** Run: `cd frontend && npm run build` Expected: SUCCESS **Step 6: Commit** ```bash git add frontend/src/components/script-editor/ScriptTemplateEditor.tsx git commit -m "feat: wire parameter detection into ScriptTemplateEditor" ``` --- ### Task 5: Manual testing checklist **Step 1: Test with variable assignments** 1. Navigate to `/scripts/manage` → click New Template 2. Paste this script body: ```powershell $ServerName = 'DC01' $OUPath = 'OU=Users,DC=contoso,DC=com' $DefaultPassword = 'Welcome123!' $ForceChange = $true $MaxRetries = 3 ``` 3. Click "Detect Parameters" 4. Verify 5 candidates appear in stepper 5. Verify type inference: ServerName=text, OUPath=text, DefaultPassword=password+sensitive, ForceChange=boolean, MaxRetries=number 6. Accept all — verify script body has `{{key}}` placeholders and Parameters section has 5 entries with defaults preserved **Step 2: Test with param() block** Paste: ```powershell param( [string]$ServerName = "DC01", [switch]$WhatIf, [SecureString]$AdminPassword, [int]$Port = 443 ) $Connection = "https://$ServerName:$Port" ``` Expected: 4 candidates from param block (ServerName, WhatIf, AdminPassword, Port) + 0 from assignments (Connection is a complex expression, not a simple literal) **Step 3: Test with function-level param (should be skipped)** Paste: ```powershell $GlobalPath = 'C:\Scripts' function Load-Users { param($filter = "*") Get-ADUser -Filter $filter } ``` Expected: 1 candidate only (GlobalPath). The function-level `param($filter)` should NOT appear. **Step 4: Test edge cases** - Empty script body → Detect Parameters button hidden - Script with only `{{key}}` placeholders → "No parameter candidates detected" - Script with PS internals like `$ErrorActionPreference = 'Stop'` → skipped - Re-running detect after accepting some → already-converted values skipped **Step 5: Commit any fixes** ```bash git commit -m "fix: address issues found during parameter detector testing" ``` --- ### Task 6: Final build verification and push **Step 1: Run full build** Run: `cd frontend && npm run build` Expected: SUCCESS with no type errors **Step 2: Push** ```bash git push ```