diff --git a/.claude/plans/2026-03-12-DarwinKitCLI-design.md b/.claude/plans/2026-03-12-DarwinKitCLI-design.md new file mode 100644 index 00000000..0d9010f9 --- /dev/null +++ b/.claude/plans/2026-03-12-DarwinKitCLI-design.md @@ -0,0 +1,134 @@ +# DarwinKit CLI Tool — Design Document + +## Goal + +Create an interactive CLI tool (`tools darwinkit`) that exposes the full DarwinKit API surface (NLP, Vision, TTS, Auth, iCloud, System) through both an interactive clack menu and flat CLI subcommands. + +## Architecture + +``` +@genesiscz/darwinkit (package) + ↓ +src/utils/macos/*.ts (util wrappers — thin layer over package) + ↓ +src/darwinkit/lib/commands.ts (registry map — single source of truth) + ↓ +src/darwinkit/index.ts (entry — interactive or CLI mode) +``` + +The CLI tool never imports from `@genesiscz/darwinkit` directly — it only calls `src/utils/macos/` utils. Phase 1 expands utils to cover iCloud/auth/system (currently missing), Phase 2 builds the CLI. + +## Phase 1: Expand Utils + +### New files in `src/utils/macos/`: + +**`auth.ts`** — Biometric authentication +- `checkBiometry()` → returns `{ available, biometry_type }` +- `authenticate(reason?)` → returns `{ success }` + +**`system.ts`** — System capabilities +- `getCapabilities()` → returns `{ version, os, arch, methods }` + +**`icloud.ts`** — iCloud Drive operations +- `icloudStatus()` → returns `{ available, container_url }` +- `icloudRead(path)` → returns `{ content }` +- `icloudWrite(path, content)` → returns `{ ok }` +- `icloudWriteBytes(path, data)` → returns `{ ok }` +- `icloudDelete(path)` → returns `{ ok }` +- `icloudMove(source, destination)` → returns `{ ok }` +- `icloudCopy(source, destination)` → returns `{ ok }` +- `icloudList(path)` → returns `ICloudDirEntry[]` +- `icloudMkdir(path)` → returns `{ ok }` +- `icloudStartMonitoring()` / `icloudStopMonitoring()` + +Update `index.ts` exports and `types.ts` re-exports for new package types. + +## Phase 2: CLI Tool + +### Command Registry (`src/darwinkit/lib/commands.ts`) + +Single source of truth — drives interactive menu, CLI dispatch, help generation, and param validation. + +```typescript +interface ParamDef { + name: string; + type: "string" | "number" | "boolean" | "string[]"; + required: boolean; + description: string; + default?: unknown; +} + +interface CommandDef { + name: string; // "detect-language" (CLI subcommand) + group: string; // "nlp" (interactive menu grouping) + description: string; // shown in help + interactive + params: ParamDef[]; // drives --help, validation, interactive prompts + run: (args: Record) => Promise; +} +``` + +### Groups & Commands (~35 total) + +**nlp** (11): detect-language, sentiment, tag, entities, lemmatize, keywords, embed, distance, similar, relevance, neighbors +**vision** (1): ocr +**text-analysis** (6): rank, batch-sentiment, group-by-language, batch-entities, deduplicate, cluster +**classification** (3): classify, classify-batch, group-by-category +**tts** (2): speak, list-voices +**auth** (2): check-biometry, authenticate +**icloud** (10): status, read, write, write-bytes, delete, move, copy, list, mkdir, monitor +**system** (1): capabilities + +### Output Formatting (`src/darwinkit/lib/format.ts`) + +`--format json|pretty|raw` + +- **json**: `JSON.stringify(result, null, 2)` — for piping +- **pretty**: Colored human-readable (tables for arrays, key-value for objects) +- **raw**: Just the value (string result → string, arrays → newline-separated) +- **Default**: pretty if TTY, json if piped + +### Interactive Flow (`src/darwinkit/lib/interactive.ts`) + +Progressive prompting with clack: + +1. `tools darwinkit` (TTY) → DarwinKit logo → group select → command select → param prompts → execute +2. `tools darwinkit` (non-TTY) → full help listing +3. `tools darwinkit ` (TTY, missing params) → shows usage line first → clack prompts for missing params +4. `tools darwinkit --all-params` → just execute, no prompting + +### File Structure + +``` +src/darwinkit/ +├── index.ts # entry: interactive vs CLI dispatch +├── lib/ +│ ├── commands.ts # registry map (single source of truth) +│ ├── format.ts # json/pretty/raw formatter +│ └── interactive.ts # clack prompts for interactive mode +``` + +### Example Usage + +```bash +# Interactive +tools darwinkit + +# CLI - flat subcommands +tools darwinkit detect-language "Bonjour le monde" +tools darwinkit sentiment "I love this product" +tools darwinkit ocr ~/screenshot.png --languages en-US,cs +tools darwinkit speak "Hello world" --voice Samantha --rate 200 +tools darwinkit icloud-list /Documents +tools darwinkit classify "fix null pointer" --categories "bug fix,feature,refactor" + +# Output control +tools darwinkit sentiment "Great!" --format json +tools darwinkit sentiment "Great!" --format raw # just: 0.95 + +# Piped +echo "Hello world" | tools darwinkit detect-language --format json | jq .language + +# Help +tools darwinkit --help +tools darwinkit ocr --help +``` diff --git a/.claude/plans/2026-03-12-DarwinKitCLI.md b/.claude/plans/2026-03-12-DarwinKitCLI.md new file mode 100644 index 00000000..8cd8024d --- /dev/null +++ b/.claude/plans/2026-03-12-DarwinKitCLI.md @@ -0,0 +1,1615 @@ +# DarwinKit CLI Tool Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Create `tools darwinkit` — an interactive CLI that exposes all DarwinKit capabilities (NLP, Vision, TTS, Auth, iCloud, System) through clack prompts and flat commander subcommands, with `--format json|pretty|raw` output control. + +**Architecture:** Two phases: (1) Expand `src/utils/macos/` with missing util wrappers (auth, system, icloud), (2) Build `src/darwinkit/` CLI tool with a single command registry map that drives commander subcommands, interactive clack prompts, and help generation. The CLI never imports from `@genesiscz/darwinkit` directly — only from utils. + +**Tech Stack:** @genesiscz/darwinkit, @clack/prompts, commander, picocolors, Bun + +--- + +## Phase 1: Expand Utils + +### Task 1: Add `src/utils/macos/auth.ts` + +**Files:** +- Create: `src/utils/macos/auth.ts` +- Modify: `src/utils/macos/types.ts` +- Modify: `src/utils/macos/index.ts` + +**Step 1: Create auth.ts** + +```typescript +import { getDarwinKit } from "./darwinkit"; +import type { AuthAvailableResult, AuthenticateResult } from "./types"; + +/** + * Check if biometric authentication (Touch ID / Optic ID) is available. + */ +export async function checkBiometry(): Promise { + return getDarwinKit().auth.available(); +} + +/** + * Authenticate using biometrics (Touch ID / Optic ID). + * @param reason - Reason string shown in the system prompt + */ +export async function authenticate(reason?: string): Promise { + return getDarwinKit().auth.authenticate(reason ? { reason } : undefined); +} +``` + +**Step 2: Add type re-exports to types.ts** + +Add to the re-export block in `src/utils/macos/types.ts`: + +```typescript +export type { + AuthAvailableResult, + AuthenticateResult, + BiometryType, +} from "@genesiscz/darwinkit"; +``` + +**Step 3: Add exports to index.ts** + +Add to `src/utils/macos/index.ts`: + +```typescript +// Auth +export { authenticate, checkBiometry } from "./auth"; +``` + +And to the type exports: + +```typescript + AuthAvailableResult, + AuthenticateResult, + BiometryType, +``` + +**Step 4: Verify** + +Run: `tsgo --noEmit 2>&1 | rg "utils/macos"` +Expected: zero errors + +**Step 5: Commit** + +```bash +git add src/utils/macos/auth.ts src/utils/macos/types.ts src/utils/macos/index.ts +git commit -m "feat(macos): add auth util wrappers" +``` + +--- + +### Task 2: Add `src/utils/macos/system.ts` + +**Files:** +- Create: `src/utils/macos/system.ts` +- Modify: `src/utils/macos/types.ts` +- Modify: `src/utils/macos/index.ts` + +**Step 1: Create system.ts** + +```typescript +import { getDarwinKit } from "./darwinkit"; +import type { CapabilitiesResult } from "./types"; + +/** + * Get DarwinKit system capabilities — version, OS, architecture, available methods. + */ +export async function getCapabilities(): Promise { + return getDarwinKit().system.capabilities(); +} +``` + +**Step 2: Add type re-exports to types.ts** + +Add `MethodCapability` to the re-export block: + +```typescript +export type { MethodCapability } from "@genesiscz/darwinkit"; +``` + +(`CapabilitiesResult` is already re-exported.) + +**Step 3: Add exports to index.ts** + +```typescript +// System +export { getCapabilities } from "./system"; +``` + +And to type exports: + +```typescript + MethodCapability, +``` + +**Step 4: Verify** + +Run: `tsgo --noEmit 2>&1 | rg "utils/macos"` +Expected: zero errors + +**Step 5: Commit** + +```bash +git add src/utils/macos/system.ts src/utils/macos/types.ts src/utils/macos/index.ts +git commit -m "feat(macos): add system util wrapper" +``` + +--- + +### Task 3: Add `src/utils/macos/icloud.ts` + +**Files:** +- Create: `src/utils/macos/icloud.ts` +- Modify: `src/utils/macos/types.ts` +- Modify: `src/utils/macos/index.ts` + +**Step 1: Create icloud.ts** + +```typescript +import { getDarwinKit } from "./darwinkit"; +import type { + ICloudDirEntry, + ICloudListDirResult, + ICloudOkResult, + ICloudReadResult, + ICloudStatusResult, +} from "./types"; + +/** + * Check iCloud Drive availability and container URL. + */ +export async function icloudStatus(): Promise { + return getDarwinKit().icloud.status(); +} + +/** + * Read a text file from iCloud Drive. + * @param path - Relative path within the iCloud container + */ +export async function icloudRead(path: string): Promise { + return getDarwinKit().icloud.read({ path }); +} + +/** + * Write a text file to iCloud Drive. + */ +export async function icloudWrite(path: string, content: string): Promise { + return getDarwinKit().icloud.write({ path, content }); +} + +/** + * Write binary data (base64-encoded) to iCloud Drive. + */ +export async function icloudWriteBytes(path: string, data: string): Promise { + return getDarwinKit().icloud.writeBytes({ path, data }); +} + +/** + * Delete a file from iCloud Drive. + */ +export async function icloudDelete(path: string): Promise { + return getDarwinKit().icloud.delete({ path }); +} + +/** + * Move/rename a file in iCloud Drive. + */ +export async function icloudMove(source: string, destination: string): Promise { + return getDarwinKit().icloud.move({ source, destination }); +} + +/** + * Copy a file in iCloud Drive. + */ +export async function icloudCopy(source: string, destination: string): Promise { + return getDarwinKit().icloud.copyFile({ source, destination }); +} + +/** + * List directory contents in iCloud Drive. + */ +export async function icloudList(path: string): Promise { + const result: ICloudListDirResult = await getDarwinKit().icloud.listDir({ path }); + return result.entries; +} + +/** + * Create a directory in iCloud Drive (recursive). + */ +export async function icloudMkdir(path: string): Promise { + return getDarwinKit().icloud.ensureDir({ path }); +} + +/** + * Start monitoring iCloud Drive for file changes. + * Use `getDarwinKit().icloud.onFilesChanged(handler)` to listen for changes. + */ +export async function icloudStartMonitoring(): Promise { + return getDarwinKit().icloud.startMonitoring(); +} + +/** + * Stop monitoring iCloud Drive for file changes. + */ +export async function icloudStopMonitoring(): Promise { + return getDarwinKit().icloud.stopMonitoring(); +} +``` + +**Step 2: Add type re-exports to types.ts** + +```typescript +export type { + ICloudDirEntry, + ICloudListDirResult, + ICloudOkResult, + ICloudReadResult, + ICloudStatusResult, +} from "@genesiscz/darwinkit"; +``` + +**Step 3: Add exports to index.ts** + +```typescript +// iCloud +export { + icloudCopy, + icloudDelete, + icloudList, + icloudMkdir, + icloudMove, + icloudRead, + icloudStartMonitoring, + icloudStatus, + icloudStopMonitoring, + icloudWrite, + icloudWriteBytes, +} from "./icloud"; +``` + +And type exports: + +```typescript + ICloudDirEntry, + ICloudListDirResult, + ICloudOkResult, + ICloudReadResult, + ICloudStatusResult, +``` + +**Step 4: Verify** + +Run: `tsgo --noEmit 2>&1 | rg "utils/macos"` +Expected: zero errors + +Run: `bunx biome check src/utils/macos/ --write` + +**Step 5: Commit** + +```bash +git add src/utils/macos/icloud.ts src/utils/macos/types.ts src/utils/macos/index.ts +git commit -m "feat(macos): add icloud util wrappers" +``` + +--- + +## Phase 2: CLI Tool + +### Task 4: Create output formatter (`src/darwinkit/lib/format.ts`) + +**Files:** +- Create: `src/darwinkit/lib/format.ts` + +**Step 1: Create format.ts** + +```typescript +import pc from "picocolors"; + +export type OutputFormat = "json" | "pretty" | "raw"; + +/** + * Detect default format: pretty for TTY, json for piped + */ +export function defaultFormat(): OutputFormat { + return process.stdout.isTTY ? "pretty" : "json"; +} + +/** + * Format any result for output + */ +export function formatOutput(data: unknown, format: OutputFormat): string { + switch (format) { + case "json": + return JSON.stringify(data, null, 2); + case "raw": + return formatRaw(data); + case "pretty": + return formatPretty(data); + } +} + +function formatRaw(data: unknown): string { + if (data === null || data === undefined) { + return ""; + } + + if (typeof data === "string") { + return data; + } + + if (typeof data === "number" || typeof data === "boolean") { + return String(data); + } + + if (Array.isArray(data)) { + return data.map((item) => formatRaw(item)).join("\n"); + } + + // For objects with a single obvious "value" field, extract it + if (typeof data === "object") { + const obj = data as Record; + + // Common single-value results + if ("text" in obj && Object.keys(obj).length <= 2) { + return String(obj.text); + } + + if ("content" in obj && Object.keys(obj).length <= 1) { + return String(obj.content); + } + + // Fall back to JSON for complex objects + return JSON.stringify(data, null, 2); + } + + return String(data); +} + +function formatPretty(data: unknown): string { + if (data === null || data === undefined) { + return pc.dim("(empty)"); + } + + if (typeof data === "string") { + return data; + } + + if (typeof data === "number" || typeof data === "boolean") { + return pc.cyan(String(data)); + } + + if (Array.isArray(data)) { + if (data.length === 0) { + return pc.dim("(empty array)"); + } + + // Array of objects → table-like output + if (typeof data[0] === "object" && data[0] !== null) { + return data + .map((item, i) => { + const prefix = pc.dim(`[${i}] `); + const fields = Object.entries(item as Record) + .map(([k, v]) => ` ${pc.bold(k)}: ${formatValue(v)}`) + .join("\n"); + return `${prefix}\n${fields}`; + }) + .join("\n"); + } + + return data.map((item) => ` ${formatValue(item)}`).join("\n"); + } + + if (typeof data === "object") { + const obj = data as Record; + return Object.entries(obj) + .map(([k, v]) => `${pc.bold(k)}: ${formatValue(v)}`) + .join("\n"); + } + + return String(data); +} + +function formatValue(value: unknown): string { + if (value === null || value === undefined) { + return pc.dim("null"); + } + + if (typeof value === "string") { + return pc.green(`"${value}"`); + } + + if (typeof value === "number") { + return pc.cyan(String(value)); + } + + if (typeof value === "boolean") { + return value ? pc.green("true") : pc.red("false"); + } + + if (Array.isArray(value)) { + if (value.length === 0) { + return pc.dim("[]"); + } + + if (value.length <= 5 && value.every((v) => typeof v !== "object")) { + return `[${value.map((v) => formatValue(v)).join(", ")}]`; + } + + return `[${value.length} items]`; + } + + if (typeof value === "object") { + return JSON.stringify(value); + } + + return String(value); +} +``` + +**Step 2: Verify** + +Run: `tsgo --noEmit 2>&1 | rg "darwinkit"` +Expected: zero errors + +**Step 3: Commit** + +```bash +git add src/darwinkit/lib/format.ts +git commit -m "feat(darwinkit): add output formatter" +``` + +--- + +### Task 5: Create command registry (`src/darwinkit/lib/commands.ts`) + +**Files:** +- Create: `src/darwinkit/lib/commands.ts` + +This is the single source of truth. It imports all utils and maps them to CLI commands. + +**Step 1: Create commands.ts** + +```typescript +import { + analyzeSentiment, + areSimilar, + authenticate, + batchSentiment, + checkBiometry, + classifyText, + clusterBySimilarity, + deduplicateTexts, + detectLanguage, + embedText, + extractEntities, + extractText, + findNeighbors, + getCapabilities, + getKeywords, + groupByLanguage, + icloudCopy, + icloudDelete, + icloudList, + icloudMkdir, + icloudMove, + icloudRead, + icloudStatus, + icloudWrite, + lemmatize, + listVoices, + rankBySimilarity, + recognizeText, + scoreRelevance, + speak, + tagText, + textDistance, +} from "@app/utils/macos"; +import type { EmbedType, NlpScheme, OcrLevel } from "@app/utils/macos"; + +// ─── Types ────────────────────────────────────────────────────────────────────── + +export interface ParamDef { + name: string; + type: "string" | "number" | "boolean" | "string[]"; + required: boolean; + description: string; + default?: unknown; + /** If true, this is the first positional argument (not a flag) */ + positional?: boolean; + /** For select prompts in interactive mode */ + choices?: string[]; +} + +export interface CommandDef { + /** CLI subcommand name, e.g. "detect-language" */ + name: string; + /** Interactive menu group, e.g. "nlp" */ + group: string; + /** One-line description for help & interactive menu */ + description: string; + /** Parameter definitions — drive help, validation, and interactive prompts */ + params: ParamDef[]; + /** Execute the command. Receives validated args, returns result to be formatted. */ + run: (args: Record) => Promise; +} + +// ─── Group Labels ─────────────────────────────────────────────────────────────── + +export const GROUP_LABELS: Record = { + nlp: "Natural Language Processing", + vision: "Computer Vision", + "text-analysis": "Text Analysis (batch)", + classification: "Classification", + tts: "Text-to-Speech", + auth: "Authentication", + icloud: "iCloud Drive", + system: "System", +}; + +export const GROUP_ORDER = ["nlp", "vision", "text-analysis", "classification", "tts", "auth", "icloud", "system"]; + +// ─── Command Registry ─────────────────────────────────────────────────────────── + +export const commands: CommandDef[] = [ + // ── NLP ────────────────────────────────────────────────────────────────── + { + name: "detect-language", + group: "nlp", + description: "Detect the language of text (BCP-47 code + confidence)", + params: [ + { name: "text", type: "string", required: true, positional: true, description: "Text to analyze" }, + ], + run: async (args) => detectLanguage(args.text as string), + }, + { + name: "sentiment", + group: "nlp", + description: "Analyze sentiment — score (-1 to 1) and label", + params: [ + { name: "text", type: "string", required: true, positional: true, description: "Text to analyze" }, + ], + run: async (args) => analyzeSentiment(args.text as string), + }, + { + name: "tag", + group: "nlp", + description: "Tag text with POS, NER, lemma, or other schemes", + params: [ + { name: "text", type: "string", required: true, positional: true, description: "Text to tag" }, + { + name: "schemes", + type: "string[]", + required: false, + description: "Tagging schemes", + default: ["lexicalClass"], + choices: ["lexicalClass", "nameType", "lemma", "sentimentScore", "language"], + }, + { name: "language", type: "string", required: false, description: "BCP-47 language code" }, + ], + run: async (args) => + tagText( + args.text as string, + (args.schemes as NlpScheme[] | undefined) ?? ["lexicalClass"], + args.language as string | undefined + ), + }, + { + name: "entities", + group: "nlp", + description: "Extract named entities (people, places, organizations)", + params: [ + { name: "text", type: "string", required: true, positional: true, description: "Text to analyze" }, + ], + run: async (args) => extractEntities(args.text as string), + }, + { + name: "lemmatize", + group: "nlp", + description: "Get root/dictionary form of each word", + params: [ + { name: "text", type: "string", required: true, positional: true, description: "Text to lemmatize" }, + { name: "language", type: "string", required: false, description: "BCP-47 language code" }, + ], + run: async (args) => lemmatize(args.text as string, args.language as string | undefined), + }, + { + name: "keywords", + group: "nlp", + description: "Extract important content words (nouns, verbs, adjectives)", + params: [ + { name: "text", type: "string", required: true, positional: true, description: "Text to analyze" }, + { name: "max", type: "number", required: false, description: "Max keywords to return", default: 10 }, + ], + run: async (args) => getKeywords(args.text as string, (args.max as number) ?? 10), + }, + { + name: "embed", + group: "nlp", + description: "Compute 512-dim semantic embedding vector", + params: [ + { name: "text", type: "string", required: true, positional: true, description: "Text to embed" }, + { name: "language", type: "string", required: false, description: "BCP-47 code", default: "en" }, + { + name: "type", + type: "string", + required: false, + description: "Embedding type", + default: "sentence", + choices: ["word", "sentence"], + }, + ], + run: async (args) => + embedText(args.text as string, (args.language as string) ?? "en", (args.type as EmbedType) ?? "sentence"), + }, + { + name: "distance", + group: "nlp", + description: "Compute cosine distance between two texts (0 = identical, 2 = opposite)", + params: [ + { name: "text1", type: "string", required: true, positional: true, description: "First text" }, + { name: "text2", type: "string", required: true, positional: true, description: "Second text" }, + { name: "language", type: "string", required: false, description: "BCP-47 code", default: "en" }, + ], + run: async (args) => + textDistance(args.text1 as string, args.text2 as string, (args.language as string) ?? "en"), + }, + { + name: "similar", + group: "nlp", + description: "Check if two texts are semantically similar (boolean)", + params: [ + { name: "text1", type: "string", required: true, positional: true, description: "First text" }, + { name: "text2", type: "string", required: true, positional: true, description: "Second text" }, + { name: "threshold", type: "number", required: false, description: "Distance threshold", default: 0.5 }, + ], + run: async (args) => + areSimilar(args.text1 as string, args.text2 as string, (args.threshold as number) ?? 0.5), + }, + { + name: "relevance", + group: "nlp", + description: "Score semantic relevance of text against a query (0-1)", + params: [ + { name: "query", type: "string", required: true, positional: true, description: "Query text" }, + { name: "text", type: "string", required: true, positional: true, description: "Text to score" }, + { name: "language", type: "string", required: false, description: "BCP-47 code", default: "en" }, + ], + run: async (args) => + scoreRelevance(args.query as string, args.text as string, (args.language as string) ?? "en"), + }, + { + name: "neighbors", + group: "nlp", + description: "Find semantically similar words or sentences", + params: [ + { name: "text", type: "string", required: true, positional: true, description: "Input text" }, + { name: "count", type: "number", required: false, description: "Number of neighbors", default: 5 }, + { name: "language", type: "string", required: false, description: "BCP-47 code", default: "en" }, + { + name: "type", + type: "string", + required: false, + description: "Embed type", + default: "word", + choices: ["word", "sentence"], + }, + ], + run: async (args) => + findNeighbors( + args.text as string, + (args.count as number) ?? 5, + (args.language as string) ?? "en", + (args.type as EmbedType) ?? "word" + ), + }, + + // ── Vision ─────────────────────────────────────────────────────────────── + { + name: "ocr", + group: "vision", + description: "Extract text from an image file using Apple Vision", + params: [ + { name: "path", type: "string", required: true, positional: true, description: "Path to image file" }, + { + name: "languages", + type: "string[]", + required: false, + description: "Recognition languages", + default: ["en-US"], + }, + { + name: "level", + type: "string", + required: false, + description: "Recognition level", + default: "accurate", + choices: ["accurate", "fast"], + }, + { + name: "text-only", + type: "boolean", + required: false, + description: "Return plain text only (no bounding boxes)", + default: false, + }, + ], + run: async (args) => { + const path = args.path as string; + const options = { + languages: args.languages as string[] | undefined, + level: (args.level as OcrLevel) ?? "accurate", + }; + + if (args["text-only"]) { + return extractText(path, options); + } + + return recognizeText(path, options); + }, + }, + + // ── Text Analysis (batch) ──────────────────────────────────────────────── + { + name: "rank", + group: "text-analysis", + description: "Rank texts by semantic similarity to a query", + params: [ + { name: "query", type: "string", required: true, positional: true, description: "Query to rank against" }, + { + name: "items", + type: "string[]", + required: true, + description: "Texts to rank (comma-separated or multiple --items flags)", + }, + { name: "max-results", type: "number", required: false, description: "Max results to return" }, + { name: "language", type: "string", required: false, description: "BCP-47 code", default: "en" }, + ], + run: async (args) => { + const items = (args.items as string[]).map((text, i) => ({ text, id: String(i) })); + return rankBySimilarity(args.query as string, items, { + language: (args.language as string) ?? "en", + maxResults: args["max-results"] as number | undefined, + }); + }, + }, + { + name: "batch-sentiment", + group: "text-analysis", + description: "Analyze sentiment for multiple texts", + params: [ + { + name: "items", + type: "string[]", + required: true, + description: "Texts to analyze (comma-separated)", + }, + ], + run: async (args) => { + const items = (args.items as string[]).map((text, i) => ({ text, id: String(i) })); + return batchSentiment(items); + }, + }, + { + name: "group-by-language", + group: "text-analysis", + description: "Detect language for each text and group by language code", + params: [ + { + name: "items", + type: "string[]", + required: true, + description: "Texts to group (comma-separated)", + }, + { + name: "min-confidence", + type: "number", + required: false, + description: "Min confidence threshold", + default: 0.7, + }, + ], + run: async (args) => { + const items = (args.items as string[]).map((text, i) => ({ text, id: String(i) })); + return groupByLanguage(items, { + minConfidence: (args["min-confidence"] as number) ?? 0.7, + }); + }, + }, + { + name: "deduplicate", + group: "text-analysis", + description: "Remove semantically duplicate texts", + params: [ + { + name: "items", + type: "string[]", + required: true, + description: "Texts to deduplicate (comma-separated)", + }, + { + name: "threshold", + type: "number", + required: false, + description: "Cosine distance threshold", + default: 0.3, + }, + { name: "language", type: "string", required: false, description: "BCP-47 code", default: "en" }, + ], + run: async (args) => { + const items = (args.items as string[]).map((text) => ({ text })); + const result = await deduplicateTexts(items, { + threshold: (args.threshold as number) ?? 0.3, + language: (args.language as string) ?? "en", + }); + return result.map((r) => r.text); + }, + }, + { + name: "cluster", + group: "text-analysis", + description: "Group semantically similar texts into clusters", + params: [ + { + name: "items", + type: "string[]", + required: true, + description: "Texts to cluster (comma-separated)", + }, + { + name: "threshold", + type: "number", + required: false, + description: "Distance threshold for same cluster", + default: 0.5, + }, + { name: "language", type: "string", required: false, description: "BCP-47 code", default: "en" }, + ], + run: async (args) => { + const items = (args.items as string[]).map((text) => ({ text })); + return clusterBySimilarity(items, { + threshold: (args.threshold as number) ?? 0.5, + language: (args.language as string) ?? "en", + }); + }, + }, + + // ── Classification ─────────────────────────────────────────────────────── + { + name: "classify", + group: "classification", + description: "Classify text into one of N candidate categories", + params: [ + { name: "text", type: "string", required: true, positional: true, description: "Text to classify" }, + { + name: "categories", + type: "string[]", + required: true, + description: "Candidate categories (comma-separated)", + }, + { name: "language", type: "string", required: false, description: "BCP-47 code", default: "en" }, + ], + run: async (args) => + classifyText(args.text as string, args.categories as string[], { + language: (args.language as string) ?? "en", + }), + }, + + // ── TTS ────────────────────────────────────────────────────────────────── + { + name: "speak", + group: "tts", + description: "Speak text aloud using macOS say with auto language detection", + params: [ + { name: "text", type: "string", required: true, positional: true, description: "Text to speak" }, + { name: "voice", type: "string", required: false, description: "Override voice name" }, + { name: "rate", type: "number", required: false, description: "Words per minute" }, + ], + run: async (args) => { + await speak(args.text as string, { + voice: args.voice as string | undefined, + rate: args.rate as number | undefined, + }); + return { spoken: true }; + }, + }, + { + name: "list-voices", + group: "tts", + description: "List available macOS speech synthesis voices", + params: [], + run: async () => listVoices(), + }, + + // ── Auth ───────────────────────────────────────────────────────────────── + { + name: "check-biometry", + group: "auth", + description: "Check if Touch ID / Optic ID is available", + params: [], + run: async () => checkBiometry(), + }, + { + name: "authenticate", + group: "auth", + description: "Authenticate using Touch ID / Optic ID", + params: [ + { name: "reason", type: "string", required: false, positional: true, description: "Reason for auth prompt" }, + ], + run: async (args) => authenticate(args.reason as string | undefined), + }, + + // ── iCloud ─────────────────────────────────────────────────────────────── + { + name: "icloud-status", + group: "icloud", + description: "Check iCloud Drive availability and container URL", + params: [], + run: async () => icloudStatus(), + }, + { + name: "icloud-read", + group: "icloud", + description: "Read a text file from iCloud Drive", + params: [ + { name: "path", type: "string", required: true, positional: true, description: "File path in iCloud" }, + ], + run: async (args) => icloudRead(args.path as string), + }, + { + name: "icloud-write", + group: "icloud", + description: "Write text to a file in iCloud Drive", + params: [ + { name: "path", type: "string", required: true, positional: true, description: "File path in iCloud" }, + { name: "content", type: "string", required: true, description: "Content to write" }, + ], + run: async (args) => icloudWrite(args.path as string, args.content as string), + }, + { + name: "icloud-delete", + group: "icloud", + description: "Delete a file from iCloud Drive", + params: [ + { name: "path", type: "string", required: true, positional: true, description: "File path to delete" }, + ], + run: async (args) => icloudDelete(args.path as string), + }, + { + name: "icloud-move", + group: "icloud", + description: "Move/rename a file in iCloud Drive", + params: [ + { name: "source", type: "string", required: true, positional: true, description: "Source path" }, + { name: "destination", type: "string", required: true, positional: true, description: "Destination path" }, + ], + run: async (args) => icloudMove(args.source as string, args.destination as string), + }, + { + name: "icloud-copy", + group: "icloud", + description: "Copy a file in iCloud Drive", + params: [ + { name: "source", type: "string", required: true, positional: true, description: "Source path" }, + { name: "destination", type: "string", required: true, positional: true, description: "Destination path" }, + ], + run: async (args) => icloudCopy(args.source as string, args.destination as string), + }, + { + name: "icloud-list", + group: "icloud", + description: "List directory contents in iCloud Drive", + params: [ + { name: "path", type: "string", required: true, positional: true, description: "Directory path" }, + ], + run: async (args) => icloudList(args.path as string), + }, + { + name: "icloud-mkdir", + group: "icloud", + description: "Create a directory in iCloud Drive", + params: [ + { name: "path", type: "string", required: true, positional: true, description: "Directory path" }, + ], + run: async (args) => icloudMkdir(args.path as string), + }, + + // ── System ─────────────────────────────────────────────────────────────── + { + name: "capabilities", + group: "system", + description: "Show DarwinKit version, OS, architecture, and available methods", + params: [], + run: async () => getCapabilities(), + }, +]; + +/** Get a command by name */ +export function getCommand(name: string): CommandDef | undefined { + return commands.find((c) => c.name === name); +} + +/** Get commands grouped by group name */ +export function getCommandsByGroup(): Map { + const groups = new Map(); + + for (const group of GROUP_ORDER) { + groups.set(group, []); + } + + for (const cmd of commands) { + const list = groups.get(cmd.group); + + if (list) { + list.push(cmd); + } + } + + return groups; +} +``` + +**Step 2: Verify** + +Run: `tsgo --noEmit 2>&1 | rg "darwinkit"` +Expected: zero errors + +**Step 3: Commit** + +```bash +git add src/darwinkit/lib/commands.ts +git commit -m "feat(darwinkit): add command registry" +``` + +--- + +### Task 6: Create interactive mode (`src/darwinkit/lib/interactive.ts`) + +**Files:** +- Create: `src/darwinkit/lib/interactive.ts` + +**Step 1: Create interactive.ts** + +```typescript +import * as p from "@clack/prompts"; +import pc from "picocolors"; +import { isCancelled, handleCancel, withCancel } from "@app/utils/prompts/clack/helpers"; +import { closeDarwinKit } from "@app/utils/macos"; +import { type CommandDef, type ParamDef, GROUP_LABELS, GROUP_ORDER, getCommandsByGroup, commands } from "./commands"; +import { type OutputFormat, defaultFormat, formatOutput } from "./format"; + +/** + * Run the full interactive menu: group → command → params → execute + */ +export async function runInteractiveMenu(): Promise { + const grouped = getCommandsByGroup(); + + const group = await withCancel( + p.select({ + message: "Choose a category", + options: GROUP_ORDER.filter((g) => { + const cmds = grouped.get(g); + return cmds && cmds.length > 0; + }).map((g) => ({ + value: g, + label: GROUP_LABELS[g] ?? g, + hint: `${grouped.get(g)!.length} commands`, + })), + }) + ); + + const groupCommands = grouped.get(group as string)!; + + const cmdName = await withCancel( + p.select({ + message: "Choose a command", + options: groupCommands.map((c) => ({ + value: c.name, + label: c.name, + hint: c.description, + })), + }) + ); + + const cmd = commands.find((c) => c.name === cmdName)!; + await runCommandInteractive(cmd); +} + +/** + * Prompt for missing params and execute a command interactively. + * Shows usage hint first, then prompts for each missing param. + */ +export async function runCommandInteractive( + cmd: CommandDef, + providedArgs: Record = {} +): Promise { + // Show usage hint + const usage = buildUsageLine(cmd); + p.log.info(pc.dim(usage)); + + // Prompt for missing required params + const args = { ...providedArgs }; + + for (const param of cmd.params) { + if (args[param.name] !== undefined) { + continue; + } + + if (!param.required && !process.stdout.isTTY) { + continue; + } + + const value = await promptForParam(param); + if (value !== undefined) { + args[param.name] = value; + } + } + + // Execute + const spin = p.spinner(); + spin.start(`Running ${cmd.name}...`); + + try { + const result = await cmd.run(args); + spin.stop(`${cmd.name} complete`); + + const format = defaultFormat(); + const output = formatOutput(result, format); + console.log(output); + } catch (error) { + spin.stop(pc.red(`${cmd.name} failed`)); + p.log.error(error instanceof Error ? error.message : String(error)); + } finally { + closeDarwinKit(); + } +} + +async function promptForParam(param: ParamDef): Promise { + if (param.choices && param.choices.length > 0) { + if (param.type === "string[]") { + // Multi-select for arrays with choices + const result = await p.multiselect({ + message: `${param.name} ${pc.dim(`(${param.description})`)}`, + options: param.choices.map((c) => ({ value: c, label: c })), + initialValues: param.default as string[] | undefined, + }); + + if (isCancelled(result)) { + handleCancel(); + } + + return result; + } + + const result = await p.select({ + message: `${param.name} ${pc.dim(`(${param.description})`)}`, + options: param.choices.map((c) => ({ value: c, label: c })), + initialValue: param.default as string | undefined, + }); + + if (isCancelled(result)) { + handleCancel(); + } + + return result; + } + + if (param.type === "boolean") { + return withCancel( + p.confirm({ + message: `${param.name}? ${pc.dim(`(${param.description})`)}`, + initialValue: (param.default as boolean) ?? false, + }) + ); + } + + if (param.type === "string[]") { + const result = await withCancel( + p.text({ + message: `${param.name} ${pc.dim(`(${param.description}, comma-separated)`)}`, + placeholder: param.default ? String(param.default) : undefined, + }) + ); + return (result as string).split(",").map((s) => s.trim()); + } + + if (param.type === "number") { + const result = await withCancel( + p.text({ + message: `${param.name} ${pc.dim(`(${param.description})`)}`, + placeholder: param.default !== undefined ? String(param.default) : undefined, + validate: (v) => { + if (!param.required && v === "") { + return; + } + if (Number.isNaN(Number(v))) { + return "Must be a number"; + } + }, + }) + ); + + const str = result as string; + if (str === "" && param.default !== undefined) { + return param.default; + } + + return str === "" ? undefined : Number(str); + } + + // string + const result = await withCancel( + p.text({ + message: `${param.name} ${pc.dim(`(${param.description})`)}`, + placeholder: param.default !== undefined ? String(param.default) : undefined, + validate: (v) => { + if (param.required && v.trim() === "") { + return `${param.name} is required`; + } + }, + }) + ); + + const str = result as string; + if (str === "" && !param.required) { + return param.default; + } + + return str; +} + +function buildUsageLine(cmd: CommandDef): string { + const positionals = cmd.params.filter((p) => p.positional); + const flags = cmd.params.filter((p) => !p.positional); + let line = `Usage: tools darwinkit ${cmd.name}`; + + for (const param of positionals) { + line += param.required ? ` <${param.name}>` : ` [${param.name}]`; + } + + for (const param of flags) { + if (param.type === "boolean") { + line += ` [--${param.name}]`; + } else { + line += ` [--${param.name} <${param.type}>]`; + } + } + + return line; +} +``` + +**Step 2: Verify** + +Run: `tsgo --noEmit 2>&1 | rg "darwinkit"` +Expected: zero errors + +**Step 3: Commit** + +```bash +git add src/darwinkit/lib/interactive.ts +git commit -m "feat(darwinkit): add interactive mode" +``` + +--- + +### Task 7: Create entry point (`src/darwinkit/index.ts`) + +**Files:** +- Create: `src/darwinkit/index.ts` + +**Step 1: Create index.ts** + +This is the main entry point. It: +1. No args + TTY → logo + interactive menu +2. No args + non-TTY → full help +3. Subcommand + all params → execute directly +4. Subcommand + missing params + TTY → show usage + clack prompts +5. Subcommand + missing params + non-TTY → show subcommand help + +```typescript +#!/usr/bin/env bun + +import { handleReadmeFlag } from "@app/utils/readme"; +import { closeDarwinKit } from "@app/utils/macos"; +import * as p from "@clack/prompts"; +import { Command } from "commander"; +import pc from "picocolors"; +import { + type CommandDef, + GROUP_LABELS, + GROUP_ORDER, + commands, + getCommandsByGroup, +} from "./lib/commands"; +import { type OutputFormat, defaultFormat, formatOutput } from "./lib/format"; +import { runCommandInteractive, runInteractiveMenu } from "./lib/interactive"; + +handleReadmeFlag(import.meta.url); + +// ─── Logo ─────────────────────────────────────────────────────────────────────── + +const LOGO = `${pc.bold(pc.cyan(" DarwinKit"))} ${pc.dim("— Apple on-device ML from the terminal")}`; + +// ─── Help Generator ───────────────────────────────────────────────────────────── + +function printFullHelp(): void { + console.log(); + console.log(LOGO); + console.log(); + + const grouped = getCommandsByGroup(); + + for (const group of GROUP_ORDER) { + const cmds = grouped.get(group); + + if (!cmds || cmds.length === 0) { + continue; + } + + console.log(pc.bold(pc.yellow(` ${GROUP_LABELS[group] ?? group}`))); + + for (const cmd of cmds) { + const positionals = cmd.params.filter((p) => p.positional); + const posStr = positionals.map((p) => (p.required ? `<${p.name}>` : `[${p.name}]`)).join(" "); + const nameCol = ` ${pc.green(cmd.name)}${posStr ? ` ${pc.dim(posStr)}` : ""}`; + console.log(`${nameCol.padEnd(50)}${pc.dim(cmd.description)}`); + } + + console.log(); + } + + console.log(pc.dim(" Options: --format json|pretty|raw")); + console.log(pc.dim(" Run without args for interactive mode (TTY only)")); + console.log(); +} + +// ─── Commander Setup ──────────────────────────────────────────────────────────── + +function buildProgram(): Command { + const program = new Command(); + + program + .name("darwinkit") + .description("Apple on-device ML from the terminal") + .version("1.0.0") + .option("--format ", "Output format: json, pretty, raw"); + + // Register each command from the registry + for (const cmd of commands) { + const sub = program.command(cmd.name).description(cmd.description); + + // Add positional arguments + const positionals = cmd.params.filter((p) => p.positional); + + for (const param of positionals) { + if (param.required) { + sub.argument(`<${param.name}>`, param.description); + } else { + sub.argument(`[${param.name}]`, param.description); + } + } + + // Add flag options + const flags = cmd.params.filter((p) => !p.positional); + + for (const param of flags) { + const flag = + param.type === "boolean" + ? `--${param.name}` + : param.type === "string[]" + ? `--${param.name} ` + : `--${param.name} <${param.type}>`; + const desc = + param.default !== undefined + ? `${param.description} (default: ${JSON.stringify(param.default)})` + : param.description; + sub.option(flag, desc); + } + + sub.option("--format ", "Output format: json, pretty, raw"); + + sub.action(async (...actionArgs: unknown[]) => { + await handleCommandAction(cmd, sub, actionArgs); + }); + } + + return program; +} + +async function handleCommandAction( + cmd: CommandDef, + sub: Command, + actionArgs: unknown[] +): Promise { + // Commander passes: positional1, positional2, ..., optionsObj, commandObj + const positionals = cmd.params.filter((p) => p.positional); + const opts = (actionArgs[positionals.length] ?? {}) as Record; + + // Build args from positionals + flags + const args: Record = {}; + + for (let i = 0; i < positionals.length; i++) { + if (actionArgs[i] !== undefined) { + args[positionals[i].name] = actionArgs[i]; + } + } + + // Merge flag options + for (const param of cmd.params.filter((p) => !p.positional)) { + // Commander converts kebab-case to camelCase, so check both + const camelName = param.name.replace(/-([a-z])/g, (_, c) => c.toUpperCase()); + + if (opts[camelName] !== undefined) { + args[param.name] = param.type === "number" ? Number(opts[camelName]) : opts[camelName]; + } else if (opts[param.name] !== undefined) { + args[param.name] = param.type === "number" ? Number(opts[param.name]) : opts[param.name]; + } + } + + // Check for missing required params + const missing = cmd.params.filter((p) => p.required && args[p.name] === undefined); + + if (missing.length > 0) { + if (process.stdout.isTTY) { + // Interactive prompting for missing params + p.intro(LOGO); + await runCommandInteractive(cmd, args); + return; + } + + // Non-TTY: show help + sub.help(); + return; + } + + // All params present — execute directly + const format: OutputFormat = (opts.format as OutputFormat) ?? defaultFormat(); + + try { + const result = await cmd.run(args); + console.log(formatOutput(result, format)); + } catch (error) { + if (process.stdout.isTTY) { + p.log.error(error instanceof Error ? error.message : String(error)); + } else { + console.error(error instanceof Error ? error.message : String(error)); + } + + process.exit(1); + } finally { + closeDarwinKit(); + } +} + +// ─── Main ─────────────────────────────────────────────────────────────────────── + +async function main(): Promise { + if (process.argv.length <= 2) { + if (process.stdout.isTTY) { + p.intro(LOGO); + await runInteractiveMenu(); + } else { + printFullHelp(); + } + + return; + } + + const program = buildProgram(); + + try { + await program.parseAsync(process.argv); + } catch (error) { + if (process.stdout.isTTY) { + p.log.error(error instanceof Error ? error.message : String(error)); + } else { + console.error(error instanceof Error ? error.message : String(error)); + } + + process.exit(1); + } +} + +main().catch((err) => { + if (process.stdout.isTTY) { + p.log.error(err instanceof Error ? err.message : String(err)); + } else { + console.error(err instanceof Error ? err.message : String(err)); + } + + closeDarwinKit(); + process.exit(1); +}); +``` + +**Step 2: Verify** + +Run: `tsgo --noEmit 2>&1 | rg "darwinkit"` +Expected: zero errors + +Run: `bunx biome check src/darwinkit/ --write` + +**Step 3: Test manually** + +```bash +# Should show help (non-TTY piped) +tools darwinkit 2>&1 | cat + +# Should show interactive menu (TTY) +tools darwinkit + +# Should execute directly +tools darwinkit detect-language "Bonjour le monde" +tools darwinkit sentiment "I love this!" --format json +tools darwinkit capabilities +``` + +**Step 4: Commit** + +```bash +git add src/darwinkit/index.ts +git commit -m "feat(darwinkit): add CLI entry point with interactive + commander modes" +``` + +--- + +### Task 8: Final verification + +**Step 1: TypeScript check** + +Run: `tsgo --noEmit` +Expected: zero errors from darwinkit/ + +**Step 2: Biome check** + +Run: `bunx biome check src/darwinkit/ src/utils/macos/` +Expected: zero errors + +**Step 3: Full test run** + +Run: `bun test` +Expected: existing tests still pass + +**Step 4: Manual smoke tests** + +```bash +# Non-interactive commands +tools darwinkit detect-language "Dobrý den, jak se máte?" +tools darwinkit sentiment "This is terrible" --format raw +tools darwinkit entities "Steve Jobs founded Apple in Cupertino" +tools darwinkit lemmatize "The cats are running quickly" +tools darwinkit keywords "Apple released a revolutionary new iPhone today" +tools darwinkit distance "budget planning" "financial review" +tools darwinkit ocr ~/Desktop/screenshot.png --text-only +tools darwinkit capabilities --format json +tools darwinkit list-voices +tools darwinkit classify "fix null pointer" --categories "bug fix,feature,refactor" + +# Interactive mode +tools darwinkit +``` + +**Step 5: Commit if any fixes needed, then squash or leave as-is** + +--- + +## Verification Checklist + +1. `tsgo --noEmit` — zero errors +2. `bunx biome check src/darwinkit/ src/utils/macos/` — zero errors +3. `bun test` — all existing tests pass +4. `tools darwinkit` (TTY) — shows logo + interactive menu +5. `tools darwinkit 2>&1 | cat` (non-TTY) — shows full help with all commands grouped +6. `tools darwinkit detect-language "hello"` — returns `{ language: "en", confidence: ... }` +7. `tools darwinkit sentiment "I love this" --format json` — returns JSON +8. `tools darwinkit sentiment "I love this" --format raw` — returns just the score +9. `tools darwinkit capabilities` — shows version, OS, methods +10. `tools darwinkit sentiment` (TTY, no text) — shows usage + prompts for text +11. `tools darwinkit sentiment | cat` (non-TTY, no text) — shows subcommand help +12. No imports from `@genesiscz/darwinkit` in `src/darwinkit/` — only from `@app/utils/macos` diff --git a/plugins/genesis-tools/hooks/track-session-files.ts b/plugins/genesis-tools/hooks/track-session-files.ts index d7a9e446..af49357d 100755 --- a/plugins/genesis-tools/hooks/track-session-files.ts +++ b/plugins/genesis-tools/hooks/track-session-files.ts @@ -11,7 +11,9 @@ import { } from "node:fs"; import { homedir } from "node:os"; import { join } from "node:path"; -import { SafeJSON } from "../../../src/utils/json"; + +// biome-ignore lint/style/noRestrictedGlobals: standalone hook script — cannot import @app/utils/json +const SafeJSON = JSON; interface HookInput { session_id: string; diff --git a/src/darwinkit/__tests__/batch.test.ts b/src/darwinkit/__tests__/batch.test.ts new file mode 100644 index 00000000..68aabfa4 --- /dev/null +++ b/src/darwinkit/__tests__/batch.test.ts @@ -0,0 +1,125 @@ +import { describe, expect, it } from "bun:test"; +import { runDarwinKit } from "./helpers"; + +describe("darwinkit batch/text-analysis commands", () => { + describe("rank", () => { + it("ranks texts by similarity to query", async () => { + const result = await runDarwinKit( + "rank", + "cooking", + "--items", + "baking bread", + "riding a bike", + "making pasta" + ); + expect(result).toBeArray(); + expect((result as unknown[]).length).toBe(3); + + const first = (result as { item: { text: string }; score: number }[])[0]; + expect(first).toHaveProperty("item"); + expect(first).toHaveProperty("score"); + expect(first.item.text).toBeDefined(); + }); + + it("splits comma-separated items correctly", async () => { + const result = await runDarwinKit("rank", "cooking", "--items", "baking bread,riding a bike,making pasta"); + expect(result).toBeArray(); + expect((result as unknown[]).length).toBe(3); + }); + }); + + describe("batch-sentiment", () => { + it("analyzes sentiment for multiple texts", async () => { + const result = await runDarwinKit("batch-sentiment", "--items", "I love this", "I hate that", "It is okay"); + expect(result).toBeArray(); + expect((result as unknown[]).length).toBe(3); + + const items = result as { id: string; label: string; score: number }[]; + expect(items[0].label).toBe("positive"); + expect(items[1].label).toBe("negative"); + }); + + it("splits comma-separated items correctly", async () => { + const result = await runDarwinKit("batch-sentiment", "--items", "I love this,I hate that,It is okay"); + expect(result).toBeArray(); + expect((result as unknown[]).length).toBe(3); + }); + }); + + describe("group-by-language", () => { + it("groups texts by detected language", async () => { + const result = await runDarwinKit( + "group-by-language", + "--items", + "Hello world", + "Bonjour le monde", + "Hola mundo", + "Ahoj světe" + ); + expect(typeof result).toBe("object"); + + const groups = result as Record; + const allLangs = Object.keys(groups); + expect(allLangs.length).toBeGreaterThan(1); + }); + + it("splits comma-separated items correctly", async () => { + const result = await runDarwinKit( + "group-by-language", + "--items", + "Hello world,Bonjour le monde,Hola mundo" + ); + const groups = result as Record; + const totalItems = Object.values(groups).flat().length; + expect(totalItems).toBe(3); + }); + }); + + describe("deduplicate", () => { + it("removes semantically duplicate texts", async () => { + const result = await runDarwinKit( + "deduplicate", + "--items", + "I love cats", + "I adore kittens", + "The weather is nice", + "It is sunny today", + "Dogs are great" + ); + expect(result).toBeArray(); + }); + + it("splits comma-separated items correctly", async () => { + const result = await runDarwinKit("deduplicate", "--items", "cats are great,dogs are great,the weather"); + expect(result).toBeArray(); + expect((result as unknown[]).length).toBeGreaterThanOrEqual(2); + }); + }); + + describe("cluster", () => { + it("groups similar texts into clusters", async () => { + const result = await runDarwinKit( + "cluster", + "--items", + "I love cats", + "I adore kittens", + "The weather is nice", + "It is sunny today" + ); + expect(result).toBeArray(); + + const clusters = result as { items: { text: string }[]; centroid: string }[]; + expect(clusters.length).toBeGreaterThan(0); + expect(clusters[0]).toHaveProperty("items"); + expect(clusters[0]).toHaveProperty("centroid"); + }); + + it("splits comma-separated items correctly", async () => { + const result = await runDarwinKit("cluster", "--items", "cats,dogs,weather"); + expect(result).toBeArray(); + const clusters = result as { items: unknown[] }[]; + const totalItems = clusters.reduce((sum, c) => sum + c.items.length, 0); + expect(totalItems).toBe(3); + }); + }); +}); diff --git a/src/darwinkit/__tests__/classification.test.ts b/src/darwinkit/__tests__/classification.test.ts new file mode 100644 index 00000000..269f4ca8 --- /dev/null +++ b/src/darwinkit/__tests__/classification.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from "bun:test"; +import { runDarwinKit } from "./helpers"; + +describe("darwinkit classification commands", () => { + describe("classify", () => { + it("classifies text into categories", async () => { + const result = await runDarwinKit( + "classify", + "This stock is going up", + "--categories", + "finance", + "sports", + "technology" + ); + expect(result.category).toBeDefined(); + expect(result.confidence).toBeNumber(); + expect(result.scores).toBeArray(); + }); + + it("returns scores for all categories", async () => { + const result = await runDarwinKit( + "classify", + "Goal scored!", + "--categories", + "finance", + "sports", + "technology" + ); + const scores = result.scores as { category: string; score: number }[]; + const categories = scores.map((s) => s.category); + expect(categories).toContain("finance"); + expect(categories).toContain("sports"); + expect(categories).toContain("technology"); + }); + + it("splits comma-separated categories correctly", async () => { + const result = await runDarwinKit("classify", "Goal scored!", "--categories", "finance,sports,technology"); + const scores = result.scores as { category: string }[]; + expect(scores.length).toBe(3); + }); + }); + + describe("classify-batch", () => { + it("classifies multiple texts", async () => { + const result = await runDarwinKit( + "classify-batch", + "--items", + "The game was exciting", + "The stock fell", + "New CPU released", + "--categories", + "finance", + "sports", + "technology" + ); + expect(result).toBeArray(); + expect((result as unknown[]).length).toBe(3); + + const items = result as { id: string; category: string; confidence: number }[]; + expect(items[0]).toHaveProperty("category"); + expect(items[0]).toHaveProperty("confidence"); + }); + }); + + describe("group-by-category", () => { + it("groups texts by classified category", async () => { + const result = await runDarwinKit( + "group-by-category", + "--items", + "The game was exciting", + "The stock fell", + "New CPU released", + "Goal scored", + "--categories", + "finance", + "sports", + "technology" + ); + expect(typeof result).toBe("object"); + + const groups = result as Record; + const totalItems = Object.values(groups).flat().length; + expect(totalItems).toBe(4); + }); + }); +}); diff --git a/src/darwinkit/__tests__/cli-flags.test.ts b/src/darwinkit/__tests__/cli-flags.test.ts new file mode 100644 index 00000000..033b904b --- /dev/null +++ b/src/darwinkit/__tests__/cli-flags.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, it } from "bun:test"; +import { SafeJSON } from "@app/utils/json"; +import { runDarwinKitRaw } from "./helpers"; + +describe("darwinkit CLI flags", () => { + describe("--help", () => { + it("shows help with command list", async () => { + const { stdout, exitCode } = await runDarwinKitRaw("--help"); + expect(exitCode).toBe(0); + expect(stdout).toContain("detect-language"); + expect(stdout).toContain("sentiment"); + expect(stdout).toContain("ocr"); + expect(stdout).toContain("capabilities"); + }); + + it("shows subcommand help", async () => { + const { stdout, exitCode } = await runDarwinKitRaw("sentiment", "--help"); + expect(exitCode).toBe(0); + expect(stdout).toContain("text"); + }); + }); + + describe("--version", () => { + it("shows version", async () => { + const { stdout, exitCode } = await runDarwinKitRaw("--version"); + expect(exitCode).toBe(0); + expect(stdout).toMatch(/^\d+\.\d+\.\d+$/); + }); + }); + + describe("--format", () => { + it("json format outputs valid JSON", async () => { + const { stdout, exitCode } = await runDarwinKitRaw("sentiment", "Great!", "--format", "json"); + expect(exitCode).toBe(0); + const parsed = SafeJSON.parse(stdout, { unbox: true }); + expect(parsed).toHaveProperty("label"); + expect(parsed).toHaveProperty("score"); + }); + + it("pretty format outputs key-value pairs, not JSON braces", async () => { + const { stdout, exitCode } = await runDarwinKitRaw("sentiment", "Great!", "--format", "pretty"); + expect(exitCode).toBe(0); + expect(stdout).not.toContain("{"); + expect(stdout).toContain("label:"); + expect(stdout).toContain("score:"); + }); + + it("raw format outputs simplified output", async () => { + const { stdout, exitCode } = await runDarwinKitRaw("sentiment", "Great!", "--format", "raw"); + expect(exitCode).toBe(0); + expect(stdout.length).toBeGreaterThan(0); + }); + }); + + describe("string[] comma splitting", () => { + it("comma-separated --items are split into separate items", async () => { + const { stdout, exitCode } = await runDarwinKitRaw( + "batch-sentiment", + "--items", + "I love this,I hate that,It is okay", + "--format", + "json" + ); + expect(exitCode).toBe(0); + const parsed = SafeJSON.parse(stdout, { unbox: true }); + expect(parsed).toBeArray(); + expect(parsed.length).toBe(3); + }); + + it("comma-separated --schemes are split correctly", async () => { + const { stdout, exitCode } = await runDarwinKitRaw( + "tag", + "Apple is great", + "--schemes", + "lemma,nameType", + "--format", + "json" + ); + expect(exitCode).toBe(0); + const parsed = SafeJSON.parse(stdout, { unbox: true }); + const schemes = new Set(parsed.tokens.map((t: { scheme: string }) => t.scheme)); + expect(schemes.has("lemma")).toBe(true); + expect(schemes.has("nameType")).toBe(true); + }); + + it("comma-separated --categories are split correctly", async () => { + const { stdout, exitCode } = await runDarwinKitRaw( + "classify", + "Goal scored!", + "--categories", + "finance,sports,technology", + "--format", + "json" + ); + expect(exitCode).toBe(0); + const parsed = SafeJSON.parse(stdout, { unbox: true }); + expect(parsed.scores.length).toBe(3); + }); + }); +}); diff --git a/src/darwinkit/__tests__/embeddings.test.ts b/src/darwinkit/__tests__/embeddings.test.ts new file mode 100644 index 00000000..119ddb97 --- /dev/null +++ b/src/darwinkit/__tests__/embeddings.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from "bun:test"; +import { runDarwinKit } from "./helpers"; + +describe("darwinkit embedding commands", () => { + describe("embed", () => { + it("computes 512-dim sentence embedding", async () => { + const result = await runDarwinKit("embed", "Hello world"); + expect(result.dimension).toBe(512); + expect(result.vector).toBeArray(); + expect((result.vector as number[]).length).toBe(512); + }); + + it("supports --type sentence", async () => { + const result = await runDarwinKit("embed", "Hello world", "--type", "sentence"); + expect(result.dimension).toBe(512); + }); + }); + + describe("distance", () => { + it("computes cosine distance between texts", async () => { + const result = await runDarwinKit("distance", "Hello world", "Hi there"); + expect(result.distance).toBeNumber(); + expect(result.distance).toBeGreaterThanOrEqual(0); + expect(result.distance).toBeLessThanOrEqual(2); + expect(result.type).toBe("cosine"); + }); + + it("returns ~0 for identical texts", async () => { + const result = await runDarwinKit("distance", "Hello world", "Hello world"); + expect(result.distance).toBeLessThan(0.01); + }); + }); + + describe("similar", () => { + it("returns boolean", async () => { + const result = await runDarwinKit("similar", "I love cats", "I adore kittens"); + expect(typeof result).toBe("boolean"); + }); + + it("returns true with high threshold for related texts", async () => { + const result = await runDarwinKit("similar", "I love cats", "I adore kittens", "--threshold", "1.5"); + expect(result).toBe(true); + }); + }); + + describe("relevance", () => { + it("scores relevance between 0 and 1", async () => { + const result = await runDarwinKit( + "relevance", + "machine learning", + "This paper discusses neural networks and deep learning" + ); + expect(typeof result).toBe("number"); + expect(result as unknown as number).toBeGreaterThan(0); + expect(result as unknown as number).toBeLessThanOrEqual(1); + }); + }); + + describe("neighbors", () => { + it("finds semantically similar words", async () => { + const result = await runDarwinKit("neighbors", "computer", "--count", "3"); + expect(result.neighbors).toBeArray(); + expect((result.neighbors as unknown[]).length).toBeLessThanOrEqual(3); + + const first = (result.neighbors as { text: string; distance: number }[])[0]; + expect(first).toHaveProperty("text"); + expect(first).toHaveProperty("distance"); + }); + }); +}); diff --git a/src/darwinkit/__tests__/helpers.ts b/src/darwinkit/__tests__/helpers.ts new file mode 100644 index 00000000..05184981 --- /dev/null +++ b/src/darwinkit/__tests__/helpers.ts @@ -0,0 +1,52 @@ +import { resolve } from "node:path"; +import { SafeJSON } from "@app/utils/json"; + +const DARWINKIT_PATH = resolve(import.meta.dir, "../index.ts"); + +/** + * Run a darwinkit CLI command and parse the JSON output. + * Throws on non-zero exit or unparseable output. + */ +// biome-ignore lint/suspicious/noExplicitAny: CLI output can be any JSON type (object, array, string, number, boolean) +export async function runDarwinKit(...args: string[]): Promise { + const proc = Bun.spawn(["bun", "run", DARWINKIT_PATH, ...args, "--format", "json"], { + stdout: "pipe", + stderr: "pipe", + env: { ...process.env, NO_COLOR: "1" }, + }); + + const [stdout, stderr] = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text()]); + + const exitCode = await proc.exited; + + if (exitCode !== 0) { + throw new Error(`darwinkit ${args.join(" ")} exited with ${exitCode}: ${stderr || stdout}`); + } + + const trimmed = stdout.trim(); + + if (!trimmed) { + throw new Error(`darwinkit ${args.join(" ")} produced no output`); + } + + return SafeJSON.parse(trimmed, { unbox: true }); +} + +/** + * Run a darwinkit CLI command and return raw stdout string. + */ +export async function runDarwinKitRaw( + ...args: string[] +): Promise<{ stdout: string; stderr: string; exitCode: number }> { + const proc = Bun.spawn(["bun", "run", DARWINKIT_PATH, ...args], { + stdout: "pipe", + stderr: "pipe", + env: { ...process.env, NO_COLOR: "1" }, + }); + + const [stdout, stderr] = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text()]); + + const exitCode = await proc.exited; + + return { stdout: stdout.trim(), stderr: stderr.trim(), exitCode }; +} diff --git a/src/darwinkit/__tests__/icloud.test.ts b/src/darwinkit/__tests__/icloud.test.ts new file mode 100644 index 00000000..35fe5742 --- /dev/null +++ b/src/darwinkit/__tests__/icloud.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from "bun:test"; +import { runDarwinKit, runDarwinKitRaw } from "./helpers"; + +describe("darwinkit iCloud commands", () => { + describe("icloud-status", () => { + it("returns availability info", async () => { + const result = await runDarwinKit("icloud-status"); + expect(result).toHaveProperty("available"); + expect(result).toHaveProperty("container_url"); + }); + }); + + describe("icloud-start-monitoring", () => { + it("returns ok", async () => { + const result = await runDarwinKit("icloud-start-monitoring"); + expect(result.ok).toBe(true); + }); + }); + + describe("icloud-stop-monitoring", () => { + it("returns ok", async () => { + const result = await runDarwinKit("icloud-stop-monitoring"); + expect(result.ok).toBe(true); + }); + }); + + describe("icloud-read", () => { + it("errors for nonexistent file", async () => { + const { exitCode } = await runDarwinKitRaw("icloud-read", "/nonexistent-file-abc123.txt"); + expect(exitCode).toBe(1); + }); + }); +}); diff --git a/src/darwinkit/__tests__/nlp.test.ts b/src/darwinkit/__tests__/nlp.test.ts new file mode 100644 index 00000000..a05d3e3f --- /dev/null +++ b/src/darwinkit/__tests__/nlp.test.ts @@ -0,0 +1,117 @@ +import { describe, expect, it } from "bun:test"; +import { runDarwinKit } from "./helpers"; + +describe("darwinkit NLP commands", () => { + describe("detect-language", () => { + it("detects English", async () => { + const result = await runDarwinKit("detect-language", "Hello, how are you today?"); + expect(result.language).toBe("en"); + expect(result.confidence).toBeGreaterThan(0.5); + }); + + it("detects French", async () => { + const result = await runDarwinKit("detect-language", "Bonjour, comment allez-vous?"); + expect(result.language).toBe("fr"); + expect(result.confidence).toBeGreaterThan(0.9); + }); + + it("detects Czech", async () => { + const result = await runDarwinKit("detect-language", "Vařřila mišička kašičku"); + expect(result.language).toBe("cs"); + expect(result.confidence).toBeGreaterThan(0.9); + }); + }); + + describe("sentiment", () => { + it("detects positive sentiment", async () => { + const result = await runDarwinKit("sentiment", "I love this beautiful day!"); + expect(result.label).toBe("positive"); + expect(result.score).toBeGreaterThan(0); + }); + + it("detects negative sentiment", async () => { + const result = await runDarwinKit("sentiment", "This is terrible and I hate it"); + expect(result.label).toBe("negative"); + expect(result.score).toBeLessThan(0); + }); + + it("detects neutral sentiment", async () => { + const result = await runDarwinKit("sentiment", "The table is made of wood"); + expect(result.score).toBeGreaterThanOrEqual(-0.8); + expect(result.score).toBeLessThanOrEqual(0.8); + }); + }); + + describe("tag", () => { + it("tags with default lexicalClass scheme", async () => { + const result = await runDarwinKit("tag", "The quick brown fox jumps"); + expect(result.tokens).toBeArray(); + expect(result.tokens.length).toBeGreaterThan(0); + + const fox = result.tokens.find((t: { text: string }) => t.text === "fox"); + expect(fox).toBeDefined(); + expect(fox.tag).toBe("Noun"); + expect(fox.scheme).toBe("lexicalClass"); + }); + + it("tags with multiple schemes (space-separated)", async () => { + const result = await runDarwinKit("tag", "Apple is great", "--schemes", "lemma", "nameType"); + expect(result.tokens).toBeArray(); + const schemes = new Set(result.tokens.map((t: { scheme: string }) => t.scheme)); + expect(schemes.has("lemma")).toBe(true); + expect(schemes.has("nameType")).toBe(true); + }); + }); + + describe("entities", () => { + it("extracts named entities", async () => { + const result = await runDarwinKit("entities", "Tim Cook is the CEO of Apple in Cupertino, California"); + expect(result).toBeArray(); + expect(result.length).toBeGreaterThan(0); + + const places = result.filter((e: { type: string }) => e.type === "place"); + expect(places.length).toBeGreaterThan(0); + }); + + it("returns empty for non-entity text", async () => { + const result = await runDarwinKit("entities", "the and or but"); + expect(result).toBeArray(); + }); + }); + + describe("lemmatize", () => { + it("returns root forms of words", async () => { + const result = await runDarwinKit("lemmatize", "The cats were running quickly through the gardens"); + expect(result).toBeArray(); + expect(result).toContain("cat"); + expect(result).toContain("be"); + expect(result).toContain("run"); + expect(result).toContain("garden"); + }); + }); + + describe("keywords", () => { + it("extracts keywords", async () => { + const result = await runDarwinKit( + "keywords", + "Machine learning and artificial intelligence are transforming software development" + ); + expect(result).toBeArray(); + expect(result.length).toBeGreaterThan(0); + expect(result[0]).toHaveProperty("word"); + expect(result[0]).toHaveProperty("lemma"); + expect(result[0]).toHaveProperty("lexicalClass"); + }); + + it("respects --max flag", async () => { + const result = await runDarwinKit( + "keywords", + "Machine learning and artificial intelligence are transforming software development and cloud computing", + "--max", + "3" + ); + expect(result).toBeArray(); + expect(result.length).toBeLessThanOrEqual(3); + }); + }); +}); diff --git a/src/darwinkit/__tests__/tts-auth-system.test.ts b/src/darwinkit/__tests__/tts-auth-system.test.ts new file mode 100644 index 00000000..08ac5f7a --- /dev/null +++ b/src/darwinkit/__tests__/tts-auth-system.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from "bun:test"; +import { runDarwinKit } from "./helpers"; + +describe("darwinkit TTS commands", () => { + describe("list-voices", () => { + it("returns array of voice strings", async () => { + const result = await runDarwinKit("list-voices"); + expect(result).toBeArray(); + expect(result.length).toBeGreaterThan(0); + expect(typeof result[0]).toBe("string"); + }); + }); + + describe("speak", () => { + it("speaks text and returns confirmation", async () => { + const result = await runDarwinKit("speak", "test"); + expect(result.spoken).toBe(true); + }); + + it("speaks with --rate flag", async () => { + const result = await runDarwinKit("speak", "fast", "--rate", "400"); + expect(result.spoken).toBe(true); + }); + }); +}); + +describe("darwinkit auth commands", () => { + describe("check-biometry", () => { + it("returns availability and type", async () => { + const result = await runDarwinKit("check-biometry"); + expect(result).toHaveProperty("available"); + expect(typeof result.available).toBe("boolean"); + + if (result.available) { + expect(result.biometry_type).toBeDefined(); + } + }); + }); +}); + +describe("darwinkit system commands", () => { + describe("capabilities", () => { + it("returns version, OS, arch, and methods", async () => { + const result = await runDarwinKit("capabilities"); + expect(result.version).toBeDefined(); + expect(result.os).toBeDefined(); + expect(result.arch).toBe("arm64"); + expect(result.methods).toBeDefined(); + + const methods = result.methods as Record; + expect(methods["nlp.language"]).toBeDefined(); + expect(methods["nlp.language"].available).toBe(true); + expect(methods["vision.ocr"]).toBeDefined(); + expect(methods["vision.ocr"].available).toBe(true); + }); + }); +}); diff --git a/src/darwinkit/__tests__/vision.test.ts b/src/darwinkit/__tests__/vision.test.ts new file mode 100644 index 00000000..0b35cac8 --- /dev/null +++ b/src/darwinkit/__tests__/vision.test.ts @@ -0,0 +1,74 @@ +import { afterAll, beforeAll, describe, expect, it } from "bun:test"; +import { runDarwinKit, runDarwinKitRaw } from "./helpers"; + +const TEST_IMAGE = "/tmp/darwinkit-ocr-test.png"; + +beforeAll(async () => { + // Create test image with text using Swift/AppKit + const proc = Bun.spawn( + [ + "swift", + "-e", + ` +import AppKit +let img = NSImage(size: NSSize(width: 400, height: 100)) +img.lockFocus() +NSColor.white.setFill() +NSRect(x: 0, y: 0, width: 400, height: 100).fill() +let attrs: [NSAttributedString.Key: Any] = [.font: NSFont.systemFont(ofSize: 24), .foregroundColor: NSColor.black] +"Hello DarwinKit OCR Test 2026".draw(at: NSPoint(x: 10, y: 40), withAttributes: attrs) +img.unlockFocus() +let tiff = img.tiffRepresentation! +let rep = NSBitmapImageRep(data: tiff)! +let png = rep.representation(using: .png, properties: [:])! +try! png.write(to: URL(fileURLWithPath: "${TEST_IMAGE}")) + `, + ], + { stdout: "pipe", stderr: "pipe" } + ); + const stderr = await new Response(proc.stderr).text(); + const exitCode = await proc.exited; + + if (exitCode !== 0) { + throw new Error(`Failed to generate OCR test image (exit ${exitCode}): ${stderr.trim()}`); + } +}); + +afterAll(async () => { + try { + const { unlinkSync } = await import("node:fs"); + unlinkSync(TEST_IMAGE); + } catch { + // ignore + } +}); + +describe("darwinkit vision commands", () => { + describe("ocr", () => { + it("extracts text with bounding boxes", async () => { + const result = await runDarwinKit("ocr", TEST_IMAGE); + expect(result.text).toBe("Hello DarwinKit OCR Test 2026"); + expect(result.blocks).toBeArray(); + + const block = (result.blocks as { text: string; confidence: string }[])[0]; + expect(block.text).toBe("Hello DarwinKit OCR Test 2026"); + expect(Number(block.confidence)).toBeGreaterThan(0.5); + }); + + it("extracts text-only with --text-only", async () => { + const result = await runDarwinKit("ocr", TEST_IMAGE, "--text-only"); + expect(result).toBe("Hello DarwinKit OCR Test 2026"); + }); + + it("works with --level fast", async () => { + const result = await runDarwinKit("ocr", TEST_IMAGE, "--level", "fast", "--text-only"); + expect(result).toBe("Hello DarwinKit OCR Test 2026"); + }); + + it("errors gracefully for nonexistent file", async () => { + const { exitCode, stderr } = await runDarwinKitRaw("ocr", "/nonexistent.png"); + expect(exitCode).toBe(1); + expect(stderr).toContain("not found"); + }); + }); +}); diff --git a/src/darwinkit/index.ts b/src/darwinkit/index.ts new file mode 100644 index 00000000..fe212b69 --- /dev/null +++ b/src/darwinkit/index.ts @@ -0,0 +1,214 @@ +#!/usr/bin/env bun + +import { parseVariadic } from "@app/utils/cli"; +import { SafeJSON } from "@app/utils/json"; +import { closeDarwinKit } from "@app/utils/macos"; +import { handleReadmeFlag } from "@app/utils/readme"; +import * as p from "@clack/prompts"; +import { Command } from "commander"; +import pc from "picocolors"; +import { type CommandDef, commands, GROUP_LABELS, GROUP_ORDER, getCommandsByGroup } from "./lib/commands"; +import { defaultFormat, formatOutput, type OutputFormat } from "./lib/format"; +import { runCommandInteractive, runInteractiveMenu } from "./lib/interactive"; + +handleReadmeFlag(import.meta.url); + +// ─── Logo ─────────────────────────────────────────────────────────────────────── + +const LOGO = `${pc.bold(pc.cyan(" DarwinKit"))} ${pc.dim("— Apple on-device ML from the terminal")}`; + +// ─── Help Generator ───────────────────────────────────────────────────────────── + +function printFullHelp(): void { + console.log(); + console.log(LOGO); + console.log(); + + const grouped = getCommandsByGroup(); + + for (const group of GROUP_ORDER) { + const cmds = grouped.get(group); + + if (!cmds || cmds.length === 0) { + continue; + } + + console.log(pc.bold(pc.yellow(` ${GROUP_LABELS[group] ?? group}`))); + + for (const cmd of cmds) { + const positionals = cmd.params.filter((pm) => pm.positional); + const posStr = positionals.map((pm) => (pm.required ? `<${pm.name}>` : `[${pm.name}]`)).join(" "); + const nameCol = ` ${pc.green(cmd.name)}${posStr ? ` ${pc.dim(posStr)}` : ""}`; + console.log(`${nameCol.padEnd(50)}${pc.dim(cmd.description)}`); + } + + console.log(); + } + + console.log(pc.dim(" Options: --format json|pretty|raw")); + console.log(pc.dim(" Run without args for interactive mode (TTY only)")); + console.log(); +} + +// ─── Commander Setup ──────────────────────────────────────────────────────────── + +function buildProgram(): Command { + const program = new Command(); + + program.name("darwinkit").description("Apple on-device ML from the terminal").version("1.0.0"); + + for (const cmd of commands) { + const sub = program.command(cmd.name).description(cmd.description); + + const positionals = cmd.params.filter((pm) => pm.positional); + + for (const param of positionals) { + if (param.required) { + sub.argument(`<${param.name}>`, param.description); + } else { + sub.argument(`[${param.name}]`, param.description); + } + } + + const flags = cmd.params.filter((pm) => !pm.positional); + + for (const param of flags) { + const flag = + param.type === "boolean" + ? `--${param.name}` + : param.type === "string[]" + ? `--${param.name} ` + : `--${param.name} <${param.type}>`; + const desc = + param.default !== undefined + ? `${param.description} (default: ${SafeJSON.stringify(param.default)})` + : param.description; + sub.option(flag, desc); + } + + sub.option("--format ", "Output format: json, pretty, raw"); + + sub.action(async (...actionArgs: unknown[]) => { + await handleCommandAction(cmd, sub, actionArgs); + }); + } + + return program; +} + +async function handleCommandAction(cmd: CommandDef, sub: Command, actionArgs: unknown[]): Promise { + const positionals = cmd.params.filter((pm) => pm.positional); + const opts = (actionArgs[positionals.length] ?? {}) as Record; + + const args: Record = {}; + + for (let i = 0; i < positionals.length; i++) { + if (actionArgs[i] !== undefined) { + args[positionals[i].name] = actionArgs[i]; + } + } + + for (const param of cmd.params.filter((pm) => !pm.positional)) { + const camelName = param.name.replace(/-([a-z])/g, (_, c: string) => c.toUpperCase()); + const rawValue = opts[camelName] ?? opts[param.name]; + + if (rawValue === undefined) { + continue; + } + + if (param.type === "number") { + const num = Number(rawValue); + + if (Number.isNaN(num)) { + console.error(`Invalid number for --${param.name}: ${rawValue}`); + process.exit(1); + } + + args[param.name] = num; + } else if (param.type === "string[]") { + args[param.name] = parseVariadic(rawValue); + } else { + args[param.name] = rawValue; + } + } + + const missing = cmd.params.filter((pm) => { + if (!pm.required) { + return false; + } + + const value = args[pm.name]; + return value === undefined || (Array.isArray(value) && value.length === 0); + }); + + const validFormats = new Set(["json", "pretty", "raw"]); + const formatOpt = opts.format as string | undefined; + const format: OutputFormat = + formatOpt && validFormats.has(formatOpt as OutputFormat) ? (formatOpt as OutputFormat) : defaultFormat(); + + if (missing.length > 0) { + if (process.stdout.isTTY) { + p.intro(LOGO); + await runCommandInteractive(cmd, args, format); + return; + } + + sub.outputHelp(); + process.exit(1); + } + + try { + const result = await cmd.run(args); + console.log(formatOutput(result, format)); + } catch (error) { + if (process.stdout.isTTY) { + p.log.error(error instanceof Error ? error.message : String(error)); + } else { + console.error(error instanceof Error ? error.message : String(error)); + } + + process.exit(1); + } finally { + closeDarwinKit(); + } +} + +// ─── Main ─────────────────────────────────────────────────────────────────────── + +async function main(): Promise { + if (process.argv.length <= 2) { + if (process.stdout.isTTY) { + p.intro(LOGO); + await runInteractiveMenu(); + } else { + printFullHelp(); + } + + return; + } + + const program = buildProgram(); + + try { + await program.parseAsync(process.argv); + } catch (error) { + if (process.stdout.isTTY) { + p.log.error(error instanceof Error ? error.message : String(error)); + } else { + console.error(error instanceof Error ? error.message : String(error)); + } + + process.exit(1); + } +} + +main().catch((err) => { + if (process.stdout.isTTY) { + p.log.error(err instanceof Error ? err.message : String(err)); + } else { + console.error(err instanceof Error ? err.message : String(err)); + } + + closeDarwinKit(); + process.exit(1); +}); diff --git a/src/darwinkit/lib/commands.ts b/src/darwinkit/lib/commands.ts new file mode 100644 index 00000000..73a44f0c --- /dev/null +++ b/src/darwinkit/lib/commands.ts @@ -0,0 +1,653 @@ +import type { EmbedType, NlpScheme, OcrLevel } from "@app/utils/macos"; +import { + analyzeSentiment, + areSimilar, + authenticate, + batchSentiment, + checkBiometry, + classifyBatch, + classifyText, + clusterBySimilarity, + deduplicateTexts, + detectLanguage, + embedText, + extractEntities, + extractText, + findNeighbors, + getCapabilities, + getKeywords, + groupByCategory, + groupByLanguage, + icloudCopy, + icloudDelete, + icloudList, + icloudMkdir, + icloudMove, + icloudRead, + icloudStartMonitoring, + icloudStatus, + icloudStopMonitoring, + icloudWrite, + icloudWriteBytes, + lemmatize, + listVoices, + rankBySimilarity, + recognizeText, + scoreRelevance, + speak, + tagText, + textDistance, +} from "@app/utils/macos"; + +// ─── Types ────────────────────────────────────────────────────────────────────── + +export interface ParamDef { + name: string; + type: "string" | "number" | "boolean" | "string[]"; + required: boolean; + description: string; + default?: unknown; + /** If true, this is the first positional argument (not a flag) */ + positional?: boolean; + /** For select prompts in interactive mode */ + choices?: string[]; +} + +export interface CommandDef { + /** CLI subcommand name, e.g. "detect-language" */ + name: string; + /** Interactive menu group, e.g. "nlp" */ + group: string; + /** One-line description for help & interactive menu */ + description: string; + /** Parameter definitions — drive help, validation, and interactive prompts */ + params: ParamDef[]; + /** Execute the command. Receives validated args, returns result to be formatted. */ + run: (args: Record) => Promise; +} + +// ─── Group Labels ─────────────────────────────────────────────────────────────── + +export const GROUP_LABELS: Record = { + nlp: "Natural Language Processing", + vision: "Computer Vision", + "text-analysis": "Text Analysis (batch)", + classification: "Classification", + tts: "Text-to-Speech", + auth: "Authentication", + icloud: "iCloud Drive", + system: "System", +}; + +export const GROUP_ORDER = ["nlp", "vision", "text-analysis", "classification", "tts", "auth", "icloud", "system"]; + +// ─── Command Registry ─────────────────────────────────────────────────────────── + +export const commands: CommandDef[] = [ + // ── NLP ────────────────────────────────────────────────────────────────── + { + name: "detect-language", + group: "nlp", + description: "Detect the language of text (BCP-47 code + confidence)", + params: [{ name: "text", type: "string", required: true, positional: true, description: "Text to analyze" }], + run: async (args) => detectLanguage(args.text as string), + }, + { + name: "sentiment", + group: "nlp", + description: "Analyze sentiment — score (-1 to 1) and label", + params: [{ name: "text", type: "string", required: true, positional: true, description: "Text to analyze" }], + run: async (args) => analyzeSentiment(args.text as string), + }, + { + name: "tag", + group: "nlp", + description: "Tag text with POS, NER, lemma, or other schemes", + params: [ + { name: "text", type: "string", required: true, positional: true, description: "Text to tag" }, + { + name: "schemes", + type: "string[]", + required: false, + description: "Tagging schemes", + default: ["lexicalClass"], + choices: ["lexicalClass", "nameType", "lemma", "sentimentScore", "language"], + }, + { name: "language", type: "string", required: false, description: "BCP-47 language code" }, + ], + run: async (args) => + tagText( + args.text as string, + (args.schemes as NlpScheme[] | undefined) ?? ["lexicalClass"], + args.language as string | undefined + ), + }, + { + name: "entities", + group: "nlp", + description: "Extract named entities (people, places, organizations)", + params: [{ name: "text", type: "string", required: true, positional: true, description: "Text to analyze" }], + run: async (args) => extractEntities(args.text as string), + }, + { + name: "lemmatize", + group: "nlp", + description: "Get root/dictionary form of each word", + params: [ + { name: "text", type: "string", required: true, positional: true, description: "Text to lemmatize" }, + { name: "language", type: "string", required: false, description: "BCP-47 language code" }, + ], + run: async (args) => lemmatize(args.text as string, args.language as string | undefined), + }, + { + name: "keywords", + group: "nlp", + description: "Extract important content words (nouns, verbs, adjectives)", + params: [ + { name: "text", type: "string", required: true, positional: true, description: "Text to analyze" }, + { name: "max", type: "number", required: false, description: "Max keywords to return", default: 10 }, + ], + run: async (args) => getKeywords(args.text as string, (args.max as number) ?? 10), + }, + { + name: "embed", + group: "nlp", + description: "Compute 512-dim semantic embedding vector", + params: [ + { name: "text", type: "string", required: true, positional: true, description: "Text to embed" }, + { name: "language", type: "string", required: false, description: "BCP-47 code", default: "en" }, + { + name: "type", + type: "string", + required: false, + description: "Embedding type", + default: "sentence", + choices: ["word", "sentence"], + }, + ], + run: async (args) => + embedText(args.text as string, (args.language as string) ?? "en", (args.type as EmbedType) ?? "sentence"), + }, + { + name: "distance", + group: "nlp", + description: "Compute cosine distance between two texts (0 = identical, 2 = opposite)", + params: [ + { name: "text1", type: "string", required: true, positional: true, description: "First text" }, + { name: "text2", type: "string", required: true, positional: true, description: "Second text" }, + { name: "language", type: "string", required: false, description: "BCP-47 code", default: "en" }, + ], + run: async (args) => + textDistance(args.text1 as string, args.text2 as string, (args.language as string) ?? "en"), + }, + { + name: "similar", + group: "nlp", + description: "Check if two texts are semantically similar (boolean)", + params: [ + { name: "text1", type: "string", required: true, positional: true, description: "First text" }, + { name: "text2", type: "string", required: true, positional: true, description: "Second text" }, + { name: "threshold", type: "number", required: false, description: "Distance threshold", default: 0.5 }, + ], + run: async (args) => areSimilar(args.text1 as string, args.text2 as string, (args.threshold as number) ?? 0.5), + }, + { + name: "relevance", + group: "nlp", + description: "Score semantic relevance of text against a query (0-1)", + params: [ + { name: "query", type: "string", required: true, positional: true, description: "Query text" }, + { name: "text", type: "string", required: true, positional: true, description: "Text to score" }, + { name: "language", type: "string", required: false, description: "BCP-47 code", default: "en" }, + ], + run: async (args) => + scoreRelevance(args.query as string, args.text as string, (args.language as string) ?? "en"), + }, + { + name: "neighbors", + group: "nlp", + description: "Find semantically similar words or sentences", + params: [ + { name: "text", type: "string", required: true, positional: true, description: "Input text" }, + { name: "count", type: "number", required: false, description: "Number of neighbors", default: 5 }, + { name: "language", type: "string", required: false, description: "BCP-47 code", default: "en" }, + { + name: "type", + type: "string", + required: false, + description: "Embed type", + default: "word", + choices: ["word", "sentence"], + }, + ], + run: async (args) => + findNeighbors( + args.text as string, + (args.count as number) ?? 5, + (args.language as string) ?? "en", + (args.type as EmbedType) ?? "word" + ), + }, + + // ── Vision ─────────────────────────────────────────────────────────────── + { + name: "ocr", + group: "vision", + description: "Extract text from an image file using Apple Vision", + params: [ + { name: "path", type: "string", required: true, positional: true, description: "Path to image file" }, + { + name: "languages", + type: "string[]", + required: false, + description: "Recognition languages", + default: ["en-US"], + }, + { + name: "level", + type: "string", + required: false, + description: "Recognition level", + default: "accurate", + choices: ["accurate", "fast"], + }, + { + name: "text-only", + type: "boolean", + required: false, + description: "Return plain text only (no bounding boxes)", + default: false, + }, + ], + run: async (args) => { + const path = args.path as string; + const options = { + languages: args.languages as string[] | undefined, + level: (args.level as OcrLevel) ?? "accurate", + }; + + if (args["text-only"]) { + return extractText(path, options); + } + + return recognizeText(path, options); + }, + }, + + // ── Text Analysis (batch) ──────────────────────────────────────────────── + { + name: "rank", + group: "text-analysis", + description: "Rank texts by semantic similarity to a query", + params: [ + { name: "query", type: "string", required: true, positional: true, description: "Query to rank against" }, + { + name: "items", + type: "string[]", + required: true, + description: "Texts to rank (comma-separated or multiple --items flags)", + }, + { name: "max-results", type: "number", required: false, description: "Max results to return" }, + { name: "language", type: "string", required: false, description: "BCP-47 code", default: "en" }, + ], + run: async (args) => { + const items = (args.items as string[]).map((text, i) => ({ text, id: String(i) })); + return rankBySimilarity(args.query as string, items, { + language: (args.language as string) ?? "en", + maxResults: args["max-results"] as number | undefined, + }); + }, + }, + { + name: "batch-sentiment", + group: "text-analysis", + description: "Analyze sentiment for multiple texts", + params: [ + { + name: "items", + type: "string[]", + required: true, + description: "Texts to analyze (comma-separated)", + }, + ], + run: async (args) => { + const items = (args.items as string[]).map((text, i) => ({ text, id: String(i) })); + return batchSentiment(items); + }, + }, + { + name: "group-by-language", + group: "text-analysis", + description: "Detect language for each text and group by language code", + params: [ + { + name: "items", + type: "string[]", + required: true, + description: "Texts to group (comma-separated)", + }, + { + name: "min-confidence", + type: "number", + required: false, + description: "Min confidence threshold", + default: 0.7, + }, + ], + run: async (args) => { + const items = (args.items as string[]).map((text, i) => ({ text, id: String(i) })); + return groupByLanguage(items, { + minConfidence: (args["min-confidence"] as number) ?? 0.7, + }); + }, + }, + { + name: "deduplicate", + group: "text-analysis", + description: "Remove semantically duplicate texts", + params: [ + { + name: "items", + type: "string[]", + required: true, + description: "Texts to deduplicate (comma-separated)", + }, + { + name: "threshold", + type: "number", + required: false, + description: "Cosine distance threshold", + default: 0.3, + }, + { name: "language", type: "string", required: false, description: "BCP-47 code", default: "en" }, + ], + run: async (args) => { + const items = (args.items as string[]).map((text) => ({ text })); + const result = await deduplicateTexts(items, { + threshold: (args.threshold as number) ?? 0.3, + language: (args.language as string) ?? "en", + }); + return result.map((r) => r.text); + }, + }, + { + name: "cluster", + group: "text-analysis", + description: "Group semantically similar texts into clusters", + params: [ + { + name: "items", + type: "string[]", + required: true, + description: "Texts to cluster (comma-separated)", + }, + { + name: "threshold", + type: "number", + required: false, + description: "Distance threshold for same cluster", + default: 0.5, + }, + { name: "language", type: "string", required: false, description: "BCP-47 code", default: "en" }, + ], + run: async (args) => { + const items = (args.items as string[]).map((text) => ({ text })); + return clusterBySimilarity(items, { + threshold: (args.threshold as number) ?? 0.5, + language: (args.language as string) ?? "en", + }); + }, + }, + + // ── Classification ─────────────────────────────────────────────────────── + { + name: "classify", + group: "classification", + description: "Classify text into one of N candidate categories", + params: [ + { name: "text", type: "string", required: true, positional: true, description: "Text to classify" }, + { + name: "categories", + type: "string[]", + required: true, + description: "Candidate categories (comma-separated)", + }, + { name: "language", type: "string", required: false, description: "BCP-47 code", default: "en" }, + ], + run: async (args) => + classifyText(args.text as string, args.categories as string[], { + language: (args.language as string) ?? "en", + }), + }, + + { + name: "classify-batch", + group: "classification", + description: "Classify multiple texts into categories", + params: [ + { + name: "items", + type: "string[]", + required: true, + description: "Texts to classify (comma-separated)", + }, + { + name: "categories", + type: "string[]", + required: true, + description: "Candidate categories (comma-separated)", + }, + { name: "language", type: "string", required: false, description: "BCP-47 code", default: "en" }, + ], + run: async (args) => { + const items = (args.items as string[]).map((text, i) => ({ text, id: String(i) })); + return classifyBatch(items, args.categories as string[], { + language: (args.language as string) ?? "en", + }); + }, + }, + { + name: "group-by-category", + group: "classification", + description: "Group texts by their classified category", + params: [ + { + name: "items", + type: "string[]", + required: true, + description: "Texts to group (comma-separated)", + }, + { + name: "categories", + type: "string[]", + required: true, + description: "Candidate categories (comma-separated)", + }, + { name: "language", type: "string", required: false, description: "BCP-47 code", default: "en" }, + ], + run: async (args) => { + const items = (args.items as string[]).map((text) => ({ text })); + return groupByCategory(items, args.categories as string[], { + language: (args.language as string) ?? "en", + }); + }, + }, + + // ── TTS ────────────────────────────────────────────────────────────────── + { + name: "speak", + group: "tts", + description: "Speak text aloud using macOS say with auto language detection", + params: [ + { name: "text", type: "string", required: true, positional: true, description: "Text to speak" }, + { name: "voice", type: "string", required: false, description: "Override voice name" }, + { name: "rate", type: "number", required: false, description: "Words per minute" }, + ], + run: async (args) => { + await speak(args.text as string, { + voice: args.voice as string | undefined, + rate: args.rate as number | undefined, + }); + return { spoken: true }; + }, + }, + { + name: "list-voices", + group: "tts", + description: "List available macOS speech synthesis voices", + params: [], + run: async () => listVoices(), + }, + + // ── Auth ───────────────────────────────────────────────────────────────── + { + name: "check-biometry", + group: "auth", + description: "Check if Touch ID / Optic ID is available", + params: [], + run: async () => checkBiometry(), + }, + { + name: "authenticate", + group: "auth", + description: "Authenticate using Touch ID / Optic ID", + params: [ + { + name: "reason", + type: "string", + required: false, + positional: true, + description: "Reason for auth prompt", + }, + ], + run: async (args) => authenticate(args.reason as string | undefined), + }, + + // ── iCloud ─────────────────────────────────────────────────────────────── + { + name: "icloud-status", + group: "icloud", + description: "Check iCloud Drive availability and container URL", + params: [], + run: async () => icloudStatus(), + }, + { + name: "icloud-read", + group: "icloud", + description: "Read a text file from iCloud Drive", + params: [ + { name: "path", type: "string", required: true, positional: true, description: "File path in iCloud" }, + ], + run: async (args) => icloudRead(args.path as string), + }, + { + name: "icloud-write", + group: "icloud", + description: "Write text to a file in iCloud Drive", + params: [ + { name: "path", type: "string", required: true, positional: true, description: "File path in iCloud" }, + { name: "content", type: "string", required: true, description: "Content to write" }, + ], + run: async (args) => icloudWrite(args.path as string, args.content as string), + }, + { + name: "icloud-write-bytes", + group: "icloud", + description: "Write binary data (base64-encoded) to iCloud Drive", + params: [ + { name: "path", type: "string", required: true, positional: true, description: "File path in iCloud" }, + { name: "data", type: "string", required: true, description: "Base64-encoded data" }, + ], + run: async (args) => icloudWriteBytes(args.path as string, args.data as string), + }, + { + name: "icloud-delete", + group: "icloud", + description: "Delete a file from iCloud Drive", + params: [ + { name: "path", type: "string", required: true, positional: true, description: "File path to delete" }, + ], + run: async (args) => icloudDelete(args.path as string), + }, + { + name: "icloud-move", + group: "icloud", + description: "Move/rename a file in iCloud Drive", + params: [ + { name: "source", type: "string", required: true, positional: true, description: "Source path" }, + { name: "destination", type: "string", required: true, positional: true, description: "Destination path" }, + ], + run: async (args) => icloudMove(args.source as string, args.destination as string), + }, + { + name: "icloud-copy", + group: "icloud", + description: "Copy a file in iCloud Drive", + params: [ + { name: "source", type: "string", required: true, positional: true, description: "Source path" }, + { name: "destination", type: "string", required: true, positional: true, description: "Destination path" }, + ], + run: async (args) => icloudCopy(args.source as string, args.destination as string), + }, + { + name: "icloud-list", + group: "icloud", + description: "List directory contents in iCloud Drive", + params: [{ name: "path", type: "string", required: true, positional: true, description: "Directory path" }], + run: async (args) => icloudList(args.path as string), + }, + { + name: "icloud-mkdir", + group: "icloud", + description: "Create a directory in iCloud Drive", + params: [{ name: "path", type: "string", required: true, positional: true, description: "Directory path" }], + run: async (args) => icloudMkdir(args.path as string), + }, + { + name: "icloud-start-monitoring", + group: "icloud", + description: "Start monitoring iCloud Drive for file changes", + params: [], + run: async () => icloudStartMonitoring(), + }, + { + name: "icloud-stop-monitoring", + group: "icloud", + description: "Stop monitoring iCloud Drive for file changes", + params: [], + run: async () => icloudStopMonitoring(), + }, + + // ── System ─────────────────────────────────────────────────────────────── + { + name: "capabilities", + group: "system", + description: "Show DarwinKit version, OS, architecture, and available methods", + params: [], + run: async () => getCapabilities(), + }, +]; + +/** Get a command by name */ +export function getCommand(name: string): CommandDef | undefined { + return commands.find((c) => c.name === name); +} + +/** Get commands grouped by group name */ +export function getCommandsByGroup(): Map { + const groups = new Map(); + + for (const group of GROUP_ORDER) { + groups.set(group, []); + } + + for (const cmd of commands) { + const list = groups.get(cmd.group); + + if (list) { + list.push(cmd); + } + } + + return groups; +} diff --git a/src/darwinkit/lib/format.ts b/src/darwinkit/lib/format.ts new file mode 100644 index 00000000..e2bf31e4 --- /dev/null +++ b/src/darwinkit/lib/format.ts @@ -0,0 +1,142 @@ +import { SafeJSON } from "@app/utils/json"; +import pc from "picocolors"; + +export type OutputFormat = "json" | "pretty" | "raw"; + +/** + * Detect default format: pretty for TTY, json for piped + */ +export function defaultFormat(): OutputFormat { + return process.stdout.isTTY ? "pretty" : "json"; +} + +/** + * Format any result for output + */ +export function formatOutput(data: unknown, format: OutputFormat): string { + switch (format) { + case "json": + return SafeJSON.stringify(data, null, 2); + case "raw": + return formatRaw(data); + case "pretty": + return formatPretty(data); + } +} + +function formatRaw(data: unknown): string { + if (data === null || data === undefined) { + return ""; + } + + if (typeof data === "string") { + return data; + } + + if (typeof data === "number" || typeof data === "boolean") { + return String(data); + } + + if (Array.isArray(data)) { + return data.map((item) => formatRaw(item)).join("\n"); + } + + // For objects with a single obvious "value" field, extract it + if (typeof data === "object") { + const obj = data as Record; + + // Common single-value results + if ("text" in obj && Object.keys(obj).length <= 2) { + return String(obj.text); + } + + if ("content" in obj && Object.keys(obj).length <= 1) { + return String(obj.content); + } + + // Fall back to JSON for complex objects + return SafeJSON.stringify(data, null, 2); + } + + return String(data); +} + +function formatPretty(data: unknown): string { + if (data === null || data === undefined) { + return pc.dim("(empty)"); + } + + if (typeof data === "string") { + return data; + } + + if (typeof data === "number" || typeof data === "boolean") { + return pc.cyan(String(data)); + } + + if (Array.isArray(data)) { + if (data.length === 0) { + return pc.dim("(empty array)"); + } + + // Array of objects → table-like output + if (typeof data[0] === "object" && data[0] !== null) { + return data + .map((item, i) => { + const prefix = pc.dim(`[${i}] `); + const fields = Object.entries(item as Record) + .map(([k, v]) => ` ${pc.bold(k)}: ${formatValue(v)}`) + .join("\n"); + return `${prefix}\n${fields}`; + }) + .join("\n"); + } + + return data.map((item) => ` ${formatValue(item)}`).join("\n"); + } + + if (typeof data === "object") { + const obj = data as Record; + return Object.entries(obj) + .map(([k, v]) => `${pc.bold(k)}: ${formatValue(v)}`) + .join("\n"); + } + + return String(data); +} + +function formatValue(value: unknown): string { + if (value === null || value === undefined) { + return pc.dim("null"); + } + + if (typeof value === "string") { + return pc.green(`"${value}"`); + } + + if (typeof value === "number") { + return pc.cyan(String(value)); + } + + if (typeof value === "boolean") { + return value ? pc.green("true") : pc.red("false"); + } + + if (Array.isArray(value)) { + if (value.length === 0) { + return pc.dim("[]"); + } + + if (value.length <= 5 && value.every((v) => typeof v !== "object")) { + return `[${value.map((v) => formatValue(v)).join(", ")}]`; + } + + return `[${value.length} items]`; + } + + if (typeof value === "object") { + return SafeJSON.stringify(value); + } + + return String(value); +} diff --git a/src/darwinkit/lib/interactive.ts b/src/darwinkit/lib/interactive.ts new file mode 100644 index 00000000..61ed8147 --- /dev/null +++ b/src/darwinkit/lib/interactive.ts @@ -0,0 +1,219 @@ +import { closeDarwinKit } from "@app/utils/macos"; +import { handleCancel, isCancelled, withCancel } from "@app/utils/prompts/clack/helpers"; +import * as p from "@clack/prompts"; +import pc from "picocolors"; +import { type CommandDef, commands, GROUP_LABELS, GROUP_ORDER, getCommandsByGroup, type ParamDef } from "./commands"; +import { defaultFormat, formatOutput, type OutputFormat } from "./format"; + +/** + * Run the full interactive menu: group -> command -> params -> execute + */ +export async function runInteractiveMenu(): Promise { + const grouped = getCommandsByGroup(); + + const group = await withCancel( + p.select({ + message: "Choose a category", + options: GROUP_ORDER.filter((g) => { + const cmds = grouped.get(g); + return cmds && cmds.length > 0; + }).map((g) => ({ + value: g, + label: GROUP_LABELS[g] ?? g, + hint: `${grouped.get(g)!.length} commands`, + })), + }) + ); + + const groupCommands = grouped.get(group as string)!; + + const cmdName = await withCancel( + p.select({ + message: "Choose a command", + options: groupCommands.map((c) => ({ + value: c.name, + label: c.name, + hint: c.description, + })), + }) + ); + + const cmd = commands.find((c) => c.name === cmdName)!; + await runCommandInteractive(cmd); +} + +/** + * Prompt for missing params and execute a command interactively. + * Shows usage hint first, then prompts for each missing param. + */ +export async function runCommandInteractive( + cmd: CommandDef, + providedArgs: Record = {}, + formatOverride?: OutputFormat +): Promise { + const usage = buildUsageLine(cmd); + p.log.info(pc.dim(usage)); + + const args = { ...providedArgs }; + + for (const param of cmd.params) { + const existing = args[param.name]; + + if (existing !== undefined && !(Array.isArray(existing) && existing.length === 0)) { + continue; + } + + if (!param.required && !process.stdout.isTTY) { + continue; + } + + const value = await promptForParam(param); + + if (value !== undefined) { + args[param.name] = value; + } + } + + const spin = p.spinner(); + spin.start(`Running ${cmd.name}...`); + + try { + const result = await cmd.run(args); + spin.stop(`${cmd.name} complete`); + + const format = formatOverride ?? defaultFormat(); + const output = formatOutput(result, format); + console.log(output); + } catch (error) { + spin.stop(pc.red(`${cmd.name} failed`)); + p.log.error(error instanceof Error ? error.message : String(error)); + process.exitCode = 1; + } finally { + closeDarwinKit(); + } +} + +async function promptForParam(param: ParamDef): Promise { + if (param.choices && param.choices.length > 0) { + if (param.type === "string[]") { + const result = await p.multiselect({ + message: `${param.name} ${pc.dim(`(${param.description})`)}`, + options: param.choices.map((c) => ({ value: c, label: c })), + initialValues: param.default as string[] | undefined, + }); + + if (isCancelled(result)) { + handleCancel(); + } + + return result; + } + + const result = await p.select({ + message: `${param.name} ${pc.dim(`(${param.description})`)}`, + options: param.choices.map((c) => ({ value: c, label: c })), + initialValue: param.default as string | undefined, + }); + + if (isCancelled(result)) { + handleCancel(); + } + + return result; + } + + if (param.type === "boolean") { + return withCancel( + p.confirm({ + message: `${param.name}? ${pc.dim(`(${param.description})`)}`, + initialValue: (param.default as boolean) ?? false, + }) + ); + } + + if (param.type === "string[]") { + const result = await withCancel( + p.text({ + message: `${param.name} ${pc.dim(`(${param.description}, comma-separated)`)}`, + placeholder: param.default ? String(param.default) : undefined, + }) + ); + const str = (result as string).trim(); + + if (str === "") { + return []; + } + + return str + .split(",") + .map((s) => s.trim()) + .filter(Boolean); + } + + if (param.type === "number") { + const result = await withCancel( + p.text({ + message: `${param.name} ${pc.dim(`(${param.description})`)}`, + placeholder: param.default !== undefined ? String(param.default) : undefined, + validate: (v) => { + if (!param.required && v === "") { + return; + } + + if (Number.isNaN(Number(v))) { + return "Must be a number"; + } + }, + }) + ); + + const str = result as string; + + if (str === "" && param.default !== undefined) { + return param.default; + } + + return str === "" ? undefined : Number(str); + } + + // string + const result = await withCancel( + p.text({ + message: `${param.name} ${pc.dim(`(${param.description})`)}`, + placeholder: param.default !== undefined ? String(param.default) : undefined, + validate: (v) => { + if (param.required && (!v || (v as string).trim() === "")) { + return `${param.name} is required`; + } + }, + }) + ); + + const str = result as string; + + if (str === "" && !param.required) { + return param.default; + } + + return str; +} + +function buildUsageLine(cmd: CommandDef): string { + const positionals = cmd.params.filter((pm) => pm.positional); + const flags = cmd.params.filter((pm) => !pm.positional); + let line = `Usage: tools darwinkit ${cmd.name}`; + + for (const param of positionals) { + line += param.required ? ` <${param.name}>` : ` [${param.name}]`; + } + + for (const param of flags) { + if (param.type === "boolean") { + line += ` [--${param.name}]`; + } else { + line += ` [--${param.name} <${param.type}>]`; + } + } + + return line; +} diff --git a/src/utils/cli.ts b/src/utils/cli.ts new file mode 100644 index 00000000..55688d44 --- /dev/null +++ b/src/utils/cli.ts @@ -0,0 +1,30 @@ +/** + * Parse a Commander variadic option value into a flat string array. + * + * Handles all common patterns: + * --flag "a" "b" "c" → ["a", "b", "c"] (Commander gives string[]) + * --flag a,b,c → ["a", "b", "c"] (Commander gives ["a,b,c"]) + * --flag "a,b,c" → ["a", "b", "c"] (Commander gives ["a,b,c"]) + * --flag "a","b","c" → ["a", "b", "c"] (Commander gives ["a,b,c"] or ["a","b","c"]) + * --flag "a, b , c" → ["a", "b", "c"] (trims whitespace) + * --flag a → ["a"] (Commander gives "a" or ["a"]) + */ +export function parseVariadic(value: unknown): string[] { + if (typeof value === "string") { + return value + .split(",") + .map((s) => s.trim()) + .filter(Boolean); + } + + if (Array.isArray(value)) { + return (value as string[]).flatMap((s) => + s + .split(",") + .map((part) => part.trim()) + .filter(Boolean) + ); + } + + return []; +} diff --git a/src/utils/json.ts b/src/utils/json.ts index 32c9ac35..219e62b5 100644 --- a/src/utils/json.ts +++ b/src/utils/json.ts @@ -5,6 +5,8 @@ type Reviver = (key: string | number, value: unknown) => unknown; type ParseOptions = { jsonl?: boolean; strict?: boolean; + /** Skip comment preservation — avoids boxing primitives (Boolean{}, String{}, Number{}) while still supporting comment-tolerant parsing */ + unbox?: boolean; reviver?: Reviver | null; }; @@ -27,17 +29,20 @@ export const SafeJSON = { if ( reviverOrOptions && typeof reviverOrOptions === "object" && - ("jsonl" in reviverOrOptions || "strict" in reviverOrOptions) + ("jsonl" in reviverOrOptions || "strict" in reviverOrOptions || "unbox" in reviverOrOptions || "reviver" in reviverOrOptions) ) { const options = reviverOrOptions as ParseOptions; + if (options.jsonl || options.strict) { // biome-ignore lint/style/noRestrictedGlobals: intentional strict-mode fallback to native JSON.parse return JSON.parse(text, options.reviver ?? undefined); } - return parse(text, options.reviver); + + // comment-json's 3rd arg `no_comments` skips primitive boxing at the source + return parse(text, options.reviver, options.unbox); } // Legacy: reviver function or null - return parse(text, reviverOrOptions as Reviver | null); + return parse(text, typeof reviverOrOptions === "function" ? reviverOrOptions : null); }, // biome-ignore lint/suspicious/noExplicitAny: match native JSON.stringify parameter types stringify: (value: any, replacerOrOptions?: any, space?: string | number): string => { diff --git a/src/utils/macos/auth.ts b/src/utils/macos/auth.ts new file mode 100644 index 00000000..f18dff53 --- /dev/null +++ b/src/utils/macos/auth.ts @@ -0,0 +1,17 @@ +import { getDarwinKit } from "./darwinkit"; +import type { AuthAvailableResult, AuthenticateResult } from "./types"; + +/** + * Check if biometric authentication (Touch ID / Optic ID) is available. + */ +export async function checkBiometry(): Promise { + return getDarwinKit().auth.available(); +} + +/** + * Authenticate using biometrics (Touch ID / Optic ID). + * @param reason - Reason string shown in the system prompt + */ +export async function authenticate(reason?: string): Promise { + return getDarwinKit().auth.authenticate(reason ? { reason } : undefined); +} diff --git a/src/utils/macos/icloud.ts b/src/utils/macos/icloud.ts new file mode 100644 index 00000000..cdf6b15e --- /dev/null +++ b/src/utils/macos/icloud.ts @@ -0,0 +1,97 @@ +import { getDarwinKit } from "./darwinkit"; +import type { + ICloudDirEntry, + ICloudListDirResult, + ICloudOkResult, + ICloudReadResult, + ICloudStatusResult, +} from "./types"; + +/** + * Check iCloud Drive availability and container URL. + */ +export async function icloudStatus(): Promise { + return getDarwinKit().icloud.status(); +} + +/** + * Read a text file from iCloud Drive. + * @param path - Relative path within the iCloud container + */ +export async function icloudRead(path: string): Promise { + return getDarwinKit().icloud.read({ path }); +} + +/** + * Write a text file to iCloud Drive. + */ +export async function icloudWrite(path: string, content: string): Promise { + return getDarwinKit().icloud.write({ path, content }); +} + +/** + * Write binary data (base64-encoded) to iCloud Drive. + */ +export async function icloudWriteBytes(path: string, data: string): Promise { + return getDarwinKit().icloud.writeBytes({ path, data }); +} + +/** + * Delete a file from iCloud Drive. + */ +export async function icloudDelete(path: string): Promise { + return getDarwinKit().icloud.delete({ path }); +} + +/** + * Move/rename a file in iCloud Drive. + */ +export async function icloudMove(source: string, destination: string): Promise { + return getDarwinKit().icloud.move({ source, destination }); +} + +/** + * Copy a file in iCloud Drive. + */ +export async function icloudCopy(source: string, destination: string): Promise { + return getDarwinKit().icloud.copyFile({ source, destination }); +} + +/** + * List directory contents in iCloud Drive. + */ +export async function icloudList(path: string): Promise { + const result: ICloudListDirResult = await getDarwinKit().icloud.listDir({ path }); + return result.entries; +} + +/** + * Create a directory in iCloud Drive (recursive). + */ +export async function icloudMkdir(path: string): Promise { + return getDarwinKit().icloud.ensureDir({ path }); +} + +/** + * Start monitoring iCloud Drive for file changes. + * Use `onIcloudFilesChanged(handler)` to listen for changes. + */ +export async function icloudStartMonitoring(): Promise { + return getDarwinKit().icloud.startMonitoring(); +} + +/** + * Stop monitoring iCloud Drive for file changes. + */ +export async function icloudStopMonitoring(): Promise { + return getDarwinKit().icloud.stopMonitoring(); +} + +/** + * Subscribe to iCloud Drive file change notifications. + * Call icloudStartMonitoring() first to begin receiving events. + * @returns Unsubscribe function + */ +export function onIcloudFilesChanged(handler: (notification: { paths: string[] }) => void): () => void { + return getDarwinKit().icloud.onFilesChanged(handler); +} diff --git a/src/utils/macos/index.ts b/src/utils/macos/index.ts index 468a5047..9de5e50b 100644 --- a/src/utils/macos/index.ts +++ b/src/utils/macos/index.ts @@ -1,9 +1,24 @@ -// Client - +// Auth +export { authenticate, checkBiometry } from "./auth"; // Classification export { classifyBatch, classifyText, groupByCategory } from "./classification"; export type { DarwinKitOptions } from "./darwinkit"; export { closeDarwinKit, DarwinKit, DarwinKitError, getDarwinKit } from "./darwinkit"; +// iCloud +export { + icloudCopy, + icloudDelete, + icloudList, + icloudMkdir, + icloudMove, + icloudRead, + icloudStartMonitoring, + icloudStatus, + icloudStopMonitoring, + icloudWrite, + icloudWriteBytes, + onIcloudFilesChanged, +} from "./icloud"; // NLP export { analyzeSentiment, @@ -28,6 +43,8 @@ export { recognizeText, recognizeTextFromBuffer, } from "./ocr"; +// System +export { getCapabilities } from "./system"; export type { BatchSentimentOptions, ClusterOptions, @@ -51,6 +68,9 @@ export { listVoices, speak } from "./tts"; // Types export type { + AuthAvailableResult, + AuthenticateResult, + BiometryType, CapabilitiesResult, ClassificationItem, ClassificationResult, @@ -58,10 +78,17 @@ export type { DistanceResult, EmbedResult, EmbedType, + ICloudDirEntry, + ICloudListDirResult, + ICloudOkResult, + ICloudReadResult, + ICloudStatusResult, Keyword, LanguageItem, LanguageResult, + MethodCapability, NamedEntity, + Neighbor, NeighborsResult, NlpScheme, OcrBlock, diff --git a/src/utils/macos/system.ts b/src/utils/macos/system.ts new file mode 100644 index 00000000..925066a1 --- /dev/null +++ b/src/utils/macos/system.ts @@ -0,0 +1,9 @@ +import { getDarwinKit } from "./darwinkit"; +import type { CapabilitiesResult } from "./types"; + +/** + * Get DarwinKit system capabilities — version, OS, architecture, available methods. + */ +export async function getCapabilities(): Promise { + return getDarwinKit().system.capabilities(); +} diff --git a/src/utils/macos/types.ts b/src/utils/macos/types.ts index 2f9a1d87..418a03ce 100644 --- a/src/utils/macos/types.ts +++ b/src/utils/macos/types.ts @@ -1,10 +1,20 @@ // Re-export types from @genesiscz/darwinkit package export type { + AuthAvailableResult, + AuthenticateResult, + BiometryType, CapabilitiesResult, DistanceResult, EmbedResult, EmbedType, + ICloudDirEntry, + ICloudListDirResult, + ICloudOkResult, + ICloudReadResult, + ICloudStatusResult, LanguageResult, + MethodCapability, + Neighbor, NeighborsResult, OCRBlock as OcrBlock, OCRBounds as OcrBounds, @@ -29,11 +39,6 @@ export interface TagResult { tokens: TaggedToken[]; } -export interface Neighbor { - text: string; - distance: number; -} - // ─── Higher-level Utility Types ─────────────────────────────────────────────── /** An item with an attached semantic similarity score (lower = more similar) */