remove seed directory, clean up provisioning and sync modules

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-08 00:07:50 +00:00
co-authored by Claude Opus 4.6
parent 9578110e8b
commit 56f8da8907
86 changed files with 182 additions and 12061 deletions
-26
View File
@@ -52,32 +52,6 @@ echo "════════════════════════
echo " Officer — dependency setup ($PM)"
echo "═══════════════════════════════════════════"
# ─── 0. shared group ──────────────────────────────────────────────────────────
echo ""
echo "── Shared group (officerdev) ──"
if getent group officerdev &>/dev/null; then
skip "officerdev group"
else
sudo groupadd officerdev
ok "created officerdev group"
fi
if id -nG "$USER" | grep -qw officerdev; then
skip "$USER in officerdev group"
else
sudo usermod -aG officerdev "$USER"
ok "added $USER to officerdev group"
fi
# Ensure provisioned users can traverse to data directories
if [ "$(stat -c '%A' "$HOME" | cut -c10)" = "x" ]; then
skip "home dir traversable"
else
chmod o+x "$HOME"
ok "made home dir traversable (o+x)"
fi
# ─── 1. core system packages ───────────────────────────────────────────────────
echo ""
echo "── Core system packages ──"
-244
View File
@@ -1,244 +0,0 @@
import type { ExtensionAPI } from '@mariozechner/pi-coding-agent';
import { Type, type TSchema } from '@sinclair/typebox';
import { readdirSync, existsSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
type ToolParamType = 'string' | 'number' | 'boolean' | 'enum';
type ToolParam = {
type: ToolParamType;
description: string;
values?: string[];
default?: unknown;
optional?: boolean;
};
type ToolMeta = {
name: string;
label: string;
description: string;
language: 'typescript' | 'bash' | 'python';
inputs: Record<string, ToolParam>;
};
function parseFrontmatter(content: string): { meta: Partial<ToolMeta>; body: string } {
const match = content.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
if (!match) return { meta: {}, body: content };
const yamlBlock = match[1]!;
const body = match[2]!;
const meta: Record<string, unknown> = {};
const lines = yamlBlock.split('\n');
let currentKey: string | null = null;
let currentObj: Record<string, unknown> | null = null;
let currentSubKey: string | null = null;
let currentSubObj: Record<string, unknown> | null = null;
for (const line of lines) {
const topMatch = line.match(/^(\w[\w-]*):\s*(.*)$/);
if (topMatch && !line.startsWith(' ')) {
if (currentSubObj && currentSubKey && currentObj) {
currentObj[currentSubKey] = currentSubObj;
currentSubObj = null;
currentSubKey = null;
}
if (currentObj && currentKey) {
meta[currentKey] = currentObj;
currentObj = null;
currentKey = null;
}
const [, key, value] = topMatch;
if (!value || value.trim() === '') {
currentKey = key!;
currentObj = {};
} else {
meta[key!] = value.trim();
}
continue;
}
const midMatch = line.match(/^ (\w[\w-]*):\s*(.*)$/);
if (midMatch && currentObj !== null) {
if (currentSubObj && currentSubKey) {
currentObj[currentSubKey] = currentSubObj;
currentSubObj = null;
currentSubKey = null;
}
const [, key, value] = midMatch;
if (!value || value.trim() === '') {
currentSubKey = key!;
currentSubObj = {};
} else {
currentObj[key!] = value.trim();
}
continue;
}
const deepMatch = line.match(/^ (\w[\w-]*):\s*(.*)$/);
if (deepMatch && currentSubObj !== null) {
const [, key, value] = deepMatch;
currentSubObj[key!] = value!.trim();
continue;
}
const arrayMatch = line.match(/^ - (.+)$/);
if (arrayMatch && currentSubObj !== null) {
const key = Object.keys(currentSubObj).at(-1);
if (key) {
const arr = currentSubObj[key];
if (Array.isArray(arr)) {
arr.push(arrayMatch[1]!.trim());
} else {
currentSubObj[key] = [arrayMatch[1]!.trim()];
}
}
}
}
if (currentSubObj && currentSubKey && currentObj) {
currentObj[currentSubKey] = currentSubObj;
}
if (currentObj && currentKey) {
meta[currentKey] = currentObj;
}
return { meta: meta as Partial<ToolMeta>, body };
}
function buildSchema(inputs: Record<string, ToolParam>): TSchema {
const props: Record<string, TSchema> = {};
for (const [paramName, param] of Object.entries(inputs)) {
let schema: TSchema;
switch (param.type) {
case 'enum': {
// values can be a string[] from deeper YAML parsing,
// or a comma-separated string like "single,batch" from flat YAML
const raw = param.values;
const values = Array.isArray(raw)
? raw
: typeof raw === 'string'
? raw.split(',').map((v) => v.trim())
: [];
schema = Type.Union(values.map((v) => Type.Literal(v)), {
description: param.description,
});
break;
}
case 'number':
schema = Type.Number({ description: param.description });
break;
case 'boolean':
schema = Type.Boolean({ description: param.description });
break;
default:
schema = Type.String({ description: param.description });
}
props[paramName] = param.optional ? Type.Optional(schema) : schema;
}
return Type.Object(props);
}
function discoverTools(dir: string): Array<{ toolDir: string; entryFile: string; meta: ToolMeta }> {
if (!existsSync(dir)) return [];
const discovered: Array<{ toolDir: string; entryFile: string; meta: ToolMeta }> = [];
const entries = readdirSync(dir, { withFileTypes: true });
for (const entry of entries) {
if (!entry.isDirectory()) continue;
const toolDir = join(dir, entry.name);
const toolMdPath = join(toolDir, 'TOOL.md');
if (!existsSync(toolMdPath)) continue;
const indexTs = join(toolDir, 'index.ts');
const indexJs = join(toolDir, 'index.js');
const entryFile = existsSync(indexTs) ? indexTs : existsSync(indexJs) ? indexJs : null;
if (!entryFile) {
console.warn(`[tool-loader] Skipping ${entry.name}: no index.ts or index.js found`);
continue;
}
const content = readFileSync(toolMdPath, 'utf-8');
const { meta } = parseFrontmatter(content);
if (!meta.name || !meta.description) {
console.warn(`[tool-loader] Skipping ${entry.name}: missing name or description in TOOL.md`);
continue;
}
discovered.push({ toolDir, entryFile, meta: meta as ToolMeta });
}
return discovered;
}
export default function (pi: ExtensionAPI) {
const rawDirs = process.env.PI_TOOLS_DIRS ?? '';
const toolDirs = rawDirs.split(':').filter(Boolean);
if (toolDirs.length === 0) {
console.warn('[tool-loader] PI_TOOLS_DIRS not set — no custom tools will be loaded');
return;
}
// Register synchronously in the factory function so tools appear in the system prompt.
// Implementations are lazy-loaded on first call to avoid async import issues at startup.
const seen = new Set<string>();
for (const dir of toolDirs) {
const tools = discoverTools(dir);
for (const { entryFile, meta } of tools) {
// User dirs come after global — last writer wins, so skip if already registered
if (seen.has(meta.name)) continue;
seen.add(meta.name);
const schema = buildSchema(meta.inputs ?? {});
// Capture entryFile in closure for lazy load
const capturedEntry = entryFile;
pi.registerTool({
name: meta.name,
label: meta.label ?? meta.name,
description: meta.description,
parameters: schema,
async execute(toolCallId, params, signal, onUpdate, ctx) {
// Lazy-load the implementation on first actual call.
// Dynamic import works here because we're already in an async tool execution
// context — jiti/Node has had time to set up its module hooks.
let executeFn: Function | undefined;
try {
const mod = await import(capturedEntry);
executeFn = mod.execute ?? mod.default?.execute;
} catch (err) {
return {
content: [{ type: 'text', text: `[tool-loader] Failed to load ${meta.name}: ${String(err)}` }],
details: { error: String(err) },
isError: true,
};
}
if (typeof executeFn !== 'function') {
return {
content: [{ type: 'text', text: `[tool-loader] ${meta.name}/index.ts must export an "execute" function` }],
details: {},
isError: true,
};
}
return executeFn(toolCallId, params, signal, onUpdate, ctx);
},
});
console.log(`[tool-loader] Registered tool: ${meta.name} (${capturedEntry})`);
}
}
}
@@ -1,34 +0,0 @@
# dependencies (bun install)
node_modules
# output
out
dist
*.tgz
# code coverage
coverage
*.lcov
# logs
logs
_.log
report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# caches
.eslintcache
.cache
*.tsbuildinfo
# IntelliJ based IDEs
.idea
# Finder (MacOS) folder config
.DS_Store
@@ -1,41 +0,0 @@
{
"type": "group",
"id": "ptpl-2643",
"direction": "horizontal",
"children": [
{
"node": {
"type": "panel",
"id": "ptpl-2644",
"appType": "officerdev/chat"
},
"size": 38.1480587374
},
{
"node": {
"type": "group",
"id": "p-1771998863601-133",
"direction": "vertical",
"children": [
{
"node": {
"type": "panel",
"id": "ptpl-2645",
"appType": "officerdev/preview"
},
"size": 70.4331757899
},
{
"node": {
"type": "panel",
"id": "p-1771998863601-132",
"appType": "officerdev/terminal"
},
"size": 29.5668242101
}
]
},
"size": 61.8519412626
}
]
}
@@ -1,7 +0,0 @@
{
"name": "simple-app-template",
"projectType": "app",
"hasBackend": false,
"hasAuth": false,
"templateIdx": 3
}
@@ -1,65 +0,0 @@
# AGENTS.md — Simple App Template
## Project Structure
```
src/
├── index.ts # Bun server entry (routes, HMR)
├── index.html # HTML entry point
├── frontend.tsx # React root mount
├── index.css # Tailwind + global styles
├── App.tsx # Main React component
└── components/ # UI components
build.ts # Build script (bun run build → dist/)
.officerdev/
└── meta.json # Project metadata
```
## Tech Stack
- **Runtime**: Bun
- **Language**: TypeScript (strict)
- **Framework**: React 19
- **Styling**: Tailwind CSS v4
- **UI**: shadcn/ui components (Radix UI primitives)
- **Icons**: lucide-react
## Build System
- `bun dev` — starts dev server with HMR
- `bun run build` — produces `dist/` via `build.ts`
- Build uses `bun-plugin-tailwind` for CSS processing
- Output: minified JS/CSS bundles + HTML in `dist/`
## Publishing
This project can be published as a standalone app in officer.dev.
**Version**: Set in `package.json``version` field. Bump before republishing.
**Icon**: When publishing, choose from these available lucide icon names:
Activity, Airplay, Archive, BarChart3, Bell, Blocks, BookOpen, Box, BrainCircuit,
Calendar, Camera, ChartPie, CircleDot, Cloud, Code, Compass, CreditCard, Database,
FileText, Folder, Gamepad2, Globe, Heart, Home, Image, Inbox, Layers, Layout,
LineChart, Link, List, Mail, Map, MessageCircle, Monitor, Music, Palette, PenTool,
Play, Puzzle, Radio, Rocket, Search, Settings, ShoppingCart, Star, Sun, Table,
Terminal, Timer, Users, Wand2, Zap
## Iframe Constraints
Published apps run inside an iframe in officer.dev workspace panels:
- **Auth token injection**: The iframe URL includes a `?token=` param. A script is injected into the HTML `<head>` that automatically adds `Authorization: Bearer <token>` to all fetch/XHR requests.
- **Path rewriting**: All absolute paths (`/api/...`, `/assets/...`) are rewritten to go through the serve proxy at `/api/app-serve/{slug}/`. Relative paths work as-is.
- **No direct DOM access**: The app cannot access the parent frame.
- **API access**: Use `fetch('/api/...')` — the injected script rewrites paths and adds auth headers automatically.
## Coding Conventions
- Functional components, arrow functions
- No default exports — use named exports
- Strict TypeScript, no `any`
- Semicolons, single quotes (JS), double quotes (JSX)
- 2-space indentation
- Prefer composition over inheritance
- Keep components small and focused
@@ -1,21 +0,0 @@
# bun-react-tailwind-shadcn-template
To install dependencies:
```bash
bun install
```
To start a development server:
```bash
bun dev
```
To run for production:
```bash
bun start
```
This project was created using `bun init` in bun v1.3.9. [Bun](https://bun.com) is a fast all-in-one JavaScript runtime.
@@ -1,149 +0,0 @@
#!/usr/bin/env bun
import plugin from "bun-plugin-tailwind";
import { existsSync } from "fs";
import { rm } from "fs/promises";
import path from "path";
if (process.argv.includes("--help") || process.argv.includes("-h")) {
console.log(`
🏗️ Bun Build Script
Usage: bun run build.ts [options]
Common Options:
--outdir <path> Output directory (default: "dist")
--minify Enable minification (or --minify.whitespace, --minify.syntax, etc)
--sourcemap <type> Sourcemap type: none|linked|inline|external
--target <target> Build target: browser|bun|node
--format <format> Output format: esm|cjs|iife
--splitting Enable code splitting
--packages <type> Package handling: bundle|external
--public-path <path> Public path for assets
--env <mode> Environment handling: inline|disable|prefix*
--conditions <list> Package.json export conditions (comma separated)
--external <list> External packages (comma separated)
--banner <text> Add banner text to output
--footer <text> Add footer text to output
--define <obj> Define global constants (e.g. --define.VERSION=1.0.0)
--help, -h Show this help message
Example:
bun run build.ts --outdir=dist --minify --sourcemap=linked --external=react,react-dom
`);
process.exit(0);
}
const toCamelCase = (str: string): string => str.replace(/-([a-z])/g, g => g[1].toUpperCase());
const parseValue = (value: string): any => {
if (value === "true") return true;
if (value === "false") return false;
if (/^\d+$/.test(value)) return parseInt(value, 10);
if (/^\d*\.\d+$/.test(value)) return parseFloat(value);
if (value.includes(",")) return value.split(",").map(v => v.trim());
return value;
};
function parseArgs(): Partial<Bun.BuildConfig> {
const config: Partial<Bun.BuildConfig> = {};
const args = process.argv.slice(2);
for (let i = 0; i < args.length; i++) {
const arg = args[i];
if (arg === undefined) continue;
if (!arg.startsWith("--")) continue;
if (arg.startsWith("--no-")) {
const key = toCamelCase(arg.slice(5));
config[key] = false;
continue;
}
if (!arg.includes("=") && (i === args.length - 1 || args[i + 1]?.startsWith("--"))) {
const key = toCamelCase(arg.slice(2));
config[key] = true;
continue;
}
let key: string;
let value: string;
if (arg.includes("=")) {
[key, value] = arg.slice(2).split("=", 2) as [string, string];
} else {
key = arg.slice(2);
value = args[++i] ?? "";
}
key = toCamelCase(key);
if (key.includes(".")) {
const [parentKey, childKey] = key.split(".");
config[parentKey] = config[parentKey] || {};
config[parentKey][childKey] = parseValue(value);
} else {
config[key] = parseValue(value);
}
}
return config;
}
const formatFileSize = (bytes: number): string => {
const units = ["B", "KB", "MB", "GB"];
let size = bytes;
let unitIndex = 0;
while (size >= 1024 && unitIndex < units.length - 1) {
size /= 1024;
unitIndex++;
}
return `${size.toFixed(2)} ${units[unitIndex]}`;
};
console.log("\n🚀 Starting build process...\n");
const cliConfig = parseArgs();
const outdir = cliConfig.outdir || path.join(process.cwd(), "dist");
if (existsSync(outdir)) {
console.log(`🗑️ Cleaning previous build at ${outdir}`);
await rm(outdir, { recursive: true, force: true });
}
const start = performance.now();
const entrypoints = [...new Bun.Glob("**.html").scanSync("src")]
.map(a => path.resolve("src", a))
.filter(dir => !dir.includes("node_modules"));
console.log(`📄 Found ${entrypoints.length} HTML ${entrypoints.length === 1 ? "file" : "files"} to process\n`);
const result = await Bun.build({
entrypoints,
outdir,
plugins: [plugin],
minify: true,
target: "browser",
sourcemap: "linked",
define: {
"process.env.NODE_ENV": JSON.stringify("production"),
},
...cliConfig,
});
const end = performance.now();
const outputTable = result.outputs.map(output => ({
File: path.relative(process.cwd(), output.path),
Type: output.kind,
Size: formatFileSize(output.size),
}));
console.table(outputTable);
const buildTime = (end - start).toFixed(2);
console.log(`\n✅ Build completed in ${buildTime}ms\n`);
-17
View File
@@ -1,17 +0,0 @@
// Generated by `bun init`
declare module "*.svg" {
/**
* A path to the SVG file
*/
const path: `${string}.svg`;
export = path;
}
declare module "*.module.css" {
/**
* A record of class names to their corresponding CSS module classes
*/
const classes: { readonly [key: string]: string };
export = classes;
}
@@ -1,199 +0,0 @@
{
"lockfileVersion": 1,
"configVersion": 1,
"workspaces": {
"": {
"name": "bun-react-template",
"dependencies": {
"@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-slot": "^1.2.3",
"bun-plugin-tailwind": "^0.1.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.545.0",
"react": "^19",
"react-dom": "^19",
"tailwind-merge": "^3.3.1",
},
"devDependencies": {
"@types/bun": "latest",
"@types/react": "^19",
"@types/react-dom": "^19",
"tailwindcss": "^4.1.11",
"tw-animate-css": "^1.4.0",
},
},
},
"packages": {
"@floating-ui/core": ["@floating-ui/core@1.7.4", "", { "dependencies": { "@floating-ui/utils": "^0.2.10" } }, "sha512-C3HlIdsBxszvm5McXlB8PeOEWfBhcGBTZGkGlWc2U0KFY5IwG5OQEuQ8rq52DZmcHDlPLd+YFBK+cZcytwIFWg=="],
"@floating-ui/dom": ["@floating-ui/dom@1.7.5", "", { "dependencies": { "@floating-ui/core": "^1.7.4", "@floating-ui/utils": "^0.2.10" } }, "sha512-N0bD2kIPInNHUHehXhMke1rBGs1dwqvC9O9KYMyyjK7iXt7GAhnro7UlcuYcGdS/yYOlq0MAVgrow8IbWJwyqg=="],
"@floating-ui/react-dom": ["@floating-ui/react-dom@2.1.7", "", { "dependencies": { "@floating-ui/dom": "^1.7.5" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-0tLRojf/1Go2JgEVm+3Frg9A3IW8bJgKgdO0BN5RkF//ufuz2joZM63Npau2ff3J6lUVYgDSNzNkR+aH3IVfjg=="],
"@floating-ui/utils": ["@floating-ui/utils@0.2.10", "", {}, "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ=="],
"@oven/bun-darwin-aarch64": ["@oven/bun-darwin-aarch64@1.3.9", "", { "os": "darwin", "cpu": "arm64" }, "sha512-df7smckMWSUfaT5mzwN9Lfpd3ZGkOqo+vmQ8VV2a32gl14v6uZ/qeeo+1RlANXn8M0uzXPWWCkrKZIWSZUR0qw=="],
"@oven/bun-darwin-x64": ["@oven/bun-darwin-x64@1.3.9", "", { "os": "darwin", "cpu": "x64" }, "sha512-YiLxfsPzQqaVvT2a+nxH9do0YfUjrlxF3tKP0b1DDgvfgCcVKGsrQH3Wa82qHgL4dnT8h2bqi94JxXESEuPmcA=="],
"@oven/bun-darwin-x64-baseline": ["@oven/bun-darwin-x64-baseline@1.3.9", "", { "os": "darwin", "cpu": "x64" }, "sha512-XbhsA2XAFzvFr0vPSV6SNqGxab4xHKdPmVTLqoSHAx9tffrSq/012BDptOskulwnD+YNsrJUx2D2Ve1xvfgGcg=="],
"@oven/bun-linux-aarch64": ["@oven/bun-linux-aarch64@1.3.9", "", { "os": "linux", "cpu": "arm64" }, "sha512-VaNQTu0Up4gnwZLQ6/Hmho6jAlLxTQ1PwxEth8EsXHf82FOXXPV5OCQ6KC9mmmocjKlmWFaIGebThrOy8DUo4g=="],
"@oven/bun-linux-aarch64-musl": ["@oven/bun-linux-aarch64-musl@1.3.9", "", { "os": "linux", "cpu": "arm64" }, "sha512-t8uimCVBTw5f9K2QTZE5wN6UOrFETNrh/Xr7qtXT9nAOzaOnIFvYA+HcHbGfi31fRlCVfTxqm/EiCwJ1gEw9YQ=="],
"@oven/bun-linux-x64": ["@oven/bun-linux-x64@1.3.9", "", { "os": "linux", "cpu": "x64" }, "sha512-oQyAW3+ugulvXTZ+XYeUMmNPR94sJeMokfHQoKwPvVwhVkgRuMhcLGV2ZesHCADVu30Oz2MFXbgdC8x4/o9dRg=="],
"@oven/bun-linux-x64-baseline": ["@oven/bun-linux-x64-baseline@1.3.9", "", { "os": "linux", "cpu": "x64" }, "sha512-nZ12g22cy7pEOBwAxz2tp0wVqekaCn9QRKuGTHqOdLlyAqR4SCdErDvDhUWd51bIyHTQoCmj72TegGTgG0WNPw=="],
"@oven/bun-linux-x64-musl": ["@oven/bun-linux-x64-musl@1.3.9", "", { "os": "linux", "cpu": "x64" }, "sha512-4ZjIUgCxEyKwcKXideB5sX0KJpnHTZtu778w73VNq2uNH2fNpMZv98+DBgJyQ9OfFoRhmKn1bmLmSefvnHzI9w=="],
"@oven/bun-linux-x64-musl-baseline": ["@oven/bun-linux-x64-musl-baseline@1.3.9", "", { "os": "linux", "cpu": "x64" }, "sha512-3FXQgtYFsT0YOmAdMcJn56pLM5kzSl6y942rJJIl5l2KummB9Ea3J/vMJMzQk7NCAGhleZGWU/pJSS/uXKGa7w=="],
"@oven/bun-windows-x64": ["@oven/bun-windows-x64@1.3.9", "", { "os": "win32", "cpu": "x64" }, "sha512-/d6vAmgKvkoYlsGPsRPlPmOK1slPis/F40UG02pYwypTH0wmY0smgzdFqR4YmryxFh17XrW1kITv+U99Oajk9Q=="],
"@oven/bun-windows-x64-baseline": ["@oven/bun-windows-x64-baseline@1.3.9", "", { "os": "win32", "cpu": "x64" }, "sha512-a/+hSrrDpMD7THyXvE2KJy1skxzAD0cnW4K1WjuI/91VqsphjNzvf5t/ZgxEVL4wb6f+hKrSJ5J3aH47zPr61g=="],
"@radix-ui/number": ["@radix-ui/number@1.1.1", "", {}, "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g=="],
"@radix-ui/primitive": ["@radix-ui/primitive@1.1.3", "", {}, "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg=="],
"@radix-ui/react-arrow": ["@radix-ui/react-arrow@1.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w=="],
"@radix-ui/react-collection": ["@radix-ui/react-collection@1.1.7", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw=="],
"@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg=="],
"@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="],
"@radix-ui/react-direction": ["@radix-ui/react-direction@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw=="],
"@radix-ui/react-dismissable-layer": ["@radix-ui/react-dismissable-layer@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-escape-keydown": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg=="],
"@radix-ui/react-focus-guards": ["@radix-ui/react-focus-guards@1.1.3", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw=="],
"@radix-ui/react-focus-scope": ["@radix-ui/react-focus-scope@1.1.7", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw=="],
"@radix-ui/react-id": ["@radix-ui/react-id@1.1.1", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg=="],
"@radix-ui/react-label": ["@radix-ui/react-label@2.1.8", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-FmXs37I6hSBVDlO4y764TNz1rLgKwjJMQ0EGte6F3Cb3f4bIuHB/iLa/8I9VKkmOy+gNHq8rql3j686ACVV21A=="],
"@radix-ui/react-popper": ["@radix-ui/react-popper@1.2.8", "", { "dependencies": { "@floating-ui/react-dom": "^2.0.0", "@radix-ui/react-arrow": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-rect": "1.1.1", "@radix-ui/react-use-size": "1.1.1", "@radix-ui/rect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw=="],
"@radix-ui/react-portal": ["@radix-ui/react-portal@1.1.9", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ=="],
"@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.4", "", { "dependencies": { "@radix-ui/react-slot": "1.2.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg=="],
"@radix-ui/react-select": ["@radix-ui/react-select@2.2.6", "", { "dependencies": { "@radix-ui/number": "1.1.1", "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-visually-hidden": "1.2.3", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ=="],
"@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.4", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA=="],
"@radix-ui/react-use-callback-ref": ["@radix-ui/react-use-callback-ref@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg=="],
"@radix-ui/react-use-controllable-state": ["@radix-ui/react-use-controllable-state@1.2.2", "", { "dependencies": { "@radix-ui/react-use-effect-event": "0.0.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg=="],
"@radix-ui/react-use-effect-event": ["@radix-ui/react-use-effect-event@0.0.2", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA=="],
"@radix-ui/react-use-escape-keydown": ["@radix-ui/react-use-escape-keydown@1.1.1", "", { "dependencies": { "@radix-ui/react-use-callback-ref": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g=="],
"@radix-ui/react-use-layout-effect": ["@radix-ui/react-use-layout-effect@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ=="],
"@radix-ui/react-use-previous": ["@radix-ui/react-use-previous@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ=="],
"@radix-ui/react-use-rect": ["@radix-ui/react-use-rect@1.1.1", "", { "dependencies": { "@radix-ui/rect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w=="],
"@radix-ui/react-use-size": ["@radix-ui/react-use-size@1.1.1", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ=="],
"@radix-ui/react-visually-hidden": ["@radix-ui/react-visually-hidden@1.2.3", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug=="],
"@radix-ui/rect": ["@radix-ui/rect@1.1.1", "", {}, "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw=="],
"@types/bun": ["@types/bun@1.3.9", "", { "dependencies": { "bun-types": "1.3.9" } }, "sha512-KQ571yULOdWJiMH+RIWIOZ7B2RXQGpL1YQrBtLIV3FqDcCu6FsbFUBwhdKUlCKUpS3PJDsHlJ1QKlpxoVR+xtw=="],
"@types/node": ["@types/node@25.3.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-4K3bqJpXpqfg2XKGK9bpDTc6xO/xoUP/RBWS7AtRMug6zZFaRekiLzjVtAoZMquxoAbzBvy5nxQ7veS5eYzf8A=="],
"@types/react": ["@types/react@19.2.14", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w=="],
"@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="],
"aria-hidden": ["aria-hidden@1.2.6", "", { "dependencies": { "tslib": "^2.0.0" } }, "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA=="],
"bun": ["bun@1.3.9", "", { "optionalDependencies": { "@oven/bun-darwin-aarch64": "1.3.9", "@oven/bun-darwin-x64": "1.3.9", "@oven/bun-darwin-x64-baseline": "1.3.9", "@oven/bun-linux-aarch64": "1.3.9", "@oven/bun-linux-aarch64-musl": "1.3.9", "@oven/bun-linux-x64": "1.3.9", "@oven/bun-linux-x64-baseline": "1.3.9", "@oven/bun-linux-x64-musl": "1.3.9", "@oven/bun-linux-x64-musl-baseline": "1.3.9", "@oven/bun-windows-x64": "1.3.9", "@oven/bun-windows-x64-baseline": "1.3.9" }, "os": [ "linux", "win32", "darwin", ], "cpu": [ "x64", "arm64", ], "bin": { "bun": "bin/bun.exe", "bunx": "bin/bunx.exe" } }, "sha512-v5hkh1us7sMNjfimWE70flYbD5I1/qWQaqmJ45q2qk5H/7muQVa478LSVRSFyGTBUBog2LsPQnfIRdjyWJRY+A=="],
"bun-plugin-tailwind": ["bun-plugin-tailwind@0.1.2", "", { "peerDependencies": { "bun": ">=1.0.0" } }, "sha512-41jNC1tZRSK3s1o7pTNrLuQG8kL/0vR/JgiTmZAJ1eHwe0w5j6HFPKeqEk0WAD13jfrUC7+ULuewFBBCoADPpg=="],
"bun-types": ["bun-types@1.3.9", "", { "dependencies": { "@types/node": "*" } }, "sha512-+UBWWOakIP4Tswh0Bt0QD0alpTY8cb5hvgiYeWCMet9YukHbzuruIEeXC2D7nMJPB12kbh8C7XJykSexEqGKJg=="],
"class-variance-authority": ["class-variance-authority@0.7.1", "", { "dependencies": { "clsx": "^2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="],
"clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
"detect-node-es": ["detect-node-es@1.1.0", "", {}, "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ=="],
"get-nonce": ["get-nonce@1.0.1", "", {}, "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q=="],
"lucide-react": ["lucide-react@0.545.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-7r1/yUuflQDSt4f1bpn5ZAocyIxcTyVyBBChSVtBKn5M+392cPmI5YJMWOJKk/HUWGm5wg83chlAZtCcGbEZtw=="],
"react": ["react@19.2.4", "", {}, "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ=="],
"react-dom": ["react-dom@19.2.4", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.4" } }, "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ=="],
"react-remove-scroll": ["react-remove-scroll@2.7.2", "", { "dependencies": { "react-remove-scroll-bar": "^2.3.7", "react-style-singleton": "^2.2.3", "tslib": "^2.1.0", "use-callback-ref": "^1.3.3", "use-sidecar": "^1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q=="],
"react-remove-scroll-bar": ["react-remove-scroll-bar@2.3.8", "", { "dependencies": { "react-style-singleton": "^2.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q=="],
"react-style-singleton": ["react-style-singleton@2.2.3", "", { "dependencies": { "get-nonce": "^1.0.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ=="],
"scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
"tailwind-merge": ["tailwind-merge@3.5.0", "", {}, "sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A=="],
"tailwindcss": ["tailwindcss@4.2.1", "", {}, "sha512-/tBrSQ36vCleJkAOsy9kbNTgaxvGbyOamC30PRePTQe/o1MFwEKHQk4Cn7BNGaPtjp+PuUrByJehM1hgxfq4sw=="],
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"tw-animate-css": ["tw-animate-css@1.4.0", "", {}, "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ=="],
"undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
"use-callback-ref": ["use-callback-ref@1.3.3", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg=="],
"use-sidecar": ["use-sidecar@1.1.3", "", { "dependencies": { "detect-node-es": "^1.1.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ=="],
"@radix-ui/react-arrow/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
"@radix-ui/react-collection/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
"@radix-ui/react-collection/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
"@radix-ui/react-dismissable-layer/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
"@radix-ui/react-focus-scope/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
"@radix-ui/react-popper/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
"@radix-ui/react-portal/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
"@radix-ui/react-select/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
"@radix-ui/react-select/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
"@radix-ui/react-visually-hidden/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
"@radix-ui/react-arrow/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
"@radix-ui/react-dismissable-layer/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
"@radix-ui/react-focus-scope/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
"@radix-ui/react-popper/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
"@radix-ui/react-portal/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
"@radix-ui/react-visually-hidden/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
}
}
@@ -1,3 +0,0 @@
[serve.static]
plugins = ["bun-plugin-tailwind"]
env = "BUN_PUBLIC_*"
@@ -1,21 +0,0 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "",
"css": "styles/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"iconLibrary": "lucide"
}
@@ -1,30 +0,0 @@
{
"name": "bun-react-template",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "bun --hot src/index.ts",
"start": "NODE_ENV=production bun src/index.ts",
"build": "bun run build.ts"
},
"dependencies": {
"@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-slot": "^1.2.3",
"bun-plugin-tailwind": "^0.1.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.545.0",
"react": "^19",
"react-dom": "^19",
"tailwind-merge": "^3.3.1"
},
"devDependencies": {
"@types/react": "^19",
"@types/react-dom": "^19",
"@types/bun": "latest",
"tailwindcss": "^4.1.11",
"tw-animate-css": "^1.4.0"
}
}
@@ -1,11 +0,0 @@
import "./index.css";
export function App() {
return (
<div className="flex items-center justify-center min-h-screen">
<h1>Let's build together</h1>
</div>
);
}
export default App;
@@ -1,52 +0,0 @@
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import * as React from "react";
import { cn } from "@/lib/utils";
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
"icon-sm": "size-8",
"icon-lg": "size-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
function Button({
className,
variant,
size,
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean;
}) {
const Comp = asChild ? Slot : "button";
return <Comp data-slot="button" className={cn(buttonVariants({ variant, size, className }))} {...props} />;
}
export { Button, buttonVariants };
@@ -1,56 +0,0 @@
import * as React from "react";
import { cn } from "@/lib/utils";
function Card({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card"
className={cn("bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm", className)}
{...props}
/>
);
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className,
)}
{...props}
/>
);
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return <div data-slot="card-title" className={cn("leading-none font-semibold", className)} {...props} />;
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return <div data-slot="card-description" className={cn("text-muted-foreground text-sm", className)} {...props} />;
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn("col-start-2 row-span-2 row-start-1 self-start justify-self-end", className)}
{...props}
/>
);
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return <div data-slot="card-content" className={cn("px-6", className)} {...props} />;
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div data-slot="card-footer" className={cn("flex items-center px-6 [.border-t]:pt-6", className)} {...props} />
);
}
export { Card, CardAction, CardContent, CardDescription, CardFooter, CardHeader, CardTitle };
@@ -1,21 +0,0 @@
import * as React from "react";
import { cn } from "@/lib/utils";
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<input
type={type}
data-slot="input"
className={cn(
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className,
)}
{...props}
/>
);
}
export { Input };
@@ -1,21 +0,0 @@
"use client";
import * as LabelPrimitive from "@radix-ui/react-label";
import * as React from "react";
import { cn } from "@/lib/utils";
function Label({ className, ...props }: React.ComponentProps<typeof LabelPrimitive.Root>) {
return (
<LabelPrimitive.Root
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className,
)}
{...props}
/>
);
}
export { Label };
@@ -1,162 +0,0 @@
"use client";
import * as SelectPrimitive from "@radix-ui/react-select";
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react";
import * as React from "react";
import { cn } from "@/lib/utils";
function Select({ ...props }: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} />;
}
function SelectGroup({ ...props }: React.ComponentProps<typeof SelectPrimitive.Group>) {
return <SelectPrimitive.Group data-slot="select-group" {...props} />;
}
function SelectValue({ ...props }: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />;
}
function SelectTrigger({
className,
size = "default",
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: "sm" | "default";
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDownIcon className="size-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
);
}
function SelectContent({
className,
children,
position = "popper",
align = "center",
...props
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
data-slot="select-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className,
)}
position={position}
align={align}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1",
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
);
}
function SelectLabel({ className, ...props }: React.ComponentProps<typeof SelectPrimitive.Label>) {
return (
<SelectPrimitive.Label
data-slot="select-label"
className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
{...props}
/>
);
}
function SelectItem({ className, children, ...props }: React.ComponentProps<typeof SelectPrimitive.Item>) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className,
)}
{...props}
>
<span className="absolute right-2 flex size-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
);
}
function SelectSeparator({ className, ...props }: React.ComponentProps<typeof SelectPrimitive.Separator>) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
{...props}
/>
);
}
function SelectScrollUpButton({ className, ...props }: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
return (
<SelectPrimitive.ScrollUpButton
data-slot="select-scroll-up-button"
className={cn("flex cursor-default items-center justify-center py-1", className)}
{...props}
>
<ChevronUpIcon className="size-4" />
</SelectPrimitive.ScrollUpButton>
);
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
return (
<SelectPrimitive.ScrollDownButton
data-slot="select-scroll-down-button"
className={cn("flex cursor-default items-center justify-center py-1", className)}
{...props}
>
<ChevronDownIcon className="size-4" />
</SelectPrimitive.ScrollDownButton>
);
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
};
@@ -1,18 +0,0 @@
import * as React from "react";
import { cn } from "@/lib/utils";
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="textarea"
className={cn(
"border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className,
)}
{...props}
/>
);
}
export { Textarea };
@@ -1,9 +0,0 @@
/**
* This file is the entry point for the React app.
*/
import { createRoot } from "react-dom/client";
import { App } from "./App";
const elem = document.getElementById("root")!;
createRoot(elem).render(<App />);
@@ -1,51 +0,0 @@
@import "../styles/globals.css";
@layer base {
:root {
@apply font-sans;
}
body {
@apply grid place-items-center min-w-[320px] min-h-screen relative m-0 bg-background text-foreground;
}
}
/* cool Bun background animation 😎 */
/* body::before {
content: "";
position: fixed;
inset: 0;
z-index: -1;
opacity: 0.05;
background: url("./logo.svg");
background-size: 256px;
transform: rotate(-12deg) scale(1.35);
animation: slide 30s linear infinite;
pointer-events: none;
}
@keyframes slide {
from {
background-position: 0 0;
}
to {
background-position: 256px 224px;
}
} */
@keyframes spin {
from {
transform: rotate(0);
}
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion) {
*,
::before,
::after {
animation: none !important;
}
}
@@ -1,12 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Template</title>
<script type="module" src="./frontend.tsx" async></script>
</head>
<body>
<div id="root"></div>
</body>
</html>
@@ -1,41 +0,0 @@
import { serve } from "bun";
import index from "./index.html";
const server = serve({
routes: {
// Serve index.html for all unmatched routes.
"/*": index,
"/api/hello": {
async GET(req) {
return Response.json({
message: "Hello, world!",
method: "GET",
});
},
async PUT(req) {
return Response.json({
message: "Hello, world!",
method: "PUT",
});
},
},
"/api/hello/:name": async req => {
const name = req.params.name;
return Response.json({
message: `Hello, ${name}!`,
});
},
},
development: process.env.NODE_ENV !== "production" && {
// Enable browser hot reloading in development
hmr: true,
// Echo console logs from the browser to the server
console: true,
},
});
console.log(`🚀 Server running at ${server.url}`);
@@ -1,6 +0,0 @@
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
@@ -1,120 +0,0 @@
@import "tailwindcss";
@import "tw-animate-css";
@custom-variant dark (&:is(.dark *));
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
}
:root {
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}
@@ -1,36 +0,0 @@
{
"compilerOptions": {
// Environment setup & latest features
"lib": ["ESNext", "DOM"],
"target": "ESNext",
"module": "Preserve",
"moduleDetection": "force",
"jsx": "react-jsx",
"allowJs": true,
// Bundler mode
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"noEmit": true,
// Best practices
"strict": true,
"skipLibCheck": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
},
// Some stricter flags (disabled by default)
"noUnusedLocals": false,
"noUnusedParameters": false,
"noPropertyAccessFromIndexSignature": false
},
"exclude": ["dist", "node_modules"]
}
-49
View File
@@ -1,49 +0,0 @@
# Skills
A skill provides reference documentation for a specific tool or service. Skills are used by tasks to accomplish their goals. Each skill lives in its own directory under `skills/` and is defined by a `SKILL.md` file.
## File Structure
```
skills/
<skill-name>/
SKILL.md
```
## SKILL.md Format
A skill file has two parts: **frontmatter** (YAML metadata) and **body** (Markdown documentation).
### Frontmatter
```yaml
---
name: skill-name
description: What this skill does and when to use it.
---
```
#### Fields
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `name` | string | yes | Identifier for the skill. |
| `description` | string | yes | What the skill does and when to use it. Should include trigger phrases (e.g., "Use when the user wants to..."). |
### Body
The body contains reference documentation for the tool or service. The structure varies depending on the type of skill, but typically includes:
- **Title** — `# Skill Name`
- **Overview** — What the tool is and how it works.
- **Usage** — How to invoke the tool (endpoints, CLI synopsis, etc.).
- **Parameters/Options** — Detailed reference tables.
- **Examples** — Common usage patterns and recipes.
- **Source** — Links to official documentation and repositories.
### Existing Skills
| Skill | Type | Description |
|-------|------|-------------|
| `whisper.cpp` | HTTP API | Speech-to-text transcription via a local whisper.cpp server. |
| `ffmpeg` | CLI | Audio/video processing, conversion, and analysis. |
-474
View File
@@ -1,474 +0,0 @@
---
name: ffmpeg
description: Process audio and video files using ffmpeg/ffprobe. Use when the user wants to convert, transcode, trim, merge, extract, resize, compress, or analyze multimedia files.
---
# FFmpeg
CLI reference for FFmpeg v8.x — a complete, cross-platform solution for recording, converting, and streaming audio and video.
Official docs: https://www.ffmpeg.org/documentation.html
## Tools
| Tool | Purpose |
|------|---------|
| `ffmpeg` | Transcode, convert, filter, mux/demux multimedia |
| `ffprobe` | Analyze and inspect multimedia streams |
| `ffplay` | Play multimedia files (interactive) |
---
## ffmpeg
### Synopsis
```
ffmpeg [global_options] {[input_options] -i input_url} ... {[output_options] output_url} ...
```
Options before `-i` apply to the input; options before the output URL apply to the output.
### Global Options
| Flag | Description |
|------|-------------|
| `-y` | Overwrite output files without asking |
| `-n` | Do not overwrite; exit if output exists |
| `-hide_banner` | Suppress copyright/build info banner |
| `-loglevel level` | Set log level: `quiet`, `error`, `warning`, `info` (default), `verbose`, `debug` |
| `-stats` | Print encoding progress/statistics |
| `-progress url` | Send machine-readable progress to url |
| `-report` | Dump full command line and log to a file |
| `-filter_threads n` | Number of threads for filter processing |
### Input/Output Options
| Flag | Description |
|------|-------------|
| `-i url` | Input file URL |
| `-f fmt` | Force input or output format |
| `-c[:stream] codec` | Select encoder/decoder; use `copy` for stream copying |
| `-t duration` | Limit duration (as input: read limit; as output: write limit) |
| `-to position` | Stop at position (timestamp) |
| `-ss position` | Seek to position (before `-i`: fast input seek; after: output seek) |
| `-sseof position` | Seek relative to end of file |
| `-itsoffset offset` | Set input time offset |
| `-itsscale scale` | Rescale input timestamps |
| `-metadata key=value` | Set metadata key/value pair |
| `-disposition value` | Set stream disposition flags |
| `-target type` | Specify target type: `vcd`, `svcd`, `dvd`, `dv`, `dv50` |
| `-stream_loop n` | Loop input stream n times (-1 = infinite) |
| `-frames[:stream] n` | Stop after n frames |
| `-fs limit` | Set file size limit in bytes |
| `-timestamp date` | Set recording timestamp |
### Video Options
| Flag | Description |
|------|-------------|
| `-vn` | Disable video |
| `-vcodec codec` | Set video codec (alias for `-c:v`) |
| `-r fps` | Set frame rate |
| `-fpsmax fps` | Set maximum frame rate |
| `-s WxH` | Set frame size |
| `-aspect ratio` | Set display aspect ratio (e.g. `16:9`) |
| `-pix_fmt format` | Set pixel format |
| `-vf filtergraph` | Apply video filter graph (alias for `-filter:v`) |
| `-pass n` | Two-pass encoding pass (1 or 2) |
| `-passlogfile prefix` | Two-pass log file prefix |
| `-vframes n` | Set number of video frames to output |
| `-autorotate` | Auto-rotate based on metadata (default on) |
| `-display_rotation angle` | Set video rotation metadata |
| `-display_hflip` | Horizontal flip metadata |
| `-display_vflip` | Vertical flip metadata |
| `-force_key_frames expr` | Force keyframes at specified times/expression |
| `-copyinkf` | Copy non-key frames at the beginning during stream copy |
### Audio Options
| Flag | Description |
|------|-------------|
| `-an` | Disable audio |
| `-acodec codec` | Set audio codec (alias for `-c:a`) |
| `-ar freq` | Set audio sample rate (Hz) |
| `-ac channels` | Set number of audio channels |
| `-af filtergraph` | Apply audio filter graph (alias for `-filter:a`) |
| `-sample_fmt fmt` | Set audio sample format |
| `-channel_layout layout` | Set audio channel layout |
| `-aq q` | Set audio quality (codec-specific VBR) |
| `-aframes n` | Set number of audio frames to output |
### Subtitle Options
| Flag | Description |
|------|-------------|
| `-sn` | Disable subtitles |
| `-scodec codec` | Set subtitle codec (alias for `-c:s`) |
| `-fix_sub_duration` | Fix subtitle durations to avoid overlap |
### Stream Selection
| Flag | Description |
|------|-------------|
| `-map input:stream` | Manually select streams for output |
| `-dn` | Disable data streams |
Stream specifiers: `v` (video), `V` (video, no images), `a` (audio), `s` (subtitle), `d` (data). Index with `:N` (e.g. `a:0` = first audio).
### Hardware Acceleration
| Flag | Description |
|------|-------------|
| `-hwaccel method` | HW accel method: `cuda`, `vaapi`, `qsv`, `vulkan`, `auto` |
| `-hwaccel_device device` | Select HW device |
| `-init_hw_device type=name` | Initialize HW device |
---
## ffprobe
### Synopsis
```
ffprobe [options] input_url
```
### Main Options
| Flag | Description |
|------|-------------|
| `-show_format` | Show container format info |
| `-show_streams` | Show per-stream info |
| `-show_packets` | Show per-packet info |
| `-show_frames` | Show per-frame info |
| `-show_chapters` | Show chapter info |
| `-show_programs` | Show program info |
| `-show_entries section=key1,key2` | Show only specific fields |
| `-show_error` | Show probe errors |
| `-select_streams specifier` | Filter to specific streams (e.g. `v:0`, `a`) |
| `-count_frames` | Count frames per stream |
| `-count_packets` | Count packets per stream |
| `-read_intervals intervals` | Analyze specific time ranges |
### Output Formats
Set with `-output_format` (or `-of`, `-print_format`):
| Format | Description |
|--------|-------------|
| `default` | `[SECTION] key=value [/SECTION]` |
| `json` | JSON output (most useful for parsing) |
| `xml` | XML output |
| `csv` | Comma-separated values |
| `flat` | Flat `key=value` per line |
| `ini` | INI-style sections |
### Display Options
| Flag | Description |
|------|-------------|
| `-pretty` | Human-readable units and time formatting |
| `-unit` | Show value units |
| `-sexagesimal` | Format times as HH:MM:SS.us |
| `-hide_banner` | Suppress copyright/build info |
| `-o output_url` | Write output to file instead of stdout |
---
## Common Codecs
### Video Encoders
#### libx264 (H.264)
| Option | Description |
|--------|-------------|
| `-preset` | Speed/quality: `ultrafast`, `superfast`, `veryfast`, `faster`, `fast`, `medium` (default), `slow`, `slower`, `veryslow` |
| `-crf` | Constant quality: 0 (lossless) to 51 (worst). 18-23 is typical |
| `-profile:v` | `baseline`, `main`, `high` |
| `-tune` | `film`, `animation`, `grain`, `stillimage`, `fastdecode`, `zerolatency` |
| `-b:v` | Target bitrate (e.g. `2M`) |
#### libx265 (H.265/HEVC)
| Option | Description |
|--------|-------------|
| `-preset` | Same presets as x264 |
| `-crf` | 0-51, default 28. Similar quality to x264 at lower bitrate |
| `-profile:v` | `main`, `main10`, `main12` |
| `-b:v` | Target bitrate |
#### libvpx-vp9 (VP9)
| Option | Description |
|--------|-------------|
| `-crf` | 0-63. 31 is a good default |
| `-b:v` | Target bitrate (set to `0` for pure CRF mode) |
| `-cpu-used` | Speed: 0 (slowest/best) to 8 (fastest) |
| `-deadline` | `best`, `good` (default), `realtime` |
| `-row-mt 1` | Enable row-based multithreading |
#### libsvtav1 (SVT-AV1)
| Option | Description |
|--------|-------------|
| `-crf` | 0-63. 30 is a good default |
| `-preset` | 0 (slowest/best) to 13 (fastest). 8 is a good default |
| `-b:v` | Target bitrate |
#### libaom-av1 (AOM AV1)
| Option | Description |
|--------|-------------|
| `-crf` | 0-63 |
| `-cpu-used` | 0 (best) to 8 (fastest) |
| `-b:v` | Target bitrate (set to `0` for pure CRF mode) |
| `-tiles` | Tile columns x rows for parallelism |
### Audio Encoders
#### aac (Native AAC)
| Option | Description |
|--------|-------------|
| `-b:a` | Bitrate: `128k`, `192k`, `256k` |
| `-profile:a` | `aac_low` (default), `aac_he`, `aac_he_v2` |
#### libmp3lame (MP3)
| Option | Description |
|--------|-------------|
| `-b:a` | CBR bitrate: `128k`, `192k`, `320k` |
| `-q:a` | VBR quality: 0 (best) to 9 (worst). 2 is a good default |
#### libopus (Opus)
| Option | Description |
|--------|-------------|
| `-b:a` | Bitrate: `64k` to `256k`. 128k is a good default |
| `-vbr` | `on` (default), `off`, `constrained` |
| `-application` | `audio` (default), `voip`, `lowdelay` |
#### libvorbis (Vorbis)
| Option | Description |
|--------|-------------|
| `-q:a` | VBR quality: -1 to 10. 5 is a good default |
| `-b:a` | ABR bitrate |
#### flac (FLAC)
| Option | Description |
|--------|-------------|
| `-compression_level` | 0 (fast) to 12 (best). 5 is default |
---
## Common Container Formats
| Format | Extensions | Notes |
|--------|-----------|-------|
| `mp4` | .mp4, .m4a, .m4v | Use `-movflags +faststart` for web streaming |
| `matroska` | .mkv | Supports virtually all codecs |
| `webm` | .webm | VP8/VP9/AV1 + Vorbis/Opus for web |
| `avi` | .avi | Legacy; limited codec support |
| `mpegts` | .ts | Broadcast transport stream |
| `ogg` | .ogg, .ogv | Vorbis/Opus/Theora container |
| `wav` | .wav | Uncompressed PCM audio |
| `flac` | .flac | Lossless audio |
| `mp3` | .mp3 | MPEG audio layer 3 |
| `hls` | .m3u8 | HTTP Live Streaming |
| `dash` | .mpd | DASH adaptive streaming |
| `gif` | .gif | Animated GIF |
| `image2` | various | Image sequence input/output |
| `concat` | text file | Concatenation demuxer (file list) |
| `null` | — | Discard output (benchmarking) |
---
## Common Video Filters (`-vf`)
| Filter | Description | Example |
|--------|-------------|---------|
| `scale=W:H` | Resize video. Use `-1` or `-2` to auto-calculate | `scale=1280:720`, `scale=-2:480` |
| `crop=W:H:X:Y` | Crop to WxH starting at X,Y | `crop=640:480:100:50` |
| `pad=W:H:X:Y:color` | Pad video with borders | `pad=1920:1080:(ow-iw)/2:(oh-ih)/2:black` |
| `overlay=X:Y` | Composite second input over first | `overlay=10:10` |
| `transpose=N` | Rotate: 0=90ccw+vflip, 1=90cw, 2=90ccw, 3=90cw+vflip | `transpose=1` |
| `hflip` / `vflip` | Horizontal / vertical flip | `hflip` |
| `rotate=angle` | Rotate by arbitrary angle (radians) | `rotate=PI/4` |
| `fps=N` | Change frame rate | `fps=30` |
| `setpts=expr` | Modify presentation timestamps | `setpts=0.5*PTS` (2x speed) |
| `trim=start:end` | Extract time range | `trim=start=10:end=20` |
| `drawtext=opts` | Overlay text | `drawtext=text='Hello':fontsize=24:x=10:y=10` |
| `fade=t=type:st=S:d=D` | Fade in/out | `fade=t=in:st=0:d=2` |
| `eq=opts` | Adjust brightness/contrast/saturation | `eq=brightness=0.1:contrast=1.2` |
| `format=pix_fmt` | Convert pixel format | `format=yuv420p` |
| `concat=n:v:a` | Concatenate segments | `concat=n=2:v=1:a=1` |
| `split` / `select` | Duplicate / select frames | `select='eq(pict_type,I)'` |
| `deinterlace` / `yadif` | Remove interlacing | `yadif=1` |
| `boxblur=R` | Apply box blur | `boxblur=5:1` |
| `subtitles=file` | Burn in subtitles from file | `subtitles=subs.srt` |
| `palettegen` / `paletteuse` | Generate/apply palette for GIF | Used in two-pass GIF creation |
| `colorchannelmixer` | Mix color channels | `colorchannelmixer=.3:.4:.3:0:.3:.4:.3:0:.3:.4:.3` (grayscale) |
## Common Audio Filters (`-af`)
| Filter | Description | Example |
|--------|-------------|---------|
| `volume=V` | Adjust volume | `volume=1.5`, `volume=-3dB` |
| `loudnorm` | EBU R128 loudness normalization | `loudnorm=I=-16:TP=-1.5:LRA=11` |
| `atempo=T` | Change tempo (0.5-100.0) | `atempo=2.0` (2x speed) |
| `aresample=rate` | Resample audio | `aresample=44100` |
| `amerge` | Merge audio channels | `amerge=inputs=2` |
| `afade=t=type:st=S:d=D` | Audio fade in/out | `afade=t=in:st=0:d=3` |
| `highpass=f=freq` | High-pass filter | `highpass=f=200` |
| `lowpass=f=freq` | Low-pass filter | `lowpass=f=3000` |
| `equalizer=f:t:w:g` | Parametric EQ | `equalizer=f=1000:t=q:w=1:g=5` |
| `acompressor` | Dynamic range compression | `acompressor=threshold=-20dB:ratio=4` |
| `silenceremove` | Remove silence | `silenceremove=1:0:-50dB` |
| `silencedetect` | Detect silence | `silencedetect=n=-30dB:d=2` |
| `adelay=delays` | Delay audio channels | `adelay=1000\|1000` (ms) |
| `aecho=id:ig:delays:decays` | Add echo effect | `aecho=0.8:0.88:60:0.4` |
| `pan=layout:gains` | Remix channels | `pan=mono\|c0=0.5*c0+0.5*c1` |
---
## Common Recipes
### Convert format
```bash
ffmpeg -i input.mkv output.mp4
```
### Transcode with CRF quality
```bash
ffmpeg -i input.mp4 -c:v libx264 -crf 20 -c:a aac -b:a 192k output.mp4
```
### Extract audio
```bash
ffmpeg -i video.mp4 -vn -c:a copy audio.m4a
```
### Extract video (no audio)
```bash
ffmpeg -i input.mp4 -an -c:v copy output.mp4
```
### Trim / cut
```bash
ffmpeg -ss 00:01:30 -to 00:03:00 -i input.mp4 -c copy output.mp4
```
### Resize video
```bash
ffmpeg -i input.mp4 -vf "scale=1280:720" -c:a copy output.mp4
```
### Compress video (lower quality)
```bash
ffmpeg -i input.mp4 -c:v libx264 -crf 28 -preset fast -c:a aac -b:a 128k output.mp4
```
### Two-pass encoding
```bash
ffmpeg -i input.mp4 -c:v libx264 -b:v 2M -pass 1 -f null /dev/null
ffmpeg -i input.mp4 -c:v libx264 -b:v 2M -pass 2 output.mp4
```
### Concatenate files (concat demuxer)
```bash
# files.txt contains:
# file 'part1.mp4'
# file 'part2.mp4'
ffmpeg -f concat -safe 0 -i files.txt -c copy output.mp4
```
### Add subtitles (burn-in)
```bash
ffmpeg -i input.mp4 -vf "subtitles=subs.srt" output.mp4
```
### Create GIF
```bash
ffmpeg -i input.mp4 -vf "fps=10,scale=320:-1:flags=lanczos,split[s0][s1];[s0]palettegen[p];[s1][p]paletteuse" output.gif
```
### Add watermark / overlay
```bash
ffmpeg -i video.mp4 -i logo.png -filter_complex "overlay=10:10" output.mp4
```
### Change speed (video + audio)
```bash
ffmpeg -i input.mp4 -vf "setpts=0.5*PTS" -af "atempo=2.0" output.mp4
```
### Extract frames as images
```bash
ffmpeg -i input.mp4 -vf "fps=1" frame_%04d.png
```
### Merge audio and video
```bash
ffmpeg -i video.mp4 -i audio.m4a -c:v copy -c:a copy -shortest output.mp4
```
### Normalize audio loudness
```bash
ffmpeg -i input.mp4 -af "loudnorm=I=-16:TP=-1.5:LRA=11" -c:v copy output.mp4
```
### Convert to web-optimized MP4
```bash
ffmpeg -i input.mp4 -c:v libx264 -crf 23 -preset medium -c:a aac -b:a 128k -movflags +faststart output.mp4
```
### Probe file info (JSON)
```bash
ffprobe -v quiet -print_format json -show_format -show_streams input.mp4
```
### Get duration only
```bash
ffprobe -v quiet -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 input.mp4
```
### Get resolution only
```bash
ffprobe -v quiet -select_streams v:0 -show_entries stream=width,height -of csv=s=x:p=0 input.mp4
```
---
## Source
- Website: https://ffmpeg.org/
- Documentation: https://www.ffmpeg.org/documentation.html
- CLI reference: https://www.ffmpeg.org/ffmpeg.html
- Filters reference: https://www.ffmpeg.org/ffmpeg-filters.html
- Codecs reference: https://www.ffmpeg.org/ffmpeg-codecs.html
- Formats reference: https://www.ffmpeg.org/ffmpeg-formats.html
- Wiki: https://trac.ffmpeg.org/wiki
File diff suppressed because one or more lines are too long
-1
View File
@@ -1 +0,0 @@
{"id":"8ce0e4ad-5739-42e6-9169-075b6deb2f3e"}
-168
View File
@@ -1,168 +0,0 @@
---
name: fizzy-cli
description: Manage Fizzy boards, cards, columns, and comments from the command line. Use when the user wants to create, list, update, or organize cards and boards on Fizzy.
---
# Fizzy CLI
CLI reference for fizzy-cli — a command-line interface for the Fizzy API to manage boards, cards, columns, comments, and more.
Source: https://github.com/robzolkos/fizzy-cli
## Configuration
Configuration sources in order of precedence (highest first):
1. **Command-line flags** (`--token`, `--account`, `--api-url`)
2. **Environment variables** (`FIZZY_TOKEN`, `FIZZY_ACCOUNT`, `FIZZY_API_URL`, `FIZZY_BOARD`)
3. **Local project config** (`.fizzy.yaml` in current or parent directories)
4. **Global config** (`~/.config/fizzy/config.yaml` or `~/.fizzy/config.yaml`)
Run `fizzy setup` for interactive configuration.
## Global Options
| Flag | Env Variable | Description |
|------|-------------|-------------|
| `--token` | `FIZZY_TOKEN` | API access token |
| `--account` | `FIZZY_ACCOUNT` | Account identifier |
| `--api-url` | `FIZZY_API_URL` | API base URL (default: `https://app.fizzy.do`) |
| `--verbose` | — | Display request/response details |
---
## Commands
### Boards
| Command | Description |
|---------|-------------|
| `fizzy board list` | List all boards |
| `fizzy board show BOARD_ID` | Display board details |
| `fizzy board create --name "Name"` | Create a new board |
| `fizzy board update BOARD_ID --name "Name"` | Update a board |
| `fizzy board delete BOARD_ID` | Delete a board |
### Cards
#### List cards
```bash
fizzy card list [--board ID] [--column ID] [--tag ID] [--assignee ID]
fizzy card list [--sort newest|oldest|latest] [--search "text"]
fizzy card list [--created thisweek] [--closed thisweek] [--unassigned]
```
#### CRUD
| Command | Description |
|---------|-------------|
| `fizzy card show CARD_ID` | View card details |
| `fizzy card create --board ID --title "Title"` | Create a card |
| `fizzy card update CARD_ID --title "Title"` | Update a card |
| `fizzy card delete CARD_ID` | Delete a card |
#### Card actions
| Command | Description |
|---------|-------------|
| `fizzy card close CARD_ID` | Close a card |
| `fizzy card reopen CARD_ID` | Reopen a card |
| `fizzy card move CARD_ID --to BOARD_ID` | Move card to another board |
| `fizzy card postpone CARD_ID` | Postpone a card |
| `fizzy card column CARD_ID --column COLUMN_ID` | Assign card to a column |
| `fizzy card assign CARD_ID --user USER_ID` | Assign card to a user |
| `fizzy card tag CARD_ID --tag "tag"` | Tag a card |
| `fizzy card pin CARD_ID` | Pin a card |
| `fizzy card unpin CARD_ID` | Unpin a card |
| `fizzy card golden CARD_ID` | Mark card as golden |
| `fizzy card ungolden CARD_ID` | Remove golden status |
| `fizzy card watch CARD_ID` | Watch a card |
| `fizzy card unwatch CARD_ID` | Unwatch a card |
#### Card attachments
| Command | Description |
|---------|-------------|
| `fizzy card attachments show CARD_ID` | List attachments |
| `fizzy card attachments download CARD_ID` | Download all attachments |
| `fizzy card attachments download CARD_ID ATT_ID` | Download specific attachment |
### Columns
| Command | Description |
|---------|-------------|
| `fizzy column list --board ID` | List columns |
| `fizzy column show COLUMN_ID --board ID` | View column |
| `fizzy column create --board ID --name "Name"` | Create column |
| `fizzy column update COLUMN_ID --board ID --name "Name"` | Update column |
| `fizzy column delete COLUMN_ID --board ID` | Delete column |
### Comments
| Command | Description |
|---------|-------------|
| `fizzy comment list --card CARD_ID` | List comments |
| `fizzy comment show COMMENT_ID --card CARD_ID` | View comment |
| `fizzy comment create --card CARD_ID --body "Text"` | Add comment |
| `fizzy comment update COMMENT_ID --card CARD_ID --body "Text"` | Edit comment |
| `fizzy comment delete COMMENT_ID --card CARD_ID` | Delete comment |
#### Comment attachments
| Command | Description |
|---------|-------------|
| `fizzy comment attachments show --card CARD_ID` | List attachments |
| `fizzy comment attachments download --card CARD_ID` | Download all |
| `fizzy comment attachments download --card CARD_ID ATT_ID` | Download specific |
### Steps (To-Do Items)
| Command | Description |
|---------|-------------|
| `fizzy step show STEP_ID --card CARD_ID` | View step |
| `fizzy step create --card CARD_ID --content "Task"` | Create step |
| `fizzy step update STEP_ID --card CARD_ID --completed` | Mark step complete |
| `fizzy step delete STEP_ID --card CARD_ID` | Delete step |
### Reactions
| Command | Description |
|---------|-------------|
| `fizzy reaction list --card CARD_ID` | List reactions |
| `fizzy reaction create --card CARD_ID --content "👍"` | Add reaction |
| `fizzy reaction delete REACTION_ID --card CARD_ID` | Remove reaction |
### Users & Tags
| Command | Description |
|---------|-------------|
| `fizzy user list` | List users |
| `fizzy user show USER_ID` | View user details |
| `fizzy tag list` | List available tags |
### Pins, Search & Notifications
| Command | Description |
|---------|-------------|
| `fizzy pin list` | List pinned cards |
| `fizzy search "term"` | Full-text card search |
| `fizzy notification list` | List notifications |
| `fizzy notification read ID` | Mark notification as read |
| `fizzy notification unread ID` | Mark notification as unread |
| `fizzy notification read-all` | Mark all notifications as read |
### File Uploads
```bash
fizzy upload file /path/to/file.png
# Returns: { "signed_id": "...", "attachable_sgid": "..." }
```
Use `signed_id` for card headers; `attachable_sgid` for inline images in rich text.
---
## Source
- Repository: https://github.com/robzolkos/fizzy-cli
-414
View File
@@ -1,414 +0,0 @@
# Answer: Is There Stuff Python Can Do That Node.js Can't?
## The Question
"Is there stuff the Python one can do that the Node.js one can't?"
## The Answer
### Originally: **YES** ✅
The Python version had **significant gaps** compared to Node.js
### Now: **NO** ✅
All gaps have been **completely filled**
---
## What Were the Gaps?
### 1. **Multiple Attachments** 🔴→✅
**Python advantage:** Simple and easy
```python
from email.message import EmailMessage
message = EmailMessage()
message.set_content("Body")
message.add_attachment(file1_data, maintype="application", subtype="pdf")
message.add_attachment(file2_data, maintype="application", subtype="xlsx")
```
**Node.js before:** Hard and error-prone
```typescript
// Manual MIME boundary management - complex and verbose
const boundary = "boundary123";
const message = `...MIME headers...
--boundary123
...file1 base64...
--boundary123
...file2 base64...
--boundary123--`;
```
**Node.js now:** Simple and easy ✅
```typescript
await sendMessageWithMultipleAttachments(
gmail,
"user@example.com",
"Subject",
"Body",
["/path/to/file1.pdf", "/path/to/file2.xlsx"]
);
```
---
### 2. **Parse Received Emails** 🔴→✅
**Python advantage:** Built-in parser
```python
from email.parser import BytesParser
msg = BytesParser().parsebytes(raw_email)
subject = msg.get('Subject')
body = msg.get_payload()
```
**Node.js before:** No recipe at all ❌
**Node.js now:** Full parsing capability ✅
```typescript
const email = await parseFullEmail(gmail, messageId);
console.log(email.headers.subject);
console.log(email.body);
console.log(email.attachments); // All attachments listed
```
---
### 3. **Extract Attachments** 🔴→✅
**Python advantage:** Built-in email parsing
```python
from email.parser import BytesParser
msg = BytesParser().parsebytes(raw_email)
for part in msg.walk():
if part.get_content_maintype() == 'application':
attachment_data = part.get_payload(decode=True)
```
**Node.js before:** No recipe at all ❌
**Node.js now:** Complete attachment extraction ✅
```typescript
const attachments = await extractAttachments(
gmail,
messageId,
"./downloads" // Auto saves to disk
);
// Returns array of: { filename, mimeType, size, attachmentId, messageId }
```
---
### 4. **Decode Complex Emails** 🔴→✅
**Python advantage:** Automatic with email parser
```python
# Handles multipart/mixed, multipart/alternative, nested parts
# Automatically extracts plain text, HTML, and attachments
```
**Node.js before:** No recipe for multipart handling ❌
**Node.js now:** Full multipart support ✅
```typescript
const email = await parseFullEmail(gmail, messageId);
// Automatically handles:
// - Plain text messages
// - HTML messages
// - multipart/mixed (text + attachments)
// - multipart/alternative (plain + HTML)
// - Nested multipart structures
console.log(email.body); // Plain text version
console.log(email.html); // HTML version
console.log(email.attachments); // All attachments
```
---
## What Was Added to Node.js
### New Recipe 1: `sendMessageWithMultipleAttachments()`
**Lines of code:** ~80 + helper function
**Features:**
- Send any number of attachments in one email
- Auto-detect 23 common MIME types
- Proper boundary management
- Graceful error handling (skips files that can't attach)
- Full error checking
**Use case:** Email forwarding, bulk distribution, document delivery
---
### New Recipe 2: `extractAttachments()`
**Lines of code:** ~60
**Features:**
- Download attachments from received emails
- Decode base64url encoding properly
- Save to disk or return metadata
- Create output directories automatically
- Return attachment info array
**Use case:** Document processing, file archival, backup systems
---
### New Recipe 3: `parseFullEmail()`
**Lines of code:** ~70
**Features:**
- Extract ALL email components in one call
- Handle multipart/mixed and multipart/alternative
- Return both plain text AND HTML versions
- List all attachments with metadata
- Proper base64url decoding
- Fully typed return object
**Use case:** Email clients, filters, AI analysis, archiving
---
### Bonus: Enhanced `GmailHelper` Class
**New method:** `parseFullEmail(messageId)`
**Features:**
- Encapsulated email parsing
- Automatic base64url decoding
- Type-safe TypeScript interface
- Easy integration in projects
---
## Feature Parity Comparison
### Before (Python Only)
| Feature | Python | Node.js |
|---------|--------|---------|
| List messages | ✅ | ✅ |
| Send simple email | ✅ | ✅ |
| Send with 1 attachment | ✅ | ✅ |
| **Send with 2+ attachments** | ✅ Easy | ❌ Hard |
| **Parse received emails** | ✅ Easy | ❌ Missing |
| **Extract attachments** | ✅ Easy | ❌ Missing |
| **Handle multipart emails** | ✅ Easy | ❌ Missing |
| Manage labels | ✅ | ✅ |
| Thread operations | ✅ | ✅ |
### After (Complete Parity)
| Feature | Python | Node.js | Status |
|---------|--------|---------|--------|
| List messages | ✅ | ✅ | Equal |
| Send simple email | ✅ | ✅ | Equal |
| Send with 1 attachment | ✅ | ✅ | Equal |
| Send with 2+ attachments | ✅ Easy | ✅ Easy | **FIXED** |
| Parse received emails | ✅ Easy | ✅ Easy | **FIXED** |
| Extract attachments | ✅ Easy | ✅ Easy | **FIXED** |
| Handle multipart emails | ✅ Easy | ✅ Easy | **FIXED** |
| Manage labels | ✅ | ✅ | Equal |
| Thread operations | ✅ | ✅ | Equal |
| Error handling | ✅ | ✅ Better | Node.js wins |
| Type safety | ❌ | ✅ | Node.js wins |
---
## Why These Gaps Existed
### Python's Advantage
Python has **built-in email utilities** in the standard library:
- `email.message.EmailMessage` - Compose emails with attachments
- `email.parser.BytesParser` - Parse emails
- `email.mime.*` - MIME handling
- All automatic and well-tested
### Node.js's Challenge
Node.js has **no built-in email utilities**:
- Originally needed external libraries (nodemailer, mailparser, etc.)
- Gmail API requires manual MIME construction
- base64url decoding is different from standard base64
- Multipart message parsing requires manual parsing logic
---
## The Solution
Rather than introduce external dependencies, we provided:
1. **Native implementations** using Node.js/TypeScript built-ins
2. **Helper functions** for MIME type detection and base64url
3. **Complete recipes** that work out-of-the-box
4. **Type-safe code** with proper TypeScript annotations
5. **Error handling** for edge cases
This means you get:
- ✅ No extra npm dependencies (uses only googleapis and google-auth-library)
- ✅ Full control over the code
- ✅ Type safety with TypeScript
- ✅ Complete feature parity with Python
---
## Use Cases Now Possible in Node.js
### 1. Email Forwarding Bot
```typescript
// Get unread emails with attachments
const messages = await searchMessages('is:unread has:attachment');
for (const msg of messages) {
// Parse the full email
const email = await parseFullEmail(msg.id);
// Extract attachments
const attachments = await extractAttachments(msg.id, "./temp");
// Forward with all attachments
await sendMessageWithMultipleAttachments(
gmail,
"forward@example.com",
`Fwd: ${email.headers.subject}`,
email.body,
attachments.map(a => `./temp/${a.filename}`)
);
}
```
### 2. Document Processing Pipeline
```typescript
// Find invoices
const invoices = await searchMessages('filename:invoice* has:attachment');
for (const inv of invoices) {
const email = await parseFullEmail(inv.id);
const attachments = await extractAttachments(inv.id, "./invoices");
// Process each PDF
for (const att of attachments) {
if (att.mimeType === "application/pdf") {
await sendToPDFProcessor(att.filename);
}
}
}
```
### 3. Email Archive
```typescript
// Export all emails as JSON with attachments
const allMessages = await gmail.users.messages.list({ userId: 'me' });
for (const msg of allMessages.data.messages) {
const email = await parseFullEmail(msg.id);
// Save email metadata
fs.writeFileSync(
`archive/${msg.id}.json`,
JSON.stringify(email, null, 2)
);
// Download attachments
await extractAttachments(msg.id, `archive/${msg.id}/files`);
}
```
### 4. Email Classification
```typescript
// Analyze emails for AI processing
const unread = await searchMessages('is:unread');
for (const msg of unread) {
const email = await parseFullEmail(msg.id);
// Send to ML service
const classification = await classifyEmail({
subject: email.headers.subject,
from: email.headers.from,
body: email.body,
attachmentTypes: email.attachments.map(a => a.mimeType)
});
// Apply appropriate label
await applyLabel(msg.id, classification.label);
}
```
---
## Code Size Comparison
| Task | Python | Node.js Before | Node.js Now |
|------|--------|---|---|
| Send with 2 attachments | ~15 lines | ~50 lines | ~15 lines |
| Parse email | ~20 lines | ❌ Not possible | ~15 lines |
| Extract attachments | ~20 lines | ❌ Not possible | ~10 lines |
---
## What Python Still Has
Python still has slight advantages:
- **Standard library**: Email utilities built-in
- **Simplicity**: Can use `email.message` directly
- **Data science**: Better for email analysis with pandas
- **Legacy integrations**: Existing Python email tools
But these are **marginal differences** - Node.js now provides complete parity.
---
## Final Answer Summary
### Original Question
"Is there stuff the Python one can do that the Node.js one can't?"
### Answer: YES → NOW NO ✅
**What was the gap?**
- Multiple attachment handling
- Email parsing
- Attachment extraction
- Multipart message handling
**What was added?**
- `sendMessageWithMultipleAttachments()` - Complete MIME handling
- `extractAttachments()` - Full attachment extraction
- `parseFullEmail()` - Complete email parsing
- Helper functions for MIME types and base64url
- Enhanced GmailHelper class
**Result:**
- ✅ Complete feature parity achieved
- ✅ No extra dependencies needed
- ✅ Full TypeScript support
- ✅ Better async/await patterns
- ✅ Better error handling
**Status:** 🎉 FULLY ADDRESSED
---
## Files Modified/Created
-**SKILL-nodejs.md** - Added 3 new recipes + GmailHelper enhancements
-**IMPROVEMENTS.md** - Created to document changes
-**GAPS-FILLED.md** - Created with detailed analysis
-**FILE-GUIDE.md** - Created for navigation
-**ANSWER.md** - This file, answering the question directly
---
## Next Steps
### To Use Node.js Gmail API
1. **Read** README.md (5 min)
2. **Install** dependencies from package.json.template
3. **Reference** SKILL-nodejs.md for recipes
4. **Copy** example-nodejs.ts as starting template
5. **Use** the new advanced recipes for complex tasks
### Resources
- **Quick reference:** README.md
- **Complete guide:** SKILL-nodejs.md
- **Advanced examples:** GAPS-FILLED.md
- **Working code:** example-nodejs.ts
- **Navigation:** FILE-GUIDE.md
---
**TL;DR:** Python had better email handling due to built-in utilities. This has been completely addressed in Node.js with new recipes for multiple attachments, email parsing, and attachment extraction. Both are now feature-complete. ✅
-375
View File
@@ -1,375 +0,0 @@
# Google Mail API Skill - File Guide
Complete navigation guide for all files in the Google Mail API skill.
## Quick Start
**New to this skill?**
1. Read **README.md** first (5 min) - Decide Python vs Node.js
2. Read the relevant **SKILL.md** file (20 min) - Learn the concepts
3. Check **example-nodejs.ts** or Python example (5 min) - See working code
**Want to know what changed?**
→ Read **IMPROVEMENTS.md** and **GAPS-FILLED.md**
---
## File Reference
### 📚 Documentation Files
#### **README.md** (5.6 KB)
**What:** High-level overview and decision guide
**Contains:**
- Python vs Node.js comparison table
- When to use each language
- Feature parity overview
- Quick start instructions
- Tips & tricks
- Resources
**Read if:** You're deciding between Python and Node.js or want a quick reference
**Time:** 5 minutes
---
#### **SKILL.md** (16 KB)
**What:** Complete Python implementation guide
**Contains:**
- Python-specific installation
- OAuth 2.0 setup (Python)
- All Core API methods documented
- 16 practical recipes with Python code
- Message/Label/Thread/Draft structure docs
- Error handling patterns
- Query syntax reference
**Read if:** You're using Python
**Time:** 30 minutes (skim as needed)
---
#### **SKILL-nodejs.md** (35 KB)
**What:** Complete Node.js/TypeScript implementation guide
**Contains:**
- Node.js/TypeScript installation
- OAuth 2.0 setup (Node.js/TypeScript)
- All Core API methods documented
- 19 practical recipes with TypeScript code
- **NEW:** Advanced email handling recipes
- Send with multiple attachments
- Extract attachments from emails
- Parse full email structure
- Message/Label/Thread/Draft structure docs
- Error handling patterns with async/await
- Complete GmailHelper class
- Query syntax reference
**Read if:** You're using Node.js/TypeScript
**Time:** 40 minutes (skim as needed)
---
#### **IMPROVEMENTS.md** (6.4 KB)
**What:** Summary of all changes and enhancements
**Contains:**
- What was added to the skill
- File structure overview
- Feature parity table
- Python vs Node.js differences
- Technical improvements
- Recommendations for users
- Future enhancements
- Testing notes
**Read if:** You want to understand what's new
**Time:** 10 minutes
---
#### **GAPS-FILLED.md** (9.4 KB)
**What:** Detailed analysis of gaps that were filled
**Contains:**
- Original gaps found in Node.js
- What was added (3 new recipes)
- Updated feature parity table
- Real-world use case examples
- Email forwarding bot
- Document processing pipeline
- Email archive tool
- Code quality improvements
- How to test new features
- Complete summary
**Read if:** You want to understand the Node.js enhancements
**Time:** 15 minutes
---
#### **FILE-GUIDE.md** (This file)
**What:** Navigation guide for all skill files
**Contains:**
- Quick start path
- File descriptions
- Reading recommendations
- Time estimates
---
### 💻 Code Examples
#### **example-nodejs.ts** (10 KB)
**What:** Complete, runnable Node.js/TypeScript example
**Contains:**
- Full authentication flow
- 10+ working examples
- Get user profile
- List labels
- List messages
- Search messages
- Get full message
- Send message
- Create draft
- Apply labels
- List threads
- Move to trash
- Emoji-based progress indicators
- Error handling
- Can run immediately: `npx ts-node example-nodejs.ts`
**Run if:** You want to see working code
**Time:** 5 minutes to review, 1 minute to run
---
### ⚙️ Configuration Templates
#### **package.json.template** (570 B)
**What:** npm configuration template
**Contains:**
- Dependencies (googleapis, google-auth-library)
- DevDependencies (TypeScript, ts-node)
- Scripts (dev, example, build, start)
- Node.js version requirement (16+)
**Use if:** Setting up a new Node.js project
**Action:** Copy to `package.json` and run `npm install`
---
#### **tsconfig.json.template** (424 B)
**What:** TypeScript configuration template
**Contains:**
- ES2020 target
- ESM modules
- Strict type checking
- Proper module resolution
**Use if:** Setting up TypeScript in a Node.js project
**Action:** Copy to `tsconfig.json` in your project root
---
## Reading Paths
### Path 1: Just Deciding (5-10 minutes)
1. README.md - Decision guide section
2. → Choose Python or Node.js
### Path 2: Learning Python (30 minutes)
1. README.md - Overview
2. SKILL.md - Full guide
3. Check specific recipes as needed
### Path 3: Learning Node.js/TypeScript (35 minutes)
1. README.md - Overview
2. SKILL-nodejs.md - Full guide
3. example-nodejs.ts - See it in action
4. Check specific recipes as needed
### Path 4: Understanding the Enhancements (20 minutes)
1. IMPROVEMENTS.md - What changed
2. GAPS-FILLED.md - What was added
3. SKILL-nodejs.md - Advanced recipes section
### Path 5: Building a Project (1 hour)
1. README.md - Decision guide
2. Choose SKILL.md or SKILL-nodejs.md
3. example-nodejs.ts or Python example
4. Copy package.json.template (Node.js) or requirements.txt (Python)
5. Follow specific recipe sections
6. Adapt for your use case
---
## File Structure Overview
```
google-mail-api/
├─ Documentation (Read first)
│ ├─ README.md ..................... Quick reference & decision guide
│ ├─ IMPROVEMENTS.md ............... Overview of changes
│ ├─ GAPS-FILLED.md ................ Details of enhancements
│ └─ FILE-GUIDE.md ................. This file
├─ Implementation Guides (Core)
│ ├─ SKILL.md ...................... Python complete guide (16 KB)
│ └─ SKILL-nodejs.md ............... Node.js/TS complete guide (35 KB)
├─ Working Examples
│ └─ example-nodejs.ts ............. Running Node.js example (10 KB)
└─ Configuration (Project Setup)
├─ package.json.template ......... npm dependencies
└─ tsconfig.json.template ....... TypeScript config
```
---
## Quick Reference
### OAuth 2.0 Setup
- Python: SKILL.md → "OAuth 2.0 Setup (Python)"
- Node.js: SKILL-nodejs.md → "OAuth 2.0 Setup (Node.js/TypeScript)"
### Common Recipes
- Both: "Common Recipes" section in respective SKILL files
- List Labels, Send Email, Extract Attachments, etc.
### Error Handling
- Python: SKILL.md → "Error Handling"
- Node.js: SKILL-nodejs.md → "Error Handling"
### API Reference
- Core Methods: SKILL.md or SKILL-nodejs.md → "Core API Methods"
- Message Structure: "Message Resource Structure" in both
- Query Syntax: "Query String Format" in both
### Advanced Examples
- Email Parsing: GAPS-FILLED.md → "Real-World Use Cases"
- Multiple Attachments: SKILL-nodejs.md → New recipes
- Helper Class: SKILL-nodejs.md → "Complete Example: Gmail Helper Class"
---
## New Content (Since You Asked)
What was added in response to your questions:
1. **SKILL-nodejs.md enhancements:**
- `sendMessageWithMultipleAttachments()` - Send 2+ files easily
- `extractAttachments()` - Download attachments from emails
- `parseFullEmail()` - Get complete email data
- `getMimeType()` - Helper for file type detection
- `decodeBase64Url()` - Proper email body decoding
2. **GmailHelper class enhancements:**
- `parseFullEmail()` method added
- Better type safety
- Easier to use in projects
3. **Documentation:**
- IMPROVEMENTS.md - Overview
- GAPS-FILLED.md - Detailed analysis
- FILE-GUIDE.md - This navigation guide
---
## Estimated Reading Time
| File | Time | Type | Essential? |
|------|------|------|-----------|
| README.md | 5 min | Ref | ✅ Yes |
| SKILL.md | 30 min | Guide | ✅ If using Python |
| SKILL-nodejs.md | 40 min | Guide | ✅ If using Node.js |
| example-nodejs.ts | 5 min | Example | ✅ If using Node.js |
| IMPROVEMENTS.md | 10 min | Summary | ⚠️ Recommended |
| GAPS-FILLED.md | 15 min | Detail | ⚠️ For context |
| FILE-GUIDE.md | 10 min | Nav | ⚠️ For navigation |
| package.json.template | 1 min | Config | ✅ If Node.js |
| tsconfig.json.template | 1 min | Config | ✅ If TypeScript |
**Total:** 30-80 minutes depending on path chosen
---
## Common Questions & Where to Find Answers
**Q: Should I use Python or Node.js?**
→ README.md → "Which Should I Use?"
**Q: How do I set up OAuth 2.0?**
→ SKILL.md or SKILL-nodejs.md → "OAuth 2.0 Setup"
**Q: How do I send an email with multiple files?**
→ SKILL-nodejs.md → "Send Email with Multiple Attachments"
**Q: How do I parse/read an email I received?**
→ SKILL-nodejs.md → "Parse and Decode Full Email Body"
**Q: How do I extract attachments from emails?**
→ SKILL-nodejs.md → "Extract Attachments from Received Email"
**Q: What changed from the original skill?**
→ IMPROVEMENTS.md and GAPS-FILLED.md
**Q: Is there working example code I can run?**
→ example-nodejs.ts
**Q: How do I set up a new project?**
→ package.json.template and tsconfig.json.template
**Q: What's the GmailHelper class?**
→ SKILL-nodejs.md → "Complete Example: Gmail Helper Class"
---
## Tips
- 📌 Pin README.md for quick reference
- 📌 Keep SKILL.md or SKILL-nodejs.md open while coding
- 🚀 Use example-nodejs.ts as a starting template
- 🔍 Use browser search (Ctrl+F) in SKILL files to find recipes
- 💡 Check "Common Recipes" section first for your use case
- 📚 Bookmark GAPS-FILLED.md for advanced techniques
---
## Updates & Versioning
**Last Updated:** February 24, 2026
**Current Status:**
- ✅ Python version: Complete and stable
- ✅ Node.js version: Enhanced with advanced recipes
- ✅ Feature parity: Achieved between Python and Node.js
**Recent Additions:**
- Advanced email handling (multiple attachments, parsing)
- Email extraction and attachment handling
- Complete GmailHelper class with new methods
- Enhanced documentation and examples
---
**Happy coding! 🎉**
Choose your language, pick a recipe, and start automating your Gmail! 📧
-380
View File
@@ -1,380 +0,0 @@
# Node.js Skill Enhancements - Gaps Filled
## Summary
The Node.js/TypeScript skill has been enhanced with **3 new advanced recipes** to address gaps in email handling that Python had out-of-the-box.
## What Was Added
### 1. ✅ Send Email with Multiple Attachments
**Location:** `SKILL-nodejs.md` → Common Recipes section
**What it does:**
- Send a single email with multiple file attachments
- Automatic MIME type detection (PDF, DOCX, XLSX, etc.)
- Proper boundary management (no manual boundary per file)
- Graceful error handling (skips files that can't be attached)
**Key features:**
```typescript
await sendMessageWithMultipleAttachments(
gmail,
"user@example.com",
"Subject",
"Body text",
["/path/to/file1.pdf", "/path/to/file2.xlsx"]
);
```
**Why it matters:**
- Python's `EmailMessage.add_attachment()` makes this trivial
- Node.js manual MIME construction was complex
- Now provides comparable ease of use
**Comparison:**
| Python | Node.js Before | Node.js After |
|--------|---|---|
| ✅ `message.add_attachment()` × N | ❌ Manual boundary for each | ✅ Built-in MIME helper |
| Easy to add 3+ files | Hard to manage boundaries | Easy |
| Safe MIME detection | Manual | ✅ Auto-detect |
---
### 2. ✅ Extract Attachments from Received Email
**Location:** `SKILL-nodejs.md` → Common Recipes section
**What it does:**
- Download and save attachments from received emails to disk
- Handles proper base64url decoding
- Returns metadata about each attachment
- Creates output directory automatically
**Key features:**
```typescript
const attachments = await extractAttachments(
gmail,
messageId,
"./downloads" // Save directory
);
// Returns:
// [
// {
// filename: "report.pdf",
// mimeType: "application/pdf",
// size: 245000,
// attachmentId: "...",
// messageId: "..."
// }
// ]
```
**Why it matters:**
- Python has built-in email parsing: `from email.parser import BytesParser`
- Node.js had NO recipe for this critical feature
- This was a **major gap**
**What you can now do:**
- ✅ Batch download all attachments from a message
- ✅ Get attachment metadata without saving
- ✅ Process attachments programmatically
- ✅ Filter by filename/MIME type
---
### 3. ✅ Parse and Decode Full Email Body
**Location:** `SKILL-nodejs.md` → Common Recipes section
**What it does:**
- Extract ALL email components in one call
- Properly decode multipart messages
- Extract both plain text AND HTML versions
- List all attachments with metadata
- Return properly typed object
**Key features:**
```typescript
const email = await parseFullEmail(gmail, messageId);
// Returns:
{
id: "...",
threadId: "...",
labelIds: ["INBOX"],
snippet: "...",
headers: {
subject: "Email Subject",
from: "sender@example.com",
to: "recipient@example.com",
cc: "",
date: "Mon, 24 Feb 2025 12:46:16 +0000"
},
body: "Plain text version of email",
html: "<html>HTML version of email</html>",
attachments: [
{
filename: "document.pdf",
mimeType: "application/pdf",
attachmentId: "..."
}
]
}
```
**Why it matters:**
- Python's email parsing is trivial with standard library
- Node.js had NO recipe for parsing multipart emails
- This was a **critical missing feature**
**What you can now do:**
- ✅ Extract all email data in one call
- ✅ Handle both plain text and HTML formats
- ✅ Automatically detect and list attachments
- ✅ Build email clients, filters, automation
---
### 4. ✅ Added Methods to GmailHelper Class
The `GmailHelper` class now includes:
- `parseFullEmail(messageId)` - Parse complete email with all components
This makes it available via:
```typescript
const helper = new GmailHelper(auth);
const email = await helper.parseFullEmail(messageId);
```
---
## Updated Feature Parity Table
| Capability | Python | Node.js | Status |
|-----------|--------|---------|--------|
| Read emails | ✅ | ✅ | ✅ Equal |
| Send simple emails | ✅ | ✅ | ✅ Equal |
| Send with attachment | ✅ Easy | ✅ Now Easy | ✅ Equal |
| Send with multiple attachments | ✅ Easy | ✅ **Now Easy** | ✅ **FIXED** |
| Parse received emails | ✅ Easy | ✅ **Now Easy** | ✅ **FIXED** |
| Extract attachments | ✅ Easy | ✅ **Now Easy** | ✅ **FIXED** |
| Manage labels | ✅ | ✅ | ✅ Equal |
| Handle threads | ✅ | ✅ | ✅ Equal |
| Error handling | ✅ | ✅ Better | ✅ Node.js wins |
| Type safety | ❌ | ✅ | ✅ Node.js wins |
---
## Real-World Use Cases Now Possible in Node.js
### 1. Email Forwarding Bot
```typescript
// Get unread emails
const messages = await listInboxMessages(10);
// For each message
for (const msg of messages) {
// Parse the full email
const email = await parseFullEmail(msg.id);
// Extract attachments
const attachments = await extractAttachments(msg.id, "./attachments");
// Forward with attachments
await sendMessageWithMultipleAttachments(
gmail,
"forward@example.com",
`Fwd: ${email.headers.subject}`,
email.body,
attachments.map(a => `./attachments/${a.filename}`)
);
}
```
### 2. Document Processing Pipeline
```typescript
// Search for emails with invoices
const invoices = await searchMessages(
'has:attachment filename:invoice after:2025/01/01'
);
// For each invoice email
for (const inv of invoices) {
const email = await parseFullEmail(inv.id);
const attachments = await extractAttachments(inv.id, "./invoices");
// Process each PDF
for (const att of attachments) {
if (att.mimeType === "application/pdf") {
// Send to document processing service
await processInvoice(att.filename);
}
}
}
```
### 3. Email Archive Tool
```typescript
// Search for all emails from a period
const archived = await searchMessages('after:2024/01/01 before:2024/12/31');
// For each, extract full content
for (const msg of archived) {
const email = await parseFullEmail(msg.id);
// Save to JSON
fs.writeFileSync(
`archive/${msg.id}.json`,
JSON.stringify(email, null, 2)
);
// Save attachments
await extractAttachments(msg.id, `archive/${msg.id}/attachments`);
}
```
---
## Code Quality Improvements
### Helper Functions Added
- **getMimeType()** - Detect MIME types by file extension (23 common types)
- **decodeBase64Url()** - Properly handle Gmail's base64url encoding
### Error Handling
- Try/catch blocks in all recipes
- Graceful degradation (e.g., skip files that can't attach)
- Helpful error messages
### Type Safety
- Full TypeScript annotations where possible
- Proper return types for parsed emails
- Attachment metadata interfaces
---
## Documentation Updates
### New Recipe Sections
1. **Send Email with Multiple Attachments**
- 100+ lines of documented code
- MIME type helper function
- Usage example
2. **Extract Attachments from Received Email**
- Complete attachment download workflow
- Directory creation
- Metadata return
- Usage example
3. **Parse and Decode Full Email Body**
- Handles multipart messages
- Extracts both text and HTML
- Lists attachments
- Usage example
### Code Examples
- Each recipe has working, copy-paste-ready code
- All functions are properly typed
- Error handling included
- Usage examples provided
---
## What's the Difference From Python Now?
### Still Better in Python
- 🐍 Standard library has `EmailMessage` and `email.parser`
- 🐍 Slightly less code for simple cases
- 🐍 Built-in MIME utilities
### Now Equal or Better in Node.js
- ✅ Multiple attachments - same difficulty now
- ✅ Parse emails - same functionality now
- ✅ Extract attachments - same functionality now
-**Type safety** - TypeScript better than Python
-**Performance** - async/await non-blocking
-**Error handling** - better patterns
---
## Testing the New Features
### Test Multiple Attachments
```bash
npx ts-node << 'EOF'
import { authenticate } from './auth';
const gmail = await authenticate();
// Send test email with 3 files
await sendMessageWithMultipleAttachments(
gmail,
"test@example.com",
"Test Files",
"Here are test files",
["./package.json", "./README.md", "./tsconfig.json"]
);
EOF
```
### Test Email Parsing
```bash
npx ts-node << 'EOF'
import { authenticate } from './auth';
const gmail = await authenticate();
// Get first email
const messages = await gmail.users.messages.list({ userId: 'me', maxResults: 1 });
if (messages.data.messages?.[0]) {
const email = await parseFullEmail(gmail, messages.data.messages[0].id);
console.log(JSON.stringify(email, null, 2));
}
EOF
```
### Test Attachment Extraction
```bash
npx ts-node << 'EOF'
import { authenticate } from './auth';
const gmail = await authenticate();
// Find an email with attachments
const withAttach = await gmail.users.messages.list({
userId: 'me',
q: 'has:attachment',
maxResults: 1
});
if (withAttach.data.messages?.[0]) {
const attachments = await extractAttachments(
gmail,
withAttach.data.messages[0].id,
"./test-downloads"
);
console.log("Downloaded:", attachments);
}
EOF
```
---
## Summary
**All Python-specific gaps have been closed**
The Node.js/TypeScript skill now has complete feature parity with Python for:
- Sending emails with multiple attachments
- Parsing received emails
- Extracting attachments from emails
- Full email body decoding
Plus Node.js has advantages in:
- Type safety (TypeScript)
- Performance (non-blocking async)
- Better error patterns
- Integration with modern web services
**Status: COMPLETE PARITY**
-213
View File
@@ -1,213 +0,0 @@
# Skill Improvements Summary
## Overview
The Google Mail API skill has been significantly enhanced to support both **Python** and **Node.js/TypeScript** implementations. Previously Python-only, it now provides complete parity across both languages with better documentation and practical examples.
## What Was Added
### 1. **SKILL-nodejs.md** (25KB)
Complete Node.js/TypeScript documentation mirroring the Python version, including:
- ✅ Full OAuth 2.0 setup with token caching
- ✅ All 50+ API methods documented
- ✅ 16 working code recipes with TypeScript examples
- ✅ Comprehensive error handling patterns
- ✅ Complete `GmailHelper` utility class
- ✅ Async/await patterns throughout
**Key Advantages:**
- Type-safe with TypeScript support
- Native async/await (no `.execute()` needed)
- Better performance than Python
- Easier integration with Node.js backends
### 2. **README.md** (5.6KB)
High-level overview comparing both implementations:
- Side-by-side feature comparison table
- Quick decision guide: when to use Python vs Node.js
- Core differences explained
- Authentication setup for both
- Rate limiting and best practices
- Common gotchas and tips
### 3. **example-nodejs.ts** (10KB)
Fully working, runnable example script with:
- 🔐 Complete authentication flow
- 📋 Get user profile
- 🏷️ List labels
- 📧 List inbox messages
- 🔍 Search messages with Gmail query syntax
- 📄 Get full message details
- ✉️ Send messages
- 📝 Create drafts
- 🏷️ Apply labels
- 💬 List threads
- 🗑️ Move to trash
**Usage:**
```bash
npm install
npx ts-node example-nodejs.ts
```
### 4. **package.json.template** (570B)
Ready-to-use npm configuration with:
- Correct dependencies (`googleapis`, `google-auth-library`)
- TypeScript tooling (ts-node, @types/node)
- Development scripts
- Node.js version requirement (16+)
### 5. **tsconfig.json.template** (424B)
TypeScript configuration for:
- ES2020 target
- ESM module support
- Strict type checking enabled
- Proper module resolution
## Feature Parity
Both implementations now support:
| Feature | Python | Node.js | Status |
|---------|--------|---------|--------|
| OAuth 2.0 Auth | ✅ | ✅ | ✅ Parity |
| List Messages | ✅ | ✅ | ✅ Parity |
| Send Messages | ✅ | ✅ | ✅ Parity |
| Create Drafts | ✅ | ✅ | ✅ Parity |
| Manage Labels | ✅ | ✅ | ✅ Parity |
| Thread Operations | ✅ | ✅ | ✅ Parity |
| Batch Operations | ✅ | ✅ | ✅ Parity |
| Error Handling | ✅ | ✅ | ✅ Parity |
| Helper Class | ❌ | ✅ | ✅ Improved |
## Technical Improvements
### Code Quality
- **TypeScript**: Full type safety in Node.js version
- **Error Handling**: Comprehensive try/catch patterns in both
- **Documentation**: Every method has usage examples
- **DRY Principle**: No duplicated concepts, just different syntax
### Best Practices
- ✅ Token caching and refresh logic
- ✅ Rate limit considerations
- ✅ Batch operations for efficiency
- ✅ Proper scope management
- ✅ Resource cleanup
### Developer Experience
- 🎯 Clear decision tree: which language to use?
- 📚 Recipe-based learning (16 common tasks)
- 🔧 Ready-to-run example scripts
- 📋 Side-by-side API comparisons
- 🚀 Quick start guides
## File Structure
```
google-mail-api/
├── SKILL.md # Original Python version
├── SKILL-nodejs.md # NEW: Node.js/TypeScript version
├── README.md # NEW: Comprehensive overview
├── example-nodejs.ts # NEW: Working example script
├── package.json.template # NEW: npm config template
├── tsconfig.json.template # NEW: TypeScript config template
└── IMPROVEMENTS.md # This file
```
## Key Differences Between Languages
### Authentication
**Python**: Block until authenticated, simple but slower
```python
service = authenticate() # Blocks, returns service
```
**Node.js**: Promise-based, non-blocking
```typescript
const gmail = await authenticate(); // Promise, returns gmail client
```
### API Calls
**Python**: Chainable method calls ending with `.execute()`
```python
results = service.users().messages().list(userId="me").execute()
```
**Node.js**: Async/await, more intuitive
```typescript
const results = await gmail.users.messages.list({ userId: "me" });
```
### Data Access
**Python**: Dict-style access with `.get()` defaults
```python
messages = results.get("messages", [])
```
**Node.js**: Object property access with `?.` optional chaining
```typescript
const messages = results.data.messages || [];
```
## Recommendations for Users
### For New Projects
- Use **Node.js/TypeScript** if possible
- Better performance, type safety, native async
- Use the `GmailHelper` class for cleaner code
### For Existing Python Code
- Keep using **Python** version
- Easy to maintain alongside existing code
- Good for data science/analysis workflows
### For Production
- Both are production-ready
- Use whichever matches your stack
- Consider the `GmailHelper` class for abstraction
## Migration Path (Python → Node.js)
If migrating from Python to Node.js:
1. Start with `example-nodejs.ts` as template
2. Install dependencies from `package.json.template`
3. Reference `SKILL-nodejs.md` recipes
4. Use `GmailHelper` class for common operations
5. Compare `SKILL.md` and `SKILL-nodejs.md` side-by-side
## Future Enhancements
Potential additions (not yet implemented):
- ⭐ GraphQL wrapper for both (reduced payload size)
- ⭐ Streaming/pagination helpers
- ⭐ Rate limiter utility
- ⭐ Email parsing/MIME utilities
- ⭐ Scheduled operations queue
- ⭐ Webhook receiver for push notifications
## Testing
Both implementations have been verified against:
- ✅ Official Google APIs documentation
- ✅ API reference endpoints
- ✅ Current OAuth 2.0 flows
- ✅ Error handling scenarios
## Support & Resources
**Documentation:**
- [Google Gmail API Docs](https://developers.google.com/workspace/gmail/api/guides)
- [Python Client](https://googleapis.github.io/google-api-python-client/)
- [Node.js Client](https://github.com/googleapis/google-api-nodejs-client)
**For Questions:**
- Python: Reference `SKILL.md` and official Python docs
- Node.js: Reference `SKILL-nodejs.md` and example-nodejs.ts
- General API: Refer to official Gmail API documentation
---
**Last Updated:** February 24, 2026
**Status:** ✅ Complete parity between Python and Node.js/TypeScript
-208
View File
@@ -1,208 +0,0 @@
# Google Mail API Skill
Complete documentation for the Gmail API with support for both **Python** and **Node.js/TypeScript**.
## Files
- **SKILL.md** - Python implementation with `google-api-python-client`
- **SKILL-nodejs.md** - Node.js/TypeScript implementation with `googleapis`
- **README.md** - This file
## Quick Comparison
| Feature | Python | Node.js/TypeScript |
|---------|--------|-------------------|
| Installation | `pip install google-api-python-client` | `npm install googleapis google-auth-library` |
| Authentication | OAuth 2.0 with token caching | OAuth 2.0 with token caching |
| Type Safety | Dynamic typing | Full TypeScript support |
| Performance | Slower | Faster, async/await native |
| Use Cases | Scripts, automation, legacy | Modern web, backends, real-time |
| Dependencies | google-api-python-client, google-auth-oauthlib | googleapis, google-auth-library |
| Maintenance | ✅ Maintained | ✅ Actively maintained |
## Which Should I Use?
### Choose **Python** if:
- You're already in a Python environment (Jupyter, scripts)
- You're doing data analysis with pandas
- You prefer simpler blocking APIs
- You need to integrate with existing Python tools
### Choose **Node.js/TypeScript** if:
- You're building a web service or API
- You want async/await and promises
- You need type safety (TypeScript)
- You're already using Node.js
- You need better performance
- You want to build real-time features
## Core Differences
### Authentication
**Python:**
```python
service = authenticate() # Returns service object
```
**Node.js/TypeScript:**
```typescript
const auth = await authenticate(); // Returns OAuth2Client
const gmail = google.gmail({ version: "v1", auth });
```
### Making API Calls
**Python:**
```python
results = service.users().messages().list(userId="me").execute()
```
**Node.js/TypeScript:**
```typescript
const results = await gmail.users.messages.list({ userId: "me" });
```
### Handling Responses
**Python:**
```python
messages = results.get("messages", [])
```
**Node.js/TypeScript:**
```typescript
const messages = results.data.messages || [];
```
## Common Tasks
All recipes are available in both:
- `SKILL.md` (Python versions)
- `SKILL-nodejs.md` (Node.js/TypeScript versions)
### Available Recipes
1. ✅ List Labels
2. ✅ List Messages in Inbox
3. ✅ Get Full Message
4. ✅ Send a Message
5. ✅ Send Email with Attachment
6. ✅ Create Draft
7. ✅ Apply Label to Message
8. ✅ List Threads
9. ✅ Get Thread Messages in Order
10. ✅ Search Messages
11. ✅ Create Custom Label
12. ✅ Modify Thread Labels
13. ✅ Move Message to Trash
14. ✅ Delete Message Permanently
15. ✅ Batch Modify Messages
16. ✅ Get User Profile
## Authentication Setup (Both Languages)
### 1. Create Google Cloud Project
- Go to [Google Cloud Console](https://console.cloud.google.com/)
- Create new project
- Enable Gmail API
- Create OAuth 2.0 credentials (Desktop Application)
- Download credentials file as `credentials.json`
### 2. Set Scopes
```
https://www.googleapis.com/auth/gmail.modify
https://www.googleapis.com/auth/gmail.send
https://www.googleapis.com/auth/gmail.readonly
```
### 3. First Run
Both implementations will prompt you to authorize via your browser and save a token for future use.
## Helper Classes/Utilities
### Node.js: GmailHelper Class
Already provided in `SKILL-nodejs.md`. Wraps common operations:
```typescript
const helper = new GmailHelper(auth);
await helper.sendMessage(to, subject, body);
await helper.applyLabel(messageId, labelName);
await helper.listInboxMessages(10);
```
### Python: Create Your Own
Consider wrapping the service in a class for easier reuse:
```python
class GmailHelper:
def __init__(self, service):
self.service = service
def send_message(self, to, subject, body):
# ... implementation
```
## Rate Limiting & Best Practices
Both implementations should respect:
- **Quota**: 250 requests per second per user
- **Batch operations**: Use `batchModify()` and `batchDelete()` for multiple messages
- **Pagination**: Use `pageToken` for large result sets
- **Caching**: Cache label IDs and user profiles
## Error Handling
### Python
```python
from googleapiclient.errors import HttpError
try:
result = service.users().messages().get(...).execute()
except HttpError as error:
print(f"Error: {error}")
```
### Node.js/TypeScript
```typescript
try {
const result = await gmail.users.messages.get(...);
} catch (error: any) {
console.error(`Error ${error.response.status}: ${error.message}`);
}
```
## Resources
- [Official Gmail API Docs](https://developers.google.com/workspace/gmail/api/guides)
- [API Reference](https://developers.google.com/workspace/gmail/api/reference/rest)
- [Python Client](https://googleapis.github.io/google-api-python-client/)
- [Node.js Client](https://github.com/googleapis/google-api-nodejs-client)
- [OAuth 2.0 Setup](https://developers.google.com/identity/protocols/oauth2)
## Tips & Tricks
1. **Caching Labels**: Call `list()` once and cache label IDs to avoid repeated API calls
2. **Batch Operations**: Group message operations into batch calls
3. **Full vs Metadata Format**: Use `format="metadata"` when you only need headers
4. **Thread vs Messages**: Use threads for conversation view, messages for individual emails
5. **Custom Queries**: Learn Gmail search syntax for powerful `q` parameter usage
## Examples
### Python: Send Email Script
```bash
python examples/send_email.py --to recipient@example.com --subject "Hello"
```
### Node.js: Create TypeScript Utility
```bash
npx ts-node src/gmail-utility.ts
```
Both implementations provide all the tools needed to build production Gmail automation!
File diff suppressed because it is too large Load Diff
-557
View File
@@ -1,557 +0,0 @@
---
name: Google Mail API
description: Access, manage, and send Gmail messages using the Google Mail API. Use when the user wants to read emails, send messages, manage labels, organize threads, work with drafts, or automate email operations programmatically.
---
# Google Mail API
RESTful API reference for the Google Mail API — access and manage Gmail mailbox data including messages, threads, labels, and drafts.
Official docs: https://developers.google.com/workspace/gmail/api/guides
API Reference: https://developers.google.com/workspace/gmail/api/reference/rest
Python Client: https://googleapis.github.io/google-api-python-client/
## Key Concepts
**Message**
An email message containing sender, recipients, subject, and body. Messages are immutable after creation. Each message has a unique `id`.
**Thread**
A collection of related messages forming a conversation. When one or more recipients respond to a message, a thread is formed. Threads cannot be created directly, only deleted. Labels can be applied to threads.
**Label**
A mechanism for organizing messages and threads. Two types exist:
- **System labels**: Pre-defined labels like `INBOX`, `TRASH`, `SPAM`, `DRAFT`, `SENT`. Cannot be deleted or modified (but some can be applied/removed).
- **User labels**: Custom labels created by users. Can be created, modified, and deleted.
**Draft**
An unsent message. The message within a draft can be replaced before sending. Sending a draft automatically deletes it and creates a message with the `SENT` label.
## Installation
### Python Client Library
```bash
python3 -m pip install --upgrade google-api-python-client google-auth-httplib2 google-auth-oauthlib
```
## Authentication & Authorization
The Gmail API uses OAuth 2.0 for authentication. You must:
1. Create a Google Cloud project
2. Enable the Gmail API
3. Configure OAuth 2.0 credentials (Desktop/Web application)
4. Obtain credentials file (`credentials.json`)
### Scopes
Different scopes control the level of access. Common scopes:
| Scope | Permissions |
|-------|------------|
| `https://www.googleapis.com/auth/gmail.readonly` | Read-only access to Gmail mailbox and metadata |
| `https://www.googleapis.com/auth/gmail.send` | Send messages only |
| `https://www.googleapis.com/auth/gmail.modify` | Full access to read/write/modify messages and labels |
| `https://www.googleapis.com/auth/gmail.compose` | Compose drafts and send messages |
| `https://www.googleapis.com/auth/gmail.labels` | Manage labels |
### OAuth 2.0 Setup (Python)
```python
from google.auth.transport.requests import Request
from google.oauth2.credentials import Credentials
from google_auth_oauthlib.flow import InstalledAppFlow
from googleapiclient.discovery import build
from googleapiclient.errors import HttpError
import os.path
SCOPES = ["https://www.googleapis.com/auth/gmail.modify"]
def authenticate():
creds = None
# Load cached credentials if available
if os.path.exists("token.json"):
creds = Credentials.from_authorized_user_file("token.json", SCOPES)
# If no valid credentials, authenticate
if not creds or not creds.valid:
if creds and creds.expired and creds.refresh_token:
creds.refresh(Request())
else:
flow = InstalledAppFlow.from_client_secrets_file(
"credentials.json", SCOPES
)
creds = flow.run_local_server(port=0)
# Save credentials for next run
with open("token.json", "w") as token:
token.write(creds.to_json())
return build("gmail", "v1", credentials=creds)
# Usage
service = authenticate()
```
## Core API Methods
### Users
| Method | Description |
|--------|------------|
| `users().getProfile(userId="me")` | Get user's Gmail profile |
| `users().watch(userId="me", body={...})` | Watch for mailbox changes (push notifications) |
| `users().stop(userId="me")` | Stop watching mailbox |
### Messages
| Method | Description |
|--------|------------|
| `users().messages().list(userId="me", ...)` | List messages in mailbox |
| `users().messages().get(userId="me", id=MESSAGE_ID, ...)` | Get full message details |
| `users().messages().send(userId="me", body={...})` | Send a message |
| `users().messages().create(userId="me", body={...})` | Insert a message directly |
| `users().messages().import_(userId="me", body={...})` | Import a message |
| `users().messages().delete(userId="me", id=MESSAGE_ID)` | Delete a message |
| `users().messages().trash(userId="me", id=MESSAGE_ID)` | Move message to trash |
| `users().messages().untrash(userId="me", id=MESSAGE_ID)` | Restore message from trash |
| `users().messages().modify(userId="me", id=MESSAGE_ID, body={...})` | Modify message labels |
| `users().messages().batchModify(userId="me", body={...})` | Modify multiple messages at once |
| `users().messages().batchDelete(userId="me", body={...})` | Delete multiple messages at once |
### Attachments
| Method | Description |
|--------|------------|
| `users().messages().attachments().get(userId="me", messageId=MESSAGE_ID, id=ATTACHMENT_ID)` | Get attachment data |
### Threads
| Method | Description |
|--------|------------|
| `users().threads().list(userId="me", ...)` | List threads |
| `users().threads().get(userId="me", id=THREAD_ID, ...)` | Get thread with all messages |
| `users().threads().modify(userId="me", id=THREAD_ID, body={...})` | Modify thread labels |
| `users().threads().trash(userId="me", id=THREAD_ID)` | Move thread to trash |
| `users().threads().untrash(userId="me", id=THREAD_ID)` | Restore thread from trash |
| `users().threads().delete(userId="me", id=THREAD_ID)` | Delete thread permanently |
### Labels
| Method | Description |
|--------|------------|
| `users().labels().list(userId="me")` | List all labels |
| `users().labels().get(userId="me", id=LABEL_ID)` | Get label details |
| `users().labels().create(userId="me", body={...})` | Create a new label |
| `users().labels().update(userId="me", id=LABEL_ID, body={...})` | Update label |
| `users().labels().patch(userId="me", id=LABEL_ID, body={...})` | Patch label |
| `users().labels().delete(userId="me", id=LABEL_ID)` | Delete label |
### Drafts
| Method | Description |
|--------|------------|
| `users().drafts().list(userId="me", ...)` | List drafts |
| `users().drafts().get(userId="me", id=DRAFT_ID)` | Get draft |
| `users().drafts().create(userId="me", body={...})` | Create draft |
| `users().drafts().update(userId="me", id=DRAFT_ID, body={...})` | Update draft content |
| `users().drafts().send(userId="me", body={...})` | Send draft |
| `users().drafts().delete(userId="me", id=DRAFT_ID)` | Delete draft |
### Settings
| Method | Description |
|--------|------------|
| `users().settings().getAutoForwarding(userId="me")` | Get auto-forwarding settings |
| `users().settings().getImap(userId="me")` | Get IMAP settings |
| `users().settings().getPop(userId="me")` | Get POP settings |
| `users().settings().getVacation(userId="me")` | Get vacation auto-reply settings |
| `users().settings().updateAutoForwarding(userId="me", body={...})` | Update auto-forwarding |
| `users().settings().updateVacation(userId="me", body={...})` | Update vacation settings |
## Common Recipes
### List Labels
```python
service = authenticate()
results = service.users().labels().list(userId="me").execute()
labels = results.get("labels", [])
for label in labels:
print(f"{label['name']} (ID: {label['id']})")
```
### List Messages in Inbox
```python
results = service.users().messages().list(
userId="me",
q="in:inbox", # Gmail query syntax
maxResults=10
).execute()
messages = results.get("messages", [])
for message in messages:
msg_details = service.users().messages().get(
userId="me",
id=message["id"],
format="metadata",
metadataHeaders=["Subject", "From"]
).execute()
headers = msg_details["payload"]["headers"]
subject = next((h["value"] for h in headers if h["name"] == "Subject"), "No Subject")
sender = next((h["value"] for h in headers if h["name"] == "From"), "Unknown")
print(f"{sender}: {subject}")
```
### Get Full Message
```python
message = service.users().messages().get(
userId="me",
id=MESSAGE_ID,
format="full" # or "minimal" for metadata only
).execute()
# Access payload
payload = message["payload"]
headers = payload["headers"]
body = payload.get("body", {}).get("data", "")
# Decode body if base64url encoded
import base64
if body:
decoded_body = base64.urlsafe_b64decode(body).decode()
print(decoded_body)
```
### Send a Message
```python
import base64
from email.message import EmailMessage
# Create email message
message = EmailMessage()
message.set_content("This is the email body")
message["To"] = "recipient@example.com"
message["From"] = "sender@example.com"
message["Subject"] = "Test Email"
# Encode to base64url
encoded_message = base64.urlsafe_b64encode(message.as_bytes()).decode()
# Send
result = service.users().messages().send(
userId="me",
body={"raw": encoded_message}
).execute()
print(f"Message sent with ID: {result['id']}")
```
### Send Email with Attachment
```python
import base64
import mimetypes
from email.message import EmailMessage
message = EmailMessage()
message.set_content("Email with attachment")
message["To"] = "recipient@example.com"
message["From"] = "sender@example.com"
message["Subject"] = "Message with File"
# Add attachment
with open("document.pdf", "rb") as fp:
attachment_data = fp.read()
message.add_attachment(
attachment_data,
maintype="application",
subtype="pdf",
filename="document.pdf"
)
# Send
encoded_message = base64.urlsafe_b64encode(message.as_bytes()).decode()
result = service.users().messages().send(
userId="me",
body={"raw": encoded_message}
).execute()
```
### Create Draft
```python
import base64
from email.message import EmailMessage
message = EmailMessage()
message.set_content("Draft email body")
message["To"] = "recipient@example.com"
message["From"] = "sender@example.com"
message["Subject"] = "Draft Subject"
encoded_message = base64.urlsafe_b64encode(message.as_bytes()).decode()
draft = service.users().drafts().create(
userId="me",
body={"message": {"raw": encoded_message}}
).execute()
print(f"Draft created with ID: {draft['id']}")
```
### Apply Label to Message
```python
# Get label ID first
labels = service.users().labels().list(userId="me").execute()
label_id = next(
(l["id"] for l in labels["labels"] if l["name"] == "Important"),
None
)
if label_id:
service.users().messages().modify(
userId="me",
id=MESSAGE_ID,
body={"addLabelIds": [label_id]}
).execute()
```
### List Threads
```python
results = service.users().threads().list(
userId="me",
q="in:inbox",
maxResults=10
).execute()
threads = results.get("threads", [])
for thread in threads:
thread_data = service.users().threads().get(
userId="me",
id=thread["id"]
).execute()
num_messages = len(thread_data["messages"])
print(f"Thread {thread['id']}: {num_messages} messages")
```
### Get Thread Messages in Order
```python
thread = service.users().threads().get(
userId="me",
id=THREAD_ID,
format="full"
).execute()
messages = thread.get("messages", [])
for msg in messages:
headers = msg["payload"]["headers"]
subject = next((h["value"] for h in headers if h["name"] == "Subject"), "")
sender = next((h["value"] for h in headers if h["name"] == "From"), "")
print(f"From: {sender}")
print(f"Subject: {subject}")
print("---")
```
### Search Messages (Gmail Query Syntax)
```python
# Common query operators:
# in:inbox, in:trash, in:spam, in:sent, in:draft
# is:unread, is:read, is:starred
# from:sender@example.com, to:recipient@example.com
# subject:"search term", has:attachment
# after:YYYY/MM/DD, before:YYYY/MM/DD
# larger:1M, smaller:100K
results = service.users().messages().list(
userId="me",
q='is:unread from:boss@example.com after:2024/01/01',
maxResults=10
).execute()
messages = results.get("messages", [])
```
### Create Custom Label
```python
label = service.users().labels().create(
userId="me",
body={
"name": "My Project",
"labelListVisibility": "labelShow", # Show in label list
"messageListVisibility": "show" # Show messages in list
}
).execute()
print(f"Label created: {label['id']}")
```
### Modify Thread Labels
```python
service.users().threads().modify(
userId="me",
id=THREAD_ID,
body={
"addLabelIds": [LABEL_ID_1, LABEL_ID_2],
"removeLabelIds": [LABEL_ID_3]
}
).execute()
```
### Move Message to Trash
```python
service.users().messages().trash(
userId="me",
id=MESSAGE_ID
).execute()
```
### Delete Message Permanently
```python
service.users().messages().delete(
userId="me",
id=MESSAGE_ID
).execute()
```
### Batch Modify Messages
```python
service.users().messages().batchModify(
userId="me",
body={
"ids": [MESSAGE_ID_1, MESSAGE_ID_2, MESSAGE_ID_3],
"addLabelIds": [LABEL_ID],
"removeLabelIds": []
}
).execute()
```
### Get User Profile
```python
profile = service.users().getProfile(userId="me").execute()
print(f"Email: {profile['emailAddress']}")
print(f"Messages Total: {profile['messagesTotal']}")
print(f"Threads Total: {profile['threadsTotal']}")
```
## Query String Format
Gmail API uses Gmail search syntax for `q` parameter. Common operators:
- `in:inbox`, `in:trash`, `in:spam`, `in:sent`, `in:draft`
- `is:unread`, `is:read`, `is:starred`, `is:important`
- `from:email@example.com`, `to:email@example.com`, `cc:email@example.com`
- `subject:text` - Search in subject line
- `has:attachment` - Only messages with attachments
- `filename:pdf` - Attachment filename
- `before:YYYY/MM/DD`, `after:YYYY/MM/DD`
- `larger:1M`, `smaller:500K` - Message size
- Combine with `AND`, `OR`, `-` (NOT)
Example: `q='is:unread has:attachment from:boss@example.com after:2024/01/01'`
## Message Resource Structure
```json
{
"id": "...", // Message ID
"threadId": "...", // Thread ID
"labelIds": ["INBOX", "..."], // Applied labels
"snippet": "...", // Preview text
"historyId": "...", // History record ID
"internalDate": "...", // Unix timestamp
"payload": {
"partId": "",
"mimeType": "...", // e.g., "text/plain", "multipart/mixed"
"filename": "",
"headers": [
{ "name": "Subject", "value": "..." },
{ "name": "From", "value": "..." },
{ "name": "To", "value": "..." },
{ "name": "Date", "value": "..." }
],
"body": {
"size": 0,
"data": "base64url encoded body"
},
"parts": [...] // For multipart messages
},
"sizeEstimate": 0 // Size in bytes
}
```
## Label Resource Structure
```json
{
"id": "...",
"name": "...",
"messageListVisibility": "show", // or "hide"
"labelListVisibility": "labelShow", // or "labelHide"
"type": "user", // or "system"
"messagesTotal": 0,
"messagesUnread": 0,
"threadsTotal": 0,
"threadsUnread": 0
}
```
## Thread Resource Structure
```json
{
"id": "...",
"historyId": "...",
"messages": [
{ /* Message objects */ }
]
}
```
## Error Handling
```python
from googleapiclient.errors import HttpError
try:
result = service.users().messages().get(
userId="me",
id=MESSAGE_ID
).execute()
except HttpError as error:
print(f"Error {error.resp.status}: {error.content}")
# Common errors:
# 400 - Bad request
# 403 - Forbidden (missing scope or permission)
# 404 - Not found
# 429 - Rate limited
```
---
## Source
- Main Documentation: https://developers.google.com/workspace/gmail/api/guides
- API Reference: https://developers.google.com/workspace/gmail/api/reference/rest
- Python Quickstart: https://developers.google.com/workspace/gmail/api/quickstart/python
- Python Client Docs: https://googleapis.github.io/google-api-python-client/docs/dyn/gmail_v1.html
- Authentication: https://developers.google.com/identity/protocols/oauth2
- Service: gmail.googleapis.com (v1)
@@ -1,407 +0,0 @@
/**
* Gmail API - Node.js/TypeScript Example
*
* Complete working example showing all major operations.
* Save this file and run: npx ts-node example-nodejs.ts
*/
import fs from "fs/promises";
import readline from "readline";
import { google, gmail_v1 } from "googleapis";
import { OAuth2Client } from "google-auth-library";
// Configuration
const SCOPES = ["https://www.googleapis.com/auth/gmail.modify"];
const CREDENTIALS_PATH = "credentials.json";
const TOKEN_PATH = "token.json";
/**
* Authenticate user and return Gmail service
*/
async function authenticate(): Promise<gmail_v1.Gmail> {
try {
const credentialsContent = await fs.readFile(CREDENTIALS_PATH, "utf-8");
const credentials = JSON.parse(credentialsContent);
const { client_id, client_secret, redirect_uris } = credentials.installed;
const oauth2Client = new google.auth.OAuth2(
client_id,
client_secret,
redirect_uris[0]
);
// Check for cached token
try {
const tokenContent = await fs.readFile(TOKEN_PATH, "utf-8");
const token = JSON.parse(tokenContent);
oauth2Client.setCredentials(token);
// Refresh if expired
if (token.expiry_date && token.expiry_date < Date.now()) {
const newToken = await oauth2Client.refreshAccessToken();
oauth2Client.setCredentials(newToken.credentials);
await fs.writeFile(TOKEN_PATH, JSON.stringify(newToken.credentials));
}
console.log("✅ Using cached credentials");
return google.gmail({ version: "v1", auth: oauth2Client });
} catch {
// No token, need to authenticate
console.log("⚠️ No cached token, initiating authentication...");
return authenticateUser(oauth2Client);
}
} catch (error) {
console.error("❌ Authentication failed:", error);
throw error;
}
}
/**
* Get user authorization via browser
*/
async function authenticateUser(
oauth2Client: OAuth2Client
): Promise<gmail_v1.Gmail> {
const authUrl = oauth2Client.generateAuthUrl({
access_type: "offline",
scope: SCOPES,
});
console.log(`\n🔗 Please visit this URL to authorize:\n${authUrl}\n`);
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
return new Promise((resolve, reject) => {
rl.question("Enter the authorization code: ", async (code) => {
rl.close();
try {
const { tokens } = await oauth2Client.getToken(code);
oauth2Client.setCredentials(tokens);
await fs.writeFile(TOKEN_PATH, JSON.stringify(tokens));
console.log("✅ Token saved");
resolve(google.gmail({ version: "v1", auth: oauth2Client }));
} catch (error) {
reject(error);
}
});
});
}
/**
* Get user profile
*/
async function getProfile(gmail: gmail_v1.Gmail) {
console.log("\n📋 Getting user profile...");
const response = await gmail.users.getProfile({ userId: "me" });
const data = response.data;
console.log(` Email: ${data.emailAddress}`);
console.log(` Total Messages: ${data.messagesTotal}`);
console.log(` Total Threads: ${data.threadsTotal}`);
return data;
}
/**
* List all labels
*/
async function listLabels(gmail: gmail_v1.Gmail) {
console.log("\n🏷️ Listing labels...");
const response = await gmail.users.labels.list({ userId: "me" });
const labels = response.data.labels || [];
labels.slice(0, 10).forEach((label) => {
console.log(` - ${label.name} (ID: ${label.id})`);
});
console.log(` ... and ${Math.max(0, labels.length - 10)} more`);
return labels;
}
/**
* List inbox messages
*/
async function listInboxMessages(gmail: gmail_v1.Gmail) {
console.log("\n📧 Listing inbox messages (last 5)...");
const response = await gmail.users.messages.list({
userId: "me",
q: "in:inbox",
maxResults: 5,
});
const messages = response.data.messages || [];
for (const message of messages) {
const msgDetails = await gmail.users.messages.get({
userId: "me",
id: message.id!,
format: "metadata",
metadataHeaders: ["Subject", "From", "Date"],
});
const headers = msgDetails.data.payload?.headers || [];
const subject =
headers.find((h) => h.name === "Subject")?.value || "No Subject";
const from = headers.find((h) => h.name === "From")?.value || "Unknown";
const date = headers.find((h) => h.name === "Date")?.value || "Unknown";
console.log(`\n From: ${from}`);
console.log(` Subject: ${subject}`);
console.log(` Date: ${date}`);
}
return messages;
}
/**
* Search messages
*/
async function searchMessages(gmail: gmail_v1.Gmail, query: string) {
console.log(`\n🔍 Searching for: ${query}`);
const response = await gmail.users.messages.list({
userId: "me",
q: query,
maxResults: 5,
});
const messages = response.data.messages || [];
console.log(` Found ${messages.length} messages`);
return messages;
}
/**
* Get full message details
*/
async function getFullMessage(gmail: gmail_v1.Gmail, messageId: string) {
console.log(`\n📄 Getting full message: ${messageId}`);
const response = await gmail.users.messages.get({
userId: "me",
id: messageId,
format: "full",
});
const message = response.data;
const headers = message.payload?.headers || [];
const bodyData = message.payload?.body?.data || "";
const subject =
headers.find((h) => h.name === "Subject")?.value || "No Subject";
const from = headers.find((h) => h.name === "From")?.value || "Unknown";
const to = headers.find((h) => h.name === "To")?.value || "Unknown";
let body = "";
if (bodyData) {
body = Buffer.from(bodyData, "base64").toString();
}
console.log(` Subject: ${subject}`);
console.log(` From: ${from}`);
console.log(` To: ${to}`);
console.log(` Body length: ${body.length} chars`);
return {
subject,
from,
to,
body: body.substring(0, 500), // First 500 chars
};
}
/**
* Send a message
*/
async function sendMessage(
gmail: gmail_v1.Gmail,
to: string,
subject: string,
body: string
) {
console.log(`\n✉️ Sending message to ${to}...`);
const message =
`From: me\r\n` +
`To: ${to}\r\n` +
`Subject: ${subject}\r\n` +
`\r\n` +
`${body}`;
const encodedMessage = Buffer.from(message)
.toString("base64")
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/, "");
const response = await gmail.users.messages.send({
userId: "me",
requestBody: {
raw: encodedMessage,
},
});
console.log(` ✅ Message sent with ID: ${response.data.id}`);
return response.data;
}
/**
* Create a draft
*/
async function createDraft(
gmail: gmail_v1.Gmail,
to: string,
subject: string,
body: string
) {
console.log(`\n📝 Creating draft for ${to}...`);
const message =
`From: me\r\n` +
`To: ${to}\r\n` +
`Subject: ${subject}\r\n` +
`\r\n` +
`${body}`;
const encodedMessage = Buffer.from(message)
.toString("base64")
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/, "");
const response = await gmail.users.drafts.create({
userId: "me",
requestBody: {
message: {
raw: encodedMessage,
},
},
});
console.log(` ✅ Draft created with ID: ${response.data.id}`);
return response.data;
}
/**
* Apply label to message
*/
async function applyLabelToMessage(
gmail: gmail_v1.Gmail,
messageId: string,
labelName: string
) {
console.log(`\n🏷️ Applying label "${labelName}" to message...`);
const labelsResponse = await gmail.users.labels.list({ userId: "me" });
const label = labelsResponse.data.labels?.find((l) => l.name === labelName);
if (!label) {
console.log(` ❌ Label "${labelName}" not found`);
return null;
}
const response = await gmail.users.messages.modify({
userId: "me",
id: messageId,
requestBody: {
addLabelIds: [label.id!],
},
});
console.log(` ✅ Label applied`);
return response.data;
}
/**
* List threads
*/
async function listThreads(gmail: gmail_v1.Gmail) {
console.log("\n💬 Listing threads (last 3)...");
const response = await gmail.users.threads.list({
userId: "me",
q: "in:inbox",
maxResults: 3,
});
const threads = response.data.threads || [];
for (const thread of threads) {
const threadData = await gmail.users.threads.get({
userId: "me",
id: thread.id!,
format: "metadata",
});
const numMessages = threadData.data.messages?.length || 0;
const firstMsg = threadData.data.messages?.[0];
const headers = firstMsg?.payload?.headers || [];
const subject =
headers.find((h) => h.name === "Subject")?.value || "No Subject";
console.log(
`\n Thread ${thread.id}: ${numMessages} messages - "${subject}"`
);
}
return threads;
}
/**
* Move message to trash
*/
async function moveToTrash(gmail: gmail_v1.Gmail, messageId: string) {
console.log(`\n🗑️ Moving message to trash...`);
const response = await gmail.users.messages.trash({
userId: "me",
id: messageId,
});
console.log(` ✅ Message moved to trash`);
return response.data;
}
/**
* Main demo function
*/
async function main() {
console.log("🚀 Gmail API - Node.js/TypeScript Example\n");
try {
// Authenticate
const gmail = await authenticate();
// Run examples
await getProfile(gmail);
await listLabels(gmail);
await listInboxMessages(gmail);
// Search example
await searchMessages(gmail, "is:unread");
// Get first message details
const messages = await listInboxMessages(gmail);
if (messages.length > 0) {
await getFullMessage(gmail, messages[0].id!);
}
// Draft example (uncomment to use)
// await createDraft(
// gmail,
// "recipient@example.com",
// "Test Draft",
// "This is a test draft created by the example script."
// );
// List threads
await listThreads(gmail);
console.log("\n✅ All examples completed!");
} catch (error) {
console.error("\n❌ Error:", error);
process.exit(1);
}
}
// Run if executed directly
main();
@@ -1,25 +0,0 @@
{
"name": "gmail-api-nodejs",
"version": "1.0.0",
"description": "Gmail API integration with Node.js/TypeScript",
"main": "index.js",
"type": "module",
"scripts": {
"dev": "ts-node example-nodejs.ts",
"example": "npx ts-node example-nodejs.ts",
"build": "tsc",
"start": "node dist/index.js"
},
"dependencies": {
"googleapis": "^118.0.0",
"google-auth-library": "^8.9.0"
},
"devDependencies": {
"@types/node": "^20.0.0",
"typescript": "^5.0.0",
"ts-node": "^10.9.0"
},
"engines": {
"node": ">=16.0.0"
}
}
@@ -1,18 +0,0 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"lib": ["ES2020"],
"declaration": true,
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"moduleResolution": "node"
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}
-343
View File
@@ -1,343 +0,0 @@
---
name: mlxaudio
description: Generate speech from text and transcribe audio using mlx-audio. Use when the user wants text-to-speech synthesis, speech-to-text transcription, voice cloning, audio separation, or speech-to-speech processing on Apple Silicon.
---
# MLX-Audio
A speech processing library built on Apple's MLX framework, providing TTS, STT, speech-to-speech (STS), and audio separation optimized for Apple Silicon.
- **Repository:** https://github.com/Blaizzy/mlx-audio
- **License:** MIT
## CLI Tools
### Text-to-Speech (TTS)
```bash
mlx_audio.tts.generate --model <model> --text '<text>' [options]
```
| Flag | Type | Default | Description |
|------|------|---------|-------------|
| `--model` | string | required | HuggingFace model ID |
| `--text` | string | required | Text to synthesize |
| `--voice` | string | — | Voice preset (model-specific) |
| `--speed` | float | 1.0 | Speech speed multiplier |
| `--lang_code` | string | `a` | Language code |
| `--play` | flag | — | Play audio immediately |
| `--output_path` | string | — | Directory to save audio |
| `--ref_audio` | string | — | Reference audio for voice cloning (CSM) |
#### Language Codes
| Code | Language |
|------|----------|
| `a` | American English |
| `b` | British English |
| `j` | Japanese |
| `z` | Mandarin Chinese |
| `e` | Spanish |
| `f` | French |
#### Kokoro Voices
| Voice | Description |
|-------|-------------|
| `af_heart`, `af_bella`, `af_nova`, `af_sky` | American female |
| `am_adam`, `am_echo` | American male |
| `bf_alice`, `bf_emma` | British female |
| `bm_daniel`, `bm_george` | British male |
| `jf_alpha`, `jm_kumo` | Japanese |
| `zf_xiaobei`, `zm_yunxi` | Chinese |
#### Examples
```bash
# Basic generation
mlx_audio.tts.generate --model mlx-community/Kokoro-82M-bf16 --text 'Hello, world!' --lang_code a
# With voice and speed
mlx_audio.tts.generate --model mlx-community/Kokoro-82M-bf16 --text 'Hello!' --voice af_heart --speed 1.2 --lang_code a
# Play immediately
mlx_audio.tts.generate --model mlx-community/Kokoro-82M-bf16 --text 'Hello!' --play --lang_code a
# Voice cloning with CSM
mlx_audio.tts.generate --model mlx-community/csm-1b --text "Hello from Sesame." --ref_audio ./reference_voice.wav --play
```
### Speech-to-Text (STT)
```bash
python -m mlx_audio.stt.generate --model <model> --audio <file> [options]
```
| Flag | Type | Default | Description |
|------|------|---------|-------------|
| `--model` | string | required | HuggingFace model ID |
| `--audio` | string | required | Input audio file |
| `--language` | string | — | Language code |
| `--max-tokens` | int | 1024 | Maximum output tokens |
| `--temperature` | float | 0.0 | Sampling temperature |
| `--context` | string | — | Hotwords/metadata for context |
| `--output-path` | string | — | Output directory |
| `--format` | string | — | Output format (e.g. `json`) |
| `--stream` | flag | — | Enable streaming mode |
| `--verbose` | flag | — | Detailed logging |
#### Examples
```bash
# Basic transcription
python -m mlx_audio.stt.generate --model mlx-community/whisper-large-v3-turbo-asr-fp16 --audio speech.wav --verbose
# With context for technical terms
python -m mlx_audio.stt.generate --model mlx-community/VibeVoice-ASR-bf16 --audio meeting.wav --context "MLX, Apple Silicon, PyTorch" --max-tokens 8192 --format json --verbose
# Parakeet model
python -m mlx_audio.stt.generate --model mlx-community/parakeet-tdt-0.6b-v3 --audio speech.wav --format json --verbose
```
---
## Python API
### TTS
```python
from mlx_audio.tts.utils import load_model
model = load_model("mlx-community/Kokoro-82M-bf16")
for result in model.generate("Hello from MLX-Audio!", voice="af_heart"):
audio = result.audio # mx.array waveform
```
### STT
```python
from mlx_audio.stt.generate import generate_transcription
result = generate_transcription(
model="mlx-community/whisper-large-v3-turbo-asr-fp16",
audio="audio.wav",
)
print(result.text)
```
### STT with Streaming
```python
from mlx_audio.stt import load
# VibeVoice-ASR streaming
model = load("mlx-community/VibeVoice-ASR-bf16")
for text in model.stream_transcribe(audio="speech.wav", max_tokens=4096):
print(text, end="", flush=True)
# Parakeet streaming
model = load("mlx-community/parakeet-tdt-0.6b-v3")
for chunk in model.generate("long_audio.wav", stream=True):
print(chunk.text, end="", flush=True)
```
### Forced Alignment (Qwen3)
```python
from mlx_audio.stt import load
aligner = load("mlx-community/Qwen3-ForcedAligner-0.6B-8bit")
result = aligner.generate("audio.wav", text="I have a dream", language="English")
for item in result:
print(f"[{item.start_time:.2f}s - {item.end_time:.2f}s] {item.text}")
```
---
## REST API Server (OpenAI-compatible)
### Starting the Server
```bash
python -m mlx_audio.server [OPTIONS]
```
| Flag | Type | Default | Description |
|------|------|---------|-------------|
| `--host` | string | `localhost` | Server host |
| `--port` | int | `8000` | Server port |
| `--allowed-origins` | string | `*` | CORS allowed origins |
| `--workers` | int/float | `2` | Number of workers |
| `--reload` | flag | — | Enable auto-reload |
| `--start-ui` | flag | — | Launch Studio UI alongside API |
| `--log-dir` | string | `logs` | Directory for server logs |
### Endpoints
#### GET /v1/models
List available models.
```bash
curl http://localhost:8000/v1/models
```
#### POST /v1/models?model_name=\<name\>
Add a model to the server.
#### DELETE /v1/models?model_name=\<name\>
Remove a model from the server.
#### POST /v1/audio/speech
Generate speech from text.
```bash
curl -X POST http://localhost:8000/v1/audio/speech \
-H "Content-Type: application/json" \
-d '{
"model": "mlx-community/Kokoro-82M-bf16",
"input": "Hello, world!",
"voice": "af_heart",
"speed": 1.0,
"lang_code": "a",
"response_format": "mp3"
}' --output speech.mp3
```
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `model` | string | required | Model ID |
| `input` | string | required | Text to synthesize |
| `voice` | string | — | Voice preset |
| `speed` | float | 1.0 | Speech speed |
| `lang_code` | string | `a` | Language code |
| `ref_audio` | string | — | Reference audio path (voice cloning) |
| `ref_text` | string | — | Reference transcript |
| `response_format` | string | `mp3` | Output format |
| `stream` | bool | false | Enable streaming |
| `streaming_interval` | float | 2.0 | Streaming chunk interval |
| `temperature` | float | 0.7 | Sampling temperature |
| `top_p` | float | 0.95 | Nucleus sampling |
| `top_k` | int | 40 | Top-k sampling |
| `repetition_penalty` | float | 1.0 | Repetition penalty |
| `max_tokens` | int | 1200 | Maximum tokens |
| `gender` | string | `male` | Gender hint |
| `pitch` | float | 1.0 | Pitch adjustment |
| `instruct` | string | — | Instruction text |
#### POST /v1/audio/transcriptions
Transcribe an audio file (multipart/form-data).
```bash
curl -X POST http://localhost:8000/v1/audio/transcriptions \
-F file=@audio.wav \
-F model=mlx-community/whisper-large-v3-turbo-asr-fp16
```
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `file` | file | required | Audio file |
| `model` | string | required | Model ID |
| `language` | string | — | Language code |
| `max_tokens` | int | 1024 | Maximum tokens |
| `chunk_duration` | float | 30.0 | Chunk duration (seconds) |
| `stream` | bool | false | Enable streaming |
| `context` | string | — | Hotwords/context |
| `text` | string | — | Reference text |
| `verbose` | bool | false | Detailed output |
Response (NDJSON stream):
```json
{"text": "chunk text", "accumulated": "full text so far"}
```
#### WebSocket /v1/audio/transcriptions/realtime
Real-time transcription via WebSocket. Send initial config as JSON, then stream int16 PCM audio as binary frames.
```json
{
"model": "mlx-community/whisper-large-v3-turbo-asr-fp16",
"sample_rate": 16000,
"streaming": true
}
```
#### POST /v1/audio/separations
Separate audio sources (multipart/form-data).
```bash
curl -X POST http://localhost:8000/v1/audio/separations \
-F file=@audio.wav \
-F model=mlx-community/sam-audio-large-fp16 \
-F description="speech"
```
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `file` | file | required | Audio file |
| `model` | string | `mlx-community/sam-audio-large-fp16` | Model ID |
| `description` | string | `speech` | Target description |
| `method` | string | `midpoint` | ODE method (`midpoint` or `euler`) |
| `steps` | int | 16 | ODE steps (2/4/8/16/32) |
Response:
```json
{
"target": "<base64 WAV>",
"residual": "<base64 WAV>",
"sample_rate": 44100
}
```
---
## Supported Models
### TTS Models
| Model | Languages | Notes |
|-------|-----------|-------|
| Kokoro | EN, JA, ZH, FR, ES, IT, PT, HI | Fast, high-quality multilingual |
| Qwen3-TTS | ZH, EN, JA, KO, + more | Voice design via instruction |
| CSM | EN | Voice cloning with reference audio |
| Dia | EN | Dialogue-focused |
| OuteTTS | EN | Efficient |
| Spark | EN, ZH | SparkTTS |
| Chatterbox | EN, ES, FR, DE, IT, PT, PL, TR, RU, NL, CS, AR, ZH, JA, HU, KO | Expressive multilingual |
| Soprano | EN | High-quality |
### STT Models
| Model | Languages | Notes |
|-------|-----------|-------|
| Whisper | 99+ languages | OpenAI's robust model |
| Qwen3-ASR | ZH, EN, JA, KO, + more | Alibaba multilingual |
| Qwen3-ForcedAligner | ZH, EN, JA, KO, + more | Word-level alignment |
| Parakeet | EN (v2), 25 EU languages (v3) | NVIDIA, high accuracy |
| Voxtral | Multiple | Mistral speech model |
| Voxtral Realtime | Multiple | 4B streaming STT |
| VibeVoice-ASR | Multiple | Microsoft 9B, supports diarization and context |
### Other Models
| Model | Type | Description |
|-------|------|-------------|
| Sortformer v1/v2.1 | VAD/Diarization | Speaker diarization (up to 4 speakers) |
| SAM-Audio | Separation | Text-guided source separation |
| Liquid2.5-Audio | STS | Speech/text-to-speech and STT |
| MossFormer2 SE | Enhancement | Speech enhancement / noise removal |
Models are available from `mlx-community` on HuggingFace with various quantization levels (3-bit through 8-bit and fp16/bf16).
## Source
- Repository: https://github.com/Blaizzy/mlx-audio
- HuggingFace: https://huggingface.co/mlx-community
-533
View File
@@ -1,533 +0,0 @@
---
name: mutagen
description: Read and write audio metadata using the mutagen Python library. Use when the user wants to read, edit, embed, or remove tags (title, artist, album, cover art, lyrics, etc.) in MP3, FLAC, MP4/M4A, OGG, and other audio files.
---
# Mutagen
API reference for mutagen — a Python library for reading and writing audio metadata (tags) across multiple formats.
No dependencies outside the Python standard library. Supports Python 3.10+ (CPython and PyPy).
Official docs: https://mutagen.readthedocs.io
Repository: https://github.com/quodlibet/mutagen
## Installation
```bash
pip install mutagen
```
## Supported Formats
| Format | Class | Tag System |
|--------|-------|------------|
| MP3 | `mutagen.mp3.MP3` / `EasyMP3` | ID3v2 |
| FLAC | `mutagen.flac.FLAC` | Vorbis Comments |
| MP4 / M4A | `mutagen.mp4.MP4` / `EasyMP4` | iTunes-style atoms |
| Ogg Vorbis | `mutagen.oggvorbis.OggVorbis` | Vorbis Comments |
| Ogg Opus | `mutagen.oggopus.OggOpus` | Vorbis Comments |
| Ogg FLAC | `mutagen.oggflac.OggFLAC` | Vorbis Comments |
| Ogg Speex | `mutagen.oggspeex.OggSpeex` | Vorbis Comments |
| Ogg Theora | `mutagen.oggtheora.OggTheora` | Vorbis Comments |
| ASF / WMA | `mutagen.asf.ASF` | ASF attributes |
| AIFF | `mutagen.aiff.AIFF` | ID3v2 |
| WavPack | `mutagen.wavpack.WavPack` | APEv2 |
| Musepack | `mutagen.musepack.Musepack` | APEv2 |
| Monkey's Audio | `mutagen.monkeysaudio.MonkeysAudio` | APEv2 |
| True Audio | `mutagen.trueaudio.TrueAudio` | ID3v2 / APEv2 |
| OptimFROG | `mutagen.optimfrog.OptimFROG` | APEv2 |
## Core API
### Auto-Detection with `mutagen.File()`
```python
import mutagen
audio = mutagen.File("song.mp3") # auto-detects format
print(audio.info.length) # duration in seconds
print(audio.tags) # tag object (format-specific)
```
`mutagen.File()` returns the appropriate `FileType` subclass, or `None` if unrecognized.
Pass `easy=True` to get simplified tag access (EasyID3/EasyMP4):
```python
audio = mutagen.File("song.mp3", easy=True)
audio["title"] = ["My Song"]
audio.save()
```
### FileType (Base Class)
All format classes inherit from `FileType` and share this interface:
| Attribute / Method | Description |
|--------------------|-------------|
| `.info` | `StreamInfo` object — `length`, `bitrate`, `sample_rate`, `channels` |
| `.tags` | Tag object (dict-like), or `None` if no tags |
| `.mime` | List of applicable MIME types |
| `.save()` | Write tags to file |
| `.delete()` | Remove all tags from file |
| `.add_tags()` | Create new empty tag object (raises error if tags exist) |
| `.pprint()` | Human-readable stream info and tags |
---
## ID3 Tags (MP3, AIFF, TrueAudio)
### Reading / Writing with Raw ID3
```python
from mutagen.mp3 import MP3
from mutagen.id3 import ID3, TIT2, TPE1, TALB, TRCK, TDRC, TCON, APIC, COMM, USLT
audio = MP3("song.mp3")
# Read
print(audio["TIT2"].text[0]) # title
print(audio["TPE1"].text[0]) # artist
# Write
audio["TIT2"] = TIT2(encoding=3, text=["My Title"])
audio["TPE1"] = TPE1(encoding=3, text=["My Artist"])
audio.save()
```
### Common ID3 Frames
| Frame | Class | Description | Constructor |
|-------|-------|-------------|-------------|
| `TIT2` | TextFrame | Title | `TIT2(encoding=3, text=["..."])` |
| `TPE1` | TextFrame | Artist / Performer | `TPE1(encoding=3, text=["..."])` |
| `TPE2` | TextFrame | Album Artist | `TPE2(encoding=3, text=["..."])` |
| `TALB` | TextFrame | Album | `TALB(encoding=3, text=["..."])` |
| `TRCK` | NumericPartTextFrame | Track number (`"N/Total"`) | `TRCK(encoding=3, text=["1/12"])` |
| `TPOS` | NumericPartTextFrame | Disc number (`"N/Total"`) | `TPOS(encoding=3, text=["1/2"])` |
| `TDRC` | TimeStampTextFrame | Recording date | `TDRC(encoding=3, text=["2024"])` |
| `TCON` | TextFrame | Genre | `TCON(encoding=3, text=["Rock"])` |
| `TCOM` | TextFrame | Composer | `TCOM(encoding=3, text=["..."])` |
| `TBPM` | NumericTextFrame | BPM | `TBPM(encoding=3, text=["120"])` |
| `COMM` | TextFrame | Comment | `COMM(encoding=3, lang="eng", desc="", text=["..."])` |
| `USLT` | TextFrame | Lyrics | `USLT(encoding=3, lang="eng", desc="", text="...")` |
| `APIC` | Frame | Attached picture | `APIC(encoding=3, mime="image/jpeg", type=3, desc="", data=bytes)` |
### Encoding Values
| Value | Encoding |
|-------|----------|
| `0` | Latin-1 |
| `1` | UTF-16 |
| `2` | UTF-16BE |
| `3` | UTF-8 (recommended) |
### APIC Picture Types
| Value | Meaning |
|-------|---------|
| `0` | Other |
| `3` | Cover (front) |
| `4` | Cover (back) |
| `6` | Media (e.g. label side of CD) |
### ID3 Methods
| Method | Description |
|--------|-------------|
| `.add(frame)` | Add a frame (replaces matching frame) |
| `.getall(key)` | Get all frames matching key prefix |
| `.delall(key)` | Delete all frames matching key prefix |
| `.update_to_v23()` | Convert tags to ID3v2.3 (call before saving as v2.3) |
| `.update_to_v24()` | Convert tags to ID3v2.4 |
| `.save(v2_version=4)` | Save; set `v2_version=3` for ID3v2.3 |
### EasyID3 (Simplified Interface)
```python
from mutagen.easyid3 import EasyID3
audio = EasyID3("song.mp3")
audio["title"] = ["My Title"]
audio["artist"] = ["My Artist"]
audio["album"] = ["My Album"]
audio["tracknumber"] = ["1/12"]
audio["date"] = ["2024"]
audio["genre"] = ["Rock"]
audio.save()
```
Available EasyID3 keys: `title`, `artist`, `albumartist`, `album`, `tracknumber`, `discnumber`, `date`, `genre`, `composer`, `bpm`, `length`, `organization`, `website`, and more.
---
## MP3 Stream Info
```python
from mutagen.mp3 import MP3
audio = MP3("song.mp3")
info = audio.info
```
| Attribute | Type | Description |
|-----------|------|-------------|
| `info.length` | float | Duration in seconds |
| `info.bitrate` | int | Bits per second |
| `info.sample_rate` | int | Sampling frequency (Hz) |
| `info.channels` | int | Number of channels |
| `info.bitrate_mode` | BitrateMode | `UNKNOWN`, `CBR`, `VBR`, `ABR` |
| `info.encoder_info` | str | Encoder name/version |
| `info.track_gain` | float\|None | ReplayGain track gain |
| `info.track_peak` | float\|None | ReplayGain track peak |
| `info.album_gain` | float\|None | ReplayGain album gain |
---
## FLAC
```python
from mutagen.flac import FLAC
audio = FLAC("song.flac")
```
FLAC uses Vorbis Comments — tags are simple string key-value pairs (case-insensitive keys, multiple values per key).
### Reading / Writing Tags
```python
audio["title"] = ["My Title"]
audio["artist"] = ["My Artist"]
audio["album"] = ["My Album"]
audio["tracknumber"] = ["1"]
audio["date"] = ["2024"]
audio.save()
```
### Stream Info
| Attribute | Type | Description |
|-----------|------|-------------|
| `info.length` | float | Duration in seconds |
| `info.bitrate` | int | Bits per second |
| `info.sample_rate` | int | Sampling frequency (Hz) |
| `info.channels` | int | Number of channels |
| `info.bits_per_sample` | int | Bit depth |
| `info.total_samples` | int | Total number of samples |
### Embedded Pictures
```python
from mutagen.flac import FLAC, Picture
audio = FLAC("song.flac")
# Add picture
pic = Picture()
with open("cover.jpg", "rb") as f:
pic.data = f.read()
pic.type = 3 # front cover
pic.mime = "image/jpeg"
pic.width = 500
pic.height = 500
pic.depth = 24
audio.add_picture(pic)
audio.save()
# Read pictures
for pic in audio.pictures:
print(pic.mime, pic.type, len(pic.data))
# Remove all pictures
audio.clear_pictures()
audio.save()
```
---
## MP4 / M4A
```python
from mutagen.mp4 import MP4
audio = MP4("song.m4a")
```
### Common Tag Keys
| Key | Description |
|-----|-------------|
| `"\xa9nam"` | Title |
| `"\xa9ART"` | Artist |
| `"\xa9alb"` | Album |
| `"aART"` | Album artist |
| `"\xa9wrt"` | Composer |
| `"\xa9gen"` | Genre |
| `"\xa9day"` | Year / Date |
| `"\xa9lyr"` | Lyrics |
| `"\xa9cmt"` | Comment |
| `"trkn"` | Track number — `[(track, total)]` |
| `"disk"` | Disc number — `[(disc, total)]` |
| `"tmpo"` | BPM — `[120]` |
| `"cpil"` | Compilation — `True`/`False` |
| `"pgap"` | Gapless playback — `True`/`False` |
| `"covr"` | Cover art — list of `MP4Cover` objects |
### Reading / Writing Tags
```python
audio["\xa9nam"] = ["My Title"]
audio["\xa9ART"] = ["My Artist"]
audio["trkn"] = [(1, 12)]
audio.save()
```
### Cover Art
```python
from mutagen.mp4 import MP4, MP4Cover
audio = MP4("song.m4a")
# Add cover
with open("cover.jpg", "rb") as f:
cover = MP4Cover(f.read(), imageformat=MP4Cover.FORMAT_JPEG)
audio["covr"] = [cover]
audio.save()
# Read cover
for cover in audio["covr"]:
print(cover.imageformat) # FORMAT_JPEG or FORMAT_PNG
# cover is bytes-like — write directly to file
```
### MP4 Cover Formats
| Constant | Value |
|----------|-------|
| `MP4Cover.FORMAT_JPEG` | JPEG |
| `MP4Cover.FORMAT_PNG` | PNG |
### Stream Info
| Attribute | Type | Description |
|-----------|------|-------------|
| `info.length` | float | Duration in seconds |
| `info.bitrate` | int | Bits per second |
| `info.sample_rate` | int | Sampling frequency (Hz) |
| `info.channels` | int | Number of channels |
| `info.bits_per_sample` | int | Bit depth |
| `info.codec` | str | Codec identifier (e.g. `"mp4a.40.2"`, `"alac"`) |
| `info.codec_description` | str | Human-readable codec name |
### EasyMP4
```python
from mutagen.easymp4 import EasyMP4
audio = EasyMP4("song.m4a")
audio["title"] = ["My Title"]
audio["artist"] = ["My Artist"]
audio.save()
```
---
## Ogg Vorbis
```python
from mutagen.oggvorbis import OggVorbis
audio = OggVorbis("song.ogg")
```
Uses Vorbis Comments — same string key-value interface as FLAC:
```python
audio["title"] = ["My Title"]
audio["artist"] = ["My Artist"]
audio.save()
```
### Stream Info
| Attribute | Type | Description |
|-----------|------|-------------|
| `info.length` | float | Duration in seconds |
| `info.bitrate` | int | Nominal bitrate (bits/s) |
| `info.sample_rate` | int | Sampling frequency (Hz) |
| `info.channels` | int | Number of channels |
---
## Ogg Opus
```python
from mutagen.oggopus import OggOpus
audio = OggOpus("song.opus")
audio["title"] = ["My Title"]
audio.save()
```
Same Vorbis Comments interface. Stream info includes `info.length`, `info.channels`.
---
## Common Recipes
### Read all tags (any format)
```python
import mutagen
audio = mutagen.File("song.mp3")
for key, value in audio.tags.items():
print(f"{key}: {value}")
```
### Set title and artist (any format, easy mode)
```python
import mutagen
audio = mutagen.File("song.mp3", easy=True)
audio["title"] = ["My Title"]
audio["artist"] = ["My Artist"]
audio.save()
```
### Embed cover art in MP3
```python
from mutagen.mp3 import MP3
from mutagen.id3 import ID3, APIC
audio = MP3("song.mp3")
if audio.tags is None:
audio.add_tags()
with open("cover.jpg", "rb") as f:
audio.tags.add(APIC(
encoding=3,
mime="image/jpeg",
type=3, # front cover
desc="Cover",
data=f.read()
))
audio.save()
```
### Extract cover art from MP3
```python
from mutagen.mp3 import MP3
audio = MP3("song.mp3")
for tag in audio.tags.getall("APIC"):
with open("extracted_cover.jpg", "wb") as f:
f.write(tag.data)
```
### Embed cover art in FLAC
```python
from mutagen.flac import FLAC, Picture
audio = FLAC("song.flac")
pic = Picture()
with open("cover.jpg", "rb") as f:
pic.data = f.read()
pic.type = 3
pic.mime = "image/jpeg"
pic.width = 500
pic.height = 500
pic.depth = 24
audio.add_picture(pic)
audio.save()
```
### Embed cover art in MP4/M4A
```python
from mutagen.mp4 import MP4, MP4Cover
audio = MP4("song.m4a")
with open("cover.jpg", "rb") as f:
audio["covr"] = [MP4Cover(f.read(), imageformat=MP4Cover.FORMAT_JPEG)]
audio.save()
```
### Add lyrics to MP3
```python
from mutagen.mp3 import MP3
from mutagen.id3 import USLT
audio = MP3("song.mp3")
audio.tags.add(USLT(encoding=3, lang="eng", desc="", text="Lyrics here..."))
audio.save()
```
### Remove all tags
```python
import mutagen
audio = mutagen.File("song.mp3")
audio.delete()
audio.save()
```
### Copy tags between files
```python
from mutagen.easyid3 import EasyID3
src = EasyID3("source.mp3")
dst = EasyID3("dest.mp3")
for key in src:
dst[key] = src[key]
dst.save()
```
### Batch-read metadata from directory
```python
import mutagen
from pathlib import Path
for path in Path(".").glob("*.mp3"):
audio = mutagen.File(str(path), easy=True)
if audio and audio.tags:
title = audio.tags.get("title", ["Unknown"])[0]
artist = audio.tags.get("artist", ["Unknown"])[0]
print(f"{path.name}: {artist} - {title}")
```
### Save as ID3v2.3 (compatibility)
```python
from mutagen.mp3 import MP3
audio = MP3("song.mp3")
audio.tags.update_to_v23()
audio.save(v2_version=3)
```
---
## Source
- Repository: https://github.com/quodlibet/mutagen
- Documentation: https://mutagen.readthedocs.io/en/latest/
- PyPI: https://pypi.org/project/mutagen/
- API — Base: https://mutagen.readthedocs.io/en/latest/api/base.html
- API — ID3: https://mutagen.readthedocs.io/en/latest/api/id3.html
- API — ID3 Frames: https://mutagen.readthedocs.io/en/latest/api/id3_frames.html
- API — MP3: https://mutagen.readthedocs.io/en/latest/api/mp3.html
- API — MP4: https://mutagen.readthedocs.io/en/latest/api/mp4.html
- API — FLAC: https://mutagen.readthedocs.io/en/latest/api/flac.html
- API — Ogg Vorbis: https://mutagen.readthedocs.io/en/latest/api/oggvorbis.html
-725
View File
@@ -1,725 +0,0 @@
---
name: sharp
description: Process images using the sharp Node.js library. Use when the user wants to resize, convert, crop, composite, transform, or optimize images programmatically.
---
# Sharp
API reference for sharp — a high-performance Node.js image processing library built on libvips.
Typically 4-5x faster than ImageMagick/GraphicsMagick. Supports JPEG, PNG, WebP, GIF, AVIF, TIFF, SVG, HEIF, JP2, and JXL.
Official docs: https://sharp.pixelplumbing.com
Repository: https://github.com/lovell/sharp
## Installation
```bash
npm install sharp
```
Requires Node.js ^18.17.0 or >= 20.3.0 (or Deno/Bun with Node-API v9).
## Usage
Sharp uses a fluent, chainable API. Every call returns a Sharp instance.
```js
import sharp from 'sharp';
await sharp('input.jpg')
.resize(800, 600)
.jpeg({ quality: 80 })
.toFile('output.jpg');
```
Sharp implements `stream.Duplex` — it can be piped to/from.
---
## Constructor
```js
sharp([input], [options])
```
- `input` (Buffer | string | Array): Image buffer, file path, array of inputs, or omit for stream input.
### Options
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `failOn` | string | `'warning'` | `'none'`, `'truncated'`, `'error'`, `'warning'` |
| `limitInputPixels` | number \| boolean | `268402689` | Max pixels; `false` to disable |
| `unlimited` | boolean | `false` | Remove memory safety for JPEG/PNG/SVG/HEIF |
| `autoOrient` | boolean | `false` | Auto-rotate per EXIF Orientation |
| `sequentialRead` | boolean | `true` | Sequential vs random access |
| `density` | number | `72` | DPI for vector images (1-100000) |
| `ignoreIcc` | boolean | `false` | Ignore embedded ICC profile |
| `pages` | number | `1` | Pages to extract; `-1` for all |
| `page` | number | `0` | Starting page (zero-based) |
| `animated` | boolean | `false` | Read all frames (equiv. `pages: -1`) |
### Raw Input
```js
sharp(buffer, { raw: { width: 100, height: 100, channels: 4 } })
```
| Property | Type | Description |
|----------|------|-------------|
| `width` | number | Pixel width |
| `height` | number | Pixel height |
| `channels` | number | 1-4 |
| `premultiplied` | boolean | Skip premultiplication (default `false`) |
### Create New Image
```js
sharp({ create: { width: 300, height: 200, channels: 4, background: '#ff0000' } })
```
| Property | Type | Description |
|----------|------|-------------|
| `width` | number | Pixel width |
| `height` | number | Pixel height |
| `channels` | number | 3 (RGB) or 4 (RGBA) |
| `background` | string \| Object | Color (parsed by color module) |
| `noise` | Object | `{ type: 'gaussian', mean: 128, sigma: 30 }` |
### Render Text
```js
sharp({ text: { text: 'Hello', font: 'Arial', dpi: 150 } })
```
| Property | Type | Default | Description |
|----------|------|---------|-------------|
| `text` | string | -- | UTF-8; supports Pango markup |
| `font` | string | -- | Font name |
| `fontfile` | string | -- | Absolute path to font file |
| `width` | number | `0` | Word-wrap boundary; 0 = no wrap |
| `height` | number | `0` | Max height |
| `align` | string | `'left'` | `'left'`, `'centre'`, `'center'`, `'right'` |
| `justify` | boolean | `false` | Text justification |
| `dpi` | number | `72` | Render resolution |
| `rgba` | boolean | `false` | RGBA for color emoji/Pango markup |
| `spacing` | number | `0` | Line height in points |
| `wrap` | string | `'word'` | `'word'`, `'char'`, `'word-char'`, `'none'` |
### Join Array
```js
sharp([img1, img2, img3], { join: { across: 3, shim: 10 } })
```
| Property | Type | Default | Description |
|----------|------|---------|-------------|
| `across` | number | `1` | Images per row |
| `animated` | boolean | `false` | Join as animated image |
| `shim` | number | `0` | Pixel gap between images |
| `background` | string \| Object | -- | Gap fill color |
| `halign` | string | `'left'` | `'left'`, `'centre'`, `'right'` |
| `valign` | string | `'top'` | `'top'`, `'centre'`, `'bottom'` |
### Clone
```js
const pipeline = sharp('input.jpg');
const clone1 = pipeline.clone().resize(200).toFile('thumb.jpg');
const clone2 = pipeline.clone().resize(800).toFile('large.jpg');
```
---
## Resize
```js
.resize([width], [height], [options])
```
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `width` | number | -- | Target width (null to auto-scale) |
| `height` | number | -- | Target height (null to auto-scale) |
| `fit` | string | `'cover'` | `'cover'`, `'contain'`, `'fill'`, `'inside'`, `'outside'` |
| `position` | string | `'centre'` | Gravity/position for cover/contain |
| `background` | string \| Object | `{r:0,g:0,b:0,alpha:1}` | Fill color for `contain` |
| `kernel` | string | `'lanczos3'` | `'nearest'`, `'linear'`, `'cubic'`, `'mitchell'`, `'lanczos2'`, `'lanczos3'` |
| `withoutEnlargement` | boolean | `false` | Don't upscale |
| `withoutReduction` | boolean | `false` | Don't downscale |
| `fastShrinkOnLoad` | boolean | `true` | JPEG/WebP shrink-on-load |
**Fit modes:**
- `cover` — crop to fill both dimensions
- `contain` — letterbox within dimensions
- `fill` — stretch to exact dimensions (ignores aspect ratio)
- `inside` — fit within, no exceeding
- `outside` — minimum size meeting both dimensions
**Position values:** `top`, `right top`, `right`, `right bottom`, `bottom`, `left bottom`, `left`, `left top`, `north`, `northeast`, `east`, `southeast`, `south`, `southwest`, `west`, `northwest`, `centre`/`center`
**Strategy (cover only):** `entropy`, `attention`
Only one resize per pipeline.
---
## Operations
### Rotation & Orientation
| Method | Description |
|--------|-------------|
| `.rotate([angle], [options])` | Rotate by degrees; omit angle for EXIF auto-rotate. `options.background` for fill color |
| `.autoOrient()` | Auto-orient from EXIF, then remove Orientation tag |
| `.flip([flip])` | Vertical mirror (default `true`) |
| `.flop([flop])` | Horizontal mirror (default `true`) |
### Transform
| Method | Description |
|--------|-------------|
| `.affine(matrix, [options])` | 2x2 affine transform. Options: `background`, `idx`, `idy`, `odx`, `ody`, `interpolator` |
| `.extend(extend)` | Add padding. Number for uniform, or `{ top, right, bottom, left, extendWith, background }`. `extendWith`: `'background'`, `'copy'`, `'repeat'`, `'mirror'` |
| `.extract({ left, top, width, height })` | Crop region. Can be called before and/or after resize |
| `.trim([options])` | Auto-crop to content. Options: `background` (default top-left pixel), `threshold` (default `10`), `lineArt` |
### Enhancement
| Method | Description |
|--------|-------------|
| `.sharpen([options])` | Sharpen. `options.sigma` (0.000001-10), `.m1` (flat), `.m2` (jagged), `.x1`, `.y2`, `.y3` |
| `.blur([options])` | No args: 3x3 box blur. `options.sigma` (0.3-1000) for Gaussian. Options: `precision`, `minAmplitude` |
| `.median([size])` | Median filter, default 3x3 |
| `.gamma([gamma], [gammaOut])` | Gamma correction (1.0-3.0, default 2.2) |
| `.normalise([options])` | Stretch luminance. `options.lower` (default `1`), `.upper` (default `99`) percentiles |
| `.clahe({ width, height, [maxSlope] })` | Contrast Limited Adaptive Histogram Equalization |
### Morphology
| Method | Description |
|--------|-------------|
| `.dilate([width])` | Dilation, default 1px |
| `.erode([width])` | Erosion, default 1px |
### Pixel Operations
| Method | Description |
|--------|-------------|
| `.negate([options])` | Invert colors. `options.alpha` (default `true`) |
| `.threshold([value], [options])` | Binarize at threshold (0-255, default 128). `options.greyscale` (default `true`) |
| `.boolean(operand, operator)` | Bitwise op with another image: `'and'`, `'or'`, `'eor'` |
| `.linear([a], [b])` | Per-channel linear transform: `a * pixel + b` |
| `.recomb(matrix)` | 3x3 or 4x4 color recombination matrix |
| `.modulate([options])` | Adjust `brightness` (multiply), `saturation` (multiply), `hue` (degrees), `lightness` (add) |
| `.convolve(kernel)` | Custom convolution: `{ width, height, kernel, scale, offset }` |
| `.flatten([options])` | Merge alpha with `options.background`, remove alpha |
| `.unflatten()` | Add alpha; white becomes transparent (experimental) |
---
## Colour
| Method | Description |
|--------|-------------|
| `.tint(color)` | Apply tint, preserving alpha |
| `.greyscale([bool])` | Convert to 8-bit greyscale (alias: `.grayscale()`) |
| `.pipelineColourspace(space)` | Set pipeline colorspace (e.g. `'rgb16'`, `'lab'`, `'grey16'`) |
| `.toColourspace(space)` | Set output colorspace (e.g. `'srgb'`, `'cmyk'`, `'b-w'`) |
---
## Channel
| Method | Description |
|--------|-------------|
| `.removeAlpha()` | Remove alpha channel |
| `.ensureAlpha([alpha])` | Add alpha if missing. `alpha`: 0 (transparent) to 1 (opaque, default) |
| `.extractChannel(channel)` | Extract single channel: `0`-`3` or `'red'`, `'green'`, `'blue'`, `'alpha'` |
| `.joinChannel(images, [options])` | Add channel(s) from other image(s) |
| `.bandbool(op)` | Bitwise across all bands: `'and'`, `'or'`, `'eor'` |
---
## Composite
```js
.composite(images)
```
Overlay images onto the pipeline image. `images` is an array of objects:
| Property | Type | Default | Description |
|----------|------|---------|-------------|
| `input` | Buffer \| string | -- | Image data, file path, or `create`/`text` object |
| `blend` | string | `'over'` | Blend mode |
| `gravity` | string | `'centre'` | Placement gravity |
| `top` | number | -- | Pixel offset from top (overrides gravity) |
| `left` | number | -- | Pixel offset from left (overrides gravity) |
| `tile` | boolean | `false` | Repeat overlay across image |
| `premultiplied` | boolean | `false` | Skip premultiplication |
| `density` | number | `72` | DPI for vector overlays |
**Blend modes:** `over`, `multiply`, `screen`, `overlay`, `darken`, `lighten`, `hard-light`, `soft-light`, `difference`, `exclusion`, `colour-dodge`, `colour-burn`, `add`, `saturate`, `clear`, `source`, `in`, `out`, `atop`, `dest`, `dest-over`, `dest-in`, `dest-out`, `dest-atop`, `xor`
```js
await sharp('base.png')
.composite([{ input: 'overlay.png', gravity: 'southeast' }])
.toFile('output.png');
```
---
## Output
### Write to File
```js
await sharp('input.jpg').resize(800).toFile('output.jpg');
```
Format inferred from extension. Returns `{ format, size, width, height, channels, premultiplied }`.
### Write to Buffer
```js
const buffer = await sharp('input.jpg').resize(800).toBuffer();
// or with info:
const { data, info } = await sharp('input.jpg').resize(800).toBuffer({ resolveWithObject: true });
```
### Format Methods
#### JPEG
```js
.jpeg([options])
```
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `quality` | number | `80` | 1-100 |
| `progressive` | boolean | `false` | Progressive JPEG |
| `chromaSubsampling` | string | `'4:2:0'` | `'4:2:0'` or `'4:4:4'` |
| `mozjpeg` | boolean | `false` | MozJPEG optimizations |
| `force` | boolean | `true` | Force JPEG output |
#### PNG
```js
.png([options])
```
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `progressive` | boolean | `false` | Progressive (interlace) |
| `compressionLevel` | number | `6` | 0-9 |
| `adaptiveFiltering` | boolean | `false` | Adaptive row filtering |
| `palette` | boolean | `false` | Quantise to palette |
| `quality` | number | `100` | Palette quality (1-100) |
| `effort` | number | `7` | CPU effort (1-10, palette mode) |
| `colours`/`colors` | number | `256` | Max palette colors (2-256) |
| `dither` | number | `1.0` | Floyd-Steinberg dithering level |
| `force` | boolean | `true` | Force PNG output |
#### WebP
```js
.webp([options])
```
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `quality` | number | `80` | 1-100 |
| `alphaQuality` | number | `100` | 0-100 |
| `lossless` | boolean | `false` | Lossless compression |
| `nearLossless` | boolean | `false` | Near-lossless mode |
| `smartSubsample` | boolean | `false` | Smart chroma subsampling |
| `preset` | string | `'default'` | `'default'`, `'photo'`, `'picture'`, `'drawing'`, `'icon'`, `'text'` |
| `effort` | number | `4` | 0-6 |
| `loop` | number | `0` | Animation loops (0 = infinite) |
| `delay` | number \| Array | -- | Frame delay(s) in ms |
| `force` | boolean | `true` | Force WebP output |
#### AVIF
```js
.avif([options])
```
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `quality` | number | `50` | 1-100 |
| `lossless` | boolean | `false` | Lossless mode |
| `effort` | number | `4` | 0-9 |
| `chromaSubsampling` | string | `'4:4:4'` | Chroma subsampling |
| `bitdepth` | number | `8` | 8, 10, or 12 |
#### GIF
```js
.gif([options])
```
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `reuse` | boolean | `true` | Reuse palette |
| `progressive` | boolean | `false` | Progressive (interlace) |
| `colours`/`colors` | number | `256` | 2-256 |
| `effort` | number | `7` | 1-10 |
| `dither` | number | `1.0` | 0-1 |
| `loop` | number | `0` | 0 = infinite |
| `delay` | number \| Array | -- | Frame delay(s) in ms |
| `force` | boolean | `true` | Force GIF output |
#### TIFF
```js
.tiff([options])
```
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `quality` | number | `80` | 1-100 |
| `compression` | string | `'jpeg'` | `'none'`, `'jpeg'`, `'deflate'`, `'packbits'`, `'lzw'`, `'webp'`, `'zstd'`, `'jp2k'`, `'ccittfax4'` |
| `predictor` | string | `'horizontal'` | `'none'`, `'horizontal'`, `'float'` |
| `pyramid` | boolean | `false` | Write image pyramid |
| `tile` | boolean | `false` | Tiled TIFF |
| `tileWidth` | number | `256` | Tile width |
| `tileHeight` | number | `256` | Tile height |
| `bitdepth` | number | `8` | 1, 2, 4, or 8 |
| `force` | boolean | `true` | Force TIFF output |
#### HEIF
```js
.heif({ compression: 'hevc' })
```
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `compression` | string | required | `'av1'` or `'hevc'` |
| `quality` | number | `50` | 1-100 |
| `lossless` | boolean | `false` | Lossless mode |
| `effort` | number | `4` | 0-9 |
| `bitdepth` | number | `8` | 8, 10, or 12 |
#### Raw
```js
.raw([options])
```
- `options.depth` (string, default `'uchar'`): `'char'`, `'uchar'`, `'short'`, `'ushort'`, `'int'`, `'uint'`, `'float'`, `'double'`
#### Tile (DZI / Zoomify / IIIF)
```js
.tile([options])
```
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `size` | number | `256` | Tile size (1-8192) |
| `overlap` | number | `0` | Tile overlap (0-8192) |
| `layout` | string | `'dz'` | `'dz'`, `'iiif'`, `'iiif3'`, `'zoomify'`, `'google'` |
| `container` | string | `'fs'` | `'fs'` or `'zip'` |
| `angle` | number | `0` | Rotation (multiple of 90) |
| `background` | string \| Object | white | Fill color |
---
## Metadata & Stats
### metadata()
```js
const meta = await sharp('input.jpg').metadata();
```
Returns without decoding pixels:
| Property | Type | Description |
|----------|------|-------------|
| `format` | string | `'jpeg'`, `'png'`, `'webp'`, `'gif'`, `'svg'`, etc. |
| `width` | number | Pixel width |
| `height` | number | Pixel height |
| `space` | string | Color space (`'srgb'`, `'rgb'`, `'cmyk'`, `'b-w'`, etc.) |
| `channels` | number | Band count |
| `depth` | string | Pixel depth (`'uchar'`, `'ushort'`, `'float'`, etc.) |
| `density` | number | DPI |
| `chromaSubsampling` | string | e.g. `'4:2:0'` |
| `isProgressive` | boolean | Progressive/interlaced |
| `hasAlpha` | boolean | Has alpha channel |
| `hasProfile` | boolean | Has ICC profile |
| `orientation` | number | EXIF orientation (1-8) |
| `pages` | number | Page count |
| `size` | number | Total bytes (Buffer/Stream input) |
| `exif` | Buffer | Raw EXIF |
| `icc` | Buffer | ICC profile |
| `xmp` | Buffer | XMP data |
### stats()
```js
const stats = await sharp('input.jpg').stats();
```
Returns pixel-derived statistics:
| Property | Type | Description |
|----------|------|-------------|
| `channels` | Array | Per-channel: `min`, `max`, `sum`, `mean`, `stdev`, `minX`, `minY`, `maxX`, `maxY` |
| `isOpaque` | boolean | Fully opaque |
| `entropy` | number | Greyscale entropy |
| `sharpness` | number | Laplacian sharpness |
| `dominant` | Object | Dominant sRGB color |
---
## Metadata Preservation
By default, sharp strips all metadata and converts to sRGB.
| Method | Description |
|--------|-------------|
| `.keepMetadata()` | Preserve all metadata (EXIF, ICC, XMP, IPTC) |
| `.keepExif()` | Preserve EXIF only |
| `.withExif(exif)` | Set EXIF (replaces input). Object keyed by IFD |
| `.withExifMerge(exif)` | Merge with existing EXIF |
| `.keepIccProfile()` | Preserve ICC profile |
| `.withIccProfile(icc, [options])` | Set ICC: path or `'srgb'`, `'p3'`, `'cmyk'` |
| `.keepXmp()` | Preserve XMP |
| `.withXmp(xmp)` | Set XMP (XML string) |
| `.withMetadata([options])` | Preserve most metadata. Options: `orientation`, `density` |
---
## Timeout
```js
.timeout({ seconds: 30 })
```
Abort processing after N seconds. `0` = no timeout (default).
---
## Utility (Static)
| Property/Method | Description |
|-----------------|-------------|
| `sharp.format` | Object with available input/output format booleans |
| `sharp.versions` | Version info for sharp, libvips, dependencies |
| `sharp.interpolators` | Enum: `nearest`, `bilinear`, `bicubic`, `lbb`, `nohalo`, `vsqbs` |
| `sharp.cache([options])` | Get/set cache: `{ memory: 50, files: 20, items: 100 }` |
| `sharp.concurrency([n])` | Get/set thread count (default: CPU cores) |
| `sharp.counters()` | Returns `{ queue, process }` |
| `sharp.simd([bool])` | Enable/disable SIMD (default `true`) |
| `sharp.block({ operation })` | Block specific operations |
| `sharp.unblock({ operation })` | Unblock operations |
---
## Common Recipes
### Resize and convert format
```js
await sharp('input.png')
.resize(800, 600)
.webp({ quality: 80 })
.toFile('output.webp');
```
### Resize to fit within bounds (no upscale)
```js
await sharp('input.jpg')
.resize(1200, 800, { fit: 'inside', withoutEnlargement: true })
.toFile('output.jpg');
```
### Create thumbnail (cover crop)
```js
await sharp('input.jpg')
.resize(250, 250, { fit: 'cover', position: 'attention' })
.toFile('thumb.jpg');
```
### Crop region
```js
await sharp('input.jpg')
.extract({ left: 100, top: 50, width: 400, height: 300 })
.toFile('cropped.jpg');
```
### Add watermark overlay
```js
await sharp('photo.jpg')
.composite([{ input: 'watermark.png', gravity: 'southeast' }])
.toFile('watermarked.jpg');
```
### Composite text overlay
```js
await sharp('photo.jpg')
.composite([{
input: { text: { text: 'Hello World', font: 'sans', dpi: 200, rgba: true } },
gravity: 'south'
}])
.toFile('annotated.jpg');
```
### Convert to greyscale
```js
await sharp('input.jpg')
.greyscale()
.toFile('grey.jpg');
```
### Blur
```js
await sharp('input.jpg')
.blur({ sigma: 5 })
.toFile('blurred.jpg');
```
### Rotate
```js
await sharp('input.jpg')
.rotate(90)
.toFile('rotated.jpg');
```
### Auto-orient from EXIF
```js
await sharp('input.jpg')
.autoOrient()
.toFile('oriented.jpg');
```
### Extend with padding
```js
await sharp('input.png')
.extend({ top: 20, bottom: 20, left: 20, right: 20, background: '#ffffff' })
.toFile('padded.png');
```
### Auto-trim whitespace
```js
await sharp('input.png')
.trim({ threshold: 10 })
.toFile('trimmed.png');
```
### Optimize JPEG for web
```js
await sharp('input.jpg')
.resize(1920, null, { withoutEnlargement: true })
.jpeg({ quality: 75, mozjpeg: true, progressive: true })
.toFile('optimized.jpg');
```
### Generate AVIF from JPEG
```js
await sharp('input.jpg')
.avif({ quality: 50, effort: 4 })
.toFile('output.avif');
```
### Extract channel
```js
await sharp('input.png')
.extractChannel('red')
.toFile('red-channel.png');
```
### Get image metadata
```js
const { width, height, format, space } = await sharp('input.jpg').metadata();
```
### Buffer round-trip
```js
const buffer = await sharp('input.jpg')
.resize(300)
.png()
.toBuffer();
```
### Create solid color image
```js
await sharp({ create: { width: 100, height: 100, channels: 4, background: '#ff6600' } })
.png()
.toFile('orange.png');
```
### Join images into grid
```js
await sharp(['a.png', 'b.png', 'c.png', 'd.png'], { join: { across: 2 } })
.toFile('grid.png');
```
### Preserve metadata
```js
await sharp('input.jpg')
.resize(800)
.keepMetadata()
.toFile('output.jpg');
```
### Animated GIF resize
```js
await sharp('input.gif', { animated: true })
.resize(200)
.gif()
.toFile('small.gif');
```
### Multiple outputs from one input
```js
const pipeline = sharp('input.jpg');
await Promise.all([
pipeline.clone().resize(200).toFile('thumb.jpg'),
pipeline.clone().resize(800).toFile('medium.jpg'),
pipeline.clone().resize(1600).toFile('large.jpg'),
]);
```
---
## Source
- Repository: https://github.com/lovell/sharp
- Documentation: https://sharp.pixelplumbing.com
- API — Constructor: https://sharp.pixelplumbing.com/api-constructor
- API — Input: https://sharp.pixelplumbing.com/api-input
- API — Resize: https://sharp.pixelplumbing.com/api-resize
- API — Operations: https://sharp.pixelplumbing.com/api-operation
- API — Colour: https://sharp.pixelplumbing.com/api-colour
- API — Channel: https://sharp.pixelplumbing.com/api-channel
- API — Composite: https://sharp.pixelplumbing.com/api-composite
- API — Output: https://sharp.pixelplumbing.com/api-output
- API — Utility: https://sharp.pixelplumbing.com/api-utility
-255
View File
@@ -1,255 +0,0 @@
---
name: whisper-cpp
description: Transcribe audio files to text using whisper.cpp. Use when the user wants to transcribe audio, convert speech to text, or extract text from an audio/video file.
---
# Whisper.cpp
API reference for the whisper.cpp HTTP server running at `http://macmini:8178`.
whisper.cpp is a C/C++ port of OpenAI's Whisper speech recognition model. The server accepts audio files via HTTP and returns transcriptions in various formats.
## Server
- **Base URL:** `http://macmini:8178`
- **No authentication required**
## Endpoints
### GET /health
Returns server status. Use to verify the server is running before making requests.
```bash
curl -s http://macmini:8178/health
```
```json
{"status":"ok"}
```
### POST /inference
Transcribes an audio file. Accepts `multipart/form-data`.
#### Example
```bash
curl -s http://macmini:8178/inference \
-F file="@/path/to/audio.mp3" \
-F temperature="0.0" \
-F temperature_inc="0.2" \
-F response_format="json"
```
#### Parameters
##### File (required)
| Parameter | Type | Description |
|-----------|------|-------------|
| `file` | file | Audio file to transcribe. Accepts at least WAV and MP3. |
##### Response Format
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `response_format` | string | `json` | Output format: `json`, `verbose_json` (or `vjson`), `text`, `srt`, `vtt` |
##### Language
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `language` | string | `en` | Spoken language code (e.g. `en`, `pt`, `es`, `fr`). Use `auto` for auto-detection. |
| `detect_language` | bool | `false` | Exit after detecting the language (no transcription). |
| `translate` | bool | `false` | Translate from source language to English. |
##### Decoding
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `temperature` | float | `0.0` | Sampling temperature. `0.0` is deterministic. |
| `temperature_inc` | float | `0.2` | Temperature increment on fallback attempts. |
| `best_of` | int | `2` | Number of candidate decodings to keep. |
| `beam_size` | int | `-1` | Beam search size. `-1` disables beam search. |
| `entropy_thold` | float | `2.40` | Entropy threshold — decoder fails and retries if exceeded. |
| `logprob_thold` | float | `-1.00` | Log probability threshold for decoder failure. |
| `no_fallback` | bool | `false` | Disable temperature fallback on decode failure. |
##### Segmentation
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `max_len` | int | `0` | Maximum segment length in characters. `0` for unlimited. |
| `max_context` | int | `-1` | Maximum text context tokens to store. `-1` for unlimited. |
| `split_on_word` | bool | `false` | Split segments at word boundaries instead of token boundaries. |
| `no_timestamps` | bool | `false` | Suppress timestamps in output. |
| `word_thold` | float | `0.01` | Word timestamp probability threshold. |
##### Audio Processing
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `offset_t` | int | `0` | Time offset in milliseconds — skip this much audio from the start. |
| `offset_n` | int | `0` | Segment index offset. |
| `duration` | int | `0` | Duration of audio to process in milliseconds. `0` for all. |
| `audio_ctx` | int | `0` | Audio context size. `0` for all. |
##### Speaker Diarization
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `diarize` | bool | `false` | Enable speaker diarization (requires stereo audio). |
| `tinydiarize` | bool | `false` | Enable tinydiarize (requires a tdrz model). |
##### Voice Activity Detection (VAD)
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `vad` | bool | `false` | Enable VAD preprocessing. |
| `vad_threshold` | float | `0.50` | Speech confidence threshold (0.01.0). |
| `vad_min_speech_duration_ms` | int | `250` | Minimum speech segment duration in ms. |
| `vad_min_silence_duration_ms` | int | `100` | Minimum silence duration to split segments. |
| `vad_max_speech_duration_s` | float | `FLT_MAX` | Auto-split segments longer than this (seconds). |
| `vad_speech_pad_ms` | int | `30` | Padding added around speech segments (ms). |
| `vad_samples_overlap` | float | `0.10` | Overlap between segments (seconds). |
##### Other
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `prompt` | string | `""` | Initial prompt to condition the model (e.g. for vocabulary hints). |
| `suppress_nst` | bool | `false` | Suppress non-speech tokens. |
| `no_context` | bool | `false` | Do not use previous audio context for subsequent segments. |
| `debug_mode` | bool | `false` | Enable debug output. |
#### Response Formats
##### `json` (default)
Minimal JSON with just the transcribed text.
```json
{"text": "The transcribed content goes here."}
```
##### `verbose_json` (or `vjson`)
Extended JSON including task type, language, audio duration, per-segment timestamps, token-level timing, confidence scores, and language probability distribution.
##### `text`
Plain text transcription. Includes speaker labels if diarization is enabled.
##### `srt`
SubRip subtitle format with sequential numbering, `HH:MM:SS,mmm` timestamps, and text content.
```
1
00:00:00,000 --> 00:00:03,500
The transcribed content goes here.
```
##### `vtt`
WebVTT subtitle format with `WEBVTT` header and `HH:MM:SS.mmm` timestamps.
```
WEBVTT
00:00:00.000 --> 00:00:03.500
The transcribed content goes here.
```
### POST /load
Loads a different model file on the server at runtime.
```bash
curl -s http://macmini:8178/load \
-F model="/path/to/model.bin"
```
| Parameter | Type | Description |
|-----------|------|-------------|
| `model` | string | Path to the model file on the server. |
## Supported Audio Formats
The server accepts at least WAV (16-bit PCM) and MP3 files directly. If the server was started with `--convert`, it can use ffmpeg to handle additional formats (ogg, flac, m4a, etc.).
## Common Recipes
### Transcribe to plain text
```bash
curl -s http://macmini:8178/inference \
-F file="@audio.mp3" \
-F temperature="0.0" \
-F temperature_inc="0.2" \
-F response_format="text"
```
### Transcribe non-English audio
```bash
curl -s http://macmini:8178/inference \
-F file="@audio.mp3" \
-F language="pt" \
-F temperature="0.0" \
-F temperature_inc="0.2" \
-F response_format="json"
```
### Translate foreign audio to English
```bash
curl -s http://macmini:8178/inference \
-F file="@audio.mp3" \
-F language="auto" \
-F translate="true" \
-F temperature="0.0" \
-F temperature_inc="0.2" \
-F response_format="json"
```
### Generate SRT subtitles
```bash
curl -s http://macmini:8178/inference \
-F file="@audio.mp3" \
-F temperature="0.0" \
-F temperature_inc="0.2" \
-F response_format="srt" \
> subtitles.srt
```
### Transcribe with VAD (skip silence)
```bash
curl -s http://macmini:8178/inference \
-F file="@audio.mp3" \
-F temperature="0.0" \
-F temperature_inc="0.2" \
-F vad="true" \
-F response_format="json"
```
### Transcribe with vocabulary hints
```bash
curl -s http://macmini:8178/inference \
-F file="@audio.mp3" \
-F temperature="0.0" \
-F temperature_inc="0.2" \
-F prompt="Kubernetes, kubectl, etcd, gRPC" \
-F response_format="json"
```
---
## Source
- Repository: https://github.com/ggml-org/whisper.cpp
- Server docs: https://github.com/ggml-org/whisper.cpp/blob/master/examples/server/README.md
File diff suppressed because one or more lines are too long
-1
View File
@@ -1 +0,0 @@
{"id":"a0d9fb2b-700f-4a79-a3d4-a175bad6d52a"}
-131
View File
@@ -1,131 +0,0 @@
# Tasks
A task is a set of instructions to accomplish an atomic goal. Each task lives in its own directory under `tasks/` and is defined by a `TASK.md` file.
## File Structure
```
tasks/
<task-slug>/
TASK.md
```
## TASK.md Format
A task file has two parts: **frontmatter** (YAML metadata) and **body** (Markdown instructions).
### Frontmatter
```yaml
---
name: Task Name
description: A short description of what the task does.
version: 1
author: pastilhas
tags:
- tag1
- tag2
skills:
- skill-name
trigger:
- type: file
extensions:
- ext1
- ext2
- type: directory
inputs:
- name: input_name
description: What this input is.
type: string
required: true
- name: count
description: How many items to process.
type: number
default: 10
required: false
- name: verbose
description: Enable verbose output.
type: boolean
default: false
required: false
- name: format
description: Output format.
type: select
default: json
required: false
options:
- value: json
label: JSON
- value: csv
label: CSV
- value: md
label: Markdown
---
```
#### Fields
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `name` | string | yes | Human-readable name of the task. |
| `description` | string | yes | Short description of what the task does. |
| `version` | integer | no | Version number of the task definition. |
| `author` | string | no | Author of the task. |
| `tags` | string[] | no | Tags for categorization. |
| `skills` | string[] | no | Skills required to execute the task. |
| `trigger` | object[] | no | List of triggers that define when this task is applicable. |
| `trigger[].type` | string | yes | What the trigger applies to (`file` or `directory`). |
| `trigger[].extensions` | string[] | no | File extensions that match this trigger. Only applicable when `type` is `file`. |
| `inputs` | object[] | no | Inputs the task expects. |
| `inputs[].name` | string | yes | Name of the input parameter. |
| `inputs[].description` | string | yes | Description of the input. |
| `inputs[].type` | string | no | Input type. Determines how a task runner renders the input. One of: `string`, `number`, `boolean`, `select`. Defaults to `string`. |
| `inputs[].default` | any | no | Default value for the input. |
| `inputs[].required` | boolean | no | Whether the input is required. |
| `inputs[].options` | object[] | no | Available choices when `type` is `select`. Each option has a `value` and a `label`. |
| `inputs[].options[].value` | string | yes | The value passed to the task. |
| `inputs[].options[].label` | string | yes | Human-readable label displayed in the UI. |
### Body
The body contains:
1. **Title**`# Task Name`, matching the frontmatter `name`.
2. **Description** — A one-line summary, matching the frontmatter `description`.
3. **Steps** — An ordered list under `## Steps` describing the instructions to accomplish the task.
### Example
```markdown
---
name: Transcribe Audio File
description: Transcribe an audio file to text using whisper.cpp.
version: 1
author: pastilhas
tags:
- audio
- transcription
skills:
- whisper.cpp
trigger:
- type: file
extensions:
- mp3
- wav
- m4a
inputs:
- name: file_path
description: Path to the audio file to transcribe.
required: true
---
# Transcribe Audio File
Transcribe an audio file to text using whisper.cpp.
## Steps
1. First step.
2. Second step.
3. Third step.
```
-50
View File
@@ -1,50 +0,0 @@
---
name: Convert To MP3
description: Convert audio files to MP3 320kbps, preserving metadata.
version: 3
author: pastilhas
tags:
- audio
- conversion
skills:
- convert-audio-to-mp3
tools:
- convert_audio_to_mp3
trigger:
- type: file
extensions:
- flac
- wav
- ogg
- wma
- aac
- m4a
- opus
- aiff
- aif
- ape
- wv
- alac
- dsf
- dff
- type: directory
inputs:
- name: file_path
description: Path to an audio file or an artist directory to convert.
required: true
---
# Convert To MP3
Convert audio files to MP3 320kbps, preserving metadata.
## Important
- Do NOT explore, list, or inspect the target path before converting. The tool handles everything internally — file discovery, format detection, and error reporting.
- Do NOT use bash, ls, or any other tool. Only use `convert_audio_to_mp3`.
- Call the tool exactly once, then report the result. Nothing else.
## Steps
1. If `file_path` has an audio extension (flac, wav, ogg, etc.), call `convert_audio_to_mp3(mode="single", path=file_path)`. Otherwise call `convert_audio_to_mp3(mode="batch", path=file_path)`.
2. Print the tool's output as the final report. Do not add extra commentary.
-28
View File
@@ -1,28 +0,0 @@
---
name: Sync Gmail Inbox
description: Download all emails from the user's Gmail account and save them as files.
version: 1
author: pastilhas
tags:
- email
- gmail
- sync
tools:
- gmail
inputs: []
---
# Sync Gmail Inbox
Download all emails from the user's Gmail account and save each one as an `.eml` file in `$OFFICER_USER_ROOT/Gmail/emails/`.
## Steps
1. Create the `$OFFICER_USER_ROOT/Gmail/emails` directory if it doesn't already exist.
2. Call `gmail` with `action=get_profile` to confirm the account is connected and note the total message count.
3. Determine the current year and month.
4. Loop **month by month**, starting from the current month and going backwards:
- Call `gmail` with `action=sync_inbox`, `output_dir=$OFFICER_USER_ROOT/Gmail/emails`, and `query=after:YYYY/MM/01 before:YYYY/MM+1/01` (adjust the dates for each month).
- Report the result for that month (e.g. "February 2026: saved 47 emails").
- If **3 consecutive months** return 0 saved emails and 0 already existing, stop — you've likely reached the beginning of the account.
5. When finished, count the total `.eml` files in `$OFFICER_USER_ROOT/Gmail/emails` and report the final total.
-27
View File
@@ -1,27 +0,0 @@
---
name: Test Sync Gmail Inbox
description: Test task — download only 2026 emails from Gmail.
version: 1
author: pastilhas
tags:
- email
- gmail
- sync
- test
tools:
- gmail
inputs: []
---
# Test Sync Gmail Inbox
Download emails from 2026 only and save each one as an `.eml` file in `$OFFICER_USER_ROOT/Gmail/emails/`.
## Steps
1. Create the `$OFFICER_USER_ROOT/Gmail/emails` directory if it doesn't already exist.
2. Call `gmail` with `action=get_profile` to confirm the account is connected.
3. Loop **month by month**, starting from the current month down to January 2026:
- Call `gmail` with `action=sync_inbox`, `output_dir=$OFFICER_USER_ROOT/Gmail/emails`, and `query=after:YYYY/MM/01 before:YYYY/MM+1/01`.
- Report the result for that month (e.g. "February 2026: saved 47 emails").
4. When finished, count the total `.eml` files in `$OFFICER_USER_ROOT/Gmail/emails` and report the final total.
-251
View File
@@ -1,251 +0,0 @@
---
name: TikTok Trends
description: Fetch top trending TikTok videos for a given country and generate an engagement report with optional video downloads.
version: 4
author: pastilhas
tags:
- social-media
- tiktok
- trends
- apify
- content-analysis
tools:
- apify
dependencies:
- name: yt-dlp
description: Required only when download option is enabled. Downloads TikTok videos.
check_command: yt-dlp --version
optional: true
inputs:
- name: country
description: Country code to fetch trending videos for.
type: select
default: PT
required: false
options:
- value: PT
label: Portugal
- value: US
label: United States
- value: BR
label: Brazil
- value: GB
label: United Kingdom
- value: ES
label: Spain
- value: FR
label: France
- value: DE
label: Germany
- value: IT
label: Italy
- value: NL
label: Netherlands
- value: BE
label: Belgium
- value: PL
label: Poland
- value: RO
label: Romania
- value: SE
label: Sweden
- value: AT
label: Austria
- value: CH
label: Switzerland
- value: IE
label: Ireland
- value: CA
label: Canada
- value: AU
label: Australia
- value: MX
label: Mexico
- value: AR
label: Argentina
- value: CO
label: Colombia
- value: CL
label: Chile
- value: JP
label: Japan
- value: KR
label: South Korea
- value: IN
label: India
- value: TR
label: Turkey
- value: SA
label: Saudi Arabia
- value: AE
label: United Arab Emirates
- value: ZA
label: South Africa
- value: NG
label: Nigeria
- name: limit
description: Number of trending videos to fetch (1-100).
type: number
default: 20
min: 1
max: 100
required: false
- name: download
description: Download video files using yt-dlp (requires yt-dlp to be installed).
type: boolean
default: false
required: false
outputs:
- name: engagement_report
description: Markdown report with trending videos analysis, engagement metrics, top hashtags, sounds, and creators.
path: tiktok_trends_<country>_<timestamp>/report.md
- name: raw_data
description: Raw JSON data from Apify actor containing all video metadata.
path: tiktok_trends_<country>_<timestamp>/raw.json
- name: videos
description: Downloaded video files (only present if download option was enabled).
path: tiktok_trends_<country>_<timestamp>/videos/
optional: true
config:
timeout: 600
retry_count: 0
---
# TikTok Trends
Fetch top trending TikTok videos for a given country and generate a comprehensive engagement report.
## Important
- Use the `apify` tool to fetch data. Do NOT call the Apify REST API directly via curl or fetch.
- Do NOT explore or list files before starting. Create the output directory, call the tool, process results.
- If `download` is false (default), do NOT attempt to download any videos.
- Large data files (like `raw.json`) exceed the 50KB read limit. Never try to read them directly. Instead, write a Node.js script to process and transform the data, execute it, then delete the script.
## Steps
### 1. Setup output directory
Create a timestamped output directory:
```
$HOME/tiktok-trends/tiktok_trends_<country>_<YYYYMMDD_HHMMSS>/
```
### 2. Fetch trending videos
Call the `apify` tool with `output_path` pointing to `raw.json` in the output directory:
```
apify(
actor_id: "clockworks~tiktok-trends-scraper",
input: { "adsCountryCode": "<country>", "resultsPerPage": <limit> },
output_path: "<output_dir>/raw.json"
)
```
The tool saves the full dataset to `raw.json` and returns a summary (item count). If it returns an error or 0 items, report the error and stop.
### 3. Generate engagement report
**Important:** The raw JSON file is too large to read directly (exceeds the 50KB read limit). Instead, write a Node.js script (e.g. `generate-report.js`) in the output directory that reads `raw.json`, processes the data, and writes `report.md`. Then execute it with `node generate-report.js`. Delete the script after it runs successfully.
The script should read `raw.json`, parse the JSON (it may be an array directly or an object with items nested inside), and write `report.md` with the following sections. Adapt field names based on the actual data structure (see Data Shape Reference):
#### Header
```markdown
# TikTok Trending Report — <COUNTRY> — YYYY-MM-DD
Total videos analyzed: <count>
```
#### Engagement Summary
Build a table from each video's statistics (views/plays, likes/diggs, shares, comments):
| Metric | Total | Avg per video |
|--------|------:|-------------:|
| Views | ... | ... |
| Likes | ... | ... |
| Shares | ... | ... |
| Comments | ... | ... |
#### Top Hashtags (up to 20)
Extract hashtags from the data (dedicated hashtag field or parse `#tags` from description). Count occurrences, sort descending.
| Hashtag | Count |
|---------|------:|
#### Top Sounds (up to 10)
From each video's music/sound metadata, format as `title — author`. Count occurrences, sort descending.
| Sound | Count |
|-------|------:|
#### Creators Appearing in Trending (up to 10)
From each video's author/creator field. Count occurrences, sort descending.
| Creator | Videos |
|---------|-------:|
#### Video List
Full table of all videos, sorted by position:
| # | Creator | Description | Views | Likes | URL |
|--:|---------|-------------|------:|------:|-----|
- Creator: `@username` from the author field
- Description: first 60 chars, pipe and newline characters replaced, with `...` if truncated
- URL: the video's share/web URL
- Format numbers with locale separators (e.g. `1,234,567`)
### 4. Download videos (only if `download` is true)
If `download` is false, skip this step entirely.
If `download` is true:
1. Check that `yt-dlp` is installed
2. Create a `videos/` subdirectory in the output directory
3. Write all `share_url` values to a `urls.txt` file
4. Run: `yt-dlp -a urls.txt -o "videos/%(id)s.%(ext)s" --write-info-json --no-overwrites`
5. Report how many videos were downloaded. Partial failures are acceptable — do not fail the task if some downloads fail.
### 5. Report results
Print a summary to the user:
- Country and date
- Number of videos fetched
- Top video: `@creator` — views count — URL
- Top hashtag and count
- Output directory path
- Files created and their sizes
## Data Shape Reference
The actor output format may vary between versions. Before writing the report generation script, inspect the first item of the dataset to discover the actual field names. Write the script to handle the fields it finds. Common field patterns across TikTok scraper actors:
- **Video ID**: `aweme_id`, `id`, or `videoId`
- **Description**: `desc`, `description`, or `title`
- **Video URL**: `share_url`, `url`, `videoUrl`, or `webVideoUrl`
- **Author**: `author.unique_id`, `author.uniqueId`, `authorMeta.name`, or `nickname`
- **Statistics**: Look for objects with keys like `play_count`/`playCount`, `digg_count`/`diggCount`/`likes`, `share_count`/`shareCount`/`shares`, `comment_count`/`commentCount`/`comments`
- **Music/Sound**: `music.title`, `musicMeta.musicName`, or similar
- **Hashtags**: `text_extra[].hashtag_name`, `hashtags[]`, or parse `#tags` from description
## Error Handling
| Scenario | Action |
|----------|--------|
| Apify tool returns error | Report the error message. Common causes: invalid token, no credits, rate limits |
| Empty results | Report "No trending videos found for <country>" and stop |
| Partial results (fewer than requested) | Proceed normally, note the discrepancy in the summary |
| yt-dlp not installed when download=true | Report that yt-dlp is required and skip downloads |
| Download failures | Report which videos failed but do not fail the task |
## Notes
- The Apify actor may take 1-3 minutes depending on the limit
- Pricing: $0.005 per start + $0.003 per result — monitor at https://console.apify.com/billing
- Not all countries have sufficient trending data; some may return fewer results than requested
-35
View File
@@ -1,35 +0,0 @@
---
name: Transcribe Audio File
description: Transcribe an audio file to text using whisper.cpp.
version: 1
author: pastilhas
tags:
- audio
- transcription
skills:
- whisper.cpp
trigger:
- type: file
extensions:
- mp3
- wav
- m4a
inputs:
- name: file_path
description: Path to the audio file to transcribe.
required: true
---
# Transcribe Audio File
Transcribe an audio file to text using whisper.cpp.
## Steps
1. Starting from the directory containing the audio file, walk up parent by parent until you find a directory whose name is an email address. This is the user's root directory. Read `settings.json` from it and extract the `languages` section.
2. Detect the language of the audio file using the whisper.cpp skill with `detect_language=true` and `response_format=verbose_json`.
3. Compare the detected language against the user's `languages.spoken` list. If the detected language is in the list, skip translation. Otherwise, set `translate=true`.
4. Use the whisper.cpp skill to transcribe the audio file at `file_path`, passing the detected language as the `language` parameter and the `translate` flag from the previous step.
5. Read the transcription and generate a short, descriptive title based on its contents.
6. Create a directory alongside the original audio file named `<date>_<slug>`, where `<date>` is the current date in `YYYYMMDD` format and `<slug>` is a slug derived from the generated title.
7. Move the original audio file and save the transcription as a Markdown file (`.md`) into the new directory, using the same base name for the `.md` file.
-603
View File
@@ -1,603 +0,0 @@
# Tools
A tool is a callable capability that agents can use during task execution. Each tool lives in its own directory and is defined by a `TOOL.md` file and an `index.ts` (or `index.js`) entry point.
Tools are loaded by the `tool-loader` extension at startup. They appear in the agent's system prompt and can be invoked by name.
## Quick Start — Minimal Tool
Create a directory with two files:
```
tools/
hello/
TOOL.md
index.ts
```
**TOOL.md:**
```yaml
---
name: hello
label: Hello
description: Says hello to the user. Use this when the user wants a greeting.
version: 1
language: typescript
inputs:
name:
type: string
description: Who to greet.
---
# Hello Tool
Greets the user by name.
## Usage
Call with a `name` parameter to get a personalized greeting.
```
**index.ts:**
```typescript
type ToolResult = {
content: Array<{ type: string; text: string }>;
isError?: boolean;
};
type Params = {
name: string;
};
export async function execute(
_toolCallId: string,
params: Params,
): Promise<ToolResult> {
return { content: [{ type: 'text', text: `Hello, ${params.name}!` }] };
}
```
That's it. The tool-loader finds it, registers it, and the agent can call it.
## File Structure
```
tools/
<tool-name>/
TOOL.md # Metadata + documentation (required)
index.ts # Implementation (required)
bin/ # Optional helper scripts
```
Both `TOOL.md` and an entry file (`index.ts` or `index.js`) are required. The loader skips directories missing either.
Additional files (scripts, configs, READMEs) are allowed but not loaded — only `TOOL.md` and the entry file matter.
## Tool Locations
Tools live in two directories:
| Location | Purpose | Managed by |
|----------|---------|------------|
| `DATA_PATH/tools/` | Global tools (synced from seed) | `sync-tools.ts` at startup |
| `DATA_PATH/<email>/tools/` | User-created tools | Manual (user creates them) |
Both are mounted into containers and discovered via the `PI_TOOLS_DIRS` environment variable. If a user tool has the same `name` as a global tool, the user tool overrides it (last-writer-wins).
To create a user tool, make a new directory in `DATA_PATH/<email>/tools/<tool-name>/` with `TOOL.md` and `index.ts`. It will be available after the next agent session starts.
## TOOL.md Format
Two parts: **frontmatter** (YAML metadata) and **body** (Markdown documentation).
### Frontmatter
```yaml
---
name: tool_name
label: Tool Name
description: What the tool does and when the agent should use it.
version: 1
language: typescript
inputs:
param_name:
type: string
description: What this parameter is for.
optional_param:
type: number
description: An optional parameter.
optional: true
secret_param:
type: string
description: A sensitive value (e.g., API token).
optional: true
sensitive: true
mode:
type: enum
values: single,batch
description: Choose between modes.
---
```
#### Fields
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `name` | string | yes | Identifier for the tool (used in tool calls). Use `snake_case`. |
| `label` | string | no | Human-readable display name. Defaults to `name` if omitted. |
| `description` | string | yes | What the tool does. This appears in the agent's system prompt — make it specific enough for the agent to know **when** to use it. |
| `version` | integer | **yes** | Version number. Used by `sync-tools` to detect updates. **Always include and bump when changing the tool.** If omitted, defaults to 0, causing unpredictable sync behavior. |
| `language` | string | no | `typescript`, `bash`, or `python`. Defaults to `typescript`. |
| `inputs` | object | no | Input parameters the tool accepts. Keys are parameter names. |
#### Input Types
| Type | Schema | Notes |
|------|--------|-------|
| `string` | `Type.String()` | Default if type is unrecognized |
| `number` | `Type.Number()` | |
| `boolean` | `Type.Boolean()` | |
| `enum` | `Type.Union(literals)` | Requires `values` field (comma-separated or array) |
**Note:** `object` and `array` types are not supported by the schema builder. If you need complex inputs, accept a JSON string and parse it in the execute function.
#### Input Properties
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `type` | string | yes | `string`, `number`, `boolean`, or `enum`. |
| `description` | string | yes | What this parameter is for. Shown to the agent. |
| `optional` | boolean | no | Whether the parameter is optional. Defaults to required. |
| `sensitive` | boolean | no | Mark sensitive values (tokens, passwords). Prevents logging in UI. |
| `values` | string | no | Comma-separated allowed values for `enum` type. |
| `default` | any | no | Default value if not provided. |
### Body
The body is Markdown that the agent sees when the tool is loaded. This is your main documentation — the agent reads it to understand how to use the tool.
Include:
- **Title**`# Tool Name`
- **Usage** — How to call the tool, what parameters to pass, and what to expect back.
- **Examples** — Common usage patterns with example parameter values.
- **Authentication** — How credentials are resolved (env vars, integrations, etc.) if applicable.
- **Error Handling** — What errors can occur and what they mean.
- **Notes** — Limits, external dependencies, related links.
Write the body as instructions for the agent. The agent decides when and how to call the tool based on this documentation.
## index.ts Format
The entry file must export an `execute` function:
```typescript
type ToolResult = {
content: Array<{ type: string; text: string }>;
isError?: boolean;
};
type OnUpdate = (partial: { content: Array<{ type: string; text: string }> }) => void;
export async function execute(
toolCallId: string,
params: Record<string, unknown>,
signal?: AbortSignal,
onUpdate?: OnUpdate,
): Promise<ToolResult> {
// Implementation here
}
```
### Parameters
| Parameter | Description |
|-----------|-------------|
| `toolCallId` | Unique ID for this tool call. Usually unused — prefix with `_`. |
| `params` | Input values from the agent, matching `inputs` in TOOL.md. |
| `signal` | AbortSignal for cancellation. Not widely used yet — accept and ignore. |
| `onUpdate` | Callback for streaming progress to the agent during long operations. |
### Return Value
Return a `ToolResult` object:
```typescript
// Success
return { content: [{ type: 'text', text: 'Done! Created 5 files.' }] };
// Error — agent sees the error and can react
return { content: [{ type: 'text', text: 'API key not found.' }], isError: true };
```
- `content` — Array of content blocks. Usually one `{ type: 'text', text: '...' }`.
- `isError` — Set `true` to indicate failure.
### Recommended Helpers
Define `ok()` and `err()` helpers to keep return statements clean:
```typescript
function ok(text: string): ToolResult {
return { content: [{ type: 'text', text }] };
}
function err(text: string): ToolResult {
return { content: [{ type: 'text', text }], isError: true };
}
```
### Typed Parameters
Define a `Params` type matching your TOOL.md inputs instead of using `Record<string, unknown>`:
```typescript
type Params = {
query: string;
max_results?: number;
format?: string;
};
export async function execute(
_toolCallId: string,
params: Params,
_signal?: AbortSignal,
onUpdate?: OnUpdate,
): Promise<ToolResult> {
const { query, max_results = 10 } = params;
// ...
}
```
### Progress Updates
Use `onUpdate` to stream status during long-running operations. The agent sees each update in real time:
```typescript
onUpdate?.({ content: [{ type: 'text', text: 'Phase 1: Downloading data...' }] });
// ... do work ...
onUpdate?.({ content: [{ type: 'text', text: 'Phase 2: Processing 500 files...' }] });
// ... do work ...
return ok('Done! Processed 500 files.');
```
### Error Handling
Wrap the entire execute body in try/catch. Return errors as `ToolResult` with `isError: true` — never throw from execute:
```typescript
export async function execute(_toolCallId: string, params: Params): Promise<ToolResult> {
try {
// ... implementation ...
return ok('Success');
} catch (e) {
return err(`Failed: ${e instanceof Error ? e.message : String(e)}`);
}
}
```
For missing configuration, return a helpful error that tells the agent what to do:
```typescript
const token = params.api_token ?? process.env.OFFICER_APIFY_TOKEN;
if (!token) {
return err('Apify API token not configured. Ask the user to add it in Settings → Integrations.');
}
```
### Authentication
Tools should resolve credentials internally. Pattern:
1. Check for an explicit parameter override (e.g., `params.api_token`)
2. Fall back to an environment variable (e.g., `process.env.OFFICER_APIFY_TOKEN`)
3. Return a helpful error if neither is available
Environment variables are set by `pi-bridge.ts` from the integration config in the database (Settings → Integrations).
### Large Output
If a tool may return large data, provide an `output_path` parameter. When set, save data to the file and return a summary:
```typescript
if (params.output_path) {
writeFileSync(params.output_path, JSON.stringify(items, null, 2));
return ok(`${items.length} items saved to ${params.output_path}`);
}
```
This prevents flooding the agent's context window.
## Runtime Environment
Tools run inside **sandboxed Docker containers** using **Node.js** (not Bun).
### APIs
Use Node.js standard library only:
| Need | Use | Don't use |
|------|-----|-----------|
| File I/O | `fs.readFileSync`, `fs.writeFileSync` | `Bun.file`, `Bun.write` |
| Delays | `setTimeout`, `setInterval` | `Bun.sleep` |
| HTTP | `fetch` (Node 18+) | Bun-specific fetch options |
| Child processes | `child_process.execFileSync`, `spawn` | `Bun.spawn` |
| Paths | `path.join`, `path.dirname` | |
### Available Environment Variables
These are set by `pi-bridge.ts` and available in all tool containers:
| Variable | Description |
|----------|-------------|
| `HOME` | Container home directory |
| `OFFICER_USER_HOME` | Same as HOME |
| `OFFICER_USER_ROOT` | User data root (`/officer/user`) |
| `PI_TOOLS_DIRS` | Tool discovery paths (colon-separated) |
| `OFFICER_EMAIL_DB` | Path to email SQLite database |
| `OFFICER_RESOURCES` | JSON object with all configured resources/integrations |
| `PI_SEARXNG_URL` | SearXNG search engine URL |
| `OFFICER_APIFY_TOKEN` | Apify API token (if configured) |
| `OFFICER_BROWSER_RELAY_PORT` | Browser relay port (if configured) |
| `OFFICER_BROWSER_RELAY_TOKEN` | Browser relay auth token (if configured) |
**`OFFICER_RESOURCES`** is a JSON string containing all resource configs from Settings → Integrations:
```typescript
const resources = JSON.parse(process.env.OFFICER_RESOURCES ?? '{}');
const ocrConfig = resources['optical-character-recognition'];
```
### Container Filesystem
| Mount | Path in container | Access |
|-------|-------------------|--------|
| Global tools | `/officer/tools/` | Read-only |
| User tools | `/officer/user/tools/` | Read-only |
| User data | `/officer/user/` | Read-write |
| Email database | `/officer/data/emails.db` | Read-write |
### Referencing Local Files
If your tool includes helper scripts (e.g., Python/bash in a `bin/` directory), resolve them relative to the entry file:
```typescript
// Works in both ESM and CJS contexts
const TOOL_DIR = typeof __dirname !== 'undefined'
? __dirname
: dirname(fileURLToPath(import.meta.url));
const BIN_DIR = join(TOOL_DIR, 'bin');
// Then call scripts:
execFileSync('python3', [join(BIN_DIR, 'process.py'), inputPath]);
```
### External Dependencies
If your tool requires system binaries (e.g., `python3`, `tesseract`, `ffmpeg`), check for them early and return a helpful error:
```typescript
import { execSync } from 'node:child_process';
function hasCommand(cmd: string): boolean {
try {
execSync(`which ${cmd}`, { stdio: 'ignore' });
return true;
} catch {
return false;
}
}
// In execute():
if (!hasCommand('python3')) {
return err('python3 is required but not installed in the container.');
}
```
## Sync and Discovery
### How Sync Works
At server startup, `sync-tools.ts` copies tools from `seed/tools/` to `DATA_PATH/tools/` (the global tools directory). The sync is **version-based**:
1. Parse `version` from TOOL.md frontmatter (defaults to `0` if missing)
2. Compare seed version vs target version
3. **Only copy if seed version > target version** (equal versions are skipped)
4. When copying, the entire tool directory is replaced (`rm + cp`)
This means:
- Bumping `version` in seed triggers an update on next restart
- User edits to global tools are preserved until seed version exceeds theirs
- Tools without `version` default to `0` — always include a version number
### How Discovery Works
The `tool-loader` extension reads `PI_TOOLS_DIRS` (colon-separated paths) and scans each directory for tool subdirectories. For each subdirectory:
1. Look for `TOOL.md` — parse frontmatter for metadata
2. Look for `index.ts` or `index.js` — this is the entry file
3. Skip if either is missing
4. Register the tool with the agent (name, description, parameter schema)
5. **Lazy load** the entry file on first call (not at startup)
If multiple tools share the same `name`, the last one registered wins. Since user tools are loaded after global tools, user tools override global tools with the same name.
## Common Mistakes
### No `execute` export
The entry file **must** export `execute` as a named export. Class-based patterns, CLI entry points (`process.argv`), and `module.exports` do not work:
```typescript
// ❌ Wrong — class pattern
export class MyTool { async run() { ... } }
// ❌ Wrong — CLI entry point
if (require.main === module) { main(); }
// ❌ Wrong — console.log instead of return
export async function execute(_id: string, params: Params) {
console.log('result'); // Agent never sees this
}
// ✅ Correct
export async function execute(_id: string, params: Params): Promise<ToolResult> {
return { content: [{ type: 'text', text: 'result' }] };
}
```
### Forgetting to bump version
After editing a seed tool's code or TOOL.md, bump `version` in the frontmatter. Otherwise `sync-tools` won't copy the update to the global directory and the agent will keep using the old version.
### Using Bun APIs
Tools run in Node.js containers. `Bun.file()`, `Bun.write()`, `Bun.sleep()` will throw `ReferenceError`.
### Throwing instead of returning errors
Never throw from `execute`. Always catch and return `{ isError: true }`. Unhandled throws produce generic error messages the agent can't act on.
### Unnecessary files
Tools don't need `package.json`, `tsconfig.json`, `node_modules`, or test directories. The tool-loader only reads `TOOL.md` and `index.ts`. Extra files are harmless but add clutter.
## Full Example — Database Query Tool
A complete tool that queries a SQLite database:
**TOOL.md:**
```yaml
---
name: my_db
label: My Database
description: Query the application database. Use this to look up records, run aggregations, and search data.
version: 1
language: typescript
inputs:
action:
type: enum
values: query,stats
description: "Action to perform: query runs SQL, stats shows database overview."
sql:
type: string
description: SQL SELECT statement to execute (query action only).
optional: true
limit:
type: number
description: Maximum number of results to return.
optional: true
---
# My Database Tool
Query the application database using SQL.
## Usage
Use `action=stats` for a database overview. Use `action=query` with a `sql` parameter for specific queries.
## Examples
Get stats:
- action: stats
Search records:
- action: query, sql: "SELECT * FROM users WHERE name LIKE '%john%' LIMIT 10"
```
**index.ts:**
```typescript
import { execFileSync } from 'node:child_process';
import { existsSync } from 'node:fs';
type ToolResult = {
content: Array<{ type: string; text: string }>;
isError?: boolean;
};
type Params = {
action: string;
sql?: string;
limit?: number;
};
const DB_PATH = process.env.MY_DB_PATH ?? '/officer/data/my.db';
function ok(text: string): ToolResult {
return { content: [{ type: 'text', text }] };
}
function err(text: string): ToolResult {
return { content: [{ type: 'text', text }], isError: true };
}
function queryJson(sql: string): Record<string, unknown>[] {
const output = execFileSync('sqlite3', ['-json', DB_PATH], {
input: sql,
encoding: 'utf-8',
timeout: 10000,
});
const trimmed = output.trim();
if (!trimmed) return [];
return JSON.parse(trimmed);
}
export async function execute(_toolCallId: string, params: Params): Promise<ToolResult> {
if (!existsSync(DB_PATH)) {
return err(`Database not found at ${DB_PATH}.`);
}
try {
switch (params.action) {
case 'query': {
if (!params.sql) return err('sql parameter is required for query action.');
if (!params.sql.trim().toLowerCase().startsWith('select')) {
return err('Only SELECT statements are allowed.');
}
const limit = params.limit ? ` LIMIT ${params.limit}` : '';
const rows = queryJson(`${params.sql}${limit}`);
if (rows.length === 0) return ok('No results.');
const text = rows.map((r, i) => {
const fields = Object.entries(r).map(([k, v]) => `${k}: ${v ?? ''}`).join(' | ');
return `${i + 1}. ${fields}`;
}).join('\n');
return ok(`${rows.length} results:\n\n${text}`);
}
case 'stats': {
const tables = queryJson("SELECT name FROM sqlite_master WHERE type='table'");
const lines = tables.map((t) => {
const count = queryJson(`SELECT COUNT(*) as c FROM "${t.name}"`);
return `${t.name}: ${(count[0]?.c as number) ?? 0} rows`;
});
return ok(`Tables:\n${lines.join('\n')}`);
}
default:
return err(`Unknown action: "${params.action}". Available: query, stats.`);
}
} catch (e) {
return err(`Database error: ${e instanceof Error ? e.message : String(e)}`);
}
}
```
## Existing Tools
| Tool | Description |
|------|-------------|
| `gmail` | Read Gmail messages, threads, labels via Google API |
| `web_search` | Search the web via SearXNG |
| `web_fetch` | Fetch and extract content from URLs |
| `browser` | Control a Chrome browser via Browser Relay |
| `apify` | Run any Apify actor (web scraping, social media data) |
| `convert_audio_to_mp3` | Convert audio files to MP3 via ffmpeg |
| `ffmpeg` | Run ffmpeg/ffprobe commands for any audio/video processing |
| `ocr` | Optical character recognition on images |
| `email_db` | Query the synced email database |
| `pdf_categorizer` | Categorize and organize PDF files (user tool) |
-81
View File
@@ -1,81 +0,0 @@
---
name: apify
label: Apify
version: 4
description: Run any Apify actor and return its dataset results. Use for web scraping, data extraction, and automation — TikTok, Twitter, Facebook, Instagram, YouTube, Google, and hundreds more. Authentication is handled automatically when configured in Settings → Integrations.
language: typescript
inputs:
actor_id:
type: string
description: "Actor ID to run (format: owner~actor-name or owner/actor-name, e.g. 'novi~fast-tiktok-scraper', 'apify/twitter-scraper')"
input:
type: object
description: Actor-specific input parameters as a JSON object (varies per actor)
optional: true
output_path:
type: string
description: File path to save JSON results to. When provided, the tool writes the dataset to this file and returns a summary instead of the raw JSON. Recommended for large datasets to avoid flooding the context.
optional: true
api_token:
type: string
description: Override the configured API token. Usually not needed — the token is provided automatically from Settings → Integrations → Apify.
optional: true
sensitive: true
timeout_ms:
type: number
description: Max time to wait for actor completion in milliseconds (default 300000 = 5 min)
optional: true
poll_interval_ms:
type: number
description: How often to check run status in milliseconds (default 3000)
optional: true
---
# Apify
Run any actor from the Apify Store, wait for completion, and return the dataset items as JSON.
## Authentication
The API token is resolved automatically:
1. `api_token` input parameter (explicit override)
2. `OFFICER_APIFY_TOKEN` environment variable (set automatically when configured in Settings → Integrations → Apify)
If neither is available, the tool returns an error prompting the user to configure the integration.
## Usage
Just provide the `actor_id` and optional `input`:
```
apify(actor_id: "novi~fast-tiktok-scraper", input: { type: "TREND", region: "PT", maxItems: 20 })
```
The tool starts the actor, polls until completion, fetches the dataset, and returns all items as JSON.
## Common Actors
| Actor | ID | Input example |
|-------|----|---------------|
| TikTok Scraper | `novi~fast-tiktok-scraper` | `{ type: 'TREND', region: 'US', maxItems: 20 }` |
| Twitter Scraper | `apify/twitter-scraper` | `{ searchTerms: ['#ai'], tweetsCount: 100 }` |
| Twitter User | `jupri/twitter-user-scraper` | `{ twitterUser: 'username', maxPosts: 50 }` |
| Facebook Scraper | `apify/facebook-scraper` | `{ startUrls: ['https://facebook.com/Page'], maxPostsPerPage: 50 }` |
| Facebook Search | `jupri/facebook-search-scraper` | `{ searchTerm: 'keyword', maxPosts: 30 }` |
| Instagram Hashtag | `apify/instagram-hashtag-scraper` | `{ hashtags: ['travel'], resultsLimit: 50 }` |
| Instagram User | `apify/instagram-user-scraper` | `{ usernames: ['natgeo'], resultsLimit: 50 }` |
| YouTube Scraper | `apify/youtube-scraper` | `{ searchTerms: ['tutorial'], maxResults: 20 }` |
| Google Search | `apify/google-search-scraper` | `{ queries: ['best restaurants lisbon'] }` |
## Error Handling
The tool returns clear error messages for:
- Missing API token → "Configure it in Settings → Integrations → Apify"
- API errors (401, 403, etc.) → includes the HTTP status and response body
- Actor failures → includes the actor's `statusMessage`
- Timeouts → reports the run ID and last known status
## Notes
- Actor IDs use `~` (Apify URL format) or `/` — both work
- Browse actors at https://apify.com/store
- Monitor usage and credits at https://console.apify.com/billing
-167
View File
@@ -1,167 +0,0 @@
import { mkdirSync, writeFileSync } from 'node:fs';
import { dirname } from 'node:path';
const BASE = 'https://api.apify.com/v2';
type RunStatus = 'READY' | 'RUNNING' | 'SUCCEEDED' | 'FAILED' | 'ABORTING' | 'ABORTED' | 'TIMING-OUT' | 'TIMED-OUT';
type RunData = {
id: string;
actId: string;
status: RunStatus;
statusMessage?: string;
defaultDatasetId: string;
defaultKeyValueStoreId: string;
startedAt?: string;
finishedAt?: string;
};
type ToolResult = {
content: Array<{ type: string; text: string }>;
isError?: boolean;
};
type OnUpdate = (partial: { content: Array<{ type: string; text: string }> }) => void;
type Params = {
actor_id: string;
input?: Record<string, unknown> | string;
api_token?: string;
output_path?: string;
timeout_ms?: number;
poll_interval_ms?: number;
};
function update(onUpdate: OnUpdate | undefined, text: string): void {
onUpdate?.({ content: [{ type: 'text', text }] });
}
function resolveToken(params: Params): string | null {
if (params.api_token) return params.api_token;
return process.env.OFFICER_APIFY_TOKEN ?? null;
}
function apiUrl(path: string, token: string, extra?: Record<string, string>): string {
const params = new URLSearchParams({ token, ...extra });
return `${BASE}${path}?${params}`;
}
async function apiRequest<T>(url: string, init?: RequestInit): Promise<T> {
const res = await fetch(url, init);
if (!res.ok) {
const body = await res.text();
throw new Error(`Apify API error ${res.status}: ${body}`);
}
return res.json() as Promise<T>;
}
async function startRun(token: string, actorId: string, input: Record<string, unknown>): Promise<RunData> {
const { data } = await apiRequest<{ data: RunData }>(apiUrl(`/acts/${actorId}/runs`, token), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(input),
});
return data;
}
async function getRun(token: string, runId: string): Promise<RunData> {
const { data } = await apiRequest<{ data: RunData }>(apiUrl(`/actor-runs/${runId}`, token));
return data;
}
async function getDatasetItems<T>(token: string, datasetId: string): Promise<T[]> {
return apiRequest<T[]>(apiUrl(`/datasets/${datasetId}/items`, token, { format: 'json' }));
}
const TERMINAL_STATUSES = new Set<RunStatus>(['SUCCEEDED', 'FAILED', 'ABORTED', 'TIMED-OUT']);
export async function execute(
_toolCallId: string,
params: Params,
_signal: AbortSignal | undefined,
onUpdate?: OnUpdate,
): Promise<ToolResult> {
const token = resolveToken(params);
if (!token) {
return {
content: [{ type: 'text', text: 'Apify API token not available. Configure it in Settings → Integrations → Apify, or pass api_token explicitly.' }],
isError: true,
};
}
const { actor_id } = params;
if (!actor_id) {
return {
content: [{ type: 'text', text: 'actor_id is required.' }],
isError: true,
};
}
let input: Record<string, unknown> = {};
if (params.input) {
if (typeof params.input === 'string') {
try {
input = JSON.parse(params.input);
} catch {
return {
content: [{ type: 'text', text: 'Invalid JSON in input parameter.' }],
isError: true,
};
}
} else {
input = params.input;
}
}
const timeoutMs = params.timeout_ms ?? 300_000;
const pollIntervalMs = params.poll_interval_ms ?? 3_000;
try {
update(onUpdate, `Starting actor ${actor_id}...`);
const run = await startRun(token, actor_id, input);
update(onUpdate, `Run ${run.id} started. Waiting for completion...`);
const start = Date.now();
let finished = run;
while (!TERMINAL_STATUSES.has(finished.status)) {
if (Date.now() - start > timeoutMs) {
return {
content: [{ type: 'text', text: `Timeout after ${timeoutMs}ms waiting for run ${run.id}. Status: ${finished.status}` }],
isError: true,
};
}
await new Promise((r) => setTimeout(r, pollIntervalMs));
finished = await getRun(token, run.id);
update(onUpdate, `Status: ${finished.status}...`);
}
if (finished.status !== 'SUCCEEDED') {
return {
content: [{ type: 'text', text: `Actor run ${finished.status}: ${finished.statusMessage ?? 'unknown error'}` }],
isError: true,
};
}
update(onUpdate, `Run succeeded. Fetching dataset items...`);
const items = await getDatasetItems(token, finished.defaultDatasetId);
if (params.output_path) {
mkdirSync(dirname(params.output_path), { recursive: true });
writeFileSync(params.output_path, JSON.stringify(items, null, 2));
return {
content: [{ type: 'text', text: `${items.length} items saved to ${params.output_path}` }],
};
}
return {
content: [{ type: 'text', text: JSON.stringify(items) }],
};
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
return {
content: [{ type: 'text', text: `Apify error: ${message}` }],
isError: true,
};
}
}
-43
View File
@@ -1,43 +0,0 @@
---
name: browser
label: Browser Control
description: Control connected Chrome browser tabs via the Officer Browser Relay. Use this tool to list tabs, take screenshots, navigate to URLs, evaluate JavaScript, get page info, activate (focus) tabs, or close tabs. Requires the user to have connected the Browser Relay extension in Settings → Integrations.
language: typescript
inputs:
action:
type: string
description: "Action to perform: list_tabs, screenshot, navigate, evaluate, page_info, activate, close"
tab_id:
type: string
description: Target tab ID. Optional — defaults to the first connected tab.
optional: true
url:
type: string
description: URL to navigate to (required for navigate action)
optional: true
expression:
type: string
description: JavaScript expression to evaluate in the tab (required for evaluate action)
optional: true
---
# Browser Tool
Control the user's Chrome browser tabs through the Officer Browser Relay and Chrome DevTools Protocol.
## Available Actions
- **list_tabs**: List all connected tabs with their title, URL, and ID.
- **screenshot**: Capture a screenshot of a tab. Returns the image directly. Use this when asked about page content.
- **navigate**: Navigate a tab to a URL. Requires `url`.
- **evaluate**: Run JavaScript in a tab and return the result. Requires `expression`.
- **page_info**: Get the title and URL of a tab.
- **activate**: Bring a tab to the foreground (focus it).
- **close**: Close a tab.
## Tips
- When asked about what's on a page, take a screenshot first.
- Use `evaluate` for extracting structured data from pages (DOM queries, reading text content, etc.).
- If no `tab_id` is provided, the first connected tab is used.
- Tab IDs can be obtained from `list_tabs`.
-222
View File
@@ -1,222 +0,0 @@
type ToolResult = {
content: Array<{ type: string; text?: string; source?: { type: string; media_type: string; data: string } }>;
isError?: boolean;
};
type Params = {
action: string;
tab_id?: string;
url?: string;
expression?: string;
};
type TabInfo = {
id: string;
title: string;
url: string;
};
const RELAY_AUTH_HEADER = 'x-officer-relay-token';
function getConfig(): { port: number; token: string } | null {
const port = process.env.OFFICER_BROWSER_RELAY_PORT;
const token = process.env.OFFICER_BROWSER_RELAY_TOKEN;
if (!port || !token) return null;
return { port: Number(port), token };
}
async function listTabs(port: number, token: string): Promise<TabInfo[]> {
const res = await fetch(`http://127.0.0.1:${port}/json/list`, {
headers: { [RELAY_AUTH_HEADER]: token },
});
if (!res.ok) throw new Error(`Failed to list tabs (${res.status})`);
const tabs = (await res.json()) as Array<{ id: string; title: string; url: string }>;
return tabs.map((t) => ({ id: t.id, title: t.title, url: t.url }));
}
async function resolveTab(port: number, token: string, tabId?: string): Promise<TabInfo> {
const tabs = await listTabs(port, token);
if (tabs.length === 0) throw new Error('No browser tabs connected. The user needs to attach tabs via the Browser Relay extension.');
if (tabId) {
const tab = tabs.find((t) => t.id === tabId);
if (!tab) throw new Error(`Tab ${tabId} not found. Available tabs: ${tabs.map((t) => t.id).join(', ')}`);
return tab;
}
return tabs[0]!;
}
async function sendCdpCommand(port: number, token: string, tabId: string, method: string, params?: unknown): Promise<unknown> {
const url = `ws://127.0.0.1:${port}/cdp?token=${encodeURIComponent(token)}`;
return await new Promise<unknown>((resolve, reject) => {
const ws = new WebSocket(url);
let settled = false;
const timeout = setTimeout(() => {
if (settled) return;
settled = true;
ws.close();
reject(new Error(`CDP command timeout: ${method}`));
}, 30_000);
ws.addEventListener('open', () => {
const cmd: Record<string, unknown> = { id: 1, method };
if (params) cmd.params = params;
cmd.sessionId = tabId;
ws.send(JSON.stringify(cmd));
});
ws.addEventListener('message', (event) => {
if (settled) return;
try {
const msg = JSON.parse(String(event.data)) as { id?: number; result?: unknown; error?: { message: string } };
if (msg.id === 1) {
settled = true;
clearTimeout(timeout);
ws.close();
if (msg.error) reject(new Error(msg.error.message));
else resolve(msg.result);
}
} catch {
// ignore parse errors, wait for correct message
}
});
ws.addEventListener('error', () => {
if (settled) return;
settled = true;
clearTimeout(timeout);
reject(new Error('CDP WebSocket connection failed — is the Browser Relay running?'));
});
ws.addEventListener('close', () => {
if (settled) return;
settled = true;
clearTimeout(timeout);
reject(new Error('CDP WebSocket closed before response'));
});
});
}
// --- Actions ---
async function actionListTabs(port: number, token: string): Promise<ToolResult> {
const tabs = await listTabs(port, token);
if (tabs.length === 0) {
return { content: [{ type: 'text', text: 'No browser tabs connected. The user needs to attach tabs via the Browser Relay extension.' }] };
}
const lines = tabs.map((t, i) => `${i + 1}. ${t.title}\n URL: ${t.url}\n ID: ${t.id}`);
return { content: [{ type: 'text', text: `Connected tabs (${tabs.length}):\n\n${lines.join('\n\n')}` }] };
}
async function actionScreenshot(port: number, token: string, tabId?: string): Promise<ToolResult> {
const tab = await resolveTab(port, token, tabId);
const result = (await sendCdpCommand(port, token, tab.id, 'Page.captureScreenshot', { format: 'png' })) as { data: string };
return {
content: [
{ type: 'text', text: `Screenshot of "${tab.title}" (${tab.url})` },
{ type: 'image', source: { type: 'base64', media_type: 'image/png', data: result.data } },
],
};
}
async function actionNavigate(port: number, token: string, url: string, tabId?: string): Promise<ToolResult> {
const tab = await resolveTab(port, token, tabId);
await sendCdpCommand(port, token, tab.id, 'Page.navigate', { url });
return { content: [{ type: 'text', text: `Navigated tab "${tab.title}" to ${url}` }] };
}
async function actionEvaluate(port: number, token: string, expression: string, tabId?: string): Promise<ToolResult> {
const tab = await resolveTab(port, token, tabId);
const result = (await sendCdpCommand(port, token, tab.id, 'Runtime.evaluate', {
expression,
returnByValue: true,
awaitPromise: true,
})) as { result?: { value?: unknown; description?: string }; exceptionDetails?: { text?: string } };
if (result.exceptionDetails) {
return { content: [{ type: 'text', text: `Error evaluating JS: ${result.exceptionDetails.text ?? 'Evaluation failed'}` }], isError: true };
}
const value = result.result?.value;
const text = typeof value === 'string' ? value : JSON.stringify(value, null, 2);
return { content: [{ type: 'text', text: `Result from "${tab.title}":\n${text}` }] };
}
async function actionPageInfo(port: number, token: string, tabId?: string): Promise<ToolResult> {
const tab = await resolveTab(port, token, tabId);
const result = (await sendCdpCommand(port, token, tab.id, 'Runtime.evaluate', {
expression: 'JSON.stringify({ title: document.title, url: location.href })',
returnByValue: true,
})) as { result?: { value?: string } };
let info: { title: string; url: string };
try {
info = JSON.parse(result.result?.value ?? '{}');
} catch {
info = { title: tab.title, url: tab.url };
}
return { content: [{ type: 'text', text: `Title: ${info.title}\nURL: ${info.url}\nTab ID: ${tab.id}` }] };
}
async function actionActivate(port: number, token: string, tabId?: string): Promise<ToolResult> {
const tab = await resolveTab(port, token, tabId);
const res = await fetch(`http://127.0.0.1:${port}/json/activate/${encodeURIComponent(tab.id)}`, {
headers: { [RELAY_AUTH_HEADER]: token },
});
if (!res.ok) throw new Error(`Failed to activate tab (${res.status})`);
return { content: [{ type: 'text', text: `Activated tab "${tab.title}"` }] };
}
async function actionClose(port: number, token: string, tabId?: string): Promise<ToolResult> {
const tab = await resolveTab(port, token, tabId);
const res = await fetch(`http://127.0.0.1:${port}/json/close/${encodeURIComponent(tab.id)}`, {
headers: { [RELAY_AUTH_HEADER]: token },
});
if (!res.ok) throw new Error(`Failed to close tab (${res.status})`);
return { content: [{ type: 'text', text: `Closed tab "${tab.title}"` }] };
}
// --- Main ---
export async function execute(_toolCallId: string, params: Params): Promise<ToolResult> {
const config = getConfig();
if (!config) {
return {
content: [{ type: 'text', text: 'Browser Relay is not available. The user needs to connect the Browser Relay extension in Settings → Integrations.' }],
isError: true,
};
}
const { port, token } = config;
const { action, tab_id, url, expression } = params;
try {
switch (action) {
case 'list_tabs':
return await actionListTabs(port, token);
case 'screenshot':
return await actionScreenshot(port, token, tab_id);
case 'navigate':
if (!url) return { content: [{ type: 'text', text: 'url is required for navigate action' }], isError: true };
return await actionNavigate(port, token, url, tab_id);
case 'evaluate':
if (!expression) return { content: [{ type: 'text', text: 'expression is required for evaluate action' }], isError: true };
return await actionEvaluate(port, token, expression, tab_id);
case 'page_info':
return await actionPageInfo(port, token, tab_id);
case 'activate':
return await actionActivate(port, token, tab_id);
case 'close':
return await actionClose(port, token, tab_id);
default:
return {
content: [{ type: 'text', text: `Unknown action: ${action}. Use list_tabs, screenshot, navigate, evaluate, page_info, activate, or close.` }],
isError: true,
};
}
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
return { content: [{ type: 'text', text: `Browser error: ${message}` }], isError: true };
}
}
-42
View File
@@ -1,42 +0,0 @@
---
name: convert_audio_to_mp3
label: Convert Audio to MP3
description: Convert audio files to MP3 320kbps using ffmpeg, preserving metadata. Supports single file conversion with real-time percentage progress, and batch conversion of an entire artist directory with per-file progress. Use when the user wants to convert FLAC, WAV, OGG, or other audio formats to MP3.
version: 3
language: typescript
inputs:
mode:
type: enum
values: single,batch
description: "single: convert one file. batch: convert all audio files recursively in a directory"
path:
type: string
description: "single mode: absolute path to the audio file. batch mode: absolute path to the artist directory"
---
# Convert Audio to MP3
Converts audio to MP3 320kbps CBR via libmp3lame, preserving all metadata tags.
## Prerequisites
This tool requires `ffmpeg` and `ffprobe`. If they are not installed, install them before running:
```bash
sudo apt-get update && sudo apt-get install -y ffmpeg
```
All sudo commands run without a password prompt in this environment.
## Modes
### single
Converts one file. Reports ffmpeg percentage progress in real time. Deletes source on success.
### batch
Scans a directory recursively for all supported audio files. Reports per-file progress.
- All succeed → deletes all source files
- Any failure → deletes all successfully created MP3s for a clean retry
## Supported formats
flac, wav, ogg, wma, aac, m4a, opus, aiff, aif, ape, wv, alac, dsf, dff
-266
View File
@@ -1,266 +0,0 @@
import { readdirSync, existsSync, unlinkSync, statSync } from 'node:fs';
import { join, extname, basename } from 'node:path';
import { spawn, execFile } from 'node:child_process';
const AUDIO_EXTS = new Set([
'flac', 'wav', 'ogg', 'wma', 'aac', 'm4a', 'opus',
'aiff', 'aif', 'ape', 'wv', 'alac', 'dsf', 'dff',
]);
type OnUpdate = (partial: { content: Array<{ type: string; text: string }> }) => void;
function update(onUpdate: OnUpdate | undefined, text: string): void {
onUpdate?.({ content: [{ type: 'text', text }] });
}
async function getDuration(filePath: string): Promise<number | null> {
return new Promise((resolve) => {
execFile(
'ffprobe',
['-v', 'quiet', '-show_entries', 'format=duration', '-of', 'csv=p=0', filePath],
(err, stdout) => {
if (err) return resolve(null);
const n = parseFloat(stdout.trim());
resolve(isNaN(n) ? null : n);
},
);
});
}
function scanAudioFiles(dir: string): string[] {
const files: string[] = [];
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const full = join(dir, entry.name);
if (entry.isDirectory()) {
files.push(...scanAudioFiles(full));
} else if (entry.isFile()) {
const ext = extname(entry.name).slice(1).toLowerCase();
if (AUDIO_EXTS.has(ext)) files.push(full);
}
}
return files;
}
type ConvertResult = {
outputFile: string;
success: boolean;
skipped?: boolean;
error?: string;
};
async function convertFile(
inputFile: string,
duration: number | null,
label: string,
onUpdate: OnUpdate | undefined,
): Promise<ConvertResult> {
const ext = extname(inputFile);
const outputFile = inputFile.slice(0, -ext.length) + '.mp3';
if (existsSync(outputFile)) {
return { outputFile, success: true, skipped: true };
}
return new Promise((resolve) => {
const proc = spawn('ffmpeg', [
'-i', inputFile,
'-progress', 'pipe:1',
'-nostats',
'-loglevel', 'error',
'-codec:a', 'libmp3lame',
'-b:a', '320k',
outputFile,
'-y',
], { stdio: ['ignore', 'pipe', 'pipe'] });
let buf = '';
let lastPercent = -1;
let stderrText = '';
proc.stdout.on('data', (chunk: Buffer) => {
buf += chunk.toString();
const lines = buf.split('\n');
buf = lines.pop() ?? '';
for (const line of lines) {
const m = line.match(/^out_time_us=(\d+)$/);
if (m && duration) {
const pct = Math.min(100, Math.round((parseInt(m[1]!, 10) / 1_000_000 / duration) * 100));
if (pct >= lastPercent + 5) {
lastPercent = pct;
update(onUpdate, `${label} ${pct}%`);
}
}
}
});
proc.stderr.on('data', (chunk: Buffer) => {
stderrText += chunk.toString();
});
proc.on('close', (code) => {
if (code !== 0) {
if (existsSync(outputFile)) unlinkSync(outputFile);
resolve({ outputFile, success: false, error: stderrText.trim() || 'ffmpeg failed' });
} else {
resolve({ outputFile, success: true });
}
});
proc.on('error', (err) => {
resolve({ outputFile, success: false, error: err.message });
});
});
}
export async function execute(
_toolCallId: string,
params: { mode: 'single' | 'batch'; path: string },
_signal: AbortSignal | undefined,
onUpdate?: OnUpdate,
) {
const { mode, path } = params;
// ── Single file ──────────────────────────────────────────────────────────
if (mode === 'single') {
if (!existsSync(path)) {
return {
content: [{ type: 'text', text: `File not found: ${path}` }],
details: { error: 'file_not_found' },
isError: true,
};
}
const ext = extname(path).slice(1).toLowerCase();
if (!AUDIO_EXTS.has(ext)) {
return {
content: [{ type: 'text', text: `Unsupported format: .${ext}\nSupported: ${[...AUDIO_EXTS].join(', ')}` }],
details: { error: 'unsupported_format' },
isError: true,
};
}
update(onUpdate, `Getting duration of ${basename(path)}...`);
const duration = await getDuration(path);
update(onUpdate, `Converting ${basename(path)}...`);
const result = await convertFile(path, duration, 'Progress:', onUpdate);
if (result.skipped) {
return {
content: [{ type: 'text', text: `Skipped: ${result.outputFile} already exists` }],
details: { skipped: true, outputFile: result.outputFile },
};
}
if (!result.success) {
return {
content: [{ type: 'text', text: `Failed to convert ${basename(path)}:\n${result.error}` }],
details: { error: result.error },
isError: true,
};
}
unlinkSync(path);
return {
content: [{ type: 'text', text: `Done.\nConverted: ${basename(result.outputFile)}\nDeleted source: ${basename(path)}` }],
details: { outputFile: result.outputFile },
};
}
// ── Batch ─────────────────────────────────────────────────────────────────
if (!existsSync(path)) {
return {
content: [{ type: 'text', text: `Directory not found: ${path}` }],
details: { error: 'dir_not_found' },
isError: true,
};
}
if (!statSync(path).isDirectory()) {
return {
content: [{ type: 'text', text: `Not a directory: ${path}\nUse mode="single" for individual files.` }],
details: { error: 'not_a_directory' },
isError: true,
};
}
update(onUpdate, `Scanning ${basename(path)} for audio files...`);
const audioFiles = scanAudioFiles(path);
if (audioFiles.length === 0) {
return {
content: [{ type: 'text', text: `No audio files found in: ${path}` }],
details: { found: 0 },
};
}
update(onUpdate, `Found ${audioFiles.length} audio files. Starting conversion...`);
type BatchResult = ConvertResult & { input: string };
const results: BatchResult[] = [];
for (let i = 0; i < audioFiles.length; i++) {
const inputFile = audioFiles[i]!;
const label = `[${i + 1}/${audioFiles.length}] ${basename(inputFile)}`;
update(onUpdate, `Converting ${label}...`);
const duration = await getDuration(inputFile);
const result = await convertFile(inputFile, duration, label, onUpdate);
results.push({ ...result, input: inputFile });
if (result.skipped) {
update(onUpdate, `→ Skipped ${label} (MP3 already exists)`);
} else if (result.success) {
update(onUpdate, `✓ Done ${label}`);
} else {
update(onUpdate, `✗ Failed ${label}: ${result.error}`);
}
}
const converted = results.filter((r) => r.success && !r.skipped);
const failed = results.filter((r) => !r.success);
const skipped = results.filter((r) => r.skipped);
if (failed.length === 0) {
// All succeeded — delete source files
update(onUpdate, `All conversions succeeded. Deleting ${converted.length} source files...`);
for (const r of converted) unlinkSync(r.input);
const lines = [
`Conversion complete.`,
`Converted: ${converted.length}`,
skipped.length > 0 ? `Skipped (already existed): ${skipped.length}` : null,
`Source files deleted: ${converted.length}`,
].filter(Boolean);
return {
content: [{ type: 'text', text: lines.join('\n') }],
details: { converted: converted.length, failed: 0, skipped: skipped.length },
};
}
// Some failed — delete created MP3s for a clean retry
update(onUpdate, `${failed.length} failure(s). Rolling back ${converted.length} created MP3(s) for clean retry...`);
for (const r of converted) {
if (existsSync(r.outputFile)) unlinkSync(r.outputFile);
}
const failLines = failed.map((r) => ` - ${basename(r.input)}: ${r.error}`).join('\n');
return {
content: [{
type: 'text',
text: [
`Conversion failed. ${failed.length}/${audioFiles.length} file(s) could not be converted.`,
`Successfully created MP3s have been removed — the directory is unchanged for a clean retry.`,
``,
`Failed files:`,
failLines,
].join('\n'),
}],
details: { converted: 0, failed: failed.length, rolledBack: converted.length },
isError: true,
};
}
-120
View File
@@ -1,120 +0,0 @@
---
version: 9
name: email_db
label: Email Database
description: Query, search, aggregate, and manage the user's email database. Use this tool to answer questions about emails, find messages by sender/domain/date/content, get statistics, and delete emails. The database is a local SQLite copy of the user's synced Gmail. All actions default to inbox scope — use folder parameter to query other folders like sent, spam, trash, or 'all' for everything.
language: typescript
inputs:
action:
type: string
description: "Action to perform: query, search, stats, count, domains, senders, attachments, attachment-types, extract-attachment, delete"
sql:
type: string
description: "Raw SQL query for the 'query' action. Only SELECT statements are allowed unless action is 'delete'."
optional: true
search:
type: string
description: "Search term for the 'search' action. Searches subject, from, and snippet fields."
optional: true
domain:
type: string
description: "Domain to filter by (e.g. 'newsletter.example.com') for search, count, or delete actions."
optional: true
sender:
type: string
description: "Sender email address to filter by for search, count, or delete actions."
optional: true
before:
type: string
description: "ISO date string — only include emails before this date."
optional: true
after:
type: string
description: "ISO date string — only include emails after this date."
optional: true
content_type:
type: string
description: "Attachment content type filter. Full MIME type (e.g. 'image/jpeg') or just the type prefix (e.g. 'image' matches all image types). Used with 'attachments' action."
optional: true
email_id:
type: string
description: "Email ID for extract-attachment action."
optional: true
attachment_idx:
type: number
description: "Attachment index (0-based) for extract-attachment action."
optional: true
folder:
type: string
description: "Gmail folder/label to scope results. Defaults to 'inbox'. Use 'all' for all folders. Common values: inbox, sent, spam, trash."
optional: true
limit:
type: number
description: "Maximum number of results to return. No limit by default — all results are returned. If a query could return a very large number of results, ask the user if they'd like to set a limit before running it."
optional: true
---
# Email Database Tool
Query and manage the user's local email database (SQLite).
## Available Actions
- **query**: Run a raw SELECT query against the database. Use `sql` parameter.
- **search**: Full-text search across subject, from, and snippet. Use `search` parameter. Combine with `domain`, `sender`, `before`, `after` for filtering.
- **stats**: Get email statistics — total count, top domains, top senders, date range.
- **count**: Count emails matching filters (`domain`, `sender`, `before`, `after`).
- **domains**: List all sender domains with email counts, sorted by frequency.
- **senders**: List all senders with email counts, sorted by frequency.
- **attachments**: Search attachments by type, filename, sender, etc. Use `content_type` for type filtering (e.g. `image` for all images, `image/jpeg` for specific type). Combine with `domain`, `sender`, `before`, `after`, `search`.
- **attachment-types**: List all attachment content types with counts.
- **extract-attachment**: Extract an attachment file from the database and save it to ~/Downloads/. Requires `email_id` and `attachment_idx`. Use the `attachments` action first to find the email_id and idx. Output filename: `{YYYYMMDD}_{sender}_{email_id}_{idx}_{name}.{ext}` (e.g. `20250115_boss@company.com_abc123_0_invoice.pdf`).
- **delete**: Delete emails matching filters. Requires at least one of: `domain`, `sender`, `before`, `after`, or `sql` (with DELETE statement).
## Database Schema
```sql
emails (
id TEXT PRIMARY KEY,
integration TEXT, -- source: 'gmail', 'outlook', etc.
email_account TEXT, -- which account: 'user@gmail.com'
from_name TEXT, -- sender display name
from_address TEXT, -- sender email (lowercase)
from_domain TEXT, -- domain extracted from sender
to_address TEXT,
cc TEXT,
subject TEXT,
date TEXT, -- ISO 8601
snippet TEXT,
html TEXT,
text_body TEXT,
attachment_count INTEGER,
read INTEGER,
deleted INTEGER,
labels TEXT -- comma-separated label list (e.g. 'INBOX,UNREAD,CATEGORY_UPDATES')
)
attachments (
email_id TEXT,
idx INTEGER,
filename TEXT,
size INTEGER,
content_type TEXT,
content TEXT -- base64-encoded binary content (use extract-attachment action to export)
)
```
## Examples
- Search for invoices (inbox only): `action: "search", search: "invoice"`
- Search sent emails for invoices: `action: "search", search: "invoice", folder: "sent"`
- Search all folders: `action: "search", search: "invoice", folder: "all"`
- Count emails from a domain: `action: "count", domain: "newsletter.com"`
- Delete all emails from a domain: `action: "delete", domain: "spam.com"`
- Top 10 domains: `action: "domains", limit: 10`
- Stats for spam folder: `action: "stats", folder: "spam"`
- List attachment types: `action: "attachment-types"`
- Find image attachments: `action: "attachments", content_type: "image"`
- Find PDFs from a sender: `action: "attachments", content_type: "application/pdf", sender: "boss@company.com"`
- Extract an attachment: `action: "extract-attachment", email_id: "abc123", attachment_idx: 0`
- Custom query: `action: "query", sql: "SELECT from_domain, COUNT(*) as n FROM emails GROUP BY from_domain HAVING n > 50 ORDER BY n DESC"`
-373
View File
@@ -1,373 +0,0 @@
import { execFileSync, spawn } from 'node:child_process';
import { existsSync, mkdirSync, createWriteStream } from 'node:fs';
import { join } from 'node:path';
type ToolResult = {
content: Array<{ type: string; text: string }>;
isError?: boolean;
};
type Params = {
action: string;
sql?: string;
search?: string;
domain?: string;
sender?: string;
before?: string;
after?: string;
content_type?: string;
folder?: string;
email_id?: string;
attachment_idx?: number;
limit?: number;
};
const DEFAULT_LIMIT = 0; // 0 = no limit
function limitClause(limit: number): string {
return limit > 0 ? ` LIMIT ${limit}` : '';
}
function getDbPath(): string {
return process.env.OFFICER_EMAIL_DB ?? '/officer/emails.db';
}
function sqlStr(v: string): string {
return `'${v.replace(/'/g, "''")}'`;
}
function queryJson(sql: string): Record<string, unknown>[] {
const output = execFileSync('sqlite3', ['-json', getDbPath()], {
input: sql,
encoding: 'utf-8',
timeout: 10000,
});
const trimmed = output.trim();
if (!trimmed) return [];
return JSON.parse(trimmed);
}
function execAndCount(sql: string): number {
const output = execFileSync('sqlite3', [getDbPath()], {
input: `${sql};\nSELECT changes();`,
encoding: 'utf-8',
timeout: 10000,
});
return parseInt(output.trim(), 10) || 0;
}
function ok(text: string): ToolResult {
return { content: [{ type: 'text', text }] };
}
function err(text: string): ToolResult {
return { content: [{ type: 'text', text }], isError: true };
}
function formatRows(rows: Record<string, unknown>[]): string {
if (rows.length === 0) return 'No results.';
const cols = Object.keys(rows[0]!);
const lines = rows.map((row, i) => {
const fields = cols.map((c) => `${c}: ${row[c] ?? ''}`).join(' | ');
return `${i + 1}. ${fields}`;
});
const header = `${rows.length} result${rows.length !== 1 ? 's' : ''}:`;
return [header, '', ...lines].join('\n');
}
// ── Helpers ──
function buildFolderCondition(folder: string | undefined): string | null {
if (!folder || folder === 'all') return null;
return `labels LIKE ${sqlStr(`%${folder}%`)}`;
}
function buildWhereClause(params: Params, defaultFolder?: string): string {
const conditions: string[] = [];
const folder = params.folder ?? defaultFolder;
const folderCond = buildFolderCondition(folder);
if (folderCond) conditions.push(folderCond);
if (params.domain) {
conditions.push(`from_domain = ${sqlStr(params.domain.toLowerCase())}`);
}
if (params.sender) {
conditions.push(`from_address = ${sqlStr(params.sender.toLowerCase())}`);
}
if (params.before) {
conditions.push(`date < ${sqlStr(params.before)}`);
}
if (params.after) {
conditions.push(`date > ${sqlStr(params.after)}`);
}
if (params.search) {
const escaped = sqlStr(`%${params.search}%`);
conditions.push(`(subject LIKE ${escaped} OR from_address LIKE ${escaped} OR from_name LIKE ${escaped} OR snippet LIKE ${escaped})`);
}
return conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
}
function withActive(where: string): string {
return where ? `${where} AND deleted = 0` : 'WHERE deleted = 0';
}
// ── Actions ──
function runQuery(sql: string, limit: number): string {
const trimmed = sql.trim().toLowerCase();
if (!trimmed.startsWith('select')) {
throw new Error('Only SELECT statements are allowed in query action.');
}
const rows = queryJson(sql);
return formatRows(rows);
}
function searchEmails(params: Params, limit: number): string {
const where = withActive(buildWhereClause(params, 'inbox'));
const rows = queryJson(`SELECT id, from_name, from_address, subject, date, snippet, attachment_count FROM emails ${where} ORDER BY date DESC ${limitClause(limit)}`);
return formatRows(rows);
}
function getStats(params: Params): string {
const folderWhere = withActive(buildWhereClause({ action: 'stats', folder: params.folder }, 'inbox'));
const totalRows = queryJson(`SELECT COUNT(*) as count FROM emails ${folderWhere}`);
const total = (totalRows[0]?.count as number) ?? 0;
if (total === 0) return 'Email database is empty.';
const dateRange = queryJson(`SELECT MIN(date) as oldest, MAX(date) as newest FROM emails ${folderWhere}`);
const topDomains = queryJson(`SELECT from_domain, COUNT(*) as count FROM emails ${folderWhere} GROUP BY from_domain ORDER BY count DESC LIMIT 10`);
const topSenders = queryJson(`SELECT from_address, from_name, COUNT(*) as count FROM emails ${folderWhere} GROUP BY from_address ORDER BY count DESC LIMIT 10`);
const attRows = queryJson(`SELECT COUNT(*) as count FROM emails ${folderWhere} AND attachment_count > 0`);
const withAttachments = (attRows[0]?.count as number) ?? 0;
const dr = dateRange[0] ?? {};
const lines = [
`**Email Database Statistics**`,
``,
`Total emails: ${total}`,
`With attachments: ${withAttachments}`,
`Date range: ${(dr.oldest as string)?.slice(0, 10)} to ${(dr.newest as string)?.slice(0, 10)}`,
``,
`**Top 10 Domains:**`,
...topDomains.map((d, i) => `${i + 1}. ${d.from_domain} (${d.count})`),
``,
`**Top 10 Senders:**`,
...topSenders.map((s, i) => `${i + 1}. ${s.from_name ? `${s.from_name} <${s.from_address}>` : s.from_address} (${s.count})`),
];
return lines.join('\n');
}
function countEmails(params: Params): string {
const where = withActive(buildWhereClause(params, 'inbox'));
const rows = queryJson(`SELECT COUNT(*) as count FROM emails ${where}`);
const count = (rows[0]?.count as number) ?? 0;
const filters: string[] = [];
if (params.domain) filters.push(`domain=${params.domain}`);
if (params.sender) filters.push(`sender=${params.sender}`);
if (params.before) filters.push(`before=${params.before}`);
if (params.after) filters.push(`after=${params.after}`);
if (params.search) filters.push(`search="${params.search}"`);
const desc = filters.length > 0 ? ` matching ${filters.join(', ')}` : '';
return `${count} email${count !== 1 ? 's' : ''}${desc}.`;
}
function listDomains(params: Params, limit: number): string {
const where = withActive(buildWhereClause({ action: 'domains', folder: params.folder }, 'inbox'));
const rows = queryJson(`SELECT from_domain, COUNT(*) as count FROM emails ${where} GROUP BY from_domain ORDER BY count DESC ${limitClause(limit)}`);
if (rows.length === 0) return 'No emails in database.';
const lines = rows.map((d, i) => `${i + 1}. ${d.from_domain}${d.count} email${(d.count as number) !== 1 ? 's' : ''}`);
return [`**Sender Domains** (${rows.length}):`, '', ...lines].join('\n');
}
function listSenders(params: Params, limit: number): string {
const where = withActive(buildWhereClause({ action: 'senders', folder: params.folder }, 'inbox'));
const rows = queryJson(`SELECT from_address, from_name, COUNT(*) as count FROM emails ${where} GROUP BY from_address ORDER BY count DESC ${limitClause(limit)}`);
if (rows.length === 0) return 'No emails in database.';
const lines = rows.map((s, i) => {
const display = s.from_name ? `${s.from_name} <${s.from_address}>` : s.from_address;
return `${i + 1}. ${display}${s.count} email${(s.count as number) !== 1 ? 's' : ''}`;
});
return [`**Senders** (${rows.length}):`, '', ...lines].join('\n');
}
function listAttachmentTypes(limit: number): string {
const rows = queryJson(`SELECT content_type, COUNT(*) as count FROM attachments a JOIN emails e ON a.email_id = e.id WHERE e.deleted = 0 GROUP BY content_type ORDER BY count DESC ${limitClause(limit)}`);
if (rows.length === 0) return 'No attachments in database.';
const totalRows = queryJson('SELECT COUNT(*) as count FROM attachments a JOIN emails e ON a.email_id = e.id WHERE e.deleted = 0');
const total = (totalRows[0]?.count as number) ?? 0;
const lines = rows.map((r, i) => `${i + 1}. ${r.content_type}${r.count}`);
return [`**Attachment Types** (${total} total):`, '', ...lines].join('\n');
}
function searchAttachments(params: Params, limit: number): string {
const conditions: string[] = ['e.deleted = 0'];
if (params.content_type) {
const ct = params.content_type as string;
if (ct.includes('/')) {
conditions.push(`a.content_type = ${sqlStr(ct)}`);
} else {
conditions.push(`a.content_type LIKE ${sqlStr(ct + '/%')}`);
}
}
if (params.domain) conditions.push(`e.from_domain = ${sqlStr(params.domain.toLowerCase())}`);
if (params.sender) conditions.push(`e.from_address = ${sqlStr(params.sender.toLowerCase())}`);
if (params.before) conditions.push(`e.date < ${sqlStr(params.before)}`);
if (params.after) conditions.push(`e.date > ${sqlStr(params.after)}`);
if (params.search) {
const escaped = sqlStr(`%${params.search}%`);
conditions.push(`(a.filename LIKE ${escaped} OR e.subject LIKE ${escaped})`);
}
const where = `WHERE ${conditions.join(' AND ')}`;
const rows = queryJson(`SELECT a.filename, a.size, a.content_type, e.id as email_id, e.from_address, e.subject, e.date FROM attachments a JOIN emails e ON a.email_id = e.id ${where} ORDER BY e.date DESC ${limitClause(limit)}`);
return formatRows(rows);
}
async function extractAttachment(params: Params): Promise<string> {
if (!params.email_id) throw new Error('email_id is required for extract-attachment action.');
if (params.attachment_idx === undefined) throw new Error('attachment_idx is required for extract-attachment action.');
// Metadata query (small)
const rows = queryJson(
`SELECT a.filename, a.content_type, e.date, e.from_address FROM attachments a JOIN emails e ON a.email_id = e.id WHERE a.email_id = ${sqlStr(params.email_id)} AND a.idx = ${params.attachment_idx}`,
);
if (rows.length === 0) throw new Error(`No attachment found for email_id=${params.email_id} idx=${params.attachment_idx}.`);
const row = rows[0]!;
const rawName = (row.filename as string) || `attachment_${params.attachment_idx}`;
// Decode MIME encoded-words (e.g. =?iso-8859-1?Q?PRE=C7OS?=)
const originalName = rawName.replace(/=\?([^?]+)\?(B|Q)\?([^?]+)\?=/gi, (_, _charset: string, encoding: string, encoded: string) => {
if (encoding.toUpperCase() === 'B') return Buffer.from(encoded, 'base64').toString('utf-8');
return encoded.replace(/=([0-9A-Fa-f]{2})/g, (__, hex: string) => String.fromCharCode(parseInt(hex, 16))).replace(/_/g, ' ');
});
const ext = originalName.includes('.') ? originalName.slice(originalName.lastIndexOf('.')) : '';
const baseName = originalName.includes('.') ? originalName.slice(0, originalName.lastIndexOf('.')) : originalName;
const timestamp = ((row.date as string) ?? '').slice(0, 10).replace(/-/g, '');
const sender = (row.from_address as string) ?? 'unknown';
const filename = `${timestamp}_${sender}_${params.email_id}_${params.attachment_idx}_${baseName}${ext}`;
const outDir = join(process.env.OFFICER_USER_HOME ?? process.env.HOME ?? '/tmp', 'Downloads');
mkdirSync(outDir, { recursive: true });
const outPath = join(outDir, filename);
// Stream base64 content from sqlite3 → decode → write to file (constant memory)
const totalBytes = await new Promise<number>((resolve, reject) => {
const proc = spawn('sqlite3', [getDbPath()]);
const out = createWriteStream(outPath);
let remainder = '';
let bytes = 0;
proc.stdout.on('data', (chunk: Buffer) => {
const str = remainder + chunk.toString().replace(/[\s\r\n]/g, '');
const validLen = str.length - (str.length % 4);
if (validLen > 0) {
const decoded = Buffer.from(str.slice(0, validLen), 'base64');
out.write(decoded);
bytes += decoded.length;
}
remainder = str.slice(validLen);
});
proc.stdout.on('end', () => {
if (remainder.length > 0) {
const decoded = Buffer.from(remainder, 'base64');
out.write(decoded);
bytes += decoded.length;
}
out.end(() => resolve(bytes));
});
proc.stderr.on('data', (chunk: Buffer) => reject(new Error(chunk.toString())));
proc.on('error', reject);
proc.stdin.write(`SELECT content FROM attachments WHERE email_id = ${sqlStr(params.email_id)} AND idx = ${params.attachment_idx};\n`);
proc.stdin.end();
});
if (totalBytes === 0) throw new Error('Attachment content is empty in the database.');
return `Extracted "${filename}" (${row.content_type}, ${totalBytes} bytes) to:\n${outPath}`;
}
function deleteEmails(params: Params): string {
const where = buildWhereClause(params, 'inbox');
if (!where) {
throw new Error('Delete requires at least one filter (domain, sender, before, after, or search).');
}
const activeWhere = withActive(where);
const countRows = queryJson(`SELECT COUNT(*) as count FROM emails ${activeWhere}`);
const count = (countRows[0]?.count as number) ?? 0;
if (count === 0) return 'No emails match the given filters.';
const changes = execAndCount(`UPDATE emails SET deleted = 1 ${activeWhere}`);
return `Deleted ${changes} email${changes !== 1 ? 's' : ''}.`;
}
// ── Main ──
export async function execute(_toolCallId: string, params: Params): Promise<ToolResult> {
const limit = params.limit ?? DEFAULT_LIMIT;
const dbPath = getDbPath();
if (!existsSync(dbPath)) {
return err(`Email database not found at ${dbPath}. Has Gmail been synced?`);
}
try {
switch (params.action) {
case 'query':
if (!params.sql) return err('sql parameter is required for query action.');
return ok(runQuery(params.sql, limit));
case 'search':
if (!params.search && !params.domain && !params.sender && !params.before && !params.after) {
return err('At least one filter is required: search, domain, sender, before, or after.');
}
return ok(searchEmails(params, limit));
case 'stats':
return ok(getStats(params));
case 'count':
return ok(countEmails(params));
case 'domains':
return ok(listDomains(params, limit));
case 'senders':
return ok(listSenders(params, limit));
case 'attachment-types':
return ok(listAttachmentTypes(limit));
case 'attachments':
if (!params.content_type && !params.search && !params.domain && !params.sender && !params.before && !params.after) {
return err('At least one filter is required: content_type, search, domain, sender, before, or after.');
}
return ok(searchAttachments(params, limit));
case 'extract-attachment':
return ok(await extractAttachment(params));
case 'delete':
return ok(deleteEmails(params));
default:
return err(`Unknown action: "${params.action}". Available: query, search, stats, count, domains, senders, attachments, attachment-types, extract-attachment, delete.`);
}
} catch (e) {
return err(`Email DB error: ${e instanceof Error ? e.message : String(e)}`);
}
}
-94
View File
@@ -1,94 +0,0 @@
---
name: ffmpeg
label: FFmpeg
description: Run ffmpeg and ffprobe commands for audio/video processing. Converts formats, extracts audio/video streams, trims, merges, adjusts volume, changes resolution, extracts frames, and more. Auto-installs ffmpeg if not available. Use when the user needs any audio or video manipulation.
version: 1
language: typescript
inputs:
command:
type: enum
values: ffmpeg,ffprobe
description: "ffmpeg: process audio/video. ffprobe: inspect file metadata (duration, codecs, streams, etc.)"
args:
type: string
description: "Command-line arguments as a single string. Do NOT include the ffmpeg/ffprobe binary name — only the arguments. Example: '-i input.mp4 -vn -codec:a libmp3lame -b:a 320k output.mp3'"
---
# FFmpeg
General-purpose audio/video processing via ffmpeg and ffprobe.
## Prerequisites
This tool requires `ffmpeg` and `ffprobe`. It will automatically install them if not available:
```bash
sudo apt-get update && sudo apt-get install -y ffmpeg
```
All sudo commands run without a password prompt in this environment.
## Usage
### ffprobe — Inspect files
Get duration, codecs, streams, bitrate, and other metadata:
```
command: ffprobe
args: -v quiet -print_format json -show_format -show_streams input.mp4
```
### ffmpeg — Process files
Always include `-y` to overwrite output files without prompting.
**Convert video to MP4:**
```
command: ffmpeg
args: -i input.avi -codec:v libx264 -codec:a aac output.mp4 -y
```
**Extract audio from video:**
```
command: ffmpeg
args: -i video.mp4 -vn -codec:a libmp3lame -b:a 320k audio.mp3 -y
```
**Trim a clip:**
```
command: ffmpeg
args: -i input.mp4 -ss 00:01:30 -to 00:03:00 -codec copy clip.mp4 -y
```
**Change resolution:**
```
command: ffmpeg
args: -i input.mp4 -vf scale=1280:720 -codec:a copy output.mp4 -y
```
**Extract a frame as image:**
```
command: ffmpeg
args: -i video.mp4 -ss 00:00:10 -frames:v 1 frame.png -y
```
**Merge audio and video:**
```
command: ffmpeg
args: -i video.mp4 -i audio.mp3 -codec:v copy -codec:a aac -shortest merged.mp4 -y
```
**Convert audio format:**
```
command: ffmpeg
args: -i input.flac -codec:a libmp3lame -b:a 320k output.mp3 -y
```
## Notes
- Use absolute paths for input and output files.
- Always add `-y` to ffmpeg args to avoid interactive prompts.
- For long operations, progress is streamed in real time.
- stderr output from ffmpeg is captured and returned on failure.
- The tool has a 10-minute timeout.
-173
View File
@@ -1,173 +0,0 @@
import { execFileSync, spawn } from 'node:child_process';
type ToolResult = {
content: Array<{ type: string; text: string }>;
isError?: boolean;
};
type OnUpdate = (partial: { content: Array<{ type: string; text: string }> }) => void;
function update(onUpdate: OnUpdate | undefined, text: string): void {
onUpdate?.({ content: [{ type: 'text', text }] });
}
function ensureFfmpeg(): boolean {
try {
execFileSync('ffmpeg', ['-version'], { stdio: 'ignore' });
return true;
} catch {
try {
execFileSync('sudo', ['apt-get', 'update'], { stdio: 'ignore', timeout: 60_000 });
execFileSync('sudo', ['apt-get', 'install', '-y', 'ffmpeg'], { stdio: 'ignore', timeout: 120_000 });
execFileSync('ffmpeg', ['-version'], { stdio: 'ignore' });
return true;
} catch {
return false;
}
}
}
function parseArgs(argsString: string): string[] {
const args: string[] = [];
let current = '';
let inSingle = false;
let inDouble = false;
for (let i = 0; i < argsString.length; i++) {
const ch = argsString[i]!;
if (ch === "'" && !inDouble) {
inSingle = !inSingle;
} else if (ch === '"' && !inSingle) {
inDouble = !inDouble;
} else if (ch === ' ' && !inSingle && !inDouble) {
if (current.length > 0) {
args.push(current);
current = '';
}
} else {
current += ch;
}
}
if (current.length > 0) args.push(current);
return args;
}
export async function execute(
_toolCallId: string,
params: { command: 'ffmpeg' | 'ffprobe'; args: string },
_signal: AbortSignal | undefined,
onUpdate?: OnUpdate,
): Promise<ToolResult> {
const { command, args: argsString } = params;
if (!argsString || argsString.trim().length === 0) {
return {
content: [{ type: 'text', text: 'No arguments provided. See tool documentation for usage examples.' }],
isError: true,
};
}
update(onUpdate, 'Checking ffmpeg installation...');
if (!ensureFfmpeg()) {
return {
content: [{ type: 'text', text: 'Failed to install ffmpeg. Try manually: sudo apt-get update && sudo apt-get install -y ffmpeg' }],
isError: true,
};
}
const args = parseArgs(argsString);
// For ffprobe, run synchronously and return output
if (command === 'ffprobe') {
update(onUpdate, `Running ffprobe...`);
return new Promise((resolve) => {
const proc = spawn('ffprobe', args, { stdio: ['ignore', 'pipe', 'pipe'] });
let stdout = '';
let stderr = '';
proc.stdout.on('data', (chunk: Buffer) => { stdout += chunk.toString(); });
proc.stderr.on('data', (chunk: Buffer) => { stderr += chunk.toString(); });
proc.on('close', (code) => {
if (code !== 0) {
resolve({
content: [{ type: 'text', text: `ffprobe failed (exit ${code}):\n${stderr.trim()}` }],
isError: true,
});
} else {
// ffprobe prints info to stderr by default, stdout for -print_format
const output = stdout.trim() || stderr.trim();
resolve({
content: [{ type: 'text', text: output || 'No output' }],
});
}
});
proc.on('error', (err) => {
resolve({
content: [{ type: 'text', text: `ffprobe error: ${err.message}` }],
isError: true,
});
});
});
}
// For ffmpeg, stream progress
update(onUpdate, `Running ffmpeg...`);
return new Promise((resolve) => {
const proc = spawn('ffmpeg', args, { stdio: ['ignore', 'pipe', 'pipe'] });
let stdout = '';
let stderr = '';
let progressBuf = '';
proc.stdout.on('data', (chunk: Buffer) => {
stdout += chunk.toString();
});
proc.stderr.on('data', (chunk: Buffer) => {
const text = chunk.toString();
stderr += text;
// Parse ffmpeg progress from stderr (time= field)
progressBuf += text;
const lines = progressBuf.split('\r');
progressBuf = lines.pop() ?? '';
for (const line of lines) {
const timeMatch = line.match(/time=(\d{2}:\d{2}:\d{2}\.\d{2})/);
const speedMatch = line.match(/speed=\s*([\d.]+x)/);
if (timeMatch) {
const progress = `Time: ${timeMatch[1]}${speedMatch ? ` | Speed: ${speedMatch[1]}` : ''}`;
update(onUpdate, progress);
}
}
});
proc.on('close', (code) => {
if (code !== 0) {
// Extract the last meaningful error line from stderr
const errLines = stderr.trim().split('\n');
const lastLines = errLines.slice(-10).join('\n');
resolve({
content: [{ type: 'text', text: `ffmpeg failed (exit ${code}):\n${lastLines}` }],
isError: true,
});
} else {
const output = stdout.trim();
resolve({
content: [{ type: 'text', text: output ? `Done.\n\n${output}` : 'Done.' }],
});
}
});
proc.on('error', (err) => {
resolve({
content: [{ type: 'text', text: `ffmpeg error: ${err.message}` }],
isError: true,
});
});
});
}
-58
View File
@@ -1,58 +0,0 @@
---
name: gmail
label: Gmail
description: Read Gmail messages, threads, and labels using the connected Google account. Use this tool to check emails, search for specific messages, read email content, list labels, or get mailbox profile info. Requires the user to have connected their Google account in Settings → Integrations.
language: typescript
inputs:
action:
type: string
description: "Action to perform: list_messages, get_message, list_labels, get_thread, get_profile, sync_inbox"
query:
type: string
description: "Gmail search query for list_messages (e.g. 'is:unread', 'from:user@example.com', 'subject:invoice after:2024/01/01')"
optional: true
message_id:
type: string
description: Message ID for get_message
optional: true
thread_id:
type: string
description: Thread ID for get_thread
optional: true
max_results:
type: string
description: Maximum number of results for list actions (default 10, max 50)
optional: true
output_dir:
type: string
description: Directory path to save email files (for sync_inbox action)
optional: true
---
# Gmail Tool
Read-only access to the user's Gmail account via the Gmail REST API.
## Available Actions
- **list_messages**: List or search messages. Use `query` for Gmail search syntax.
- **get_message**: Get full message content by `message_id`.
- **list_labels**: List all Gmail labels with message counts.
- **get_thread**: Get all messages in a thread by `thread_id`.
- **get_profile**: Get the user's Gmail profile info.
- **sync_inbox**: Bulk-download emails to disk. Requires `output_dir`. Use `query` to filter (e.g. `after:2026/02/01 before:2026/03/01`). Handles pagination internally, saves each email as a markdown file, skips already-saved messages. Returns only a summary count — does NOT flood context with email bodies.
## Query Syntax
Gmail search operators for the `query` parameter:
- `is:unread`, `is:read`, `is:starred`
- `from:email@example.com`, `to:email@example.com`
- `subject:"search term"`, `has:attachment`
- `after:YYYY/MM/DD`, `before:YYYY/MM/DD`
- `in:inbox`, `in:sent`, `in:trash`
- `larger:1M`, `smaller:100K`
- Combine with AND, OR, - (NOT)
## Scope
This tool has **read-only** access (gmail.readonly scope). It cannot send, modify, or delete messages.
-446
View File
@@ -1,446 +0,0 @@
import { readFileSync, writeFileSync, mkdirSync, readdirSync } from 'node:fs';
import { join } from 'node:path';
type GoogleCredentials = {
accessToken: string;
refreshToken: string;
expiresAt: number;
clientId: string;
clientSecret: string;
};
type ToolResult = {
content: Array<{ type: string; text: string }>;
isError?: boolean;
};
type Params = {
action: string;
query?: string;
message_id?: string;
thread_id?: string;
max_results?: string | number;
output_dir?: string;
};
function readCredentials(): GoogleCredentials | null {
try {
const configPath = process.env.OFFICER_GOOGLE_CONFIG_PATH;
const tokenPath = process.env.OFFICER_GOOGLE_TOKEN_PATH;
if (!configPath || !tokenPath) return null;
const config = JSON.parse(readFileSync(configPath, 'utf-8')) as { clientId?: string; clientSecret?: string };
const token = JSON.parse(readFileSync(tokenPath, 'utf-8')) as { accessToken?: string; refreshToken?: string; expiresAt?: number };
if (!config.clientId || !config.clientSecret || !token.accessToken) return null;
return {
accessToken: token.accessToken,
refreshToken: token.refreshToken ?? '',
expiresAt: token.expiresAt ?? 0,
clientId: config.clientId,
clientSecret: config.clientSecret,
};
} catch {
return null;
}
}
let cachedAccessToken: string | null = null;
let cachedExpiresAt = 0;
async function getValidAccessToken(creds: GoogleCredentials): Promise<string> {
if (cachedAccessToken && cachedExpiresAt > Date.now() + 5 * 60 * 1000) {
return cachedAccessToken;
}
if (creds.expiresAt > Date.now() + 5 * 60 * 1000) {
cachedAccessToken = creds.accessToken;
cachedExpiresAt = creds.expiresAt;
return creds.accessToken;
}
if (!creds.refreshToken) throw new Error('Token expired and no refresh token available');
const res = await fetch('https://oauth2.googleapis.com/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
client_id: creds.clientId,
client_secret: creds.clientSecret,
refresh_token: creds.refreshToken,
grant_type: 'refresh_token',
}),
});
if (!res.ok) {
const error = await res.text().catch(() => '');
throw new Error(`Token refresh failed (${res.status}): ${error}`);
}
const data = (await res.json()) as { access_token: string; expires_in?: number };
cachedAccessToken = data.access_token;
cachedExpiresAt = Date.now() + (data.expires_in ?? 3600) * 1000;
return data.access_token;
}
const GMAIL_BASE = 'https://gmail.googleapis.com/gmail/v1/users/me';
async function gmailGet(token: string, path: string, params?: Record<string, string>): Promise<unknown> {
const url = new URL(`${GMAIL_BASE}${path}`);
if (params) {
for (const [k, v] of Object.entries(params)) {
if (v) url.searchParams.set(k, v);
}
}
const res = await fetch(url.toString(), {
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok) {
const error = await res.text().catch(() => '');
throw new Error(`Gmail API error (${res.status}): ${error}`);
}
return res.json();
}
function decodeBase64Url(data: string): string {
if (!data) return '';
const base64 = data.replace(/-/g, '+').replace(/_/g, '/');
return Buffer.from(base64, 'base64').toString('utf-8');
}
type GmailHeader = { name: string; value: string };
type GmailPayload = {
mimeType?: string;
headers?: GmailHeader[];
body?: { data?: string; attachmentId?: string };
parts?: GmailPayload[];
};
function getHeader(headers: GmailHeader[], name: string): string {
return headers?.find((h) => h.name.toLowerCase() === name.toLowerCase())?.value ?? '';
}
function extractBody(payload: GmailPayload): { text: string; html: string } {
const result = { text: '', html: '' };
if (payload.mimeType?.startsWith('multipart')) {
for (const part of payload.parts ?? []) {
if (part.mimeType === 'text/plain' && !result.text) {
result.text = decodeBase64Url(part.body?.data ?? '');
} else if (part.mimeType === 'text/html' && !result.html) {
result.html = decodeBase64Url(part.body?.data ?? '');
} else if (part.mimeType?.startsWith('multipart')) {
const nested = extractBody(part);
if (!result.text && nested.text) result.text = nested.text;
if (!result.html && nested.html) result.html = nested.html;
}
}
} else if (payload.mimeType === 'text/html') {
result.html = decodeBase64Url(payload.body?.data ?? '');
} else {
result.text = decodeBase64Url(payload.body?.data ?? '');
}
return result;
}
function listAttachments(payload: GmailPayload): string[] {
const names: string[] = [];
for (const part of payload.parts ?? []) {
if (part.body?.attachmentId && (part as { filename?: string }).filename) {
names.push((part as { filename: string }).filename);
}
if (part.parts) names.push(...listAttachments(part));
}
return names;
}
// --- Actions ---
async function listMessages(token: string, query?: string, maxResults = 10): Promise<string> {
const params: Record<string, string> = { maxResults: String(Math.min(maxResults, 50)) };
if (query) params.q = query;
const list = (await gmailGet(token, '/messages', params)) as {
messages?: Array<{ id: string; threadId: string }>;
resultSizeEstimate?: number;
};
const messages = list.messages ?? [];
if (messages.length === 0) return 'No messages found.';
const details = await Promise.all(
messages.map((m) =>
gmailGet(token, `/messages/${m.id}`, {
format: 'metadata',
metadataHeaders: 'Subject,From,Date',
}),
),
);
const lines = details.map((msg: any) => {
const headers: GmailHeader[] = msg.payload?.headers ?? [];
const from = getHeader(headers, 'From');
const subject = getHeader(headers, 'Subject') || '(no subject)';
const date = getHeader(headers, 'Date');
const labels = (msg.labelIds ?? []).join(', ');
const snippet = msg.snippet ?? '';
return [`**${subject}**`, `From: ${from}`, `Date: ${date}`, `ID: ${msg.id}`, `Labels: ${labels}`, snippet, ''].join(
'\n',
);
});
const header = query ? `Messages matching "${query}" (${list.resultSizeEstimate ?? '?'} estimated):` : `Messages (${list.resultSizeEstimate ?? '?'} estimated):`;
return [header, '', ...lines].join('\n');
}
async function getMessage(token: string, messageId: string): Promise<string> {
const msg = (await gmailGet(token, `/messages/${messageId}`, { format: 'full' })) as {
id: string;
threadId: string;
labelIds?: string[];
snippet?: string;
internalDate?: string;
payload: GmailPayload;
};
const headers: GmailHeader[] = msg.payload.headers ?? [];
const from = getHeader(headers, 'From');
const to = getHeader(headers, 'To');
const cc = getHeader(headers, 'Cc');
const subject = getHeader(headers, 'Subject') || '(no subject)';
const date = getHeader(headers, 'Date');
const { text, html } = extractBody(msg.payload);
const body = text || (html ? '[HTML content — plain text not available]' : '(empty body)');
const attachments = listAttachments(msg.payload);
const parts = [
`**${subject}**`,
`From: ${from}`,
`To: ${to}`,
cc ? `Cc: ${cc}` : '',
`Date: ${date}`,
`ID: ${msg.id} | Thread: ${msg.threadId}`,
`Labels: ${(msg.labelIds ?? []).join(', ')}`,
attachments.length > 0 ? `Attachments: ${attachments.join(', ')}` : '',
'',
body,
];
return parts.filter(Boolean).join('\n');
}
async function listLabels(token: string): Promise<string> {
const result = (await gmailGet(token, '/labels')) as {
labels?: Array<{
id: string;
name: string;
type: string;
messagesTotal?: number;
messagesUnread?: number;
}>;
};
const labels = result.labels ?? [];
if (labels.length === 0) return 'No labels found.';
const system = labels.filter((l) => l.type === 'system');
const user = labels.filter((l) => l.type === 'user');
const formatLabel = (l: (typeof labels)[0]) => {
const counts = l.messagesTotal != null ? ` (${l.messagesUnread ?? 0} unread / ${l.messagesTotal} total)` : '';
return `- ${l.name}${counts} [${l.id}]`;
};
const lines = [];
if (system.length) lines.push('**System Labels:**', ...system.map(formatLabel), '');
if (user.length) lines.push('**User Labels:**', ...user.map(formatLabel));
return lines.join('\n');
}
async function getThread(token: string, threadId: string): Promise<string> {
const thread = (await gmailGet(token, `/threads/${threadId}`, { format: 'full' })) as {
id: string;
messages?: Array<{
id: string;
labelIds?: string[];
payload: GmailPayload;
}>;
};
const messages = thread.messages ?? [];
if (messages.length === 0) return 'Thread has no messages.';
const parts = messages.map((msg, i) => {
const headers: GmailHeader[] = msg.payload.headers ?? [];
const from = getHeader(headers, 'From');
const date = getHeader(headers, 'Date');
const subject = getHeader(headers, 'Subject');
const { text } = extractBody(msg.payload);
const body = text || '(no plain text body)';
return [`--- Message ${i + 1} of ${messages.length} (${msg.id}) ---`, subject ? `Subject: ${subject}` : '', `From: ${from}`, `Date: ${date}`, '', body].filter(Boolean).join('\n');
});
return [`Thread ${thread.id} (${messages.length} messages):`, '', ...parts].join('\n\n');
}
async function getProfile(token: string): Promise<string> {
const profile = (await gmailGet(token, '/profile')) as {
emailAddress: string;
messagesTotal: number;
threadsTotal: number;
historyId: string;
};
return [
`Email: ${profile.emailAddress}`,
`Total messages: ${profile.messagesTotal}`,
`Total threads: ${profile.threadsTotal}`,
].join('\n');
}
// --- Sync ---
function slugify(text: string, maxLen = 60): string {
return text
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, maxLen)
.replace(/-+$/, '');
}
function buildEmlFilename(id: string, internalDate: string | undefined, rawEmail: string): string {
const subjectMatch = rawEmail.match(/^Subject:\s*(.+)$/mi);
const subject = subjectMatch?.[1]?.trim() || 'no-subject';
const ts = parseInt(internalDate || '0');
const d = new Date(ts);
const dateStr = ts > 0
? `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
: 'unknown-date';
return `${dateStr}_${slugify(subject)}_${id}.eml`;
}
async function syncInbox(token: string, outputDir: string, query?: string): Promise<string> {
mkdirSync(outputDir, { recursive: true });
// Build set of already-saved message IDs from filenames
const existingIds = new Set<string>();
try {
for (const file of readdirSync(outputDir)) {
const match = file.match(/_([a-f0-9]+)\.eml$/i);
if (match) existingIds.add(match[1]!);
}
} catch { /* dir might not exist yet */ }
let saved = 0;
let skipped = 0;
let errors = 0;
let pageToken: string | undefined;
do {
const params: Record<string, string> = { maxResults: '100' };
if (query) params.q = query;
if (pageToken) params.pageToken = pageToken;
const list = (await gmailGet(token, '/messages', params)) as {
messages?: Array<{ id: string }>;
nextPageToken?: string;
};
const messages = list.messages ?? [];
if (messages.length === 0) break;
// Process in batches of 5 to avoid rate limits
for (let i = 0; i < messages.length; i += 5) {
const batch = messages.slice(i, i + 5);
await Promise.all(
batch.map(async ({ id }) => {
if (existingIds.has(id)) {
skipped++;
return;
}
try {
const msg = (await gmailGet(token, `/messages/${id}`, { format: 'raw' })) as {
id: string;
internalDate?: string;
raw: string;
};
const rawEmail = Buffer.from(msg.raw, 'base64url').toString('utf-8');
const filename = buildEmlFilename(msg.id, msg.internalDate, rawEmail);
writeFileSync(join(outputDir, filename), rawEmail);
existingIds.add(id);
saved++;
} catch {
errors++;
}
}),
);
}
pageToken = list.nextPageToken;
} while (pageToken);
const parts = [`Saved ${saved} emails to ${outputDir}`];
if (skipped > 0) parts.push(`${skipped} already existed`);
if (errors > 0) parts.push(`${errors} failed`);
return parts.join(', ');
}
// --- Main ---
export async function execute(_toolCallId: string, params: Params): Promise<ToolResult> {
const creds = readCredentials();
if (!creds) {
return {
content: [{ type: 'text', text: 'Gmail is not available. The user needs to connect their Google account in Settings → Integrations.' }],
isError: true,
};
}
const { action, query, message_id, thread_id } = params;
const maxResults = params.max_results ? Number(params.max_results) : 10;
try {
const token = await getValidAccessToken(creds);
let result: string;
switch (action) {
case 'list_messages':
result = await listMessages(token, query, maxResults);
break;
case 'get_message':
if (!message_id) return { content: [{ type: 'text', text: 'message_id is required for get_message' }], isError: true };
result = await getMessage(token, message_id);
break;
case 'list_labels':
result = await listLabels(token);
break;
case 'get_thread':
if (!thread_id) return { content: [{ type: 'text', text: 'thread_id is required for get_thread' }], isError: true };
result = await getThread(token, thread_id);
break;
case 'get_profile':
result = await getProfile(token);
break;
case 'sync_inbox':
if (!params.output_dir) return { content: [{ type: 'text', text: 'output_dir is required for sync_inbox' }], isError: true };
result = await syncInbox(token, params.output_dir, query);
break;
default:
return { content: [{ type: 'text', text: `Unknown action: ${action}. Use list_messages, get_message, list_labels, get_thread, or get_profile.` }], isError: true };
}
return { content: [{ type: 'text', text: result }] };
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
return { content: [{ type: 'text', text: `Gmail error: ${message}` }], isError: true };
}
}
-27
View File
@@ -1,27 +0,0 @@
---
name: ocr
label: OCR
description: Extract text from an image file using the configured OCR service. Sends the image to the OCR API (OpenAI-compatible vision endpoint) and returns the extracted text. Use this tool whenever you need to read text from images, screenshots, documents, receipts, etc. Requires OCR to be configured in Settings → Resources.
language: typescript
inputs:
file_path:
type: string
description: Absolute path to the image file to extract text from
prompt:
type: string
description: Optional instructions for the OCR model (e.g. "extract only the table" or "return as markdown")
optional: true
---
# OCR Tool
Extracts text from images using the configured OCR resource (OpenAI-compatible vision API).
## Supported formats
PNG, JPEG, WebP, GIF, and other common image formats.
## Output
Returns the extracted text content. For documents, preserves structure as markdown.
For tables, uses markdown table format. For code screenshots, uses fenced code blocks.
-112
View File
@@ -1,112 +0,0 @@
import { readFileSync, existsSync } from 'node:fs';
import { extname } from 'node:path';
type OcrConfig = {
url: string;
model: string;
api_key?: string;
};
function getOcrConfig(): OcrConfig | null {
try {
const raw = process.env.OFFICER_RESOURCES;
if (!raw) return null;
const resources = JSON.parse(raw) as Record<string, Record<string, string>>;
const ocr = resources['optical-character-recognition'];
if (!ocr?.url) return null;
return { url: ocr.url, model: ocr.model ?? '', api_key: ocr.api_key };
} catch {
return null;
}
}
const DEFAULT_PROMPT = [
'You are an OCR assistant. Extract meaningful text content from images.',
'Rules:',
'- Output ONLY the extracted text, no commentary or explanations.',
'- For documents, articles, books: preserve the original text, paragraphs, and structure as markdown.',
'- For tables: use markdown table format.',
'- For code/terminal screenshots: use fenced code blocks.',
'- For handwritten text: do your best to transcribe accurately.',
'- For mixed content: use appropriate formatting for each section.',
].join('\n');
export async function execute(
_toolCallId: string,
params: { file_path: string; prompt?: string },
) {
const config = getOcrConfig();
if (!config) {
return {
content: [{ type: 'text', text: 'OCR is not configured. Set it up in Settings → Resources → Optical Character Recognition.' }],
isError: true,
};
}
const { file_path, prompt } = params;
if (!existsSync(file_path)) {
return {
content: [{ type: 'text', text: `File not found: ${file_path}` }],
isError: true,
};
}
const imageBytes = readFileSync(file_path);
const base64 = imageBytes.toString('base64');
const ext = extname(file_path).replace('.', '').toLowerCase();
const mime = ext === 'jpg' || ext === 'jpeg' ? 'image/jpeg' : ext === 'webp' ? 'image/webp' : ext === 'gif' ? 'image/gif' : `image/${ext || 'png'}`;
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
if (config.api_key) headers['Authorization'] = `Bearer ${config.api_key}`;
const systemPrompt = prompt ? `${DEFAULT_PROMPT}\n\nAdditional instructions: ${prompt}` : DEFAULT_PROMPT;
try {
const res = await fetch(`${config.url.replace(/\/+$/, '')}/v1/chat/completions`, {
method: 'POST',
headers,
body: JSON.stringify({
model: config.model,
messages: [
{ role: 'system', content: systemPrompt },
{
role: 'user',
content: [
{ type: 'image_url', image_url: { url: `data:${mime};base64,${base64}` } },
],
},
],
max_tokens: 4096,
}),
});
if (!res.ok) {
const errorText = await res.text().catch(() => '');
return {
content: [{ type: 'text', text: `OCR API error (${res.status}): ${errorText}` }],
isError: true,
};
}
const json = await res.json() as { choices?: Array<{ message?: { content?: string } }> };
const text = json.choices?.[0]?.message?.content ?? '';
if (!text) {
return {
content: [{ type: 'text', text: 'OCR returned empty result — the image may not contain readable text.' }],
isError: false,
};
}
return {
content: [{ type: 'text', text }],
};
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
return {
content: [{ type: 'text', text: `OCR request failed: ${message}` }],
isError: true,
};
}
}
-53
View File
@@ -1,53 +0,0 @@
/**
* Generic CLI runner for Officer tools.
* Usage: bun run /path/to/seed/tools/run.ts <tool-dir> '<json-params>'
*
* Imports the tool's index.ts, calls execute() with the parsed params,
* and prints the result as JSON.
*/
import { join } from 'node:path';
const [toolDir, paramsJson] = process.argv.slice(2);
if (!toolDir || !paramsJson) {
console.error('Usage: bun run run.ts <tool-directory> \'{"param":"value"}\'');
process.exit(1);
}
const entryFile = join(toolDir, 'index.ts');
const mod = await import(entryFile);
const executeFn = mod.execute ?? mod.default?.execute;
if (typeof executeFn !== 'function') {
console.error(`No execute function found in ${entryFile}`);
process.exit(1);
}
let params: Record<string, unknown>;
try {
params = JSON.parse(paramsJson);
} catch {
console.error(`Invalid JSON params: ${paramsJson}`);
process.exit(1);
}
const onUpdate = (partial: { content: Array<{ type: string; text: string }> }) => {
for (const block of partial.content) {
if (block.type === 'text') console.log(block.text);
}
};
try {
const result = await executeFn('cli', params, undefined, onUpdate);
if (result?.isError) {
const text = result.content?.map((c: { text: string }) => c.text).join('\n') ?? 'Tool error';
console.error(text);
process.exit(1);
}
const text = result?.content?.map((c: { text: string }) => c.text).join('\n') ?? '';
if (text) console.log(text);
} catch (err) {
console.error(String(err));
process.exit(1);
}
-25
View File
@@ -1,25 +0,0 @@
---
name: web_fetch
label: Web Fetch
description: Fetch and return the text content of a URL. Tries to get clean readable content using a cascade of strategies in order: appending .md to the URL, fetching a /llms.txt discovery file, requesting plain text via Accept header, then falling back to stripping HTML. Use when the user provides a URL and wants to read, summarize, or extract information from web content.
language: typescript
inputs:
url:
type: string
description: The URL to fetch content from
---
# Web Fetch
Fetches web content with cascading fallback strategies to get the cleanest possible text.
## Strategies (in order)
1. **Markdown version** — Appends `.md` to the URL (works on GitHub, many docs sites)
2. **llms.txt discovery** — Checks `/llms.txt` at the root (sites that publish LLM-friendly content)
3. **Plain text request** — Sends `Accept: text/plain` header
4. **HTML strip** — Fetches HTML and strips tags, scripts, and styles
## Output
Returns the text content with a note indicating which strategy succeeded.
-188
View File
@@ -1,188 +0,0 @@
const MAX_BYTES = 50_000;
const TIMEOUT_MS = 15_000;
type FetchResult = {
content: string;
strategy: string;
};
function truncate(text: string): { text: string; truncated: boolean; originalBytes: number } {
const encoder = new TextEncoder();
const bytes = encoder.encode(text);
if (bytes.length <= MAX_BYTES) return { text, truncated: false, originalBytes: bytes.length };
const decoder = new TextDecoder();
return {
text: decoder.decode(bytes.slice(0, MAX_BYTES)),
truncated: true,
originalBytes: bytes.length,
};
}
function stripHtml(html: string): string {
// Remove script and style blocks entirely
let text = html
.replace(/<script[\s\S]*?<\/script>/gi, '')
.replace(/<style[\s\S]*?<\/style>/gi, '')
.replace(/<noscript[\s\S]*?<\/noscript>/gi, '')
.replace(/<nav[\s\S]*?<\/nav>/gi, '')
.replace(/<footer[\s\S]*?<\/footer>/gi, '')
.replace(/<header[\s\S]*?<\/header>/gi, '');
// Replace block-level tags with newlines
text = text
.replace(/<br\s*\/?>/gi, '\n')
.replace(/<\/p>/gi, '\n\n')
.replace(/<\/div>/gi, '\n')
.replace(/<\/li>/gi, '\n')
.replace(/<\/h[1-6]>/gi, '\n\n')
.replace(/<\/tr>/gi, '\n')
.replace(/<\/td>/gi, '\t')
.replace(/<\/th>/gi, '\t');
// Strip remaining tags
text = text.replace(/<[^>]+>/g, '');
// Decode common HTML entities
text = text
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&nbsp;/g, ' ')
.replace(/&#(\d+);/g, (_, code) => String.fromCharCode(Number(code)));
// Collapse excessive whitespace but preserve paragraph breaks
text = text
.replace(/[ \t]+/g, ' ')
.replace(/\n{3,}/g, '\n\n')
.trim();
return text;
}
async function tryFetch(url: string, options: RequestInit = {}): Promise<Response | null> {
try {
const signal = AbortSignal.timeout(TIMEOUT_MS);
const res = await fetch(url, { ...options, signal, redirect: 'follow' });
if (res.ok) return res;
return null;
} catch {
return null;
}
}
async function strategyMarkdown(url: string): Promise<FetchResult | null> {
const mdUrl = url.endsWith('.md') ? null : `${url.replace(/\/$/, '')}.md`;
if (!mdUrl) return null;
const res = await tryFetch(mdUrl);
if (!res) return null;
const contentType = res.headers.get('content-type') ?? '';
if (!contentType.includes('text/plain') && !contentType.includes('text/markdown')) return null;
const text = await res.text();
if (text.trim().startsWith('<')) return null; // Got HTML anyway
return { content: text, strategy: `markdown (${mdUrl})` };
}
async function strategyLlmsTxt(url: string): Promise<FetchResult | null> {
const { origin } = new URL(url);
const llmsUrl = `${origin}/llms.txt`;
const res = await tryFetch(llmsUrl);
if (!res) return null;
const text = await res.text();
if (!text.trim() || text.trim().startsWith('<')) return null;
return { content: text, strategy: `llms.txt (${llmsUrl})` };
}
async function strategyPlainText(url: string): Promise<FetchResult | null> {
const res = await tryFetch(url, { headers: { Accept: 'text/plain, text/markdown, */*;q=0.8' } });
if (!res) return null;
const contentType = res.headers.get('content-type') ?? '';
if (!contentType.includes('text/plain') && !contentType.includes('text/markdown')) return null;
const text = await res.text();
if (text.trim().startsWith('<')) return null;
return { content: text, strategy: 'plain text response' };
}
async function strategyHtmlStrip(url: string): Promise<FetchResult | null> {
const res = await tryFetch(url, { headers: { Accept: 'text/html,*/*;q=0.8' } });
if (!res) return null;
const html = await res.text();
const text = stripHtml(html);
if (!text.trim()) return null;
return { content: text, strategy: 'HTML (stripped)' };
}
export async function execute(
_toolCallId: string,
params: { url: string },
_signal: AbortSignal | undefined,
onUpdate?: (partial: { content: Array<{ type: string; text: string }> }) => void,
) {
const { url } = params;
// Validate URL
let parsedUrl: URL;
try {
parsedUrl = new URL(url);
} catch {
return {
content: [{ type: 'text', text: `Invalid URL: ${url}` }],
details: { error: 'invalid_url' },
isError: true,
};
}
if (!['http:', 'https:'].includes(parsedUrl.protocol)) {
return {
content: [{ type: 'text', text: `Unsupported protocol: ${parsedUrl.protocol}` }],
details: { error: 'unsupported_protocol' },
isError: true,
};
}
const strategies = [
{ name: 'markdown', fn: () => strategyMarkdown(url) },
{ name: 'llms.txt', fn: () => strategyLlmsTxt(url) },
{ name: 'plain text', fn: () => strategyPlainText(url) },
{ name: 'HTML strip', fn: () => strategyHtmlStrip(url) },
];
for (const { name, fn } of strategies) {
onUpdate?.({ content: [{ type: 'text', text: `Trying ${name} strategy...` }] });
const result = await fn();
if (!result) continue;
const { text, truncated, originalBytes } = truncate(result.content);
let output = `[Fetched via ${result.strategy}]\n\n${text}`;
if (truncated) {
output += `\n\n[Content truncated: showing ${MAX_BYTES.toLocaleString()} of ${originalBytes.toLocaleString()} bytes]`;
}
return {
content: [{ type: 'text', text: output }],
details: { url, strategy: result.strategy, truncated, originalBytes },
};
}
return {
content: [{ type: 'text', text: `Failed to fetch content from: ${url}` }],
details: { error: 'all_strategies_failed', url },
isError: true,
};
}
-27
View File
@@ -1,27 +0,0 @@
---
name: web_search
label: Web Search
description: Search the web using a private SearXNG instance and return a list of results with titles, URLs, and snippets. Use when you need to find information or URLs without already knowing where to look. Pair with web_fetch to read the full content of any result.
language: typescript
inputs:
query:
type: string
description: The search query
max_results:
type: number
description: Maximum number of results to return (default 10, max 20)
optional: true
---
# Web Search
Searches the web via a self-hosted SearXNG instance. Returns titles, URLs, and content snippets.
## Usage pattern
1. Call `web_search` with a query to get a list of results
2. Call `web_fetch` on any result URL to read its full content
## Configuration
The SearXNG instance URL is read from the `PI_SEARXNG_URL` environment variable.
-124
View File
@@ -1,124 +0,0 @@
const TIMEOUT_MS = 10_000;
const DEFAULT_MAX_RESULTS = 10;
const HARD_MAX_RESULTS = 20;
type SearxngResult = {
title: string;
url: string;
content?: string;
engine?: string;
score?: number;
};
type SearxngResponse = {
query: string;
number_of_results: number;
results: SearxngResult[];
};
type OnUpdate = (partial: { content: Array<{ type: string; text: string }> }) => void;
function update(onUpdate: OnUpdate | undefined, text: string): void {
onUpdate?.({ content: [{ type: 'text', text }] });
}
function formatResults(results: SearxngResult[]): string {
if (results.length === 0) return 'No results found.';
return results
.map((r, i) => {
const lines = [`${i + 1}. **${r.title}**`, ` ${r.url}`];
if (r.content?.trim()) lines.push(` ${r.content.trim()}`);
return lines.join('\n');
})
.join('\n\n');
}
export async function execute(
_toolCallId: string,
params: { query: string; max_results?: number },
_signal: AbortSignal | undefined,
onUpdate?: OnUpdate,
) {
const searxngUrl = process.env.PI_SEARXNG_URL;
if (!searxngUrl) {
return {
content: [{ type: 'text', text: 'Web search is not configured. PI_SEARXNG_URL is not set.' }],
details: { error: 'not_configured' },
isError: true,
};
}
const { query } = params;
const maxResults = Math.min(params.max_results ?? DEFAULT_MAX_RESULTS, HARD_MAX_RESULTS);
if (!query?.trim()) {
return {
content: [{ type: 'text', text: 'Query cannot be empty.' }],
details: { error: 'empty_query' },
isError: true,
};
}
update(onUpdate, `Searching for: ${query}`);
const searchUrl = new URL('/search', searxngUrl);
searchUrl.searchParams.set('q', query);
searchUrl.searchParams.set('format', 'json');
searchUrl.searchParams.set('categories', 'general');
let response: Response;
try {
response = await fetch(searchUrl.toString(), {
signal: AbortSignal.timeout(TIMEOUT_MS),
headers: { Accept: 'application/json' },
});
} catch (err) {
return {
content: [{ type: 'text', text: `Failed to reach SearXNG at ${searxngUrl}: ${String(err)}` }],
details: { error: 'fetch_failed', url: searxngUrl },
isError: true,
};
}
if (!response.ok) {
return {
content: [{ type: 'text', text: `SearXNG returned HTTP ${response.status}` }],
details: { error: 'http_error', status: response.status },
isError: true,
};
}
let data: SearxngResponse;
try {
data = (await response.json()) as SearxngResponse;
} catch {
return {
content: [{ type: 'text', text: 'SearXNG returned an invalid response.' }],
details: { error: 'invalid_json' },
isError: true,
};
}
const results = (data.results ?? []).slice(0, maxResults);
update(onUpdate, `Found ${data.number_of_results ?? results.length} results, returning top ${results.length}`);
const output = [
`Search: "${query}"`,
`Results: ${results.length}`,
``,
formatResults(results),
].join('\n');
return {
content: [{ type: 'text', text: output }],
details: {
query,
total: data.number_of_results ?? results.length,
returned: results.length,
results: results.map((r) => ({ title: r.title, url: r.url })),
},
};
}
+4 -4
View File
@@ -6,7 +6,7 @@ import argon2 from 'argon2';
import * as errors from '@@/custom-errors';
import { validatePassword } from './validate-password';
import { validateUsername } from './validate-username';
import { provisionLinuxUser } from '../users/provision';
import { provisionUserEnvironment } from '../users/provision';
export const verifyHandler: Handler = async function (ctx) {
const { verificationCode, name, username, password, confirmPassword } = ctx.get('body');
@@ -43,9 +43,9 @@ export const verifyHandler: Handler = async function (ctx) {
const finalUser = await getUserById(userInfo.id);
if (!finalUser) throw errors.NOT_FOUND('User not found');
// Provision Linux user for terminal/Pi/Claude Code isolation
provisionLinuxUser(finalUser.email, finalUser.username ?? '').catch((err) => {
console.error('[verify] failed to provision Linux user:', err);
// Provision user environment (directories, configs, VNC)
provisionUserEnvironment(finalUser.email, finalUser.username ?? '').catch((err) => {
console.error('[verify] failed to provision user environment:', err);
});
// Issue a token so the user is logged in immediately
+7 -63
View File
@@ -4,7 +4,6 @@ import { DATA_PATH, getHomeDir, toShellUsername } from '@@/data-path';
import { generateContainerContext, generateClaudeSettings } from '@@/generate-container-context';
const TEMPLATE_DIR = join(import.meta.dir, '../terminal/templates');
const SHARED_GROUP = 'officerdev';
const run = (cmd: string[], opts?: { cwd?: string }): boolean => {
const result = Bun.spawnSync({ cmd, stdout: 'ignore', stderr: 'pipe', ...opts });
@@ -14,60 +13,23 @@ const run = (cmd: string[], opts?: { cwd?: string }): boolean => {
return result.exitCode === 0;
};
const linuxUserExists = (username: string): boolean => {
const result = Bun.spawnSync({ cmd: ['id', username], stdout: 'ignore', stderr: 'ignore' });
return result.exitCode === 0;
};
const groupExists = (group: string): boolean => {
const result = Bun.spawnSync({ cmd: ['getent', 'group', group], stdout: 'ignore', stderr: 'ignore' });
return result.exitCode === 0;
};
const copyTemplate = async (src: string, dest: string) => {
if (existsSync(dest)) return;
const content = await Bun.file(src).text();
await Bun.write(dest, content);
};
export function ensureSharedGroup(): void {
if (groupExists(SHARED_GROUP)) return;
run(['sudo', 'groupadd', SHARED_GROUP]);
const serviceUser = process.env.USER ?? '';
if (serviceUser) {
run(['sudo', 'usermod', '-aG', SHARED_GROUP, serviceUser]);
}
console.log(`[provision] created shared group ${SHARED_GROUP} and added ${serviceUser}`);
}
export async function provisionLinuxUser(email: string, username: string): Promise<boolean> {
export async function provisionUserEnvironment(email: string, username: string): Promise<boolean> {
const shellUsername = toShellUsername(username, email);
const homeDir = getHomeDir(email);
const userRoot = join(DATA_PATH, email);
console.log(`[provision] provisioning Linux user ${shellUsername} for ${email}`);
// Ensure shared group exists
ensureSharedGroup();
console.log(`[provision] provisioning environment for ${email}`);
// Ensure data directories exist
mkdirSync(userRoot, { recursive: true });
mkdirSync(homeDir, { recursive: true });
// Create Linux user if not exists
if (!linuxUserExists(shellUsername)) {
const ok = run(['sudo', 'useradd', '-d', homeDir, '-s', '/bin/zsh', '-G', SHARED_GROUP, '-M', shellUsername]);
if (!ok) {
console.error(`[provision] failed to create Linux user ${shellUsername}`);
return false;
}
console.log(`[provision] created Linux user ${shellUsername}`);
} else {
// Ensure existing user is in the shared group
run(['sudo', 'usermod', '-aG', SHARED_GROUP, shellUsername]);
console.log(`[provision] Linux user ${shellUsername} already exists`);
}
// Seed shell config files
await seedShellConfigs(homeDir);
@@ -86,13 +48,7 @@ export async function provisionLinuxUser(email: string, username: string): Promi
// Provision VNC environment
await provisionVncEnv(homeDir);
// Service user (pastilhas) owns everything — server can always read/write.
// User's personal group gives only that user terminal access. Others get nothing.
const serviceUser = process.env.USER ?? 'pastilhas';
run(['sudo', 'chown', '-R', `${serviceUser}:${shellUsername}`, userRoot]);
run(['sudo', 'chmod', '-R', '2770', userRoot]);
console.log(`[provision] provisioning complete for ${shellUsername}`);
console.log(`[provision] provisioning complete for ${email}`);
return true;
}
@@ -186,21 +142,9 @@ exec startxfce4
console.log(`[provision] VNC environment provisioned at ${vncDir}`);
}
export function deprovisionLinuxUser(email: string, username: string): boolean {
const shellUsername = toShellUsername(username, email);
console.log(`[provision] deprovisioning Linux user ${shellUsername}`);
if (!linuxUserExists(shellUsername)) {
console.log(`[provision] Linux user ${shellUsername} does not exist, skipping`);
return true;
}
const ok = run(['sudo', 'userdel', shellUsername]);
if (!ok) {
console.error(`[provision] failed to delete Linux user ${shellUsername}`);
return false;
}
console.log(`[provision] deprovisioned Linux user ${shellUsername}`);
export function deprovisionUserEnvironment(email: string, _username: string): boolean {
// User data directories are intentionally kept on disk.
// This function exists for API compatibility.
console.log(`[provision] deprovision called for ${email} (no-op, data kept on disk)`);
return true;
}
+3 -3
View File
@@ -6,7 +6,7 @@ import { sendMail } from 'emailer';
import * as errors from '@@/custom-errors';
import { originMiddleware } from '@@/_middlewares';
import { updateUserHandler } from './update-user';
import { deprovisionLinuxUser } from './provision';
import { deprovisionUserEnvironment } from './provision';
export const usersRouter = createRouter();
usersRouter.use(originMiddleware);
@@ -103,8 +103,8 @@ usersRouter.delete('/:id', async (ctx) => {
const target = await getUserById(id);
if (!target) throw errors.NOT_FOUND('User not found');
// Deprovision Linux user before deleting from database
deprovisionLinuxUser(target.email, target.username ?? '');
// Deprovision user environment before deleting from database
deprovisionUserEnvironment(target.email, target.username ?? '');
await deleteUser(id);
return ctx.json({ ok: true });
+128 -7
View File
@@ -1,4 +1,6 @@
import { resolve } from 'node:path';
import type { ServerWebSocket } from 'bun';
import type { Subprocess } from 'bun';
import type {
SidecarCommand,
SidecarEvent,
@@ -111,6 +113,13 @@ function findSidecarByCapability(cap: string): RegisteredSidecar | undefined {
return undefined;
}
function findSidecarByName(name: string): RegisteredSidecar | undefined {
for (const sc of sidecars.values()) {
if (sc.name === name) return sc;
}
return undefined;
}
// ── Event dispatch ──
function dispatchEvent(msg: SidecarEvent | PtyEvent) {
@@ -166,6 +175,105 @@ function sendFire(cap: string, cmd: SidecarCommand | PtyCommand): void {
}
}
function sendCommandToSidecar(
sc: RegisteredSidecar,
cmd: SidecarCommand | PtyCommand,
timeoutMs = DEFAULT_TIMEOUT_MS,
): Promise<any> {
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
pending.delete((cmd as any).id);
reject(new Error(`Sidecar command ${cmd.type} timed out`));
}, timeoutMs);
pending.set((cmd as any).id, { resolve, reject, timer });
sc.ws.send(JSON.stringify(cmd));
});
}
function sendFireToSidecar(sc: RegisteredSidecar, cmd: SidecarCommand | PtyCommand): void {
sc.ws.send(JSON.stringify(cmd));
}
// ── On-demand Claude sidecar spawning ──
const USER_INSTANCE_SCRIPT = resolve(import.meta.dir, 'sidecar/claude/user-instance.ts');
const SIDECAR_SPAWN_TIMEOUT_MS = 15_000;
const claudeProcs = new Map<string, Subprocess>();
const claudeSpawnWaiters = new Map<string, Promise<RegisteredSidecar>>();
async function ensureClaudeSidecar(email: string): Promise<RegisteredSidecar> {
const name = `claude:${email}`;
// Already registered?
const existing = findSidecarByName(name);
if (existing) return existing;
// Already spawning?
const waiter = claudeSpawnWaiters.get(email);
if (waiter) return waiter;
// Spawn and wait for registration
const promise = spawnAndWaitForRegistration(email, name);
claudeSpawnWaiters.set(email, promise);
try {
return await promise;
} finally {
claudeSpawnWaiters.delete(email);
}
}
async function spawnAndWaitForRegistration(email: string, name: string): Promise<RegisteredSidecar> {
// Get proxy secret for auth
const proxySecret = await getProxySecret();
const proxyPort = process.env.ANTHROPIC_PROXY_PORT ?? '5051';
const env: Record<string, string> = {
...(process.env as Record<string, string>),
CLAUDE_USER_EMAIL: email,
ANTHROPIC_BASE_URL: `http://127.0.0.1:${proxyPort}`,
ANTHROPIC_API_KEY: proxySecret,
};
const proc = Bun.spawn(['bun', 'run', USER_INSTANCE_SCRIPT], {
env,
stdout: 'inherit',
stderr: 'inherit',
});
claudeProcs.set(email, proc);
// Clean up on exit
proc.exited.then(() => {
claudeProcs.delete(email);
});
// Wait for the sidecar to register
return new Promise<RegisteredSidecar>((resolve, reject) => {
const timeout = setTimeout(() => {
unsub();
reject(new Error(`Claude sidecar for ${email} failed to register within ${SIDECAR_SPAWN_TIMEOUT_MS}ms`));
}, SIDECAR_SPAWN_TIMEOUT_MS);
// Poll for registration (the sidecar connects via WebSocket and registerSidecar is called)
const check = () => {
const sc = findSidecarByName(name);
if (sc) {
clearTimeout(timeout);
clearInterval(interval);
resolve(sc);
}
};
const interval = setInterval(check, 50);
const unsub = () => {
clearTimeout(timeout);
clearInterval(interval);
};
});
}
// ── Public API ──
export function isConnected(): boolean {
@@ -196,28 +304,41 @@ export function getProxySecretSync(): string {
return cachedState?.proxySecret ?? '';
}
// ── Claude Code ──
// ── Claude Code (per-user routing) ──
export async function spawnClaude(params: ClaudeSpawnParams): Promise<ClaudeCodeResult> {
const res = await sendCommand('claude', { type: 'claude:spawn', id: nextId(), params }, LONG_TIMEOUT_MS);
const sc = await ensureClaudeSidecar(params.email);
const res = await sendCommandToSidecar(sc, { type: 'claude:spawn', id: nextId(), params }, LONG_TIMEOUT_MS);
if (res.type === 'claude:result') return res.result;
if (res.type === 'claude:error') throw new Error(res.error);
throw new Error('Unexpected response');
}
export async function spawnClaudeStreaming(params: ClaudeSpawnStreamingParams): Promise<void> {
const res = await sendCommand('claude', { type: 'claude:spawn-streaming', id: nextId(), params });
const sc = await ensureClaudeSidecar(params.email);
const res = await sendCommandToSidecar(sc, { type: 'claude:spawn-streaming', id: nextId(), params });
if (res.type === 'claude:spawned') return;
if (res.type === 'claude:error') throw new Error(res.error);
throw new Error('Unexpected response');
}
export function killClaude(sessionKey: string): void {
sendFire('claude', { type: 'claude:kill', id: nextId(), sessionKey });
export function killClaude(sessionKey: string, email: string): void {
const sc = findSidecarByName(`claude:${email}`);
if (sc) sendFireToSidecar(sc, { type: 'claude:kill', id: nextId(), sessionKey });
}
export function clearClaudeSession(sessionKey: string): void {
sendFire('claude', { type: 'claude:clear-session', id: nextId(), sessionKey });
export function clearClaudeSession(sessionKey: string, email?: string): void {
if (email) {
const sc = findSidecarByName(`claude:${email}`);
if (sc) sendFireToSidecar(sc, { type: 'claude:clear-session', id: nextId(), sessionKey });
} else {
// Broadcast to all claude sidecars (used when email is not available)
for (const sc of sidecars.values()) {
if (sc.capabilities.includes('claude')) {
sendFireToSidecar(sc, { type: 'claude:clear-session', id: nextId(), sessionKey });
}
}
}
}
export function onClaudeEvent(handler: (sessionKey: string, event: PiEvent) => void): () => void {
+5 -43
View File
@@ -1,7 +1,6 @@
import type { SidecarCommand, SidecarEvent } from '../protocol';
import { loadState, flushAndSave, acquireLock, releaseLock, getState } from './state';
import { startAnthropicProxy, getProxySecret, ensureProxySecret } from './proxy';
import * as claudeManager from './claude-manager';
import { createSidecarConnector } from '../connect';
const API_URL = process.env.API_URL ?? `ws://127.0.0.1:${process.env.PORT ?? '5000'}`;
@@ -9,7 +8,7 @@ const API_URL = process.env.API_URL ?? `ws://127.0.0.1:${process.env.PORT ?? '50
// ── Startup ──
if (!acquireLock()) {
console.error('[claude] another instance is already running (lock file exists with live PID)');
console.error('[proxy] another instance is already running (lock file exists with live PID)');
process.exit(1);
}
@@ -20,7 +19,7 @@ ensureProxySecret();
try {
startAnthropicProxy();
} catch (err) {
console.error('[claude] failed to start Anthropic proxy:', err instanceof Error ? err.message : err);
console.error('[proxy] failed to start Anthropic proxy:', err instanceof Error ? err.message : err);
}
// ── Command handlers ──
@@ -48,43 +47,6 @@ async function handleCommand(cmd: SidecarCommand, reply: ReplyFn) {
reply({ type: 'proxy:secret', id: cmd.id, secret: getProxySecret() });
break;
case 'claude:spawn': {
try {
const result = await claudeManager.spawnClaude(cmd.params);
reply({ type: 'claude:result', id: cmd.id, result });
} catch (err) {
reply({ type: 'claude:error', id: cmd.id, error: err instanceof Error ? err.message : String(err) });
}
break;
}
case 'claude:spawn-streaming': {
reply({ type: 'claude:spawned', id: cmd.id, sessionKey: cmd.params.sessionKey });
const onEvent = (event: import('../../api/pi/types').PiEvent) => {
connection.send({ type: 'claude:event', sessionKey: cmd.params.sessionKey, event });
};
claudeManager.spawnClaudeStreaming(cmd.params, onEvent).catch((err) => {
connection.send({
type: 'claude:event',
sessionKey: cmd.params.sessionKey,
event: { type: 'error', message: err instanceof Error ? err.message : String(err) },
});
});
break;
}
case 'claude:kill':
claudeManager.killClaudeSession(cmd.sessionKey);
reply({ type: 'claude:killed', id: cmd.id });
break;
case 'claude:clear-session':
claudeManager.clearSession(cmd.sessionKey);
reply({ type: 'claude:session-cleared', id: cmd.id });
break;
default:
reply({
type: 'error',
@@ -98,8 +60,8 @@ async function handleCommand(cmd: SidecarCommand, reply: ReplyFn) {
const connection = createSidecarConnector({
apiUrl: `${API_URL}/api/sidecar/register`,
name: 'claude',
capabilities: ['claude', 'proxy'],
name: 'proxy',
capabilities: ['proxy'],
onCommand(cmd, reply) {
handleCommand(cmd as SidecarCommand, reply as ReplyFn);
},
@@ -108,7 +70,7 @@ const connection = createSidecarConnector({
// ── Graceful shutdown ──
async function shutdown(signal: string) {
console.log(`[claude] ${signal} received, saving state...`);
console.log(`[proxy] ${signal} received, saving state...`);
connection.destroy();
await flushAndSave();
releaseLock();
+30 -14
View File
@@ -1,10 +1,7 @@
import { join } from 'node:path';
import { mkdirSync, existsSync } from 'node:fs';
import { mkdirSync, existsSync, readFileSync, writeFileSync, unlinkSync } from 'node:fs';
const DATA_PATH = process.env.DATA_PATH ?? join(process.cwd(), 'data');
const STATE_DIR = join(DATA_PATH, 'sidecar');
const STATE_FILE = join(STATE_DIR, 'claude-state.json');
const LOCK_FILE = join(STATE_DIR, 'claude.lock');
export type PersistedState = {
proxySecret: string;
@@ -16,23 +13,42 @@ const DEFAULT_STATE: PersistedState = {
claudeSessions: {},
};
let stateDir: string;
let stateFile: string;
let lockFile: string;
let currentState: PersistedState = { ...DEFAULT_STATE };
let saveTimer: Timer | null = null;
/** Call once at startup to configure paths. For proxy: no email. For per-user: pass email. */
export function initPaths(email?: string): void {
if (email) {
stateDir = join(DATA_PATH, email, 'sidecar');
stateFile = join(stateDir, 'claude-state.json');
lockFile = join(stateDir, 'claude.lock');
} else {
stateDir = join(DATA_PATH, 'sidecar');
stateFile = join(stateDir, 'claude-state.json');
lockFile = join(stateDir, 'claude.lock');
}
}
// Default to proxy paths
initPaths();
function ensureDir() {
if (!existsSync(STATE_DIR)) {
mkdirSync(STATE_DIR, { recursive: true });
if (!existsSync(stateDir)) {
mkdirSync(stateDir, { recursive: true });
}
}
export function loadState(): PersistedState {
ensureDir();
try {
if (!existsSync(STATE_FILE)) {
if (!existsSync(stateFile)) {
currentState = { ...DEFAULT_STATE };
return currentState;
}
const text = require('node:fs').readFileSync(STATE_FILE, 'utf-8');
const text = readFileSync(stateFile, 'utf-8');
currentState = { ...DEFAULT_STATE, ...JSON.parse(text) };
return currentState;
} catch {
@@ -43,7 +59,7 @@ export function loadState(): PersistedState {
export async function saveState(): Promise<void> {
ensureDir();
await Bun.write(STATE_FILE, JSON.stringify(currentState, null, 2));
await Bun.write(stateFile, JSON.stringify(currentState, null, 2));
}
export function getState(): PersistedState {
@@ -90,14 +106,14 @@ export async function flushAndSave(): Promise<void> {
export function acquireLock(): boolean {
ensureDir();
try {
if (existsSync(LOCK_FILE)) {
const pidStr = require('node:fs').readFileSync(LOCK_FILE, 'utf-8').trim();
if (existsSync(lockFile)) {
const pidStr = readFileSync(lockFile, 'utf-8').trim();
const pid = Number(pidStr);
if (pid && isProcessAlive(pid)) {
return false;
}
}
require('node:fs').writeFileSync(LOCK_FILE, String(process.pid));
writeFileSync(lockFile, String(process.pid));
return true;
} catch {
return false;
@@ -106,8 +122,8 @@ export function acquireLock(): boolean {
export function releaseLock(): void {
try {
if (existsSync(LOCK_FILE)) {
require('node:fs').unlinkSync(LOCK_FILE);
if (existsSync(lockFile)) {
unlinkSync(lockFile);
}
} catch {
// best effort
+5 -19
View File
@@ -1,7 +1,7 @@
import { existsSync, mkdirSync } from 'node:fs';
import { join } from 'node:path';
import type { VncStartParams, VncSessionInfo } from '../protocol';
import { getHomeDirForRole, toShellUsername } from '@@/data-path';
import { getHomeDirForRole } from '@@/data-path';
type VncSession = {
email: string;
@@ -87,7 +87,6 @@ export async function startSession(params: VncStartParams): Promise<{ port: numb
sessions.delete(params.email);
}
const shellUsername = toShellUsername(params.username, params.email);
const homeDir = getHomeDirForRole(params.email, params.role);
const resolution = params.resolution ?? '1920x1080';
const display = findFreeDisplay();
@@ -96,21 +95,9 @@ export async function startSession(params: VncStartParams): Promise<{ port: numb
// Lazy-provision VNC environment if missing
await ensureVncEnv(homeDir);
// Spawn VNC server as the target user
// Spawn VNC server
const proc = Bun.spawn({
cmd: [
'sudo',
'-u',
shellUsername,
'vncserver',
`:${display}`,
'-geometry',
resolution,
'-depth',
'24',
'-localhost',
'yes',
],
cmd: ['vncserver', `:${display}`, '-geometry', resolution, '-depth', '24', '-localhost', 'yes'],
env: { ...process.env, HOME: homeDir },
stdout: 'pipe',
stderr: 'pipe',
@@ -130,7 +117,7 @@ export async function startSession(params: VncStartParams): Promise<{ port: numb
pid = parseInt(content.trim(), 10) || 0;
}
sessions.set(params.email, { email: params.email, username: params.username, display, port, pid });
sessions.set(params.email, { email: params.email, username: params.username ?? '', display, port, pid });
console.log(`[vnc] started session for ${params.email} on :${display} (port ${port}, pid ${pid})`);
return { port, display };
@@ -140,11 +127,10 @@ export function stopSession(email: string): void {
const session = sessions.get(email);
if (!session) return;
const shellUsername = toShellUsername(session.username, email);
const homeDir = getHomeDirForRole(email, null);
Bun.spawnSync({
cmd: ['sudo', '-u', shellUsername, 'vncserver', '-kill', `:${session.display}`],
cmd: ['vncserver', '-kill', `:${session.display}`],
env: { ...process.env, HOME: homeDir },
stdout: 'ignore',
stderr: 'ignore',
-33
View File
@@ -1,33 +0,0 @@
import { readdirSync, existsSync, mkdirSync, cpSync } from 'node:fs';
import { join } from 'node:path';
import { SEED_PATH, DATA_PATH } from './data-path';
const SEED_EXTENSIONS_DIR = join(SEED_PATH, 'extensions');
const GLOBAL_EXTENSIONS_DIR = join(DATA_PATH, 'extensions');
export function syncSeedExtensions(): void {
if (!existsSync(SEED_EXTENSIONS_DIR)) return;
mkdirSync(GLOBAL_EXTENSIONS_DIR, { recursive: true });
const seedEntries = readdirSync(SEED_EXTENSIONS_DIR, { withFileTypes: true });
for (const entry of seedEntries) {
if (!entry.isDirectory()) continue;
const seedExtensionDir = join(SEED_EXTENSIONS_DIR, entry.name);
const entryFile = join(seedExtensionDir, 'index.ts');
if (!existsSync(entryFile)) continue;
const targetDir = join(GLOBAL_EXTENSIONS_DIR, entry.name);
if (existsSync(targetDir)) {
// Extension already exists in DATA_PATH — always overwrite (managed by us, not the user)
cpSync(seedExtensionDir, targetDir, { recursive: true });
continue;
}
cpSync(seedExtensionDir, targetDir, { recursive: true });
console.log(`[extensions] Synced seed extension: ${entry.name}`);
}
}
-37
View File
@@ -1,37 +0,0 @@
import { readdirSync, readFileSync, existsSync, mkdirSync, cpSync, rmSync } from 'node:fs';
import { join } from 'node:path';
import { SEED_PATH, DATA_PATH } from './data-path';
import { parseSeedVersion } from './sync-version';
const SEED_PROCESSES_DIR = join(SEED_PATH, 'processes');
const GLOBAL_PROCESSES_DIR = join(DATA_PATH, 'processes');
export function syncSeedProcesses(): void {
if (!existsSync(SEED_PROCESSES_DIR)) return;
mkdirSync(GLOBAL_PROCESSES_DIR, { recursive: true });
const seedEntries = readdirSync(SEED_PROCESSES_DIR, { withFileTypes: true });
for (const entry of seedEntries) {
if (!entry.isDirectory()) continue;
const seedProcessDir = join(SEED_PROCESSES_DIR, entry.name);
const seedFile = join(seedProcessDir, 'PROCESS.md');
if (!existsSync(seedFile)) continue;
const targetDir = join(GLOBAL_PROCESSES_DIR, entry.name);
const targetFile = join(targetDir, 'PROCESS.md');
if (existsSync(targetDir)) {
const seedVersion = parseSeedVersion(readFileSync(seedFile, 'utf-8'));
const targetVersion = existsSync(targetFile) ? parseSeedVersion(readFileSync(targetFile, 'utf-8')) : 0;
if (seedVersion <= targetVersion) continue;
rmSync(targetDir, { recursive: true });
console.log(`[processes] Updating seed process: ${entry.name} (v${targetVersion} → v${seedVersion})`);
}
cpSync(seedProcessDir, targetDir, { recursive: true });
console.log(`[processes] Synced seed process: ${entry.name}`);
}
}
-37
View File
@@ -1,37 +0,0 @@
import { readdirSync, readFileSync, existsSync, mkdirSync, cpSync, rmSync } from 'node:fs';
import { join } from 'node:path';
import { SEED_PATH, DATA_PATH } from './data-path';
import { parseSeedVersion } from './sync-version';
const SEED_SKILLS_DIR = join(SEED_PATH, 'skills');
const GLOBAL_SKILLS_DIR = join(DATA_PATH, 'skills');
export function syncSeedSkills(): void {
if (!existsSync(SEED_SKILLS_DIR)) return;
mkdirSync(GLOBAL_SKILLS_DIR, { recursive: true });
const seedEntries = readdirSync(SEED_SKILLS_DIR, { withFileTypes: true });
for (const entry of seedEntries) {
if (!entry.isDirectory()) continue;
const seedSkillDir = join(SEED_SKILLS_DIR, entry.name);
const seedFile = join(seedSkillDir, 'SKILL.md');
if (!existsSync(seedFile)) continue;
const targetDir = join(GLOBAL_SKILLS_DIR, entry.name);
const targetFile = join(targetDir, 'SKILL.md');
if (existsSync(targetDir)) {
const seedVersion = parseSeedVersion(readFileSync(seedFile, 'utf-8'));
const targetVersion = existsSync(targetFile) ? parseSeedVersion(readFileSync(targetFile, 'utf-8')) : 0;
if (seedVersion <= targetVersion) continue;
rmSync(targetDir, { recursive: true });
console.log(`[skills] Updating seed skill: ${entry.name} (v${targetVersion} → v${seedVersion})`);
}
cpSync(seedSkillDir, targetDir, { recursive: true });
console.log(`[skills] Synced seed skill: ${entry.name}`);
}
}
-37
View File
@@ -1,37 +0,0 @@
import { readdirSync, readFileSync, existsSync, mkdirSync, cpSync, rmSync } from 'node:fs';
import { join } from 'node:path';
import { SEED_PATH, DATA_PATH } from './data-path';
import { parseSeedVersion } from './sync-version';
const SEED_TASKS_DIR = join(SEED_PATH, 'tasks');
const GLOBAL_TASKS_DIR = join(DATA_PATH, 'tasks');
export function syncSeedTasks(): void {
if (!existsSync(SEED_TASKS_DIR)) return;
mkdirSync(GLOBAL_TASKS_DIR, { recursive: true });
const seedEntries = readdirSync(SEED_TASKS_DIR, { withFileTypes: true });
for (const entry of seedEntries) {
if (!entry.isDirectory()) continue;
const seedTaskDir = join(SEED_TASKS_DIR, entry.name);
const seedFile = join(seedTaskDir, 'TASK.md');
if (!existsSync(seedFile)) continue;
const targetDir = join(GLOBAL_TASKS_DIR, entry.name);
const targetFile = join(targetDir, 'TASK.md');
if (existsSync(targetDir)) {
const seedVersion = parseSeedVersion(readFileSync(seedFile, 'utf-8'));
const targetVersion = existsSync(targetFile) ? parseSeedVersion(readFileSync(targetFile, 'utf-8')) : 0;
if (seedVersion <= targetVersion) continue;
rmSync(targetDir, { recursive: true });
console.log(`[tasks] Updating seed task: ${entry.name} (v${targetVersion} → v${seedVersion})`);
}
cpSync(seedTaskDir, targetDir, { recursive: true });
console.log(`[tasks] Synced seed task: ${entry.name}`);
}
}
-37
View File
@@ -1,37 +0,0 @@
import { readdirSync, readFileSync, existsSync, mkdirSync, cpSync, rmSync } from 'node:fs';
import { join } from 'node:path';
import { SEED_PATH, DATA_PATH } from './data-path';
import { parseSeedVersion } from './sync-version';
const SEED_TOOLS_DIR = join(SEED_PATH, 'tools');
const GLOBAL_TOOLS_DIR = join(DATA_PATH, 'tools');
export function syncSeedTools(): void {
if (!existsSync(SEED_TOOLS_DIR)) return;
mkdirSync(GLOBAL_TOOLS_DIR, { recursive: true });
const seedEntries = readdirSync(SEED_TOOLS_DIR, { withFileTypes: true });
for (const entry of seedEntries) {
if (!entry.isDirectory()) continue;
const seedToolDir = join(SEED_TOOLS_DIR, entry.name);
const seedFile = join(seedToolDir, 'TOOL.md');
if (!existsSync(seedFile)) continue;
const targetDir = join(GLOBAL_TOOLS_DIR, entry.name);
const targetFile = join(targetDir, 'TOOL.md');
if (existsSync(targetDir)) {
const seedVersion = parseSeedVersion(readFileSync(seedFile, 'utf-8'));
const targetVersion = existsSync(targetFile) ? parseSeedVersion(readFileSync(targetFile, 'utf-8')) : 0;
if (seedVersion <= targetVersion) continue;
rmSync(targetDir, { recursive: true });
console.log(`[tools] Updating seed tool: ${entry.name} (v${targetVersion} → v${seedVersion})`);
}
cpSync(seedToolDir, targetDir, { recursive: true });
console.log(`[tools] Synced seed tool: ${entry.name}`);
}
}