refactor(ui+skills): split import.ts + primitives.tsx god files
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.
This commit is contained in:
1 parent
dbdec86cdd
commit
00b0e0aaed
17 files changed
+836
-786
No files matched your search
@@ -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<string>();
|
||||
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<string>();
|
||||
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;
|
||||
}
|
||||
@@ -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<SkillImportResult[]> {
|
||||
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;
|
||||
}
|
||||
@@ -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<Response>,
|
||||
): Promise<SkillImportResult[]> {
|
||||
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;
|
||||
}
|
||||
+41
-367
@@ -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<string> {
|
||||
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<void> {
|
||||
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<SkillImportResult[]> {
|
||||
return importFromLocalImpl(ctx, companyId, localPath);
|
||||
}
|
||||
|
||||
async function importFromUrl(
|
||||
companyId: string,
|
||||
url: string,
|
||||
safeFetch: (target: string) => Promise<Response>,
|
||||
): Promise<SkillImportResult[]> {
|
||||
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<SkillImportResult[]> {
|
||||
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<Response>,
|
||||
): Promise<SkillImportResult[]> {
|
||||
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<string>();
|
||||
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<string>();
|
||||
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,
|
||||
|
||||
@@ -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<string> {
|
||||
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<void> {
|
||||
if (existsSync(dir)) {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
@@ -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 (
|
||||
<button
|
||||
type={type}
|
||||
aria-label={iconOnly ? (ariaLabel ?? label) : ariaLabel}
|
||||
title={iconOnly ? label : undefined}
|
||||
className={`focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 inline-flex items-center rounded-lg border text-[12px] font-bold transition-all duration-150 active:scale-[0.97] active:translate-y-px ${sizing} ${toneClass} ${isDisabled ? "pointer-events-none opacity-60" : ""} ${loading ? "scale-[0.98]" : ""}`}
|
||||
onClick={onClick}
|
||||
disabled={isDisabled}
|
||||
aria-busy={loading}
|
||||
>
|
||||
{loading ? (
|
||||
<Spinner size={16} />
|
||||
) : Icon ? (
|
||||
<Icon size={16} />
|
||||
) : null}
|
||||
{iconOnly ? null : <span>{label}</span>}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<span className={`ml-auto inline-flex h-[18px] min-w-[18px] items-center justify-center rounded-full px-1.5 text-[10px] font-bold ${toneClass}`}>
|
||||
{count > 99 ? "99+" : count}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<Tag className={`rounded-lg border border-border bg-card ${paddingClass} ${className}`}>
|
||||
{children}
|
||||
</Tag>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div className={`text-[11px] font-semibold uppercase tracking-[0.08em] ${toneClass} ${className}`}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { type ReactNode } from "react";
|
||||
|
||||
export function Field({ label, children }: { label: string; children: ReactNode }) {
|
||||
return (
|
||||
<label className="block">
|
||||
<div className="mb-1.5 text-[12px] font-medium text-muted-foreground">{label}</div>
|
||||
{children}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
@@ -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 = (
|
||||
<input
|
||||
id={id}
|
||||
aria-label={ariaLabel}
|
||||
type={type}
|
||||
className="focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 w-full rounded-lg border border-border bg-card px-3.5 py-2.5 text-[14px] text-foreground placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-60"
|
||||
value={value}
|
||||
placeholder={placeholder}
|
||||
readOnly={readOnly}
|
||||
disabled={disabled}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
/>
|
||||
);
|
||||
if (label) {
|
||||
return (
|
||||
<label className="block">
|
||||
<div className="mb-1.5 flex items-center gap-1.5 text-[12px] font-medium text-muted-foreground">
|
||||
{label}
|
||||
{hint ? <span title={hint} className="cursor-help text-[10px] text-muted-foreground/60 hover:text-muted-foreground">(?)</span> : null}
|
||||
</div>
|
||||
{input}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
return input;
|
||||
}
|
||||
@@ -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 (
|
||||
<section className={`space-y-4 ${className}`}>
|
||||
<div className={`flex items-end justify-between gap-4 ${tone === "surface" ? "border-b border-border pb-3" : ""}`}>
|
||||
<div>
|
||||
{eyebrow ? <div className="mb-1 text-[11px] font-semibold uppercase tracking-[0.08em] text-primary">{eyebrow}</div> : null}
|
||||
<h2 className="text-[20px] font-bold tracking-[-0.025em] text-foreground">{title}</h2>
|
||||
</div>
|
||||
{action}
|
||||
</div>
|
||||
<div>{children}</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<button
|
||||
aria-current={active ? "page" : undefined}
|
||||
aria-label={label}
|
||||
title={label}
|
||||
className={`focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 relative flex w-full items-center justify-center rounded-lg p-2 transition duration-150 ${
|
||||
active
|
||||
? "bg-muted text-foreground"
|
||||
: "text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
}`}
|
||||
onClick={onClick}
|
||||
>
|
||||
<Icon size={18} weight={active ? "fill" : "regular"} className={active ? "text-primary" : ""} />
|
||||
{badge ? (
|
||||
<span className={`absolute right-1 top-1 h-[6px] w-[6px] rounded-full ${
|
||||
badgeTone === "danger" ? "bg-destructive" : badgeTone === "warn" ? "bg-warning" : "bg-[color:var(--muted-foreground)]"
|
||||
}`} />
|
||||
) : null}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<button
|
||||
aria-current={active ? "page" : undefined}
|
||||
className={`focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 relative flex w-full items-center gap-2.5 rounded-lg px-3 py-[7px] text-left transition duration-150 ${
|
||||
active
|
||||
? "bg-accent text-foreground"
|
||||
: "text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
}`}
|
||||
onClick={onClick}
|
||||
>
|
||||
<span className={`absolute left-0 top-1/2 h-4 w-[3px] -translate-y-1/2 rounded-r-full bg-primary transition-all duration-200 ${active ? "opacity-100 scale-y-100" : "opacity-0 scale-y-0"}`} aria-hidden="true" />
|
||||
<Icon size={17} weight={active ? "fill" : "regular"} className={active ? "text-primary" : ""} />
|
||||
<span className={`text-[13px] tracking-[0.005em] ${active ? "font-bold" : "font-medium"}`}>{label}</span>
|
||||
{badge ? <Badge count={badge} tone={badgeTone} /> : null}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -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 = (
|
||||
<select
|
||||
className={`focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 w-full max-w-full border bg-card text-foreground ${
|
||||
compact
|
||||
? "rounded-lg border-border px-2.5 py-1.5 text-[13px] font-medium"
|
||||
: "rounded-lg border-border px-3.5 py-2.5 text-[14px]"
|
||||
} disabled:cursor-not-allowed disabled:opacity-60 truncate`}
|
||||
value={value}
|
||||
disabled={disabled}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
>
|
||||
{options.map((option) => (
|
||||
<option key={option.value} value={option.value} className="bg-card text-foreground">
|
||||
{option.label.length > 80 ? option.label.slice(0, 77) + "..." : option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
);
|
||||
if (label) {
|
||||
return (
|
||||
<label className="block">
|
||||
<div className="mb-1.5 flex items-center gap-1.5 text-[12px] font-medium text-muted-foreground">
|
||||
{label}
|
||||
{hint ? <span title={hint} className="cursor-help text-[10px] text-muted-foreground/60 hover:text-muted-foreground">(?)</span> : null}
|
||||
</div>
|
||||
{select}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
return select;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
export function Spinner({ size = 16, className = "" }: { size?: number; className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
className={`animate-spin ${className}`}
|
||||
style={{ width: size, height: size }}
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<circle cx="8" cy="8" r="6" stroke="currentColor" strokeWidth="2" opacity="0.3" />
|
||||
<path d="M14 8a6 6 0 0 0-6-6" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -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 = (
|
||||
<textarea
|
||||
className="focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 w-full max-w-full rounded-lg border border-border bg-card px-3.5 py-2.5 text-[14px] text-foreground placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-60 break-words"
|
||||
style={{ overflowWrap: "anywhere", wordBreak: "break-word", whiteSpace: "pre-wrap" }}
|
||||
value={value}
|
||||
rows={rows}
|
||||
placeholder={placeholder}
|
||||
readOnly={readOnly}
|
||||
disabled={disabled}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
/>
|
||||
);
|
||||
if (label) {
|
||||
return (
|
||||
<label className="block">
|
||||
<div className="mb-1.5 flex items-center gap-1.5 text-[12px] font-medium text-muted-foreground">
|
||||
{label}
|
||||
{hint ? <span title={hint} className="cursor-help text-[10px] text-muted-foreground/60 hover:text-muted-foreground">(?)</span> : null}
|
||||
</div>
|
||||
{textarea}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
return textarea;
|
||||
}
|
||||
@@ -1,419 +1,11 @@
|
||||
import { type ElementType, type ReactNode as ReactNodeType } from "react";
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Spinner */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export function Spinner({ size = 16, className = "" }: { size?: number; className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
className={`animate-spin ${className}`}
|
||||
style={{ width: size, height: size }}
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<circle cx="8" cy="8" r="6" stroke="currentColor" strokeWidth="2" opacity="0.3" />
|
||||
<path d="M14 8a6 6 0 0 0-6-6" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Eyebrow */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export function Eyebrow({
|
||||
children,
|
||||
tone = "muted",
|
||||
className = "",
|
||||
}: {
|
||||
children: ReactNodeType;
|
||||
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 (
|
||||
<div className={`text-[11px] font-semibold uppercase tracking-[0.08em] ${toneClass} ${className}`}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Card */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export function Card({
|
||||
children,
|
||||
className = "",
|
||||
padded = true,
|
||||
padding,
|
||||
as: Tag = "div",
|
||||
}: {
|
||||
children: ReactNodeType;
|
||||
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 (
|
||||
<Tag className={`rounded-lg border border-border bg-card ${paddingClass} ${className}`}>
|
||||
{children}
|
||||
</Tag>
|
||||
);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Badge */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
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 (
|
||||
<span className={`ml-auto inline-flex h-[18px] min-w-[18px] items-center justify-center rounded-full px-1.5 text-[10px] font-bold ${toneClass}`}>
|
||||
{count > 99 ? "99+" : count}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* SectionButton */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
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 (
|
||||
<button
|
||||
aria-current={active ? "page" : undefined}
|
||||
aria-label={label}
|
||||
title={label}
|
||||
className={`focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 relative flex w-full items-center justify-center rounded-lg p-2 transition duration-150 ${
|
||||
active
|
||||
? "bg-muted text-foreground"
|
||||
: "text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
}`}
|
||||
onClick={onClick}
|
||||
>
|
||||
<Icon size={18} weight={active ? "fill" : "regular"} className={active ? "text-primary" : ""} />
|
||||
{badge ? (
|
||||
<span className={`absolute right-1 top-1 h-[6px] w-[6px] rounded-full ${
|
||||
badgeTone === "danger" ? "bg-destructive" : badgeTone === "warn" ? "bg-warning" : "bg-[color:var(--muted-foreground)]"
|
||||
}`} />
|
||||
) : null}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<button
|
||||
aria-current={active ? "page" : undefined}
|
||||
className={`focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 relative flex w-full items-center gap-2.5 rounded-lg px-3 py-[7px] text-left transition duration-150 ${
|
||||
active
|
||||
? "bg-accent text-foreground"
|
||||
: "text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
}`}
|
||||
onClick={onClick}
|
||||
>
|
||||
<span className={`absolute left-0 top-1/2 h-4 w-[3px] -translate-y-1/2 rounded-r-full bg-primary transition-all duration-200 ${active ? "opacity-100 scale-y-100" : "opacity-0 scale-y-0"}`} aria-hidden="true" />
|
||||
<Icon size={17} weight={active ? "fill" : "regular"} className={active ? "text-primary" : ""} />
|
||||
<span className={`text-[13px] tracking-[0.005em] ${active ? "font-bold" : "font-medium"}`}>{label}</span>
|
||||
{badge ? <Badge count={badge} tone={badgeTone} /> : null}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Panel */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
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 (
|
||||
<section className={`space-y-4 ${className}`}>
|
||||
<div className={`flex items-end justify-between gap-4 ${tone === "surface" ? "border-b border-border pb-3" : ""}`}>
|
||||
<div>
|
||||
{eyebrow ? <div className="mb-1 text-[11px] font-semibold uppercase tracking-[0.08em] text-primary">{eyebrow}</div> : null}
|
||||
<h2 className="text-[20px] font-bold tracking-[-0.025em] text-foreground">{title}</h2>
|
||||
</div>
|
||||
{action}
|
||||
</div>
|
||||
<div>{children}</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Field */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export function Field({ label, children }: { label: string; children: ReactNode }) {
|
||||
return (
|
||||
<label className="block">
|
||||
<div className="mb-1.5 text-[12px] font-medium text-muted-foreground">{label}</div>
|
||||
{children}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Input */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
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 = (
|
||||
<input
|
||||
id={id}
|
||||
aria-label={ariaLabel}
|
||||
type={type}
|
||||
className="focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 w-full rounded-lg border border-border bg-card px-3.5 py-2.5 text-[14px] text-foreground placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-60"
|
||||
value={value}
|
||||
placeholder={placeholder}
|
||||
readOnly={readOnly}
|
||||
disabled={disabled}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
/>
|
||||
);
|
||||
if (label) {
|
||||
return (
|
||||
<label className="block">
|
||||
<div className="mb-1.5 flex items-center gap-1.5 text-[12px] font-medium text-muted-foreground">
|
||||
{label}
|
||||
{hint ? <span title={hint} className="cursor-help text-[10px] text-muted-foreground/60 hover:text-muted-foreground">(?)</span> : null}
|
||||
</div>
|
||||
{input}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
return input;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* TextArea */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
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 = (
|
||||
<textarea
|
||||
className="focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 w-full max-w-full rounded-lg border border-border bg-card px-3.5 py-2.5 text-[14px] text-foreground placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-60 break-words"
|
||||
style={{ overflowWrap: "anywhere", wordBreak: "break-word", whiteSpace: "pre-wrap" }}
|
||||
value={value}
|
||||
rows={rows}
|
||||
placeholder={placeholder}
|
||||
readOnly={readOnly}
|
||||
disabled={disabled}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
/>
|
||||
);
|
||||
if (label) {
|
||||
return (
|
||||
<label className="block">
|
||||
<div className="mb-1.5 flex items-center gap-1.5 text-[12px] font-medium text-muted-foreground">
|
||||
{label}
|
||||
{hint ? <span title={hint} className="cursor-help text-[10px] text-muted-foreground/60 hover:text-muted-foreground">(?)</span> : null}
|
||||
</div>
|
||||
{textarea}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
return textarea;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Select */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
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 = (
|
||||
<select
|
||||
className={`focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 w-full max-w-full border bg-card text-foreground ${
|
||||
compact
|
||||
? "rounded-lg border-border px-2.5 py-1.5 text-[13px] font-medium"
|
||||
: "rounded-lg border-border px-3.5 py-2.5 text-[14px]"
|
||||
} disabled:cursor-not-allowed disabled:opacity-60 truncate`}
|
||||
value={value}
|
||||
disabled={disabled}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
>
|
||||
{options.map((option) => (
|
||||
<option key={option.value} value={option.value} className="bg-card text-foreground">
|
||||
{option.label.length > 80 ? option.label.slice(0, 77) + "..." : option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
);
|
||||
if (label) {
|
||||
return (
|
||||
<label className="block">
|
||||
<div className="mb-1.5 flex items-center gap-1.5 text-[12px] font-medium text-muted-foreground">
|
||||
{label}
|
||||
{hint ? <span title={hint} className="cursor-help text-[10px] text-muted-foreground/60 hover:text-muted-foreground">(?)</span> : null}
|
||||
</div>
|
||||
{select}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
return select;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* ActionButton */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
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 (
|
||||
<button
|
||||
type={type}
|
||||
aria-label={iconOnly ? (ariaLabel ?? label) : ariaLabel}
|
||||
title={iconOnly ? label : undefined}
|
||||
className={`focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 inline-flex items-center rounded-lg border text-[12px] font-bold transition-all duration-150 active:scale-[0.97] active:translate-y-px ${sizing} ${toneClass} ${isDisabled ? "pointer-events-none opacity-60" : ""} ${loading ? "scale-[0.98]" : ""}`}
|
||||
onClick={onClick}
|
||||
disabled={isDisabled}
|
||||
aria-busy={loading}
|
||||
>
|
||||
{loading ? (
|
||||
<Spinner size={16} />
|
||||
) : Icon ? (
|
||||
<Icon size={16} />
|
||||
) : null}
|
||||
{iconOnly ? null : <span>{label}</span>}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
export { Spinner } from "./Spinner";
|
||||
export { Eyebrow } from "./Eyebrow";
|
||||
export { Card } from "./Card";
|
||||
export { Badge } from "./Badge";
|
||||
export { SectionButton } from "./SectionButton";
|
||||
export { Panel } from "./Panel";
|
||||
export { Field } from "./Field";
|
||||
export { Input } from "./Input";
|
||||
export { TextArea } from "./TextArea";
|
||||
export { Select } from "./Select";
|
||||
export { ActionButton } from "./ActionButton";
|
||||
Reference in new issue
Block a user