remove seed directory, clean up provisioning and sync modules
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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`);
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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. |
|
||||
@@ -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 +0,0 @@
|
||||
{"id":"8ce0e4ad-5739-42e6-9169-075b6deb2f3e"}
|
||||
@@ -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
|
||||
@@ -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. ✅
|
||||
@@ -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! 📧
|
||||
@@ -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** ✅
|
||||
@@ -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
|
||||
@@ -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
@@ -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"]
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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.0–1.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 +0,0 @@
|
||||
{"id":"a0d9fb2b-700f-4a79-a3d4-a175bad6d52a"}
|
||||
@@ -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.
|
||||
```
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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) |
|
||||
@@ -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
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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`.
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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"`
|
||||
@@ -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)}`);
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/ /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,
|
||||
};
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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 })),
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user