From 75acc640d1ca6763ec9e5f4f160dad081cdc99aa Mon Sep 17 00:00:00 2001 From: Martin Date: Thu, 12 Mar 2026 03:48:31 +0100 Subject: [PATCH 01/15] refactor(macos): use Neighbor type from darwinkit, remove local definition --- src/utils/macos/index.ts | 1 + src/utils/macos/types.ts | 6 +----- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/src/utils/macos/index.ts b/src/utils/macos/index.ts index 468a5047..8334cecf 100644 --- a/src/utils/macos/index.ts +++ b/src/utils/macos/index.ts @@ -62,6 +62,7 @@ export type { LanguageItem, LanguageResult, NamedEntity, + Neighbor, NeighborsResult, NlpScheme, OcrBlock, diff --git a/src/utils/macos/types.ts b/src/utils/macos/types.ts index 2f9a1d87..8a8925d3 100644 --- a/src/utils/macos/types.ts +++ b/src/utils/macos/types.ts @@ -5,6 +5,7 @@ export type { EmbedResult, EmbedType, LanguageResult, + Neighbor, NeighborsResult, OCRBlock as OcrBlock, OCRBounds as OcrBounds, @@ -29,11 +30,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) */ From d6ae2c5a5bf68dff173b702fa91a23a07ea955cd Mon Sep 17 00:00:00 2001 From: Martin Date: Thu, 12 Mar 2026 04:02:11 +0100 Subject: [PATCH 02/15] docs: add DarwinKit CLI tool design and implementation plan --- .../plans/2026-03-12-DarwinKitCLI-design.md | 134 ++ .claude/plans/2026-03-12-DarwinKitCLI.md | 1615 +++++++++++++++++ 2 files changed, 1749 insertions(+) create mode 100644 .claude/plans/2026-03-12-DarwinKitCLI-design.md create mode 100644 .claude/plans/2026-03-12-DarwinKitCLI.md 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..ff2e7ad6 --- /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 `{ entries: [...] }` +- `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` From cc9d8555c7d295744d98fe149d4190fb58dd4f7f Mon Sep 17 00:00:00 2001 From: Martin Date: Thu, 12 Mar 2026 04:15:49 +0100 Subject: [PATCH 03/15] feat(macos): add auth util wrappers --- src/utils/macos/auth.ts | 17 +++++++++++++++++ src/utils/macos/index.ts | 29 +++++++++++++++++++++++++++-- src/utils/macos/types.ts | 9 +++++++++ 3 files changed, 53 insertions(+), 2 deletions(-) create mode 100644 src/utils/macos/auth.ts 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/index.ts b/src/utils/macos/index.ts index 8334cecf..67c22427 100644 --- a/src/utils/macos/index.ts +++ b/src/utils/macos/index.ts @@ -1,9 +1,23 @@ -// 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, +} from "./icloud"; // NLP export { analyzeSentiment, @@ -28,6 +42,8 @@ export { recognizeText, recognizeTextFromBuffer, } from "./ocr"; +// System +export { getCapabilities } from "./system"; export type { BatchSentimentOptions, ClusterOptions, @@ -51,6 +67,9 @@ export { listVoices, speak } from "./tts"; // Types export type { + AuthAvailableResult, + AuthenticateResult, + BiometryType, CapabilitiesResult, ClassificationItem, ClassificationResult, @@ -58,9 +77,15 @@ export type { DistanceResult, EmbedResult, EmbedType, + ICloudDirEntry, + ICloudListDirResult, + ICloudOkResult, + ICloudReadResult, + ICloudStatusResult, Keyword, LanguageItem, LanguageResult, + MethodCapability, NamedEntity, Neighbor, NeighborsResult, diff --git a/src/utils/macos/types.ts b/src/utils/macos/types.ts index 8a8925d3..418a03ce 100644 --- a/src/utils/macos/types.ts +++ b/src/utils/macos/types.ts @@ -1,10 +1,19 @@ // 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, From 2802eecdf341dd179c8f3f02e389da25124b1bec Mon Sep 17 00:00:00 2001 From: Martin Date: Thu, 12 Mar 2026 04:16:00 +0100 Subject: [PATCH 04/15] feat(macos): add icloud util wrappers --- src/utils/macos/icloud.ts | 88 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 88 insertions(+) create mode 100644 src/utils/macos/icloud.ts diff --git a/src/utils/macos/icloud.ts b/src/utils/macos/icloud.ts new file mode 100644 index 00000000..22183cf1 --- /dev/null +++ b/src/utils/macos/icloud.ts @@ -0,0 +1,88 @@ +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(); +} From 0e3edb4cfb06bbaafa8225f1e7a2973f7c39d0b1 Mon Sep 17 00:00:00 2001 From: Martin Date: Thu, 12 Mar 2026 04:16:47 +0100 Subject: [PATCH 05/15] feat(macos): add system util wrapper --- src/utils/macos/system.ts | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 src/utils/macos/system.ts 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(); +} From 6068d30572fcad0c937418be069120dd71e5e738 Mon Sep 17 00:00:00 2001 From: Martin Date: Thu, 12 Mar 2026 04:22:26 +0100 Subject: [PATCH 06/15] feat(darwinkit): add output formatter --- src/darwinkit/lib/format.ts | 142 ++++++++++++++++++++++++++++++++++++ 1 file changed, 142 insertions(+) create mode 100644 src/darwinkit/lib/format.ts 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); +} From 7826e16d88681db0821a0e65a2761b3b24c8b8fe Mon Sep 17 00:00:00 2001 From: Martin Date: Thu, 12 Mar 2026 04:23:18 +0100 Subject: [PATCH 07/15] feat(darwinkit): add command registry --- src/darwinkit/lib/commands.ts | 571 ++++++++++++++++++++++++++++++++++ 1 file changed, 571 insertions(+) create mode 100644 src/darwinkit/lib/commands.ts diff --git a/src/darwinkit/lib/commands.ts b/src/darwinkit/lib/commands.ts new file mode 100644 index 00000000..02ecbae9 --- /dev/null +++ b/src/darwinkit/lib/commands.ts @@ -0,0 +1,571 @@ +import type { EmbedType, NlpScheme, OcrLevel } from "@app/utils/macos"; +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"; + +// ─── 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; +} From 6d72ba3c47363426b5d6f499deb7fad3ea6abaac Mon Sep 17 00:00:00 2001 From: Martin Date: Thu, 12 Mar 2026 04:25:30 +0100 Subject: [PATCH 08/15] feat(darwinkit): add interactive mode --- src/darwinkit/lib/interactive.ts | 206 +++++++++++++++++++++++++++++++ 1 file changed, 206 insertions(+) create mode 100644 src/darwinkit/lib/interactive.ts diff --git a/src/darwinkit/lib/interactive.ts b/src/darwinkit/lib/interactive.ts new file mode 100644 index 00000000..db552b6d --- /dev/null +++ b/src/darwinkit/lib/interactive.ts @@ -0,0 +1,206 @@ +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 } 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 { + const usage = buildUsageLine(cmd); + p.log.info(pc.dim(usage)); + + 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; + } + } + + 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[]") { + 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 || 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((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; +} From 1322995e679d30b74dd7049763c3442d99de756e Mon Sep 17 00:00:00 2001 From: Martin Date: Thu, 12 Mar 2026 04:26:38 +0100 Subject: [PATCH 09/15] feat(darwinkit): add CLI entry point with interactive + commander modes --- src/darwinkit/index.ts | 193 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 193 insertions(+) create mode 100644 src/darwinkit/index.ts diff --git a/src/darwinkit/index.ts b/src/darwinkit/index.ts new file mode 100644 index 00000000..0a186961 --- /dev/null +++ b/src/darwinkit/index.ts @@ -0,0 +1,193 @@ +#!/usr/bin/env bun + +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") + .option("--format ", "Output format: json, pretty, raw"); + + 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()); + + 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]; + } + } + + const missing = cmd.params.filter((pm) => pm.required && args[pm.name] === undefined); + + if (missing.length > 0) { + if (process.stdout.isTTY) { + p.intro(LOGO); + await runCommandInteractive(cmd, args); + return; + } + + sub.help(); + return; + } + + 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); +}); From fb638d22010a52351bc7b5693bc3304f69f0ca77 Mon Sep 17 00:00:00 2001 From: Martin Date: Thu, 12 Mar 2026 04:28:48 +0100 Subject: [PATCH 10/15] fix(darwinkit): resolve TS warnings in interactive.ts and index.ts --- src/darwinkit/index.ts | 1 - src/darwinkit/lib/interactive.ts | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/src/darwinkit/index.ts b/src/darwinkit/index.ts index 0a186961..8e7c8cb1 100644 --- a/src/darwinkit/index.ts +++ b/src/darwinkit/index.ts @@ -131,7 +131,6 @@ async function handleCommandAction(cmd: CommandDef, sub: Command, actionArgs: un } sub.help(); - return; } const format: OutputFormat = (opts.format as OutputFormat) ?? defaultFormat(); diff --git a/src/darwinkit/lib/interactive.ts b/src/darwinkit/lib/interactive.ts index db552b6d..e88705b8 100644 --- a/src/darwinkit/lib/interactive.ts +++ b/src/darwinkit/lib/interactive.ts @@ -169,7 +169,7 @@ async function promptForParam(param: ParamDef): Promise { message: `${param.name} ${pc.dim(`(${param.description})`)}`, placeholder: param.default !== undefined ? String(param.default) : undefined, validate: (v) => { - if (param.required && (!v || v.trim() === "")) { + if (param.required && (!v || (v as string).trim() === "")) { return `${param.name} is required`; } }, From f045862d74afac4ec03102e19c3e1aef2dcb2164 Mon Sep 17 00:00:00 2001 From: Martin Date: Thu, 12 Mar 2026 04:47:43 +0100 Subject: [PATCH 11/15] fix(darwinkit): address PR #104 review feedback - Add return after sub.outputHelp() for explicit process exit (t1/t6) - Fix icloudList doc to reflect flat ICloudDirEntry[] return (t2) - Add missing commands: classify-batch, group-by-category, icloud-write-bytes, icloud-start-monitoring, icloud-stop-monitoring (t3) - Split comma-separated string[] args and validate numeric input (t4) - Forward --format flag to interactive fallback (t5) - Validate --format option values (t7) - Handle empty string for string[] interactive params (t8) - Add onIcloudFilesChanged subscription wrapper (t9) --- .../plans/2026-03-12-DarwinKitCLI-design.md | 2 +- src/darwinkit/index.ts | 39 +++++++-- src/darwinkit/lib/commands.ts | 82 +++++++++++++++++++ src/darwinkit/lib/interactive.ts | 18 +++- src/utils/macos/icloud.ts | 9 ++ src/utils/macos/index.ts | 1 + 6 files changed, 139 insertions(+), 12 deletions(-) diff --git a/.claude/plans/2026-03-12-DarwinKitCLI-design.md b/.claude/plans/2026-03-12-DarwinKitCLI-design.md index ff2e7ad6..0d9010f9 100644 --- a/.claude/plans/2026-03-12-DarwinKitCLI-design.md +++ b/.claude/plans/2026-03-12-DarwinKitCLI-design.md @@ -37,7 +37,7 @@ The CLI tool never imports from `@genesiscz/darwinkit` directly — it only call - `icloudDelete(path)` → returns `{ ok }` - `icloudMove(source, destination)` → returns `{ ok }` - `icloudCopy(source, destination)` → returns `{ ok }` -- `icloudList(path)` → returns `{ entries: [...] }` +- `icloudList(path)` → returns `ICloudDirEntry[]` - `icloudMkdir(path)` → returns `{ ok }` - `icloudStartMonitoring()` / `icloudStopMonitoring()` diff --git a/src/darwinkit/index.ts b/src/darwinkit/index.ts index 8e7c8cb1..e4d44ba7 100644 --- a/src/darwinkit/index.ts +++ b/src/darwinkit/index.ts @@ -113,11 +113,31 @@ async function handleCommandAction(cmd: CommandDef, sub: Command, actionArgs: un 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 (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]; + 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] = + typeof rawValue === "string" + ? rawValue + .split(",") + .map((s: string) => s.trim()) + .filter(Boolean) + : rawValue; + } else { + args[param.name] = rawValue; } } @@ -126,14 +146,19 @@ async function handleCommandAction(cmd: CommandDef, sub: Command, actionArgs: un if (missing.length > 0) { if (process.stdout.isTTY) { p.intro(LOGO); - await runCommandInteractive(cmd, args); + const fmtOpt = opts.format as string | undefined; + await runCommandInteractive(cmd, args, fmtOpt as OutputFormat | undefined); return; } - sub.help(); + sub.outputHelp(); + process.exit(0); } - const format: OutputFormat = (opts.format as OutputFormat) ?? defaultFormat(); + 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(); try { const result = await cmd.run(args); diff --git a/src/darwinkit/lib/commands.ts b/src/darwinkit/lib/commands.ts index 02ecbae9..73a44f0c 100644 --- a/src/darwinkit/lib/commands.ts +++ b/src/darwinkit/lib/commands.ts @@ -5,6 +5,7 @@ import { authenticate, batchSentiment, checkBiometry, + classifyBatch, classifyText, clusterBySimilarity, deduplicateTexts, @@ -15,6 +16,7 @@ import { findNeighbors, getCapabilities, getKeywords, + groupByCategory, groupByLanguage, icloudCopy, icloudDelete, @@ -22,8 +24,11 @@ import { icloudMkdir, icloudMove, icloudRead, + icloudStartMonitoring, icloudStatus, + icloudStopMonitoring, icloudWrite, + icloudWriteBytes, lemmatize, listVoices, rankBySimilarity, @@ -415,6 +420,59 @@ export const commands: CommandDef[] = [ }), }, + { + 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", @@ -492,6 +550,16 @@ export const commands: CommandDef[] = [ ], 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", @@ -535,6 +603,20 @@ export const commands: CommandDef[] = [ 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 ─────────────────────────────────────────────────────────────── { diff --git a/src/darwinkit/lib/interactive.ts b/src/darwinkit/lib/interactive.ts index e88705b8..c4923fe1 100644 --- a/src/darwinkit/lib/interactive.ts +++ b/src/darwinkit/lib/interactive.ts @@ -3,7 +3,7 @@ import { handleCancel, isCancelled, withCancel } from "@app/utils/prompts/clack/ 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 } from "./format"; +import { defaultFormat, formatOutput, type OutputFormat } from "./format"; /** * Run the full interactive menu: group -> command -> params -> execute @@ -48,7 +48,8 @@ export async function runInteractiveMenu(): Promise { */ export async function runCommandInteractive( cmd: CommandDef, - providedArgs: Record = {} + providedArgs: Record = {}, + formatOverride?: OutputFormat ): Promise { const usage = buildUsageLine(cmd); p.log.info(pc.dim(usage)); @@ -78,7 +79,7 @@ export async function runCommandInteractive( const result = await cmd.run(args); spin.stop(`${cmd.name} complete`); - const format = defaultFormat(); + const format = formatOverride ?? defaultFormat(); const output = formatOutput(result, format); console.log(output); } catch (error) { @@ -134,7 +135,16 @@ async function promptForParam(param: ParamDef): Promise { placeholder: param.default ? String(param.default) : undefined, }) ); - return (result as string).split(",").map((s) => s.trim()); + const str = (result as string).trim(); + + if (str === "") { + return []; + } + + return str + .split(",") + .map((s) => s.trim()) + .filter(Boolean); } if (param.type === "number") { diff --git a/src/utils/macos/icloud.ts b/src/utils/macos/icloud.ts index 22183cf1..8411fcf1 100644 --- a/src/utils/macos/icloud.ts +++ b/src/utils/macos/icloud.ts @@ -86,3 +86,12 @@ export async function icloudStartMonitoring(): Promise { 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 67c22427..9de5e50b 100644 --- a/src/utils/macos/index.ts +++ b/src/utils/macos/index.ts @@ -17,6 +17,7 @@ export { icloudStopMonitoring, icloudWrite, icloudWriteBytes, + onIcloudFilesChanged, } from "./icloud"; // NLP export { From 302228a21227cfc8b5d58d233e01b8f063cb7802 Mon Sep 17 00:00:00 2001 From: Martin Date: Sun, 15 Mar 2026 17:40:19 +0100 Subject: [PATCH 12/15] fix(darwinkit): fix string[] comma splitting and --format flag - Add parseVariadic() to src/utils/cli.ts for correct Commander variadic option handling with comma-separated values - Remove duplicate --format from parent program (Commander routed value to parent opts, subcommand always fell back to json) - Add unbox option to SafeJSON.parse to unwrap boxed primitives from comment-json without losing comment support - Add 58 E2E tests covering all darwinkit commands --- src/darwinkit/__tests__/batch.test.ts | 125 ++++++++++++++++++ .../__tests__/classification.test.ts | 86 ++++++++++++ src/darwinkit/__tests__/cli-flags.test.ts | 103 +++++++++++++++ src/darwinkit/__tests__/embeddings.test.ts | 70 ++++++++++ src/darwinkit/__tests__/helpers.ts | 52 ++++++++ src/darwinkit/__tests__/icloud.test.ts | 33 +++++ src/darwinkit/__tests__/nlp.test.ts | 117 ++++++++++++++++ .../__tests__/tts-auth-system.test.ts | 57 ++++++++ src/darwinkit/__tests__/vision.test.ts | 69 ++++++++++ src/darwinkit/index.ts | 15 +-- src/utils/cli.ts | 30 +++++ src/utils/json.ts | 17 ++- 12 files changed, 760 insertions(+), 14 deletions(-) create mode 100644 src/darwinkit/__tests__/batch.test.ts create mode 100644 src/darwinkit/__tests__/classification.test.ts create mode 100644 src/darwinkit/__tests__/cli-flags.test.ts create mode 100644 src/darwinkit/__tests__/embeddings.test.ts create mode 100644 src/darwinkit/__tests__/helpers.ts create mode 100644 src/darwinkit/__tests__/icloud.test.ts create mode 100644 src/darwinkit/__tests__/nlp.test.ts create mode 100644 src/darwinkit/__tests__/tts-auth-system.test.ts create mode 100644 src/darwinkit/__tests__/vision.test.ts create mode 100644 src/utils/cli.ts 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..3fc9e820 --- /dev/null +++ b/src/darwinkit/__tests__/cli-flags.test.ts @@ -0,0 +1,103 @@ +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); + // Pretty format for objects should be "key: value" lines, not JSON + 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); + // Raw format for objects without text/content falls back to JSON, + // but should be distinguishable from pretty format + 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..7bda6b56 --- /dev/null +++ b/src/darwinkit/__tests__/vision.test.ts @@ -0,0 +1,69 @@ +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" } + ); + await proc.exited; +}); + +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 index e4d44ba7..3930b883 100644 --- a/src/darwinkit/index.ts +++ b/src/darwinkit/index.ts @@ -1,5 +1,6 @@ #!/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"; @@ -54,11 +55,7 @@ function printFullHelp(): void { 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"); + 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); @@ -129,13 +126,7 @@ async function handleCommandAction(cmd: CommandDef, sub: Command, actionArgs: un args[param.name] = num; } else if (param.type === "string[]") { - args[param.name] = - typeof rawValue === "string" - ? rawValue - .split(",") - .map((s: string) => s.trim()) - .filter(Boolean) - : rawValue; + args[param.name] = parseVariadic(rawValue); } else { args[param.name] = rawValue; } 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..cbe09556 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; + /** Unwrap boxed primitives (Boolean{}, String{}, Number{}) that comment-json produces for top-level primitive values */ + unbox?: boolean; reviver?: Reviver | null; }; @@ -27,14 +29,25 @@ export const SafeJSON = { if ( reviverOrOptions && typeof reviverOrOptions === "object" && - ("jsonl" in reviverOrOptions || "strict" in reviverOrOptions) + ("jsonl" in reviverOrOptions || "strict" in reviverOrOptions || "unbox" 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); + + const result = parse(text, options.reviver); + + if (options.unbox && result !== null && result !== undefined && typeof result === "object") { + // comment-json wraps top-level primitives as Boolean{}, String{}, Number{} objects + if (result instanceof Boolean || result instanceof String || result instanceof Number) { + return result.valueOf(); + } + } + + return result; } // Legacy: reviver function or null return parse(text, reviverOrOptions as Reviver | null); From 4f5e11e49a33dfb4b531b670d542ae66200a9413 Mon Sep 17 00:00:00 2001 From: Martin Date: Sun, 15 Mar 2026 20:54:26 +0100 Subject: [PATCH 13/15] fix(darwinkit): address PR #104 review feedback (round 2) - Exit 1 (not 0) for missing required args in non-TTY mode - Validate --format before interactive fallback - Set process.exitCode=1 on interactive command failure - Treat empty string[] as missing in interactive prompts - Use comment-json's no_comments flag for unbox (no manual valueOf) - Add reviver to ParseOptions detection in SafeJSON.parse - Update icloud monitoring doc to reference exported wrapper - Stricter test assertions, remove redundant comments --- src/darwinkit/__tests__/cli-flags.test.ts | 3 --- src/darwinkit/index.ts | 24 ++++++++++++++--------- src/darwinkit/lib/interactive.ts | 5 ++++- src/utils/json.ts | 18 +++++------------ src/utils/macos/icloud.ts | 2 +- 5 files changed, 25 insertions(+), 27 deletions(-) diff --git a/src/darwinkit/__tests__/cli-flags.test.ts b/src/darwinkit/__tests__/cli-flags.test.ts index 3fc9e820..033b904b 100644 --- a/src/darwinkit/__tests__/cli-flags.test.ts +++ b/src/darwinkit/__tests__/cli-flags.test.ts @@ -40,7 +40,6 @@ describe("darwinkit CLI flags", () => { it("pretty format outputs key-value pairs, not JSON braces", async () => { const { stdout, exitCode } = await runDarwinKitRaw("sentiment", "Great!", "--format", "pretty"); expect(exitCode).toBe(0); - // Pretty format for objects should be "key: value" lines, not JSON expect(stdout).not.toContain("{"); expect(stdout).toContain("label:"); expect(stdout).toContain("score:"); @@ -49,8 +48,6 @@ describe("darwinkit CLI flags", () => { it("raw format outputs simplified output", async () => { const { stdout, exitCode } = await runDarwinKitRaw("sentiment", "Great!", "--format", "raw"); expect(exitCode).toBe(0); - // Raw format for objects without text/content falls back to JSON, - // but should be distinguishable from pretty format expect(stdout.length).toBeGreaterThan(0); }); }); diff --git a/src/darwinkit/index.ts b/src/darwinkit/index.ts index 3930b883..fe212b69 100644 --- a/src/darwinkit/index.ts +++ b/src/darwinkit/index.ts @@ -132,25 +132,31 @@ async function handleCommandAction(cmd: CommandDef, sub: Command, actionArgs: un } } - const missing = cmd.params.filter((pm) => pm.required && args[pm.name] === undefined); + 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); - const fmtOpt = opts.format as string | undefined; - await runCommandInteractive(cmd, args, fmtOpt as OutputFormat | undefined); + await runCommandInteractive(cmd, args, format); return; } sub.outputHelp(); - process.exit(0); + process.exit(1); } - 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(); - try { const result = await cmd.run(args); console.log(formatOutput(result, format)); diff --git a/src/darwinkit/lib/interactive.ts b/src/darwinkit/lib/interactive.ts index c4923fe1..61ed8147 100644 --- a/src/darwinkit/lib/interactive.ts +++ b/src/darwinkit/lib/interactive.ts @@ -57,7 +57,9 @@ export async function runCommandInteractive( const args = { ...providedArgs }; for (const param of cmd.params) { - if (args[param.name] !== undefined) { + const existing = args[param.name]; + + if (existing !== undefined && !(Array.isArray(existing) && existing.length === 0)) { continue; } @@ -85,6 +87,7 @@ export async function runCommandInteractive( } catch (error) { spin.stop(pc.red(`${cmd.name} failed`)); p.log.error(error instanceof Error ? error.message : String(error)); + process.exitCode = 1; } finally { closeDarwinKit(); } diff --git a/src/utils/json.ts b/src/utils/json.ts index cbe09556..219e62b5 100644 --- a/src/utils/json.ts +++ b/src/utils/json.ts @@ -5,7 +5,7 @@ type Reviver = (key: string | number, value: unknown) => unknown; type ParseOptions = { jsonl?: boolean; strict?: boolean; - /** Unwrap boxed primitives (Boolean{}, String{}, Number{}) that comment-json produces for top-level primitive values */ + /** Skip comment preservation — avoids boxing primitives (Boolean{}, String{}, Number{}) while still supporting comment-tolerant parsing */ unbox?: boolean; reviver?: Reviver | null; }; @@ -29,7 +29,7 @@ export const SafeJSON = { if ( reviverOrOptions && typeof reviverOrOptions === "object" && - ("jsonl" in reviverOrOptions || "strict" in reviverOrOptions || "unbox" in reviverOrOptions) + ("jsonl" in reviverOrOptions || "strict" in reviverOrOptions || "unbox" in reviverOrOptions || "reviver" in reviverOrOptions) ) { const options = reviverOrOptions as ParseOptions; @@ -38,19 +38,11 @@ export const SafeJSON = { return JSON.parse(text, options.reviver ?? undefined); } - const result = parse(text, options.reviver); - - if (options.unbox && result !== null && result !== undefined && typeof result === "object") { - // comment-json wraps top-level primitives as Boolean{}, String{}, Number{} objects - if (result instanceof Boolean || result instanceof String || result instanceof Number) { - return result.valueOf(); - } - } - - return result; + // 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/icloud.ts b/src/utils/macos/icloud.ts index 8411fcf1..cdf6b15e 100644 --- a/src/utils/macos/icloud.ts +++ b/src/utils/macos/icloud.ts @@ -74,7 +74,7 @@ export async function icloudMkdir(path: string): Promise { /** * Start monitoring iCloud Drive for file changes. - * Use `getDarwinKit().icloud.onFilesChanged(handler)` to listen for changes. + * Use `onIcloudFilesChanged(handler)` to listen for changes. */ export async function icloudStartMonitoring(): Promise { return getDarwinKit().icloud.startMonitoring(); From cc5de053f8256b0bcfe008b0032e6e6d2fe22741 Mon Sep 17 00:00:00 2001 From: Martin Date: Sun, 15 Mar 2026 20:56:04 +0100 Subject: [PATCH 14/15] fix: resolve pre-existing biome lint errors blocking CI - Add biome-ignore for JSON usage in standalone hook script - Replace JSON.stringify with SafeJSON.stringify in telegram ask --- plugins/genesis-tools/hooks/track-session-files.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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; From 0d4d3f0b8db6060d7277c2e0f7118597ebc7318a Mon Sep 17 00:00:00 2001 From: Martin Date: Sun, 15 Mar 2026 21:21:55 +0100 Subject: [PATCH 15/15] fix(darwinkit): check Swift exit code in vision test + fix biome-ignore placement --- src/darwinkit/__tests__/vision.test.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/darwinkit/__tests__/vision.test.ts b/src/darwinkit/__tests__/vision.test.ts index 7bda6b56..0b35cac8 100644 --- a/src/darwinkit/__tests__/vision.test.ts +++ b/src/darwinkit/__tests__/vision.test.ts @@ -26,7 +26,12 @@ try! png.write(to: URL(fileURLWithPath: "${TEST_IMAGE}")) ], { stdout: "pipe", stderr: "pipe" } ); - await proc.exited; + 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 () => {