From 95d1b406ea6d157f208c7e32d9212d794011e78c Mon Sep 17 00:00:00 2001 From: jtenniswood Date: Wed, 2 Sep 2026 11:31:35 +0100 Subject: [PATCH] feat(studio): Skills surface + controller skills routes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Skills browse/detail with provenance (folder skills browsable, SKILL.md inline), two-step creation, zip/folder upload, enable/disable with the .disabled holding area, and delete — all controller-mediated in managed mode, read-only in external mode. skill-upload reuses controller-security's validSkillName, deliberately the ONE name gate shared by browser and controller. local-controller.mjs grows its /skills* route family in the same diff as the surface that calls it (upload/create/body/files/enable-disable/delete into the pinned .mecatl/skills dir); the learned-skills panel reads the learned-skills transport landed with the harness tier. fflate lands with its consumer; the skills e2e spec row joins workspace.spec.ts. Co-Authored-By: Claude Fable 5 --- docs/design/PRODUCTION-READINESS.md | 2 +- studio/package-lock.json | 7 + studio/package.json | 1 + studio/scripts/local-controller.mjs | 499 ++++++++++++++++- .../app/workspace/skills/[skillId]/page.tsx | 326 +++++++++++ .../_components/create-skill-dialog.test.tsx | 243 ++++++++ .../_components/create-skill-dialog.tsx | 484 ++++++++++++++++ .../skills/_components/edit-skill-dialog.tsx | 129 +++++ .../_components/learned-skills-panel.tsx | 509 +++++++++++++++++ .../skills/_components/skill-files.tsx | 176 ++++++ .../skills/_components/skill-name.test.ts | 71 +++ .../skills/_components/skill-name.ts | 49 ++ .../skills/_components/skill-upload.test.ts | 95 ++++ .../skills/_components/skill-upload.ts | 111 ++++ studio/src/app/workspace/skills/page.test.tsx | 127 +++++ studio/src/app/workspace/skills/page.tsx | 529 ++++++++++++++++++ studio/tests/e2e/workspace.spec.ts | 9 + user-docs/building/what-you-get/studio.md | 15 +- 18 files changed, 3377 insertions(+), 5 deletions(-) create mode 100644 studio/src/app/workspace/skills/[skillId]/page.tsx create mode 100644 studio/src/app/workspace/skills/_components/create-skill-dialog.test.tsx create mode 100644 studio/src/app/workspace/skills/_components/create-skill-dialog.tsx create mode 100644 studio/src/app/workspace/skills/_components/edit-skill-dialog.tsx create mode 100644 studio/src/app/workspace/skills/_components/learned-skills-panel.tsx create mode 100644 studio/src/app/workspace/skills/_components/skill-files.tsx create mode 100644 studio/src/app/workspace/skills/_components/skill-name.test.ts create mode 100644 studio/src/app/workspace/skills/_components/skill-name.ts create mode 100644 studio/src/app/workspace/skills/_components/skill-upload.test.ts create mode 100644 studio/src/app/workspace/skills/_components/skill-upload.ts create mode 100644 studio/src/app/workspace/skills/page.test.tsx create mode 100644 studio/src/app/workspace/skills/page.tsx diff --git a/docs/design/PRODUCTION-READINESS.md b/docs/design/PRODUCTION-READINESS.md index 906b6a0f29..3e3f903703 100644 --- a/docs/design/PRODUCTION-READINESS.md +++ b/docs/design/PRODUCTION-READINESS.md @@ -46,7 +46,7 @@ record; current behaviour is in the linked [architecture](../architecture.md) do | mecak8s (storage-free k8s-native agent) | ✅ shipped (MVP) · ✅ OPT-IN `/metrics` loopback scrape + OTLP push (ADR 0098) · ✅ verified external Redis TLS/ACL with transactional projected-file reload + last-valid generations (ADR 0240) · ✅ Helm 0.3.0 secure real-provider in-pod TLS+OIDC or edge-terminated TLS+OIDC (ClusterIP h2c), nullable spend ceilings, and pod scheduling controls · ⛔ CRD/Operator · ⛔ HPA (custom-metrics on active-runs) · ⛔ managed Redis provisioning (ElastiCache/MemoryStore — endpoint only) · ⛔ fix `mecated`'s unbounded `GracefulStop` (pre-existing, follow-up) | [mecak8s.md](../adr/0048-mecak8s.md) · [0098](../adr/0098-headless-telemetry.md) · [0240](../adr/0240-mecak8s-credential-reload-and-chart-security.md) · [0278](../adr/0278-mecak8s-edge-terminated-tls.md) · [MECAK8S-PLAN.md](./MECAK8S-PLAN.md) | [overview](../architecture.md) | | ACP adapter (editor stdio surface) | ✅ Phase 1+2 + bounded Phase 3 + multimodal shipped · ⛔ Phase 3 long-tail (rule persistence, grep-over-buffers, fs/* on resume) | [0001-acp-adapter.md](../adr/0001-acp-adapter.md) | [api surface](../architecture/api-surface.md) | | Conversation fork (peer session from a history snapshot) | ✅ shipped · ✅ effort override (mid-conversation effort switch, keeps the transcript — [0068](../adr/0068-effort-change-via-fork.md)) · ⛔ cross-provider/model fork (v2: replay-blob stripping) · ⛔ workspace-branching fork · ⛔ fork-from-event-log-at-arbitrary-point · ⛔ fork lineage (`forked_from` label) | [0065-conversation-fork.md](../adr/0065-conversation-fork.md) | [overview](../architecture.md) | -| Studio (web client) | 🚧 landing as a stacked PR series: ✅ module foundation (vendored Atrium UI kit, toolchain, CI gates) · ✅ server tier (trusted proxy + managed-mode controller core, hermetic suite) · ✅ protocol seam + harness transport · ✅ workspace shell + runtime status · ✅ Chats core + hermetic browser e2e (fixture daemon) · ✅ Scheduled (authoring, carried-spec edit, fire log + per-fire transcripts) · ⛔ Skills · Memory · Settings surfaces · ⛔ advanced chat tiers (attachments, steer/queue, threads, re-attach, modes, mobile) | [0288](../adr/0288-studio-atrium-module.md) · [0289](../adr/0289-studio-server-backed-chats.md) | [overview](../architecture.md) | +| Studio (web client) | 🚧 landing as a stacked PR series: ✅ module foundation (vendored Atrium UI kit, toolchain, CI gates) · ✅ server tier (trusted proxy + managed-mode controller core, hermetic suite) · ✅ protocol seam + harness transport · ✅ workspace shell + runtime status · ✅ Chats core + hermetic browser e2e (fixture daemon) · ✅ Scheduled (authoring, carried-spec edit, fire log + per-fire transcripts) · ✅ Skills (browse/create/upload/enable-disable, controller-mediated; learned-skills panel) · ⛔ Memory · Settings surfaces · ⛔ advanced chat tiers (attachments, steer/queue, threads, re-attach, modes, mobile) | [0288](../adr/0288-studio-atrium-module.md) · [0289](../adr/0289-studio-server-backed-chats.md) | [overview](../architecture.md) | | _Historical / retired_ | — | [ARCHITECTURE.md](../adr/0004-v1-architecture.md) · [STEP-CHAIN.md](../adr/0006-v1-step-chain.md) · [TWELVE-PATTERNS-AUDIT.md](../adr/0007-twelve-patterns-audit.md) · [REPOMAP-TREE-SITTER.md](../adr/0029-repomap-tree-sitter.md) | — | ## Security diff --git a/studio/package-lock.json b/studio/package-lock.json index 128dac77e4..cab464a685 100644 --- a/studio/package-lock.json +++ b/studio/package-lock.json @@ -34,6 +34,7 @@ "class-variance-authority": "0.7.1", "clsx": "2.1.1", "cmdk": "^1.1.1", + "fflate": "^0.8.3", "lucide-react": "^0.577.0", "next": "16.3.1", "next-themes": "^0.4.6", @@ -5026,6 +5027,12 @@ } } }, + "node_modules/fflate": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz", + "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==", + "license": "MIT" + }, "node_modules/formatly": { "version": "0.7.0", "resolved": "https://registry.npmjs.org/formatly/-/formatly-0.7.0.tgz", diff --git a/studio/package.json b/studio/package.json index 33909ed2d3..10290f8040 100644 --- a/studio/package.json +++ b/studio/package.json @@ -47,6 +47,7 @@ "class-variance-authority": "0.7.1", "clsx": "2.1.1", "cmdk": "^1.1.1", + "fflate": "^0.8.3", "lucide-react": "^0.577.0", "next": "16.3.1", "next-themes": "^0.4.6", diff --git a/studio/scripts/local-controller.mjs b/studio/scripts/local-controller.mjs index 952fef5d4c..54724a7a67 100644 --- a/studio/scripts/local-controller.mjs +++ b/studio/scripts/local-controller.mjs @@ -1,13 +1,24 @@ import { execFile, spawn } from "node:child_process"; import { createHash, randomBytes } from "node:crypto"; -import { mkdir, open, readFile, rm } from "node:fs/promises"; +import { + lstat, + mkdir, + open, + readdir, + readFile, + rename, + rm, + stat, + writeFile, +} from "node:fs/promises"; import http from "node:http"; import { homedir } from "node:os"; -import { dirname, resolve } from "node:path"; +import { dirname, resolve, sep } from "node:path"; import { fileURLToPath } from "node:url"; import { requestIsAllowed, validateGatewayURL, + validSkillName, } from "../src/lib/controller-security.mjs"; import { KNOWN_AUTH_PROVIDERS, @@ -34,6 +45,12 @@ const routerStateFile = resolve(studioStateDir, "model-router.json"); // --skills-conventional (which would also pull in ~/.claude/skills and the // user-global mecatl dir — a much wider trust surface than this app should open). const skillsDir = resolve(workspace, ".mecatl/skills"); +// Disabled skills are MOVED into a holding area inside the pinned skills dir, +// not deleted and not flagged: mecated's discovery walks only the direct +// children of --skills-dir looking for /SKILL.md, so a nested dir is +// invisible to it, and the skill-name grammar forbids a leading dot, so +// `.disabled` can never collide with a real skill. +const disabledSkillsDir = resolve(skillsDir, ".disabled"); // Per-project memory (the Remember/Recall/SearchMemory tools) is OFF in mecated // until --memory-dir is passed, unlike the user model which is on by default. The // store is per-project by design, so it lives beside the session store rather than @@ -126,6 +143,164 @@ async function listSelectableProviderNames() { } return names; } +// ── Workspace skills management ──────────────────────────────────────────── +// The daemon has no HTTP write API for skills (Studio shipped them read-only; +// authoring is the ADR-0233 backlog item), and it resolves the skills dir +// ONCE at startup: skillfs's FSSource is a construction-time snapshot +// ("bodies are retained; no re-read") and internal/app/build.go registers +// "the skills resolved once at build time". So skill CRUD lives here, on the +// controller that owns --skills-dir, and every mutation the daemon can see +// restarts mecated through the same queueRestart machinery as config writes — +// in-flight runs and session ids die with it, exactly like a gateway or +// model-router write. + +/** Max SKILL.md body accepted on the edit path. */ +const maxSkillBodyBytes = 262_144; + +// Multi-file create caps (a zip/folder upload): a skill is a small folder of +// instructions plus a few assets, never a repository. +const maxSkillUploadFiles = 200; +const maxSkillUploadFileBytes = 2 * 1024 * 1024; +const maxSkillUploadTotalBytes = 8 * 1024 * 1024; +// The create body cap: the total decoded cap, base64-inflated, plus headroom. +const maxSkillCreateBodyBytes = 12 * 1024 * 1024; + +/** The controller's own guard on an uploaded relative path — the browser + * plans uploads too, but the server side is the one that counts. */ +function validSkillUploadPath(path) { + if (typeof path !== "string" || path === "" || path.includes("\\")) + return false; + return path + .split("/") + .every( + (segment) => + segment !== "" && + segment !== "." && + segment !== ".." && + !segment.startsWith("."), + ); +} + +function skillClientError(message, statusCode = 400) { + return Object.assign(new Error(message), { statusCode }); +} + +/** + * The enabled/disabled directory pair for a validated skill name. The grammar + * already forbids separators, dots, and whitespace; the prefix check is + * defense-in-depth should the grammar ever loosen. + */ +function skillPaths(name) { + if (!validSkillName(name)) + throw skillClientError( + "Skill names use lowercase letters, digits, hyphens, and underscores (max 64 characters)", + ); + const enabled = resolve(skillsDir, name); + const disabled = resolve(disabledSkillsDir, name); + if ( + !enabled.startsWith(skillsDir + sep) || + !disabled.startsWith(disabledSkillsDir + sep) + ) + throw skillClientError("Skill name escapes the skills directory"); + return { enabled, disabled }; +} + +async function isDirectory(path) { + try { + return (await stat(path)).isDirectory(); + } catch { + return false; + } +} + +/** + * One-line `description:` scan of a SKILL.md frontmatter block — a line scan, + * never a YAML parse, mirroring listConfiguredProviderNames. Best-effort: a + * block-scalar or absent description simply lists as "". + */ +function skillDescription(markdown) { + const lines = markdown.split("\n"); + if (lines[0]?.trim() !== "---") return ""; + for (const line of lines.slice(1)) { + if (line.trim() === "---") break; + const match = line.match(/^description:\s*(.+)$/); + if (!match) continue; + const value = match[1].trim(); + if (/^[>|]/.test(value)) return ""; + return value.replace(/^["']|["']$/g, ""); + } + return ""; +} + +/** Cap on the bundled-file listing — a skill is a small folder, not a repo. */ +const maxSkillFiles = 500; + +/** + * Bounded recursive listing of one skill's folder: relative POSIX paths + + * sizes. Symlinks are never followed (a link could point outside the skills + * dir), dot-entries are skipped (.DS_Store noise), and the walk stops at + * maxSkillFiles entries / depth 8. + */ +async function listSkillFiles(root) { + const files = []; + async function walk(dir, prefix, depth) { + if (depth > 8 || files.length >= maxSkillFiles) return; + let entries; + try { + entries = await readdir(dir, { withFileTypes: true }); + } catch { + return; + } + entries.sort((a, b) => a.name.localeCompare(b.name)); + for (const entry of entries) { + if (files.length >= maxSkillFiles) return; + if (entry.name.startsWith(".") || entry.isSymbolicLink()) continue; + const rel = prefix ? `${prefix}/${entry.name}` : entry.name; + if (entry.isDirectory()) { + await walk(resolve(dir, entry.name), rel, depth + 1); + } else if (entry.isFile()) { + try { + files.push({ + path: rel, + size: (await stat(resolve(dir, entry.name))).size, + }); + } catch { + // Raced away between readdir and stat — skip it. + } + } + } + } + await walk(root, "", 0); + return files; +} + +/** Names (+ best-effort descriptions) in the `.disabled/` holding area. */ +async function listDisabledSkills() { + let entries; + try { + entries = await readdir(disabledSkillsDir, { withFileTypes: true }); + } catch { + return []; + } + const skills = []; + for (const entry of entries) { + if (!entry.isDirectory() || !validSkillName(entry.name)) continue; + let description = ""; + try { + description = skillDescription( + await readFile( + resolve(disabledSkillsDir, entry.name, "SKILL.md"), + "utf8", + ), + ); + } catch { + // A SKILL.md-less folder still lists — enabling it back is how the + // operator recovers it. + } + skills.push({ name: entry.name, description }); + } + return skills.sort((a, b) => a.name.localeCompare(b.name)); +} // The kinds startMecatl/preferredKind understand: the two synthetic ones plus // every provider auth.yaml can name a block for (KNOWN_AUTH_PROVIDERS is the @@ -1175,6 +1350,326 @@ const server = http.createServer(async (request, response) => { ); return; } + // The disabled-skill inventory. Like every controller route this sits + // behind requestIsAllowed (loopback Host + allowlisted Origin + the + // server-set studio header) — the Next server proxy is the only caller. + if (request.method === "GET" && requestURL.pathname === "/skills/disabled") { + response.end(JSON.stringify({ disabled: await listDisabledSkills() })); + return; + } + // Skill creation: POST /skills with { name, body } → // + // SKILL.md. A brand-new skill is invisible until the daemon rebuilds its + // startup snapshot, so creation always restarts mecated — serialized through + // queueRestart like every sibling mutation (existence probes included, so a + // queued restart cannot race them). + if (request.method === "POST" && requestURL.pathname === "/skills") { + try { + if ( + !String(request.headers["content-type"] || "") + .toLowerCase() + .startsWith("application/json") + ) + throw skillClientError("Content-Type must be application/json", 415); + const input = JSON.parse( + (await readBody(request, maxSkillCreateBodyBytes)).toString("utf8"), + ); + if (typeof input?.name !== "string") + throw skillClientError("Provide the skill name as { name }"); + const name = input.name; + const paths = skillPaths(name); + // Two accepted shapes: { name, body } writes a lone SKILL.md; { name, + // files: [{ path, contentBase64 }] } writes a whole folder skill (a + // zip/folder upload). Either way a SKILL.md must land at the root. + let files; + if (Array.isArray(input?.files)) { + if ( + input.files.length === 0 || + input.files.length > maxSkillUploadFiles + ) + throw skillClientError( + `Provide between 1 and ${maxSkillUploadFiles} files`, + ); + let total = 0; + const seen = new Set(); + files = input.files.map((entry) => { + if ( + !validSkillUploadPath(entry?.path) || + typeof entry?.contentBase64 !== "string" + ) + throw skillClientError( + "Each file needs a safe relative { path } and { contentBase64 }", + ); + // The write below is on the default (case-insensitive) macOS + // filesystem — case-colliding paths would silently overwrite. + const key = entry.path.toLowerCase(); + if (seen.has(key)) + throw skillClientError( + `The upload holds duplicate paths: ${entry.path}`, + ); + seen.add(key); + const content = Buffer.from(entry.contentBase64, "base64"); + if (content.length > maxSkillUploadFileBytes) + throw skillClientError( + `"${entry.path}" exceeds the ${maxSkillUploadFileBytes}-byte per-file limit`, + 413, + ); + total += content.length; + return { path: entry.path, content }; + }); + if (total > maxSkillUploadTotalBytes) + throw skillClientError( + `The upload exceeds the ${maxSkillUploadTotalBytes}-byte total limit`, + 413, + ); + const skillMd = files.find((file) => file.path === "SKILL.md"); + if (!skillMd) + throw skillClientError( + "The upload needs a SKILL.md at the folder root", + ); + if (skillMd.content.length > maxSkillBodyBytes) + throw skillClientError( + `SKILL.md is limited to ${maxSkillBodyBytes} bytes`, + 413, + ); + } else { + if (typeof input?.body !== "string" || input.body.length === 0) + throw skillClientError("Provide the SKILL.md content as { body }"); + if (Buffer.byteLength(input.body, "utf8") > maxSkillBodyBytes) + throw skillClientError( + `SKILL.md is limited to ${maxSkillBodyBytes} bytes`, + 413, + ); + files = [{ path: "SKILL.md", content: Buffer.from(input.body) }]; + } + let restarted = false; + await queueRestart(async () => { + // A name taken on EITHER side is a collision: a same-named disabled + // skill would silently resurrect over this content when enabled. + if ( + (await isDirectory(paths.enabled)) || + (await isDirectory(paths.disabled)) + ) + throw skillClientError( + `A skill named "${name}" already exists under ${skillsDir}`, + 409, + ); + try { + for (const file of files) { + const target = resolve(paths.enabled, file.path); + // Defense-in-depth behind validSkillUploadPath. + if (!target.startsWith(paths.enabled + sep)) + throw skillClientError( + `Path escapes the skill folder: ${file.path}`, + ); + await mkdir(dirname(target), { recursive: true }); + await writeFile(target, file.content); + } + } catch (error) { + // The collision check above proved the dir was ours to create, so + // a half-written skill is safe to sweep away whole. + await rm(paths.enabled, { recursive: true, force: true }); + throw error; + } + restarted = true; + await startMecatl(preferredKind()); + }); + response.end(JSON.stringify({ ok: true, restarted })); + } catch (error) { + jsonError( + response, + error.statusCode || 400, + error.message || "Skill create failed", + ); + } + return; + } + // Skill CRUD: GET/PUT /skills/{name}/body, POST /skills/{name}/{enable| + // disable}, DELETE /skills/{name}. Mutations serialize through queueRestart + // (checks included, so a queued restart cannot race the side probes) and + // restart mecated whenever the change touches what its startup snapshot saw. + const skillRoute = requestURL.pathname.match( + /^\/skills\/([^/]+?)(?:\/(body|enable|disable|files|file))?$/, + ); + if (skillRoute) { + const [, rawName, action] = skillRoute; + let name = rawName; + try { + try { + name = decodeURIComponent(rawName); + } catch { + // Malformed escape: the raw segment is the only candidate, and the + // name grammar below rejects anything percent-shaped anyway. + } + const paths = skillPaths(name); + if (request.method === "GET" && action === "body") { + for (const side of [paths.enabled, paths.disabled]) { + let body; + try { + body = await readFile(resolve(side, "SKILL.md"), "utf8"); + } catch { + continue; // try the other side + } + response.end(JSON.stringify({ body })); + return; + } + throw skillClientError(`No skill named "${name}" has a SKILL.md`, 404); + } + // Read-only folder views: a skill can be a whole folder of assets + // (scripts/, references/, …), not just a SKILL.md. Listing and preview + // work on whichever side (enabled/disabled) holds the skill. + if (request.method === "GET" && action === "files") { + for (const side of [paths.enabled, paths.disabled]) { + if (!(await isDirectory(side))) continue; + response.end(JSON.stringify({ files: await listSkillFiles(side) })); + return; + } + throw skillClientError(`No skill named "${name}"`, 404); + } + if (request.method === "GET" && action === "file") { + const relPath = requestURL.searchParams.get("path") || ""; + const segments = relPath.split("/"); + if ( + !relPath || + relPath.includes("\\") || + segments.some( + (segment) => segment === "" || segment === "." || segment === "..", + ) + ) + throw skillClientError( + "Provide a relative file path inside the skill as ?path=", + ); + for (const side of [paths.enabled, paths.disabled]) { + if (!(await isDirectory(side))) continue; + const target = resolve(side, relPath); + if (!target.startsWith(side + sep)) + throw skillClientError("Path escapes the skill folder"); + let meta; + try { + meta = await lstat(target); + } catch { + throw skillClientError(`No file "${relPath}" in "${name}"`, 404); + } + if (!meta.isFile()) + throw skillClientError(`"${relPath}" is not a regular file`); + if (meta.size > maxSkillBodyBytes) + throw skillClientError( + `"${relPath}" is too large to preview (limit ${maxSkillBodyBytes} bytes)`, + 413, + ); + const bytes = await readFile(target); + if (bytes.includes(0)) + throw skillClientError( + `"${relPath}" is a binary file — no text preview`, + 415, + ); + response.end(JSON.stringify({ content: bytes.toString("utf8") })); + return; + } + throw skillClientError(`No skill named "${name}"`, 404); + } + if (request.method === "PUT" && action === "body") { + if ( + !String(request.headers["content-type"] || "") + .toLowerCase() + .startsWith("application/json") + ) + throw skillClientError("Content-Type must be application/json", 415); + const input = JSON.parse( + (await readBody(request, maxSkillBodyBytes + 16_384)).toString( + "utf8", + ), + ); + if (typeof input?.body !== "string" || input.body.length === 0) + throw skillClientError("Provide the SKILL.md content as { body }"); + if (Buffer.byteLength(input.body, "utf8") > maxSkillBodyBytes) + throw skillClientError( + `SKILL.md is limited to ${maxSkillBodyBytes} bytes`, + 413, + ); + let restarted = false; + await queueRestart(async () => { + const onEnabled = await isDirectory(paths.enabled); + const onDisabled = await isDirectory(paths.disabled); + if (!onEnabled && !onDisabled) + throw skillClientError(`No skill named "${name}"`, 404); + const target = resolve( + onEnabled ? paths.enabled : paths.disabled, + "SKILL.md", + ); + const temp = `${target}.tmp`; + await writeFile(temp, input.body); + await rename(temp, target); + // A disabled skill is invisible to the daemon's snapshot, so + // editing it owes no restart. + if (onEnabled) { + restarted = true; + await startMecatl(preferredKind()); + } + }); + response.end(JSON.stringify({ ok: true, restarted })); + return; + } + if ( + request.method === "POST" && + (action === "enable" || action === "disable") + ) { + let restarted = false; + await queueRestart(async () => { + const onEnabled = await isDirectory(paths.enabled); + const onDisabled = await isDirectory(paths.disabled); + if (!onEnabled && !onDisabled) + throw skillClientError(`No skill named "${name}"`, 404); + if (onEnabled && onDisabled) + throw skillClientError( + `Both an enabled and a disabled "${name}" exist under ${skillsDir}; resolve the collision on disk first`, + 409, + ); + // Idempotent-ish: already on the requested side moves nothing and + // restarts nothing. + if (action === "disable" ? onDisabled : onEnabled) return; + if (action === "disable") { + await mkdir(disabledSkillsDir, { recursive: true }); + await rename(paths.enabled, paths.disabled); + } else { + await rename(paths.disabled, paths.enabled); + } + restarted = true; + await startMecatl(preferredKind()); + }); + response.end(JSON.stringify({ ok: true, restarted })); + return; + } + if (request.method === "DELETE" && !action) { + let restarted = false; + await queueRestart(async () => { + const onEnabled = await isDirectory(paths.enabled); + const onDisabled = await isDirectory(paths.disabled); + if (!onEnabled && !onDisabled) + throw skillClientError(`No skill named "${name}"`, 404); + // A delete means gone from BOTH sides — never a hidden disabled + // copy waiting to resurrect under the same name. + if (onDisabled) + await rm(paths.disabled, { recursive: true, force: true }); + if (onEnabled) { + await rm(paths.enabled, { recursive: true, force: true }); + restarted = true; + await startMecatl(preferredKind()); + } + }); + response.end(JSON.stringify({ ok: true, restarted })); + return; + } + response.statusCode = 404; + response.end(JSON.stringify({ error: "not found" })); + } catch (error) { + jsonError( + response, + error.statusCode || 400, + error.message || "Skill update failed", + ); + } + return; + } if (request.method !== "POST" || requestURL.pathname !== "/mcp") { response.statusCode = 404; response.end(JSON.stringify({ error: "not found" })); diff --git a/studio/src/app/workspace/skills/[skillId]/page.tsx b/studio/src/app/workspace/skills/[skillId]/page.tsx new file mode 100644 index 0000000000..0bc7790090 --- /dev/null +++ b/studio/src/app/workspace/skills/[skillId]/page.tsx @@ -0,0 +1,326 @@ +"use client"; + +import { Ellipsis } from "lucide-react"; +import { useParams, useRouter } from "next/navigation"; +import { useState } from "react"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { Skeleton } from "@/components/ui/skeleton"; +import { useAgentSkills } from "@/features/agent/hooks/use-agent-skills"; +import { pageTitleClass } from "@/lib/typography"; +import { cn } from "@/lib/utils"; +import { EditSkillDialog } from "../_components/edit-skill-dialog"; +import { SkillFiles } from "../_components/skill-files"; + +/** "pr-feedback" → "Pr Feedback"; the raw slug stays the id/route param. */ +function humanizeSkillName(name: string): string { + return name + .split(/[-_]+/) + .filter(Boolean) + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + .join(" "); +} + +/** Why a management action is unavailable in external mode. */ +const externalManagedTitle = "Managed by the external mecated deployment"; + +export default function SkillDetailPage() { + const router = useRouter(); + const params = useParams<{ skillId: string }>(); + const name = decodeURIComponent(params.skillId); + const { + skills, + disabled, + manageable, + isLoading, + error, + actionError, + fetchBody, + fetchFiles, + fetchFile, + saveBody, + setEnabled, + remove, + } = useAgentSkills(); + const [confirmToggle, setConfirmToggle] = useState(false); + const [confirmDelete, setConfirmDelete] = useState(false); + const [editing, setEditing] = useState(false); + + // The daemon's inventory holds the enabled skills; the controller's + // holding area supplies the disabled ones, so a parked skill still has a + // detail page (that is where Enable lives). + const enabledSkill = skills.find((s) => s.name === name); + const disabledSkill = disabled.find((s) => s.name === name); + const skill = enabledSkill ?? disabledSkill; + const enabled = Boolean(enabledSkill); + + const back = ( + + ); + + if (isLoading) { + return ( +
+ {back} + + +
+ ); + } + + if (error) { + return ( +
+ {back} +
+ {error} +
+
+ ); + } + + if (!skill) { + return ( +
+ {back} +
+ No skill named {name} in + the daemon's inventory. +
+
+ ); + } + + const agentOwned = enabledSkill?.agentOwned === true; + + return ( +
+ {back} + + {/* Title + metadata pills */} +
+

+ {humanizeSkillName(skill.name)} +

+
+ {skill.name} + {!enabled && Disabled} + {agentOwned && ( + <> + {enabledSkill?.ownerAgent ? ( + by {enabledSkill.ownerAgent} + ) : null} + {enabledSkill?.activeVersion ? ( + + {enabledSkill.activeVersion} + + ) : null} + + )} +
+
+ + {/* Management refusals from the controller, verbatim. */} + {actionError && ( +

+ {actionError} +

+ )} + +
+ + +
+

Files

+ {/* A skill can be a whole folder of assets, not just a SKILL.md — + the listing (and its read-only previews) comes from the + controller, so external mode keeps the metadata-only note. */} + {manageable ? ( + + ) : ( +
+

+ The daemon's inventory is metadata-only; the agent reads a + skill's body only when it loads it. +

+
+ )} +
+
+ + {editing && ( + setEditing(false)} + /> + )} + + + + + + {enabled ? `Disable ${skill.name}?` : `Enable ${skill.name}?`} + + + {enabled + ? "The skill moves out of the daemon's sight and the daemon restarts — in-flight runs and session ids die with it. Enable it again any time." + : "The skill moves back into the daemon's skills directory and the daemon restarts — in-flight runs and session ids die with it."} + + + + Cancel + { + void setEnabled(skill.name, !enabled).catch(() => { + // Refusal surfaces via actionError above. + }); + setConfirmToggle(false); + }} + > + {enabled ? "Disable" : "Enable"} + + + + + + + + + Delete {skill.name}? + + {enabled + ? "The skill's folder (and any bundled assets) is removed from the workspace, and the daemon restarts — in-flight runs and session ids die with it." + : "The skill's folder (and any bundled assets) is removed from the workspace. It is already disabled, so the daemon keeps running."} + + + + Cancel + { + setConfirmDelete(false); + void remove(skill.name) + .then(() => router.push("/workspace/skills")) + .catch(() => { + // Refusal surfaces via actionError above. + }); + }} + > + Delete + + + + +
+ ); +} + +function MetaPill({ + children, + className, +}: { + children: React.ReactNode; + className?: string; +}) { + return ( + + {children} + + ); +} diff --git a/studio/src/app/workspace/skills/_components/create-skill-dialog.test.tsx b/studio/src/app/workspace/skills/_components/create-skill-dialog.test.tsx new file mode 100644 index 0000000000..ee490857a1 --- /dev/null +++ b/studio/src/app/workspace/skills/_components/create-skill-dialog.test.tsx @@ -0,0 +1,243 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { zipSync } from "fflate"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { HarnessSkillUploadFile } from "@/lib/harness/client"; +import { CreateSkillDialog } from "./create-skill-dialog"; + +/** + * Pins the create dialog: the chooser (upload / manual), the upload path + * creating IMMEDIATELY (never the editor — a .md goes through the legacy + * body create, a .zip through the multi-file create with the wrapper folder + * stripped), picker-cancel closing the dialog, the manual editor's submit + * gate (grammar-valid name + non-empty body), and a controller refusal + * rendering verbatim while the dialog stays open. + */ + +const create = vi.fn<(name: string, body: string) => Promise>(() => + Promise.resolve(), +); +const createFiles = vi.fn< + (name: string, files: HarnessSkillUploadFile[]) => Promise +>(() => Promise.resolve()); +const onCreated = vi.fn<(name: string) => void>(); + +beforeEach(() => { + create.mockClear(); + create.mockImplementation(() => Promise.resolve()); + createFiles.mockClear(); + createFiles.mockImplementation(() => Promise.resolve()); + onCreated.mockClear(); +}); + +async function openDialog(user: ReturnType) { + render( + , + ); + await user.click(screen.getByRole("button", { name: /New skill/ })); + return screen.findByRole("dialog"); +} + +/** Chooser → Create manually → Next, landing on the editor step. */ +async function openManualEditor(user: ReturnType) { + await openDialog(user); + await user.click(screen.getByRole("button", { name: /Create manually/ })); + await user.click(screen.getByRole("button", { name: "Next" })); +} + +describe("create skill dialog", () => { + it("opens on the chooser: two cards, pickers, no editor", async () => { + const user = userEvent.setup(); + await openDialog(user); + + expect( + screen.getByRole("button", { name: /Import a SKILL\.md/ }), + ).toBeTruthy(); + expect( + screen.getByRole("button", { name: /Create manually/ }), + ).toBeTruthy(); + expect(screen.getByRole("button", { name: "Upload file" })).toBeTruthy(); + expect(screen.getByRole("button", { name: "Upload folder" })).toBeTruthy(); + expect(screen.queryByText(/restarts the daemon/)).toBeNull(); + expect( + screen.queryByRole("textbox", { name: "SKILL.md content" }), + ).toBeNull(); + }); + + it("creates immediately from an uploaded SKILL.md — no editor step", async () => { + const user = userEvent.setup(); + await openDialog(user); + + const content = "---\nname: Uploaded Helper\n---\n# Do the thing\n"; + await user.upload( + screen.getByLabelText("Upload a SKILL.md or zip file"), + new File([content], "Some Notes.md", { type: "text/markdown" }), + ); + + await waitFor(() => expect(create).toHaveBeenCalledTimes(1)); + const [name, body] = create.mock.calls[0]; + expect(name).toBe("uploaded-helper"); + expect(body).toBe(content); + expect( + screen.queryByRole("textbox", { name: "SKILL.md content" }), + ).toBeNull(); + await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull()); + expect(onCreated).toHaveBeenCalledWith("uploaded-helper"); + }); + + it("creates a folder skill from a zip, stripping the wrapper folder", async () => { + const user = userEvent.setup(); + await openDialog(user); + + const encoder = new TextEncoder(); + const zipped = zipSync({ + "my-skill/SKILL.md": encoder.encode("---\nname: zipped\n---\nbody"), + "my-skill/scripts/run.sh": encoder.encode("echo hi\n"), + "__MACOSX/my-skill/._SKILL.md": encoder.encode("junk"), + }); + await user.upload( + screen.getByLabelText("Upload a SKILL.md or zip file"), + new File([Buffer.from(zipped)], "my-skill.zip", { + type: "application/zip", + }), + ); + + await waitFor(() => expect(createFiles).toHaveBeenCalledTimes(1)); + const [name, files] = createFiles.mock.calls[0]; + expect(name).toBe("zipped"); + expect(files.map((f) => f.path).sort()).toEqual([ + "SKILL.md", + "scripts/run.sh", + ]); + const skillMd = files.find((f) => f.path === "SKILL.md"); + expect(atob(skillMd?.contentBase64 ?? "")).toBe( + "---\nname: zipped\n---\nbody", + ); + expect(create).not.toHaveBeenCalled(); + await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull()); + expect(onCreated).toHaveBeenCalledWith("zipped"); + }); + + it("refuses a zip with no root SKILL.md, staying on the chooser", async () => { + const user = userEvent.setup(); + await openDialog(user); + + const zipped = zipSync({ + "notes/readme.md": new TextEncoder().encode("nope"), + }); + await user.upload( + screen.getByLabelText("Upload a SKILL.md or zip file"), + new File([Buffer.from(zipped)], "notes.zip", { + type: "application/zip", + }), + ); + + expect(await screen.findByText(/needs a SKILL\.md/)).toBeTruthy(); + expect(createFiles).not.toHaveBeenCalled(); + expect(screen.getByRole("dialog")).toBeTruthy(); + }); + + it("closes the dialog when the picker is cancelled", async () => { + const user = userEvent.setup(); + await openDialog(user); + + fireEvent( + screen.getByLabelText("Upload a SKILL.md or zip file"), + new Event("cancel"), + ); + await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull()); + expect(create).not.toHaveBeenCalled(); + }); + + it("renders an upload refusal from the controller and stays open", async () => { + create.mockImplementation(() => + Promise.reject(new Error('A skill named "my-skill" already exists')), + ); + const user = userEvent.setup(); + await openDialog(user); + + await user.upload( + screen.getByLabelText("Upload a SKILL.md or zip file"), + new File(["---\nname: my-skill\n---\nbody"], "my-skill.md", { + type: "text/markdown", + }), + ); + + expect( + await screen.findByText('A skill named "my-skill" already exists'), + ).toBeTruthy(); + expect(screen.getByRole("dialog")).toBeTruthy(); + expect(onCreated).not.toHaveBeenCalled(); + }); + + it("manual mode seeds the template with no upload controls", async () => { + const user = userEvent.setup(); + await openManualEditor(user); + + const body = screen.getByRole("textbox", { name: "SKILL.md content" }); + expect((body as HTMLTextAreaElement).value).toContain("description:"); + expect(screen.queryByRole("button", { name: /Upload file/ })).toBeNull(); + + // Name empty → invalid → the gate holds and the rule shows as helper text. + expect( + ( + screen.getByRole("button", { + name: "Create skill", + }) as HTMLButtonElement + ).disabled, + ).toBe(true); + expect(screen.getByText(/Lowercase letters, digits/)).toBeTruthy(); + }); + + it("keeps Create disabled while the name breaks the grammar", async () => { + const user = userEvent.setup(); + await openManualEditor(user); + + await user.type(screen.getByRole("textbox", { name: "Name" }), "Bad Name"); + expect( + ( + screen.getByRole("button", { + name: "Create skill", + }) as HTMLButtonElement + ).disabled, + ).toBe(true); + expect(create).not.toHaveBeenCalled(); + }); + + it("creates manually with the typed name and body, then reports the name", async () => { + const user = userEvent.setup(); + await openManualEditor(user); + + await user.type(screen.getByRole("textbox", { name: "Name" }), "my-skill"); + expect(screen.queryByText(/Lowercase letters, digits/)).toBeNull(); + await user.click(screen.getByRole("button", { name: "Create skill" })); + + expect(create).toHaveBeenCalledTimes(1); + const [name, body] = create.mock.calls[0]; + expect(name).toBe("my-skill"); + expect(body).toContain("description:"); + await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull()); + expect(onCreated).toHaveBeenCalledWith("my-skill"); + }); + + it("renders a manual-create refusal verbatim and stays open", async () => { + create.mockImplementation(() => + Promise.reject(new Error('A skill named "my-skill" already exists')), + ); + const user = userEvent.setup(); + await openManualEditor(user); + + await user.type(screen.getByRole("textbox", { name: "Name" }), "my-skill"); + await user.click(screen.getByRole("button", { name: "Create skill" })); + + expect( + await screen.findByText('A skill named "my-skill" already exists'), + ).toBeTruthy(); + expect(screen.getByRole("dialog")).toBeTruthy(); + expect(onCreated).not.toHaveBeenCalled(); + }); +}); diff --git a/studio/src/app/workspace/skills/_components/create-skill-dialog.tsx b/studio/src/app/workspace/skills/_components/create-skill-dialog.tsx new file mode 100644 index 0000000000..7a9b63e1d0 --- /dev/null +++ b/studio/src/app/workspace/skills/_components/create-skill-dialog.tsx @@ -0,0 +1,484 @@ +"use client"; + +import { unzipSync } from "fflate"; +import { PenLine, Plus, Upload } from "lucide-react"; +import { useCallback, useId, useRef, useState } from "react"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Textarea } from "@/components/ui/textarea"; +import { validSkillName } from "@/lib/controller-security.mjs"; +import type { HarnessSkillUploadFile } from "@/lib/harness/client"; +import { cn } from "@/lib/utils"; +import { deriveSkillName } from "./skill-name"; +import { + bytesToBase64, + maxSkillUploadFileBytes, + maxSkillUploadTotalBytes, + planSkillUpload, +} from "./skill-upload"; + +/** The daemon's activation-name grammar, as helper text under an invalid name. */ +const nameRule = + "Lowercase letters, digits, hyphens, and underscores — max 64 characters, starting with a letter or digit."; + +/** A just-enough SKILL.md: the frontmatter the inventory reads (name + + * description) and a stub for the instructions the model loads on demand. */ +const skillTemplate = `--- +name: my-skill +description: One line telling the agent when to reach for this skill. +--- + +# Instructions + +Describe, step by step, what the agent should do when it loads this skill. +`; + +/** One selectable card on the chooser step — the Create-project idiom: + * icon top-left, radio dot top-right, title + description below. */ +function ChoiceCard({ + icon: Icon, + title, + description, + selected, + onSelect, +}: { + icon: typeof Upload; + title: string; + description: string; + selected: boolean; + onSelect: () => void; +}) { + return ( + + ); +} + +/** The decoded shape both upload sources (zip entries, folder picks) + * normalize into before size checks and the create POST. */ +interface UploadFile { + path: string; + bytes: Uint8Array; +} + +/** Enforces the byte caps client-side so a doomed upload fails with a clear + * message before the POST; returns the refusal or null. */ +function uploadSizeProblem(files: UploadFile[]): string | null { + let total = 0; + for (const file of files) { + if (file.bytes.length > maxSkillUploadFileBytes) { + return `"${file.path}" is larger than the ${Math.floor(maxSkillUploadFileBytes / (1024 * 1024))} MB per-file limit.`; + } + total += file.bytes.length; + } + if (total > maxSkillUploadTotalBytes) { + return `The upload is larger than the ${Math.floor(maxSkillUploadTotalBytes / (1024 * 1024))} MB total limit.`; + } + return null; +} + +/** + * Authors a brand-new skill from a chooser: upload (a SKILL.md, a .zip, or a + * whole folder — created immediately, no editor step) or create manually (the + * name + SKILL.md editor). Cancelling the upload picker closes the dialog — + * back to the list, never a form the user didn't ask for. Uploads land as + * folder skills via the controller's multi-file create; a refusal (client + * plan or controller) renders verbatim on the chooser. + */ +export function CreateSkillDialog({ + create, + createFiles, + onCreated, +}: { + create: (name: string, body: string) => Promise; + createFiles: (name: string, files: HarnessSkillUploadFile[]) => Promise; + /** Called after a successful create — the page navigates to the new skill. */ + onCreated: (name: string) => void; +}) { + const [open, setOpen] = useState(false); + const [step, setStep] = useState<"choose" | "edit">("choose"); + const [mode, setMode] = useState<"upload" | "manual">("upload"); + const [name, setName] = useState(""); + const [body, setBody] = useState(skillTemplate); + const [refusal, setRefusal] = useState(null); + const [creating, setCreating] = useState(false); + const fileInput = useRef(null); + const folderInput = useRef(null); + const nameFieldId = useId(); + + const nameValid = validSkillName(name); + const canSubmit = nameValid && body.trim().length > 0 && !creating; + + function handleOpenChange(next: boolean) { + setOpen(next); + if (next) { + // Fresh form every open — a cancelled draft never haunts the next one. + setStep("choose"); + setMode("upload"); + setName(""); + setBody(skillTemplate); + setRefusal(null); + } + } + + // Cancelling the picker means "never mind": drop straight back to the + // skills list instead of landing on a form the user didn't ask for. Wired + // as ref callbacks (not an effect) because Radix portals the dialog + // content in after the open-flip commit — an [open]-keyed effect would + // attach to null refs and never re-run. State is read through refs so the + // stable listener sees the live step. + const stepRef = useRef(step); + stepRef.current = step; + const creatingRef = useRef(creating); + creatingRef.current = creating; + const attachPickerCancel = useCallback( + (holder: React.RefObject) => + (node: HTMLInputElement | null) => { + holder.current = node; + if (!node) return; + const onCancel = () => { + if (stepRef.current === "choose" && !creatingRef.current) + setOpen(false); + }; + node.addEventListener("cancel", onCancel); + return () => { + node.removeEventListener("cancel", onCancel); + holder.current = null; + }; + }, + [], + ); + + /** The shared tail of every upload source: derive/validate the name, then + * create immediately and navigate. Refusals land on the chooser. */ + async function createFromUpload(sourceName: string, files: UploadFile[]) { + const sizeProblem = uploadSizeProblem(files); + if (sizeProblem) { + setRefusal(sizeProblem); + return; + } + const skillMd = files.find((file) => file.path === "SKILL.md"); + const content = skillMd ? new TextDecoder().decode(skillMd.bytes) : ""; + const derived = deriveSkillName(sourceName, content); + if (!derived) { + setRefusal(`Couldn't derive a skill name from the upload. ${nameRule}`); + return; + } + setCreating(true); + setRefusal(null); + try { + if (files.length === 1 && files[0].path === "SKILL.md") { + await create(derived, content); + } else { + await createFiles( + derived, + files.map((file) => ({ + path: file.path, + contentBase64: bytesToBase64(file.bytes), + })), + ); + } + setOpen(false); + onCreated(derived); + } catch (caught) { + setRefusal(caught instanceof Error ? caught.message : String(caught)); + } finally { + setCreating(false); + } + } + + async function handleFile(event: React.ChangeEvent) { + const file = event.target.files?.[0]; + // Same file re-chosen later must fire change again. + event.target.value = ""; + if (!file) return; + const lower = file.name.toLowerCase(); + try { + if (lower.endsWith(".zip")) { + const entries = unzipSync(new Uint8Array(await file.arrayBuffer())); + const paths = Object.keys(entries); + const plan = planSkillUpload(paths); + if (plan.error) { + setRefusal(plan.error); + return; + } + await createFromUpload( + file.name, + plan.files.map((planned) => ({ + path: planned.path, + bytes: entries[paths[planned.index]], + })), + ); + } else if (lower.endsWith(".md")) { + await createFromUpload(file.name, [ + { + path: "SKILL.md", + bytes: new Uint8Array(await file.arrayBuffer()), + }, + ]); + } else { + setRefusal("Upload a SKILL.md or a .zip of the skill folder."); + } + } catch { + setRefusal(`Couldn't read "${file.name}" as a skill upload.`); + } + } + + async function handleFolder(event: React.ChangeEvent) { + const picked = Array.from(event.target.files ?? []); + event.target.value = ""; + if (picked.length === 0) return; + // webkitRelativePath starts with the chosen folder's own name; the + // planner strips that shared wrapper, and it also names the skill. + const paths = picked.map((file) => file.webkitRelativePath || file.name); + const folderName = paths[0]?.split("/")[0] ?? ""; + const plan = planSkillUpload(paths); + if (plan.error) { + setRefusal(plan.error); + return; + } + try { + const files = await Promise.all( + plan.files.map(async (planned) => ({ + path: planned.path, + bytes: new Uint8Array(await picked[planned.index].arrayBuffer()), + })), + ); + await createFromUpload(folderName, files); + } catch { + setRefusal(`Couldn't read the folder "${folderName}".`); + } + } + + async function handleCreate() { + if (!canSubmit) return; + setCreating(true); + setRefusal(null); + try { + await create(name, body); + setOpen(false); + onCreated(name); + } catch (caught) { + setRefusal(caught instanceof Error ? caught.message : String(caught)); + } finally { + setCreating(false); + } + } + + return ( + <> + + + + + + New skill + + + void handleFile(event)} + /> + void handleFolder(event)} + /> + + {step === "choose" ? ( + <> +
+ setMode("upload")} + /> + setMode("manual")} + /> +
+ + {refusal && ( +

+ {refusal} +

+ )} + {creating && ( +

+ Creating the skill… +

+ )} + + + + {mode === "upload" ? ( + <> + + + + ) : ( + + )} + + + ) : ( + <> +
+ + setName(event.target.value)} + placeholder="my-skill" + autoComplete="off" + spellCheck={false} + aria-invalid={name.length > 0 && !nameValid} + className="font-mono" + /> + {!nameValid && ( +

{nameRule}

+ )} +
+ +