/* This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ /** * `PromoteToolDialog` — turn a saved script into a persistent tool. * * Reads the script source, infers a minimal capability set via * `inferCapabilities`, lets the user pick a name / category / icon / * hotkey, then routes through `CapabilityReview` for the security * gate before installing. * * Spec: docs/architecture/ai-customization/01-extension-model.md + * `09-implementation-plan.md` P1.T11 / T12. */ import { useMemo, useState } from 'react'; import { ChevronRight, Sparkles, X } from 'lucide-react'; import { inferCapabilities, packBundle, sha256Hex, type Bundle, type ExtensionManifest, } from '@ifc-lite/extensions'; import { ICON_CHOICES } from './icon-registry'; import { cn } from '@/lib/utils'; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from '@/components/ui/dialog'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { CapabilityReview } from './CapabilityReview'; import { useExtensionHost } from '@/sdk/ExtensionHostProvider'; import type { ExtensionInstallSummary } from '@/services/extensions/host'; import { ExtensionInstallError } from '@/services/extensions/host'; import { toast } from '@/components/ui/toast'; interface PromoteToolDialogProps { open: boolean; /** The script source the user is promoting. */ source: string; /** Initial label the user can edit. */ initialName?: string; onClose(): void; } // Icon choices live in ./icon-registry — a single source of truth // shared with ExtensionToolbarSlot so the icon the user picks here // is the icon that lands in the menubar. export function PromoteToolDialog({ open, source, initialName, onClose }: PromoteToolDialogProps) { const host = useExtensionHost(); const [name, setName] = useState(initialName ?? 'My tool'); const [hotkey, setHotkey] = useState(''); const [icon, setIcon] = useState('sparkles'); const [pending, setPending] = useState<{ bytes: Uint8Array; summary: ExtensionInstallSummary } | null>(null); const [busy, setBusy] = useState(false); const inference = useMemo(() => inferCapabilities(source), [source]); const handlePromote = async () => { if (busy) return; setBusy(true); try { const { bytes, summary } = await synthesiseBundle({ name, source, hotkey, icon, capabilities: inference.capabilities, }); setPending({ bytes, summary }); } catch (err) { toast.error(`Failed to package tool: ${err instanceof Error ? err.message : String(err)}`); } finally { setBusy(false); } }; const handleApprove = async (grants: string[]) => { if (!pending || busy) return; setBusy(true); try { const status = await host.installFromBytes(pending.bytes, grants); // Point the user at the payoff — the actual button. The // synthesised manifest puts the command on `toolbar.right`, // so it shows up as an icon button at the top-right of the // toolbar. Mention the hotkey too if they set one. const where = hotkey.trim() ? `It's now a button in the toolbar (top-right) — or press ${hotkey.trim()}.` : `It's now a button in the toolbar (top-right).`; toast.success(`Installed "${name}". ${where}`); setPending(null); onClose(); void status; } catch (err) { if (err instanceof ExtensionInstallError) { toast.error(`Install rejected: ${err.validationErrors[0]?.message ?? err.message}`); } else { toast.error(`Install failed: ${err instanceof Error ? err.message : String(err)}`); } } finally { setBusy(false); } }; if (pending) { return ( setPending(null)} /> ); } return ( { if (!o) onClose(); }}>
Promote script to a tool
Turn this saved script into a persistent, sandboxed tool. The tool appears in the command palette and on the toolbar. It runs in the same sandbox as your scripts with only the capabilities you grant.
setName(e.target.value)} placeholder="Fire-rating report" />
setHotkey(e.target.value)} placeholder="Ctrl+Alt+F" />
{ICON_CHOICES.map(({ key, Icon, label }) => { const selected = icon === key; return ( ); })}
Inferred capabilities
{inference.capabilities.length === 0 ? (
No `bim.*` calls detected. The tool will request only model.read.
) : (
    {inference.capabilities.map((cap) => (
  • {cap}
  • ))}
)} {inference.observations.some((o) => o.unknown) && (
Unknown `bim.*` calls detected — review the source before approving.
)} {inference.parseErrors.length > 0 && (
Script does not parse cleanly — promotion may fail.
)}
); } interface SynthArgs { name: string; source: string; hotkey: string; icon: string; capabilities: string[]; } async function synthesiseBundle( args: SynthArgs, ): Promise<{ bytes: Uint8Array; summary: ExtensionInstallSummary }> { const slug = slugFromName(args.name); const id = `com.local.tools.${slug}`; const commandId = `${id}.run`; const caps = args.capabilities.length > 0 ? args.capabilities : ['model.read']; // Engine range MUST match the running SDK or the loader skips the // bundle on install — the tool then never appears as a toolbar // button. Pin to ">=" using the live app version // instead of a hardcoded guess. const sdkVersion = typeof __APP_VERSION__ === 'string' && __APP_VERSION__.length > 0 ? __APP_VERSION__ : '1.0.0'; const manifest: ExtensionManifest = { manifestVersion: 1, id, name: args.name, description: `Promoted from a saved script.`, version: '0.1.0', engines: { ifcLiteSdk: `>=${sdkVersion}` }, capabilities: caps, activation: [`onCommand:${commandId}`], contributes: { commands: [{ id: commandId, title: args.name, icon: args.icon }], toolbar: [{ command: commandId, slot: 'toolbar.right' }], ...(args.hotkey ? { keybindings: [{ command: commandId, key: args.hotkey.trim() }] } : {}), }, entry: { commands: { [commandId]: 'src/commands/run.js' } }, }; const handler = wrapScriptAsCommand(args.source); const files = new Map(); const manifestText = `${JSON.stringify(manifest, null, 2)}\n`; files.set('manifest.json', { path: 'manifest.json', bytes: new TextEncoder().encode(manifestText), text: manifestText }); files.set('src/commands/run.js', { path: 'src/commands/run.js', bytes: new TextEncoder().encode(handler), text: handler }); const bundle: Bundle = { manifest, files, source: { kind: 'memory' } }; const bytes = packBundle(bundle); const hash = await sha256Hex(bytes); return { bytes, summary: { id, version: '0.1.0', bundleHash: hash, capabilities: caps, bundle, signed: false }, }; } function slugFromName(name: string): string { return name .toLowerCase() .replace(/[^a-z0-9]+/g, '-') .replace(/^-+|-+$/g, '') || `tool-${Math.random().toString(36).slice(2, 8)}`; } function wrapScriptAsCommand(source: string): string { // `run` is intentionally NOT async. The bim.* SDK is fully // synchronous, and a promoted script is plain top-level code. An // `async` wrapper would return a pending promise whose body only // runs once the QuickJS job queue is drained — so the tool would // silently do nothing (0 logs, instant "success"). A sync function // runs to completion inside evalCode, exactly like the one-shot. return `/* Promoted from a saved script. */ function run(ctx) { const bim = ctx.bim; ${source.trim().split('\n').map((line) => ` ${line}`).join('\n')} } `; }