From 00b0e0aaedae89cde78850a6db1d56f46fc793dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Fri, 17 Apr 2026 16:35:30 -0400 Subject: [PATCH] refactor(ui+skills): split import.ts + primitives.tsx god files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two more > 400 LOC modules split along responsibility seams. Zero behavior change — public APIs re-export from the original file paths, so every consumer import stays working. | Original | Split into | LOC each | |---------------------------------------|-------------------------------|----------| | src/main/skills/import.ts (458) | import (orchestrator) / | 132 | | | discovery (findSkillMdFiles, | 153 | | | buildLocalInventory) | | | | materialize | 24 | | | import-local | 147 | | | import-url | 85 | | src/renderer/components/ui/ | primitives (barrel re-export) | 11 | | primitives.tsx (419) | Card / Eyebrow / Badge / | 9–59 | | | Spinner / Panel / Field / | each | | | Input / Select / TextArea / | | | | ActionButton / SectionButton | | `createSkillImporter` still returns `{ importFromSource, importFromGitHub, importFromLocal, importFromUrl, cleanupSkillFiles, parseGitHubUrl, findSkillMdFiles, buildLocalInventory }` — every existing consumer resolves unchanged. `primitives.tsx` is now a pure barrel re-exporting the 11 primitives. Consumers can migrate to direct imports (`../ui/Card`) opportunistically but the barrel path keeps working. --- src/main/skills/discovery.ts | 153 +++++++ src/main/skills/import-local.ts | 147 +++++++ src/main/skills/import-url.ts | 85 ++++ src/main/skills/import.ts | 408 ++---------------- src/main/skills/materialize.ts | 24 ++ src/renderer/components/ui/ActionButton.tsx | 55 +++ src/renderer/components/ui/Badge.tsx | 9 + src/renderer/components/ui/Card.tsx | 30 ++ src/renderer/components/ui/Eyebrow.tsx | 27 ++ src/renderer/components/ui/Field.tsx | 10 + src/renderer/components/ui/Input.tsx | 49 +++ src/renderer/components/ui/Panel.tsx | 30 ++ src/renderer/components/ui/SectionButton.tsx | 59 +++ src/renderer/components/ui/Select.tsx | 48 +++ src/renderer/components/ui/Spinner.tsx | 14 + src/renderer/components/ui/TextArea.tsx | 44 ++ src/renderer/components/ui/primitives.tsx | 430 +------------------ 17 files changed, 836 insertions(+), 786 deletions(-) create mode 100644 src/main/skills/discovery.ts create mode 100644 src/main/skills/import-local.ts create mode 100644 src/main/skills/import-url.ts create mode 100644 src/main/skills/materialize.ts create mode 100644 src/renderer/components/ui/ActionButton.tsx create mode 100644 src/renderer/components/ui/Badge.tsx create mode 100644 src/renderer/components/ui/Card.tsx create mode 100644 src/renderer/components/ui/Eyebrow.tsx create mode 100644 src/renderer/components/ui/Field.tsx create mode 100644 src/renderer/components/ui/Input.tsx create mode 100644 src/renderer/components/ui/Panel.tsx create mode 100644 src/renderer/components/ui/SectionButton.tsx create mode 100644 src/renderer/components/ui/Select.tsx create mode 100644 src/renderer/components/ui/Spinner.tsx create mode 100644 src/renderer/components/ui/TextArea.tsx diff --git a/src/main/skills/discovery.ts b/src/main/skills/discovery.ts new file mode 100644 index 00000000..7c382c94 --- /dev/null +++ b/src/main/skills/discovery.ts @@ -0,0 +1,153 @@ +import { readdirSync, realpathSync, type Dirent } from "node:fs"; +import { join, resolve, relative } from "node:path"; +import type { FileInventoryEntry } from "../skill-types.js"; +import { classifyInventoryKind } from "../skill-format.js"; + +export const DEFAULT_SKILL_WALK_MAX_DEPTH = 8; +export const DEFAULT_SKILL_WALK_MAX_FILES = 256; + +const BLOCKED_PATH_PREFIXES_UNIX = ["/etc", "/var", "/proc", "/sys", "/dev", "/boot", "/sbin", "/usr/sbin"]; +const BLOCKED_PATH_PREFIXES_WIN = ["C:\\Windows", "C:\\Program Files", "C:\\ProgramData"]; + +export interface DiscoveryLogger { + warn: (...args: unknown[]) => void; +} + +export function assertNotSystemPath(realPath: string): void { + const normalized = realPath.replace(/\\/g, "/"); + const blocked = process.platform === "win32" ? BLOCKED_PATH_PREFIXES_WIN : BLOCKED_PATH_PREFIXES_UNIX; + for (const prefix of blocked) { + const normalizedPrefix = prefix.replace(/\\/g, "/"); + if (normalized === normalizedPrefix || normalized.startsWith(normalizedPrefix + "/")) { + throw new Error(`Importing skills from system directory is not allowed: ${realPath}`); + } + } +} + +export function findSkillMdFiles( + rootDir: string, + logger: DiscoveryLogger, + opts?: { maxDepth?: number; maxFiles?: number }, +): string[] { + const maxDepth = opts?.maxDepth ?? DEFAULT_SKILL_WALK_MAX_DEPTH; + const maxFiles = opts?.maxFiles ?? DEFAULT_SKILL_WALK_MAX_FILES; + const found: string[] = []; + const visitedRealPaths = new Set(); + let filesSeen = 0; + + function walk(dir: string, depth: number) { + if (depth > maxDepth) { + throw new Error( + `Skill directory too deep, refusing to walk beyond ${maxDepth} levels`, + ); + } + let realDir: string; + try { + realDir = realpathSync(dir); + } catch (err) { + logger.warn(`[skill-import] realpathSync failed for ${dir}:`, err instanceof Error ? err.message : String(err)); + return; + } + if (visitedRealPaths.has(realDir)) { + return; + } + visitedRealPaths.add(realDir); + + let entries: Dirent[]; + try { + entries = readdirSync(dir, { withFileTypes: true }); + } catch (err) { + logger.warn(`[skill-import] readdirSync failed for ${dir}:`, err instanceof Error ? err.message : String(err)); + return; + } + for (const entry of entries) { + filesSeen++; + if (filesSeen > maxFiles) { + throw new Error( + `Skill directory contains too many files, refusing after ${maxFiles} entries`, + ); + } + const fullPath = join(dir, entry.name); + if (entry.isDirectory()) { + walk(fullPath, depth + 1); + } else if (entry.name === "SKILL.md") { + found.push(fullPath); + } + } + } + walk(rootDir, 0); + return found; +} + +export function buildLocalInventory( + skillDir: string, + skillMdPath: string, + logger: DiscoveryLogger, + opts?: { maxDepth?: number; maxFiles?: number }, +): FileInventoryEntry[] { + const maxDepth = opts?.maxDepth ?? DEFAULT_SKILL_WALK_MAX_DEPTH; + const maxFiles = opts?.maxFiles ?? DEFAULT_SKILL_WALK_MAX_FILES; + const inventory: FileInventoryEntry[] = [ + { path: "SKILL.md", kind: "skill" }, + ]; + const visitedRealPaths = new Set(); + let filesSeen = 0; + + function walk(dir: string, depth: number) { + if (depth > maxDepth) { + throw new Error( + `Skill directory too deep, refusing to walk beyond ${maxDepth} levels`, + ); + } + let realDir: string; + try { + realDir = realpathSync(dir); + } catch (err) { + logger.warn(`[skill-import] realpathSync failed for ${dir}:`, err instanceof Error ? err.message : String(err)); + return; + } + if (visitedRealPaths.has(realDir)) { + return; + } + visitedRealPaths.add(realDir); + + let entries: Dirent[]; + try { + entries = readdirSync(dir, { withFileTypes: true }); + } catch (err) { + logger.warn(`[skill-import] readdirSync failed for ${dir}:`, err instanceof Error ? err.message : String(err)); + return; + } + for (const entry of entries) { + filesSeen++; + if (filesSeen > maxFiles) { + throw new Error( + `Skill directory contains too many files, refusing after ${maxFiles} entries`, + ); + } + const fullPath = join(dir, entry.name); + if (entry.isDirectory()) { + walk(fullPath, depth + 1); + continue; + } + if (!entry.isFile()) continue; + const resolvedPath = resolve(fullPath); + if (resolvedPath === skillMdPath) continue; + const relativePath = relative(skillDir, resolvedPath).replace(/\\/g, "/"); + inventory.push({ + path: relativePath, + kind: classifyInventoryKind(relativePath), + }); + } + } + + try { + walk(skillDir, 0); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + if (msg.startsWith("Skill directory too deep") || msg.startsWith("Skill directory contains too many files")) { + throw err; + } + } + return inventory; +} diff --git a/src/main/skills/import-local.ts b/src/main/skills/import-local.ts new file mode 100644 index 00000000..39ecd7bd --- /dev/null +++ b/src/main/skills/import-local.ts @@ -0,0 +1,147 @@ +import { randomUUID } from "node:crypto"; +import { existsSync, readFileSync, realpathSync, statSync } from "node:fs"; +import { resolve, basename, dirname } from "node:path"; +import type { AppDatabase } from "../database"; +import type { FileInventoryEntry, SkillImportResult } from "../skill-types.js"; +import { parseFrontmatterMarkdown, deriveTrustLevel } from "../skill-format.js"; +import { + deriveCanonicalSkillKey, + normalizeSkillSlug, + hashSkillValue, +} from "../skill-keys.js"; +import { + assertNotSystemPath, + findSkillMdFiles, + buildLocalInventory, +} from "./discovery"; +import { materializeSkillFiles } from "./materialize"; + +export interface LocalImportContext { + db: AppDatabase; + logger: { + info: (...args: unknown[]) => void; + warn: (...args: unknown[]) => void; + error: (...args: unknown[]) => void; + }; + skillFilesDir: (companyId: string, slug: string) => string; +} + +export async function importFromLocal( + ctx: LocalImportContext, + companyId: string, + localPath: string, +): Promise { + const { db, logger, skillFilesDir } = ctx; + const resolvedPath = resolve(localPath.replace(/^~/, process.env.HOME ?? process.env.USERPROFILE ?? "")); + + if (!existsSync(resolvedPath)) { + throw new Error(`Local path does not exist: ${resolvedPath}`); + } + + // Resolve symlinks to prevent traversal attacks (agent importing /tmp/evil-link → /etc/shadow) + const realPath = realpathSync(resolvedPath); + assertNotSystemPath(realPath); + + const stat = statSync(resolvedPath); + const results: SkillImportResult[] = []; + const nowTs = new Date().toISOString(); + + if (stat.isFile()) { + const markdown = readFileSync(resolvedPath, "utf-8"); + const parsed = parseFrontmatterMarkdown(markdown); + const rawSlug = + normalizeSkillSlug(parsed.frontmatter.slug as string | undefined) ?? + normalizeSkillSlug(basename(resolvedPath, ".md")) ?? + "skill"; + const inventory: FileInventoryEntry[] = [ + { path: "SKILL.md", kind: "skill" }, + ]; + const trustLevel = deriveTrustLevel(inventory); + const contentHash = hashSkillValue(markdown); + + const key = deriveCanonicalSkillKey(companyId, { + slug: rawSlug, + sourceType: "local_path", + sourceLocator: resolvedPath, + metadata: null, + }); + + const skillId = randomUUID(); + await materializeSkillFiles(skillFilesDir(companyId, rawSlug), markdown, inventory); + + const { isUpdate } = db.upsertCompanySkill({ + id: skillId, + companyId, + key, + slug: rawSlug, + name: (typeof parsed.frontmatter.name === "string" ? parsed.frontmatter.name : null) ?? rawSlug, + description: typeof parsed.frontmatter.description === "string" ? parsed.frontmatter.description : null, + sourceType: "local_path", + sourceLocator: resolvedPath, + sourceRef: contentHash, + trustLevel, + compatibility: "compatible", + fileInventory: JSON.stringify(inventory), + metadata: null, + createdAt: nowTs, + updatedAt: nowTs, + }); + + results.push({ skillId, key, name: rawSlug, isUpdate }); + logger.info(`Skill ${isUpdate ? "updated" : "imported"} from local file: ${key}`); + return results; + } + + // Directory: walk to find SKILL.md files + const skillMdFiles = findSkillMdFiles(resolvedPath, logger); + if (skillMdFiles.length === 0) { + throw new Error(`No SKILL.md files found in ${resolvedPath}`); + } + + for (const skillMdPath of skillMdFiles) { + const skillDir = dirname(skillMdPath); + const markdown = readFileSync(skillMdPath, "utf-8"); + const parsed = parseFrontmatterMarkdown(markdown); + const rawSlug = + normalizeSkillSlug(parsed.frontmatter.slug as string | undefined) ?? + normalizeSkillSlug(basename(skillDir)) ?? + "skill"; + + const inventory = buildLocalInventory(skillDir, skillMdPath, logger); + const trustLevel = deriveTrustLevel(inventory); + const contentHash = hashSkillValue(markdown); + + const key = deriveCanonicalSkillKey(companyId, { + slug: rawSlug, + sourceType: "local_path", + sourceLocator: skillDir, + metadata: null, + }); + + const skillId = randomUUID(); + await materializeSkillFiles(skillFilesDir(companyId, rawSlug), markdown, inventory); + + const { isUpdate } = db.upsertCompanySkill({ + id: skillId, + companyId, + key, + slug: rawSlug, + name: (typeof parsed.frontmatter.name === "string" ? parsed.frontmatter.name : null) ?? rawSlug, + description: typeof parsed.frontmatter.description === "string" ? parsed.frontmatter.description : null, + sourceType: "local_path", + sourceLocator: skillDir, + sourceRef: contentHash, + trustLevel, + compatibility: "compatible", + fileInventory: JSON.stringify(inventory), + metadata: null, + createdAt: nowTs, + updatedAt: nowTs, + }); + + results.push({ skillId, key, name: rawSlug, isUpdate }); + logger.info(`Skill ${isUpdate ? "updated" : "imported"} from local path: ${key}`); + } + + return results; +} diff --git a/src/main/skills/import-url.ts b/src/main/skills/import-url.ts new file mode 100644 index 00000000..41ced222 --- /dev/null +++ b/src/main/skills/import-url.ts @@ -0,0 +1,85 @@ +import { randomUUID } from "node:crypto"; +import type { AppDatabase } from "../database"; +import type { FileInventoryEntry, SkillImportResult } from "../skill-types.js"; +import { parseFrontmatterMarkdown } from "../skill-format.js"; +import { deriveCanonicalSkillKey, normalizeSkillSlug } from "../skill-keys.js"; +import { materializeSkillFiles } from "./materialize"; + +export interface UrlImportContext { + db: AppDatabase; + logger: { + info: (...args: unknown[]) => void; + warn: (...args: unknown[]) => void; + error: (...args: unknown[]) => void; + }; + skillFilesDir: (companyId: string, slug: string) => string; +} + +export async function importFromUrl( + ctx: UrlImportContext, + companyId: string, + url: string, + safeFetch: (target: string) => Promise, +): Promise { + const { db, logger, skillFilesDir } = ctx; + const response = await safeFetch(url); + if (!response.ok) { + throw new Error(`Failed to fetch skill from URL: ${response.status} ${url}`); + } + const markdown = await response.text(); + const parsed = parseFrontmatterMarkdown(markdown); + const nowTs = new Date().toISOString(); + + let urlSlug: string | null = null; + try { + const urlObj = new URL(url); + const pathParts = urlObj.pathname.split("/").filter(Boolean); + const lastPart = pathParts[pathParts.length - 1] ?? ""; + urlSlug = normalizeSkillSlug(lastPart.replace(/\.md$/i, "")); + } catch (err) { + logger.warn(`[skill-import] Unable to parse skill URL for slug derivation (${url}):`, err instanceof Error ? err.message : String(err)); + } + const rawSlug = + normalizeSkillSlug(parsed.frontmatter.slug as string | undefined) ?? + urlSlug ?? + "skill"; + + const inventory: FileInventoryEntry[] = [ + { path: "SKILL.md", kind: "skill" }, + ]; + const trustLevel = "markdown_only" as const; + + const key = deriveCanonicalSkillKey(companyId, { + slug: rawSlug, + sourceType: "url", + sourceLocator: url, + metadata: null, + }); + + const skillId = randomUUID(); + await materializeSkillFiles(skillFilesDir(companyId, rawSlug), markdown, inventory); + + const { isUpdate } = db.upsertCompanySkill({ + id: skillId, + companyId, + key, + slug: rawSlug, + name: (typeof parsed.frontmatter.name === "string" ? parsed.frontmatter.name : null) ?? rawSlug, + description: typeof parsed.frontmatter.description === "string" ? parsed.frontmatter.description : null, + sourceType: "url", + sourceLocator: url, + sourceRef: null, + trustLevel, + compatibility: "compatible", + fileInventory: JSON.stringify(inventory), + metadata: null, + createdAt: nowTs, + updatedAt: nowTs, + }); + + const results: SkillImportResult[] = [ + { skillId, key, name: rawSlug, isUpdate }, + ]; + logger.info(`Skill ${isUpdate ? "updated" : "imported"} from URL: ${key}`); + return results; +} diff --git a/src/main/skills/import.ts b/src/main/skills/import.ts index 2542b8f7..67c2254d 100644 --- a/src/main/skills/import.ts +++ b/src/main/skills/import.ts @@ -1,40 +1,15 @@ -import { randomUUID } from "node:crypto"; -import { existsSync, readdirSync, readFileSync, realpathSync, statSync, type Dirent } from "node:fs"; -import { mkdir, writeFile, rm } from "node:fs/promises"; -import { join, resolve, basename, dirname, relative } from "node:path"; import type { AppDatabase } from "../database"; import type { DesktopResult } from "@shared/contracts"; -import type { FileInventoryEntry, SkillImportResult } from "../skill-types.js"; -import { - parseFrontmatterMarkdown, - classifyInventoryKind, - deriveTrustLevel, -} from "../skill-format.js"; -import { - deriveCanonicalSkillKey, - normalizeSkillSlug, - hashSkillValue, - parseSkillImportSource, -} from "../skill-keys.js"; +import type { SkillImportResult } from "../skill-types.js"; +import { parseSkillImportSource } from "../skill-keys.js"; import { createGitHubImporter } from "./import-github"; -import { MAX_SKILL_BYTES } from "../skill-github.js"; - -const BLOCKED_PATH_PREFIXES_UNIX = ["/etc", "/var", "/proc", "/sys", "/dev", "/boot", "/sbin", "/usr/sbin"]; -const BLOCKED_PATH_PREFIXES_WIN = ["C:\\Windows", "C:\\Program Files", "C:\\ProgramData"]; - -const DEFAULT_SKILL_WALK_MAX_DEPTH = 8; -const DEFAULT_SKILL_WALK_MAX_FILES = 256; - -function assertNotSystemPath(realPath: string): void { - const normalized = realPath.replace(/\\/g, "/"); - const blocked = process.platform === "win32" ? BLOCKED_PATH_PREFIXES_WIN : BLOCKED_PATH_PREFIXES_UNIX; - for (const prefix of blocked) { - const normalizedPrefix = prefix.replace(/\\/g, "/"); - if (normalized === normalizedPrefix || normalized.startsWith(normalizedPrefix + "/")) { - throw new Error(`Importing skills from system directory is not allowed: ${realPath}`); - } - } -} +import { + findSkillMdFiles as findSkillMdFilesImpl, + buildLocalInventory as buildLocalInventoryImpl, +} from "./discovery"; +import { cleanupSkillFilesDir } from "./materialize"; +import { importFromLocal as importFromLocalImpl } from "./import-local"; +import { importFromUrl as importFromUrlImpl } from "./import-url"; interface ImportDeps { db: AppDatabase; @@ -57,29 +32,40 @@ export function createSkillImporter({ db, profileDir: _profileDir, logger, skill } const ghImporter = createGitHubImporter({ db, logger, skillFilesDir }); - - async function materializeSkillFiles( - companyId: string, - slug: string, - markdown: string, - _inventory: FileInventoryEntry[], - ): Promise { - if (markdown.length * 2 > MAX_SKILL_BYTES) { - throw new Error( - `SKILL.md exceeds ${MAX_SKILL_BYTES}-byte limit`, - ); - } - const dir = skillFilesDir(companyId, slug); - await mkdir(dir, { recursive: true }); - await writeFile(join(dir, "SKILL.md"), markdown, "utf-8"); - return dir; - } + const ctx = { db, logger, skillFilesDir }; async function cleanupSkillFiles(companyId: string, slug: string): Promise { - const dir = skillFilesDir(companyId, slug); - if (existsSync(dir)) { - await rm(dir, { recursive: true, force: true }); - } + await cleanupSkillFilesDir(skillFilesDir(companyId, slug)); + } + + async function importFromLocal( + companyId: string, + localPath: string, + ): Promise { + return importFromLocalImpl(ctx, companyId, localPath); + } + + async function importFromUrl( + companyId: string, + url: string, + safeFetch: (target: string) => Promise, + ): Promise { + return importFromUrlImpl(ctx, companyId, url, safeFetch); + } + + function findSkillMdFiles( + rootDir: string, + opts?: { maxDepth?: number; maxFiles?: number }, + ): string[] { + return findSkillMdFilesImpl(rootDir, logger, opts); + } + + function buildLocalInventory( + skillDir: string, + skillMdPath: string, + opts?: { maxDepth?: number; maxFiles?: number }, + ) { + return buildLocalInventoryImpl(skillDir, skillMdPath, logger, opts); } async function importFromSource( @@ -133,318 +119,6 @@ export function createSkillImporter({ db, profileDir: _profileDir, logger, skill } } - async function importFromLocal( - companyId: string, - localPath: string, - ): Promise { - const resolvedPath = resolve(localPath.replace(/^~/, process.env.HOME ?? process.env.USERPROFILE ?? "")); - - if (!existsSync(resolvedPath)) { - throw new Error(`Local path does not exist: ${resolvedPath}`); - } - - // Resolve symlinks to prevent traversal attacks (agent importing /tmp/evil-link → /etc/shadow) - const realPath = realpathSync(resolvedPath); - assertNotSystemPath(realPath); - - const stat = statSync(resolvedPath); - const results: SkillImportResult[] = []; - const nowTs = new Date().toISOString(); - - if (stat.isFile()) { - const markdown = readFileSync(resolvedPath, "utf-8"); - const parsed = parseFrontmatterMarkdown(markdown); - const rawSlug = - normalizeSkillSlug(parsed.frontmatter.slug as string | undefined) ?? - normalizeSkillSlug(basename(resolvedPath, ".md")) ?? - "skill"; - const inventory: FileInventoryEntry[] = [ - { path: "SKILL.md", kind: "skill" }, - ]; - const trustLevel = deriveTrustLevel(inventory); - const contentHash = hashSkillValue(markdown); - - const key = deriveCanonicalSkillKey(companyId, { - slug: rawSlug, - sourceType: "local_path", - sourceLocator: resolvedPath, - metadata: null, - }); - - const skillId = randomUUID(); - await materializeSkillFiles(companyId, rawSlug, markdown, inventory); - - const { isUpdate } = db.upsertCompanySkill({ - id: skillId, - companyId, - key, - slug: rawSlug, - name: (typeof parsed.frontmatter.name === "string" ? parsed.frontmatter.name : null) ?? rawSlug, - description: typeof parsed.frontmatter.description === "string" ? parsed.frontmatter.description : null, - sourceType: "local_path", - sourceLocator: resolvedPath, - sourceRef: contentHash, - trustLevel, - compatibility: "compatible", - fileInventory: JSON.stringify(inventory), - metadata: null, - createdAt: nowTs, - updatedAt: nowTs, - }); - - results.push({ skillId, key, name: rawSlug, isUpdate }); - logger.info(`Skill ${isUpdate ? "updated" : "imported"} from local file: ${key}`); - return results; - } - - // Directory: walk to find SKILL.md files - const skillMdFiles = findSkillMdFiles(resolvedPath); - if (skillMdFiles.length === 0) { - throw new Error(`No SKILL.md files found in ${resolvedPath}`); - } - - for (const skillMdPath of skillMdFiles) { - const skillDir = dirname(skillMdPath); - const markdown = readFileSync(skillMdPath, "utf-8"); - const parsed = parseFrontmatterMarkdown(markdown); - const rawSlug = - normalizeSkillSlug(parsed.frontmatter.slug as string | undefined) ?? - normalizeSkillSlug(basename(skillDir)) ?? - "skill"; - - const inventory = buildLocalInventory(skillDir, skillMdPath); - const trustLevel = deriveTrustLevel(inventory); - const contentHash = hashSkillValue(markdown); - - const key = deriveCanonicalSkillKey(companyId, { - slug: rawSlug, - sourceType: "local_path", - sourceLocator: skillDir, - metadata: null, - }); - - const skillId = randomUUID(); - await materializeSkillFiles(companyId, rawSlug, markdown, inventory); - - const { isUpdate } = db.upsertCompanySkill({ - id: skillId, - companyId, - key, - slug: rawSlug, - name: (typeof parsed.frontmatter.name === "string" ? parsed.frontmatter.name : null) ?? rawSlug, - description: typeof parsed.frontmatter.description === "string" ? parsed.frontmatter.description : null, - sourceType: "local_path", - sourceLocator: skillDir, - sourceRef: contentHash, - trustLevel, - compatibility: "compatible", - fileInventory: JSON.stringify(inventory), - metadata: null, - createdAt: nowTs, - updatedAt: nowTs, - }); - - results.push({ skillId, key, name: rawSlug, isUpdate }); - logger.info(`Skill ${isUpdate ? "updated" : "imported"} from local path: ${key}`); - } - - return results; - } - - async function importFromUrl( - companyId: string, - url: string, - safeFetch: (target: string) => Promise, - ): Promise { - const response = await safeFetch(url); - if (!response.ok) { - throw new Error(`Failed to fetch skill from URL: ${response.status} ${url}`); - } - const markdown = await response.text(); - const parsed = parseFrontmatterMarkdown(markdown); - const nowTs = new Date().toISOString(); - - let urlSlug: string | null = null; - try { - const urlObj = new URL(url); - const pathParts = urlObj.pathname.split("/").filter(Boolean); - const lastPart = pathParts[pathParts.length - 1] ?? ""; - urlSlug = normalizeSkillSlug(lastPart.replace(/\.md$/i, "")); - } catch (err) { - logger.warn(`[skill-import] Unable to parse skill URL for slug derivation (${url}):`, err instanceof Error ? err.message : String(err)); - } - const rawSlug = - normalizeSkillSlug(parsed.frontmatter.slug as string | undefined) ?? - urlSlug ?? - "skill"; - - const inventory: FileInventoryEntry[] = [ - { path: "SKILL.md", kind: "skill" }, - ]; - const trustLevel = "markdown_only" as const; - - const key = deriveCanonicalSkillKey(companyId, { - slug: rawSlug, - sourceType: "url", - sourceLocator: url, - metadata: null, - }); - - const skillId = randomUUID(); - await materializeSkillFiles(companyId, rawSlug, markdown, inventory); - - const { isUpdate } = db.upsertCompanySkill({ - id: skillId, - companyId, - key, - slug: rawSlug, - name: (typeof parsed.frontmatter.name === "string" ? parsed.frontmatter.name : null) ?? rawSlug, - description: typeof parsed.frontmatter.description === "string" ? parsed.frontmatter.description : null, - sourceType: "url", - sourceLocator: url, - sourceRef: null, - trustLevel, - compatibility: "compatible", - fileInventory: JSON.stringify(inventory), - metadata: null, - createdAt: nowTs, - updatedAt: nowTs, - }); - - const results: SkillImportResult[] = [ - { skillId, key, name: rawSlug, isUpdate }, - ]; - logger.info(`Skill ${isUpdate ? "updated" : "imported"} from URL: ${key}`); - return results; - } - - function findSkillMdFiles( - rootDir: string, - opts?: { maxDepth?: number; maxFiles?: number }, - ): string[] { - const maxDepth = opts?.maxDepth ?? DEFAULT_SKILL_WALK_MAX_DEPTH; - const maxFiles = opts?.maxFiles ?? DEFAULT_SKILL_WALK_MAX_FILES; - const found: string[] = []; - const visitedRealPaths = new Set(); - let filesSeen = 0; - - function walk(dir: string, depth: number) { - if (depth > maxDepth) { - throw new Error( - `Skill directory too deep, refusing to walk beyond ${maxDepth} levels`, - ); - } - let realDir: string; - try { - realDir = realpathSync(dir); - } catch (err) { - logger.warn(`[skill-import] realpathSync failed for ${dir}:`, err instanceof Error ? err.message : String(err)); - return; - } - if (visitedRealPaths.has(realDir)) { - return; - } - visitedRealPaths.add(realDir); - - let entries: Dirent[]; - try { - entries = readdirSync(dir, { withFileTypes: true }); - } catch (err) { - logger.warn(`[skill-import] readdirSync failed for ${dir}:`, err instanceof Error ? err.message : String(err)); - return; - } - for (const entry of entries) { - filesSeen++; - if (filesSeen > maxFiles) { - throw new Error( - `Skill directory contains too many files, refusing after ${maxFiles} entries`, - ); - } - const fullPath = join(dir, entry.name); - if (entry.isDirectory()) { - walk(fullPath, depth + 1); - } else if (entry.name === "SKILL.md") { - found.push(fullPath); - } - } - } - walk(rootDir, 0); - return found; - } - - function buildLocalInventory( - skillDir: string, - skillMdPath: string, - opts?: { maxDepth?: number; maxFiles?: number }, - ): FileInventoryEntry[] { - const maxDepth = opts?.maxDepth ?? DEFAULT_SKILL_WALK_MAX_DEPTH; - const maxFiles = opts?.maxFiles ?? DEFAULT_SKILL_WALK_MAX_FILES; - const inventory: FileInventoryEntry[] = [ - { path: "SKILL.md", kind: "skill" }, - ]; - const visitedRealPaths = new Set(); - let filesSeen = 0; - - function walk(dir: string, depth: number) { - if (depth > maxDepth) { - throw new Error( - `Skill directory too deep, refusing to walk beyond ${maxDepth} levels`, - ); - } - let realDir: string; - try { - realDir = realpathSync(dir); - } catch (err) { - logger.warn(`[skill-import] realpathSync failed for ${dir}:`, err instanceof Error ? err.message : String(err)); - return; - } - if (visitedRealPaths.has(realDir)) { - return; - } - visitedRealPaths.add(realDir); - - let entries: Dirent[]; - try { - entries = readdirSync(dir, { withFileTypes: true }); - } catch (err) { - logger.warn(`[skill-import] readdirSync failed for ${dir}:`, err instanceof Error ? err.message : String(err)); - return; - } - for (const entry of entries) { - filesSeen++; - if (filesSeen > maxFiles) { - throw new Error( - `Skill directory contains too many files, refusing after ${maxFiles} entries`, - ); - } - const fullPath = join(dir, entry.name); - if (entry.isDirectory()) { - walk(fullPath, depth + 1); - continue; - } - if (!entry.isFile()) continue; - const resolvedPath = resolve(fullPath); - if (resolvedPath === skillMdPath) continue; - const relativePath = relative(skillDir, resolvedPath).replace(/\\/g, "/"); - inventory.push({ - path: relativePath, - kind: classifyInventoryKind(relativePath), - }); - } - } - - try { - walk(skillDir, 0); - } catch (err) { - // Re-throw limit errors so caller can surface them; silently ignore others. - const msg = err instanceof Error ? err.message : String(err); - if (msg.startsWith("Skill directory too deep") || msg.startsWith("Skill directory contains too many files")) { - throw err; - } - } - return inventory; - } - return { importFromSource, importFromGitHub: ghImporter.importFromGitHub, diff --git a/src/main/skills/materialize.ts b/src/main/skills/materialize.ts new file mode 100644 index 00000000..5ee2caf0 --- /dev/null +++ b/src/main/skills/materialize.ts @@ -0,0 +1,24 @@ +import { existsSync } from "node:fs"; +import { mkdir, writeFile, rm } from "node:fs/promises"; +import { join } from "node:path"; +import type { FileInventoryEntry } from "../skill-types.js"; +import { MAX_SKILL_BYTES } from "../skill-github.js"; + +export async function materializeSkillFiles( + dir: string, + markdown: string, + _inventory: FileInventoryEntry[], +): Promise { + if (markdown.length * 2 > MAX_SKILL_BYTES) { + throw new Error(`SKILL.md exceeds ${MAX_SKILL_BYTES}-byte limit`); + } + await mkdir(dir, { recursive: true }); + await writeFile(join(dir, "SKILL.md"), markdown, "utf-8"); + return dir; +} + +export async function cleanupSkillFilesDir(dir: string): Promise { + if (existsSync(dir)) { + await rm(dir, { recursive: true, force: true }); + } +} diff --git a/src/renderer/components/ui/ActionButton.tsx b/src/renderer/components/ui/ActionButton.tsx new file mode 100644 index 00000000..f3525619 --- /dev/null +++ b/src/renderer/components/ui/ActionButton.tsx @@ -0,0 +1,55 @@ +import { type ElementType } from "react"; +import { Spinner } from "./Spinner"; + +export function ActionButton({ + label, + onClick, + icon: Icon, + tone = "default", + loading = false, + disabled = false, + iconOnly = false, + type = "button", + ariaLabel, +}: { + label: string; + onClick?: () => void; + icon?: ElementType; + tone?: "default" | "accent" | "danger"; + loading?: boolean; + disabled?: boolean; + iconOnly?: boolean; + type?: "button" | "submit" | "reset"; + ariaLabel?: string; +}) { + const isDisabled = loading || disabled; + const toneClass = + tone === "accent" + ? "border-transparent bg-primary text-primary-foreground shadow-[0_1px_3px_rgba(74,93,122,0.35)] hover:bg-primary/90" + : tone === "danger" + ? "border-transparent bg-destructive text-primary-foreground shadow-[0_1px_3px_rgba(220,53,69,0.3)] hover:brightness-90" + : "border-border bg-card text-foreground hover:bg-muted"; + + const sizing = iconOnly + ? "h-10 w-10 p-0 justify-center" + : "gap-2 px-4 py-2.5"; + + return ( + + ); +} diff --git a/src/renderer/components/ui/Badge.tsx b/src/renderer/components/ui/Badge.tsx new file mode 100644 index 00000000..7519ec6d --- /dev/null +++ b/src/renderer/components/ui/Badge.tsx @@ -0,0 +1,9 @@ +export function Badge({ count, tone = "default" }: { count: number; tone?: "default" | "warn" | "danger" }) { + if (count <= 0) return null; + const toneClass = tone === "danger" ? "bg-destructive text-primary-foreground" : tone === "warn" ? "bg-warning text-primary-foreground" : "bg-[color:var(--muted-foreground)] text-primary-foreground"; + return ( + + {count > 99 ? "99+" : count} + + ); +} diff --git a/src/renderer/components/ui/Card.tsx b/src/renderer/components/ui/Card.tsx new file mode 100644 index 00000000..1c498bfa --- /dev/null +++ b/src/renderer/components/ui/Card.tsx @@ -0,0 +1,30 @@ +import { type ReactNode } from "react"; + +export function Card({ + children, + className = "", + padded = true, + padding, + as: Tag = "div", +}: { + children: ReactNode; + className?: string; + padded?: boolean; + padding?: "none" | "sm" | "md" | "lg"; + as?: "div" | "section" | "article"; +}) { + const resolvedPadding = padding ?? (padded ? "md" : "none"); + const paddingClass = + resolvedPadding === "lg" + ? "p-5" + : resolvedPadding === "sm" + ? "px-4 py-3" + : resolvedPadding === "none" + ? "" + : "p-4"; + return ( + + {children} + + ); +} diff --git a/src/renderer/components/ui/Eyebrow.tsx b/src/renderer/components/ui/Eyebrow.tsx new file mode 100644 index 00000000..41aa9254 --- /dev/null +++ b/src/renderer/components/ui/Eyebrow.tsx @@ -0,0 +1,27 @@ +import { type ReactNode } from "react"; + +export function Eyebrow({ + children, + tone = "muted", + className = "", +}: { + children: ReactNode; + tone?: "muted" | "primary" | "warning" | "destructive" | "success"; + className?: string; +}) { + const toneClass = + tone === "primary" + ? "text-primary" + : tone === "warning" + ? "text-warning" + : tone === "destructive" + ? "text-destructive" + : tone === "success" + ? "text-success" + : "text-muted-foreground"; + return ( +
+ {children} +
+ ); +} diff --git a/src/renderer/components/ui/Field.tsx b/src/renderer/components/ui/Field.tsx new file mode 100644 index 00000000..5c1e1810 --- /dev/null +++ b/src/renderer/components/ui/Field.tsx @@ -0,0 +1,10 @@ +import { type ReactNode } from "react"; + +export function Field({ label, children }: { label: string; children: ReactNode }) { + return ( + + ); +} diff --git a/src/renderer/components/ui/Input.tsx b/src/renderer/components/ui/Input.tsx new file mode 100644 index 00000000..a4709c61 --- /dev/null +++ b/src/renderer/components/ui/Input.tsx @@ -0,0 +1,49 @@ +export function Input({ + value, + onChange, + placeholder, + type = "text", + readOnly = false, + disabled = false, + label, + hint, + id, + ariaLabel, +}: { + value: string; + onChange: (value: string) => void; + placeholder?: string; + type?: string; + readOnly?: boolean; + disabled?: boolean; + label?: string; + hint?: string; + id?: string; + ariaLabel?: string; +}) { + const input = ( + onChange(event.target.value)} + /> + ); + if (label) { + return ( + + ); + } + return input; +} diff --git a/src/renderer/components/ui/Panel.tsx b/src/renderer/components/ui/Panel.tsx new file mode 100644 index 00000000..7bd8adc6 --- /dev/null +++ b/src/renderer/components/ui/Panel.tsx @@ -0,0 +1,30 @@ +import { type ReactNode } from "react"; + +export function Panel({ + title, + eyebrow, + children, + action, + className = "", + tone = "plain", +}: { + title: string; + eyebrow?: string; + children: ReactNode; + action?: ReactNode; + className?: string; + tone?: "plain" | "surface"; +}) { + return ( +
+
+
+ {eyebrow ?
{eyebrow}
: null} +

{title}

+
+ {action} +
+
{children}
+
+ ); +} diff --git a/src/renderer/components/ui/SectionButton.tsx b/src/renderer/components/ui/SectionButton.tsx new file mode 100644 index 00000000..4e70cf81 --- /dev/null +++ b/src/renderer/components/ui/SectionButton.tsx @@ -0,0 +1,59 @@ +import { type ElementType } from "react"; +import { Badge } from "./Badge"; + +export function SectionButton({ + active, + label, + icon: Icon, + onClick, + badge, + badgeTone, + collapsed = false, +}: { + active: boolean; + label: string; + icon: ElementType; + onClick: () => void; + badge?: number; + badgeTone?: "default" | "warn" | "danger"; + collapsed?: boolean; +}) { + if (collapsed) { + return ( + + ); + } + return ( + + ); +} diff --git a/src/renderer/components/ui/Select.tsx b/src/renderer/components/ui/Select.tsx new file mode 100644 index 00000000..b7402e63 --- /dev/null +++ b/src/renderer/components/ui/Select.tsx @@ -0,0 +1,48 @@ +export function Select({ + value, + onChange, + options, + compact = false, + label, + hint, + disabled = false, +}: { + value: string; + onChange: (value: string) => void; + options: Array<{ value: string; label: string }>; + compact?: boolean; + label?: string; + hint?: string; + disabled?: boolean; +}) { + const select = ( + + ); + if (label) { + return ( + + ); + } + return select; +} diff --git a/src/renderer/components/ui/Spinner.tsx b/src/renderer/components/ui/Spinner.tsx new file mode 100644 index 00000000..1e2c7d75 --- /dev/null +++ b/src/renderer/components/ui/Spinner.tsx @@ -0,0 +1,14 @@ +export function Spinner({ size = 16, className = "" }: { size?: number; className?: string }) { + return ( + + ); +} diff --git a/src/renderer/components/ui/TextArea.tsx b/src/renderer/components/ui/TextArea.tsx new file mode 100644 index 00000000..276da780 --- /dev/null +++ b/src/renderer/components/ui/TextArea.tsx @@ -0,0 +1,44 @@ +export function TextArea({ + value, + onChange, + placeholder, + rows = 4, + label, + hint, + readOnly = false, + disabled = false, +}: { + value: string; + onChange: (value: string) => void; + placeholder?: string; + rows?: number; + label?: string; + hint?: string; + readOnly?: boolean; + disabled?: boolean; +}) { + const textarea = ( +