Plugin Development Guide
Build capabilities for new file types
Overview
Plugins are standalone executables that add capabilities to FileGrind. A plugin can handle one file type (like PDFs) or many. It declares what it can do via a manifest, and FileGrind calls it when those capabilities are needed.
What a plugin does
- Responds to
manifestcommand with its capabilities - Implements one or more capability commands (extract-metadata, grind, etc.)
- Reads input from command-line arguments or stdin
- Writes JSON output to stdout
Languages
Plugins can be written in any language that produces a native executable. We provide SDKs for:
- Rust -
fgnd-plugin-sdkcrate - Go -
fgnd-plugin-sdk-gomodule - Swift/Objective-C -
FGNDPluginSDKframework
The SDK is optional—you can write a plugin in Python, C++, or anything else as long as it follows the protocol.
Architecture
Plugins run in a sandboxed XPC service, separate from the main app. FileGrind never loads plugin code directly.
Execution flow
- FileGrind's XPC service discovers plugins at startup
- For each executable, it runs
plugin manifest - Parses the JSON manifest to learn what capabilities exist
- Registers capabilities with the router
- When a capability is needed, spawns the plugin with arguments
- Plugin writes output to stdout, errors to stderr
- XPC service captures output and returns it to the app
Plugin locations
The XPC service looks for plugins in two places:
FileGrind.app/Contents/Resources/pluginsrv-signed/plugins/- Bundled/Library/Application Support/FileGrind/Plugins/- User-installed
Security requirements
Plugins distributed via FileGrind must be:
- Code-signed with a Developer ID certificate
- Notarized by Apple
- Packaged as a
.pkginstaller
For development, you can test unsigned plugins by placing them in the plugins directory and allowing them in System Settings.
Plugin Manifest
When called with manifest as the first argument, your plugin
must print a JSON object describing itself.
Manifest structure
{
"name": "myplugin",
"version": "1.0.0",
"description": "Processes XYZ files",
"authors": ["Your Name"],
"caps": [
{
"urn": "cap:op=extract;format=xyz;target=metadata",
"title": "Extract XYZ Metadata",
"description": "Extract metadata from XYZ files",
"command": "extract-metadata",
"arguments": {
"required": [...],
"optional": [...]
},
"output": {...},
"accepts_stdin": true
}
]
}
Fields
name |
Plugin identifier (lowercase, no spaces) |
version |
Semantic version (e.g., "1.0.0") |
description |
What the plugin does |
authors |
List of author names (optional) |
caps |
Array of capability definitions |
Example: Rust implementation
use clap::{Parser, Subcommand};
#[derive(Parser)]
struct Args {
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand)]
enum Commands {
Manifest,
ExtractMetadata { file: String },
}
fn main() {
let args = Args::parse();
match args.command {
Commands::Manifest => {
let manifest = get_plugin_manifest();
println!("{}", serde_json::to_string_pretty(&manifest).unwrap());
}
Commands::ExtractMetadata { file } => {
// ... implementation
}
}
}
Declaring Capabilities
Each capability in your manifest describes one operation. Capabilities use CAPNS URNs for identification.
Standard capabilities
FileGrind recognizes these standard operations:
extract-metadata |
Extract file metadata (title, author, page count, etc.) |
extract-outline |
Extract table of contents / document structure |
grind |
Extract text content by page or section |
generate-thumbnail |
Generate preview image |
Capability URN format
URNs are built from tags that describe the operation:
cap:op=extract;format=pdf;target=metadata cap:op=extract;format=epub;target=pages cap:op=generate;format=pdf;target=thumbnail
Capability definition
{
"urn": "cap:op=extract;format=xyz;target=metadata",
"title": "Extract XYZ Metadata",
"description": "Extract metadata from XYZ files",
"command": "extract-metadata",
"metadata": {
"file_types": "xyz,abc"
},
"arguments": {
"required": [
{
"name": "file_path",
"media_spec": "std:str.v1",
"arg_description": "Path to input file",
"cli_flag": "file_path",
"position": 0
}
],
"optional": [
{
"name": "output",
"media_spec": "std:str.v1",
"arg_description": "Output file path",
"cli_flag": "--output"
}
]
},
"output": {
"media_spec": "std:obj.v1",
"output_description": "Extracted metadata as JSON"
},
"accepts_stdin": false
}
Argument types (media_spec)
std:str.v1- Stringstd:int.v1- Integerstd:num.v1- Number (float)std:bool.v1- Booleanstd:obj.v1- JSON objectstd:binary.v1- Binary data
Implementing Commands
Each capability maps to a command that your plugin handles.
The command field in the capability definition tells
FileGrind how to invoke your plugin.
Command invocation
FileGrind calls your plugin like this:
# Extract metadata ./myplugin extract-metadata /path/to/file.xyz # With optional output file ./myplugin extract-metadata /path/to/file.xyz --output metadata.json # Generate thumbnail ./myplugin generate-thumbnail /path/to/file.xyz --width 256 --height 256
Example: extract-metadata
fn extract_metadata(file_path: &str) -> Result<FileMetadata> {
// 1. Validate file exists and is readable
let path = Path::new(file_path);
if !path.exists() {
anyhow::bail!("File not found: {}", file_path);
}
// 2. Read and parse the file
let content = fs::read(file_path)?;
let parsed = parse_xyz_format(&content)?;
// 3. Build metadata structure
let mut metadata = FileMetadata::new(
file_path.to_string(),
"xyz".to_string(),
content.len() as u64,
);
metadata.title = parsed.title;
metadata.authors = parsed.authors;
metadata.page_count = Some(parsed.pages.len());
Ok(metadata)
}
fn main() {
match args.command {
Commands::ExtractMetadata { file, output } => {
match extract_metadata(&file) {
Ok(metadata) => {
let json = serde_json::to_string_pretty(&metadata).unwrap();
if let Some(out_path) = output {
fs::write(out_path, &json).unwrap();
} else {
println!("{}", json);
}
}
Err(e) => {
eprintln!("Error: {}", e);
std::process::exit(1);
}
}
}
}
}
Example: grind
fn grind(file_path: &str, page_range: Option<&str>) -> Result<GroundChips> {
let parsed = parse_xyz_file(file_path)?;
let pages_to_extract = parse_page_range(page_range, parsed.pages.len())?;
let mut doc_pages = GroundChips::new(file_path, "xyz");
for page_num in pages_to_extract {
let page_content = &parsed.pages[page_num];
let doc_page = FileChip::new_with_text(
page_num + 1, // 1-indexed
page_content.text.clone(),
);
doc_pages.add_chip(doc_page);
}
Ok(doc_pages)
}
Input/Output
Input
Plugins receive input through:
- Command-line arguments - File paths, flags, options
- Stdin - If
accepts_stdin: true, can receive file content
Output
Plugins write output to:
- Stdout - JSON data or binary content
- Stderr - Progress messages, warnings, errors
- File - If
--outputflag is provided
Exit codes
0- Success1- General error2- Invalid arguments
Output formats
FileMetadata output
{
"file_path": "/path/to/file.xyz",
"file_size_bytes": 1048576,
"document_type": "xyz",
"title": "Document Title",
"authors": ["Author Name"],
"page_count": 42,
"creation_date": "2024-01-10T15:30:00Z",
"keywords": ["tag1", "tag2"],
"extended_metadata": {
"custom_field": "value"
}
}
DocumentOutline output
{
"source_file": "/path/to/file.xyz",
"document_type": "xyz",
"total_pages": 42,
"has_outline": true,
"entries": [
{
"title": "Chapter 1",
"level": 0,
"page": 1,
"children": [
{
"title": "Section 1.1",
"level": 1,
"page": 5,
"children": []
}
]
}
],
"extraction_info": {
"extractor_name": "myplugin",
"extractor_version": "1.0.0"
}
}
GroundChips output
{
"source_file": "/path/to/file.xyz",
"document_type": "xyz",
"total_pages": 42,
"pages": [
{
"order_index": 1,
"text_content": "Content of page 1...",
"word_count": 250,
"character_count": 1500
},
{
"order_index": 2,
"text_content": "Content of page 2...",
"word_count": 300,
"character_count": 1800
}
]
}
Using the SDK
The SDK provides standard data structures and helpers. Using it ensures your output matches what FileGrind expects.
Rust SDK
# Cargo.toml
[dependencies]
fgnd-plugin-sdk = { git = "https://github.com/fgnd/fgnd-plugin-sdk" }
capns = { git = "https://github.com/fgnd/capns" }
clap = { version = "4.0", features = ["derive"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
anyhow = "1.0"
use fgnd_plugin_sdk::{
FileMetadata, DocumentOutline, GroundChips,
OutlineEntry, FileChip, ExtractionInfo,
};
// Create metadata
let mut metadata = FileMetadata::new(path, "xyz", file_size);
metadata.title = Some("Title".into());
metadata.add_author("Author");
// Create outline
let mut outline = DocumentOutline::new(path, "xyz", page_count);
let entry = OutlineEntry::new("Chapter 1", 0).with_page(1);
outline.add_entry(entry);
// Create pages
let mut pages = GroundChips::new(path, "xyz");
let page = FileChip::new_with_text(1, "Content...");
pages.add_chip(page);
Go SDK
import sdk "github.com/fgnd/fgnd-plugin-sdk-go"
// Create metadata
metadata := sdk.NewFileMetadata(path, "xyz", fileSize)
metadata.Title = "Title"
metadata.AddAuthor("Author")
// Create outline
outline := sdk.NewDocumentOutline(path, "xyz", pageCount)
entry := sdk.NewOutlineEntry("Chapter 1", 0).WithPage(1)
outline.AddEntry(entry)
// Create pages
pages := sdk.NewGroundChips(path, "xyz")
page := sdk.NewFileChipWithText(1, "Content...")
pages.AddPage(page)
SDK data structures
See SDK Reference for complete documentation.
Testing
Manual testing
# Test manifest ./myplugin manifest | jq . # Test extract-metadata ./myplugin extract-metadata test-file.xyz | jq . # Test with output file ./myplugin extract-metadata test-file.xyz -o output.json cat output.json | jq .
Validate manifest
The SDK includes a validator tool:
plugin-validator ./myplugin
Test with FileGrind
- Build your plugin
- Copy to
/Library/Application Support/FileGrind/Plugins/ - Restart FileGrind
- Add a file of your supported type
- Check that grinding works
Packaging
Plugins are distributed as .pkg installers.
This handles macOS security requirements (Gatekeeper, quarantine).
Build requirements
- Universal binary (arm64 + x86_64) or Apple Silicon only
- Signed with Developer ID certificate
- Notarized by Apple
Creating a .pkg
# Build universal binary (Rust example) cargo build --release --target aarch64-apple-darwin cargo build --release --target x86_64-apple-darwin lipo -create -output myplugin \ target/aarch64-apple-darwin/release/myplugin \ target/x86_64-apple-darwin/release/myplugin # Sign the binary codesign --sign "Developer ID Application: Your Name (TEAMID)" \ --options runtime \ --timestamp \ myplugin # Create installer package pkgbuild --root ./pkg-root \ --identifier com.yourcompany.myplugin \ --version 1.0.0 \ --install-location "/Library/Application Support/FileGrind/Plugins" \ myplugin.pkg # Sign the package productsign --sign "Developer ID Installer: Your Name (TEAMID)" \ myplugin.pkg \ myplugin-signed.pkg # Notarize xcrun notarytool submit myplugin-signed.pkg \ --apple-id your@email.com \ --team-id TEAMID \ --password "@keychain:AC_PASSWORD" \ --wait # Staple xcrun stapler staple myplugin-signed.pkg
Publishing
To make your plugin available through FileGrind's plugin browser:
Requirements
- Plugin source code in a public GitHub repository
- Signed and notarized
.pkginstaller - README with description and usage
Submit for inclusion
Email plugins@filegrind.com with:
- GitHub repository URL
- Link to downloadable
.pkgfile - Brief description of what file types it handles
We review submissions and add approved plugins to the registry. Future versions will automate this process.
Plugin registry format
The registry is a JSON file listing available plugins:
{
"plugins": [
{
"name": "myplugin",
"version": "1.0.0",
"description": "Processes XYZ files",
"download_url": "https://github.com/.../releases/download/v1.0.0/myplugin.pkg",
"sha256": "abc123...",
"file_types": ["xyz", "abc"],
"repository": "https://github.com/yourname/myplugin"
}
]
}