Skip to content

Latest commit

 

History

History
553 lines (432 loc) · 14.5 KB

File metadata and controls

553 lines (432 loc) · 14.5 KB

Osaurus Xlsx - Osaurus Plugin

This is an Osaurus plugin project. Use this guide to develop, test, and submit the plugin.

Project Structure

osaurus-xlsx/
├── Package.swift              # Swift Package Manager configuration
├── Sources/
│   └── osaurus_xlsx/
│       └── Plugin.swift       # Main plugin implementation
├── README.md                  # User-facing documentation
├── CLAUDE.md                  # This file (AI guidance)
└── .github/
    └── workflows/
        └── release.yml        # CI/CD for releases

Architecture Overview

Osaurus plugins use a C ABI interface. The plugin exports a single entry point (osaurus_plugin_entry) that returns a function table with:

  • init() - Initialize plugin, return context pointer
  • destroy(ctx) - Clean up resources
  • get_manifest(ctx) - Return JSON describing plugin capabilities
  • invoke(ctx, type, id, payload) - Execute a tool with JSON payload
  • free_string(s) - Free strings returned to host

Adding New Tools

Step 1: Define the Tool Structure

private struct MyTool {
    let name = "my_tool"  // Must match manifest id
    let description = "What this tool does"
    
    struct Args: Decodable {
        let inputParam: String
        let optionalParam: String?
    }
    
    func run(args: String) -> String {
        // 1. Parse JSON input
        guard let data = args.data(using: .utf8),
              let input = try? JSONDecoder().decode(Args.self, from: data) else {
            return "{\"error\": \"Invalid arguments\"}"
        }
        
        // 2. Execute tool logic
        let result = processInput(input.inputParam)
        
        // 3. Return JSON response
        return "{\"result\": \"\(result)\"}"
    }
}

Step 2: Add Tool to PluginContext

private class PluginContext {
    let helloTool = HelloTool()
    let myTool = MyTool()  // Add your new tool
}

Step 3: Register in Manifest

Add the tool to the capabilities.tools array in get_manifest():

{
  "id": "my_tool",
  "description": "What this tool does (shown to users)",
  "parameters": {
    "type": "object",
    "properties": {
      "inputParam": {
        "type": "string",
        "description": "Description of this parameter"
      },
      "optionalParam": {
        "type": "string",
        "description": "Optional parameter"
      }
    },
    "required": ["inputParam"]
  },
  "requirements": [],
  "permission_policy": "ask"
}

Step 4: Handle in invoke()

api.invoke = { ctxPtr, typePtr, idPtr, payloadPtr in
    // ... existing code ...
    
    if type == "tool" {
        switch id {
        case ctx.helloTool.name:
            return makeCString(ctx.helloTool.run(args: payload))
        case ctx.myTool.name:
            return makeCString(ctx.myTool.run(args: payload))
        default:
            return makeCString("{\"error\": \"Unknown tool\"}")
        }
    }
    
    return makeCString("{\"error\": \"Unknown capability\"}")
}

Using Secrets (API Keys)

If your plugin needs API keys or other credentials, declare them in the manifest and access them via the _secrets key in the payload.

Step 1: Declare Secrets in Manifest

Add a secrets array at the top level of your manifest:

{
  "plugin_id": "dev.example.osaurus-xlsx",
  "name": "Osaurus Xlsx",
  "version": "0.1.0",
  "secrets": [
    {
      "id": "api_key",
      "label": "API Key",
      "description": "Get your key from [Example](https://example.com/api)",
      "required": true,
      "url": "https://example.com/api"
    }
  ],
  "capabilities": { ... }
}

Step 2: Access Secrets in Your Tool

private struct MyAPITool {
    let name = "call_api"
    
    struct Args: Decodable {
        let query: String
        let _secrets: [String: String]?  // Secrets injected by Osaurus
    }
    
    func run(args: String) -> String {
        guard let data = args.data(using: .utf8),
              let input = try? JSONDecoder().decode(Args.self, from: data)
        else {
            return "{\"error\": \"Invalid arguments\"}"
        }
        
        // Get the API key
        guard let apiKey = input._secrets?["api_key"] else {
            return "{\"error\": \"API key not configured\"}"
        }
        
        // Use the API key in your request
        let result = makeAPICall(apiKey: apiKey, query: input.query)
        return "{\"result\": \"\(result)\"}"
    }
}

Secret Fields

Field Type Required Description
id string Yes Unique key (e.g., "api_key")
label string Yes Display name in UI
description string No Help text (supports markdown links)
required boolean Yes Whether the secret is required
url string No Link to get the secret

User Experience

  • Users are prompted to configure secrets when installing plugins that require them
  • A "Needs API Key" badge appears if required secrets are missing
  • Users can edit secrets anytime via the plugin menu
  • Secrets are stored securely in the macOS Keychain

Using Folder Context (Working Directory)

When a user has a working directory selected in Agent Mode, Osaurus automatically injects the folder context into tool payloads. This allows your plugin to resolve relative file paths.

Automatic Injection

When a folder context is active, every tool invocation receives a _context object:

{
  "input_path": "Screenshots/image.png",
  "_context": {
    "working_directory": "/Users/foo/project"
  }
}

Accessing Folder Context in Your Tool

private struct MyFileTool {
    let name = "process_file"
    
    struct FolderContext: Decodable {
        let working_directory: String
    }
    
    struct Args: Decodable {
        let path: String
        let _context: FolderContext?  // Folder context injected by Osaurus
    }
    
    func run(args: String) -> String {
        guard let data = args.data(using: .utf8),
              let input = try? JSONDecoder().decode(Args.self, from: data)
        else {
            return "{\"error\": \"Invalid arguments\"}"
        }
        
        // Resolve relative path using working directory
        let absolutePath: String
        if let workingDir = input._context?.working_directory {
            absolutePath = "\(workingDir)/\(input.path)"
        } else {
            // No folder context - assume absolute path or return error
            absolutePath = input.path
        }
        
        // SECURITY: Validate path stays within working directory
        if let workingDir = input._context?.working_directory {
            let resolvedPath = URL(fileURLWithPath: absolutePath).standardized.path
            guard resolvedPath.hasPrefix(workingDir) else {
                return "{\"error\": \"Path outside working directory\"}"
            }
        }
        
        // Process the file at absolutePath...
        return "{\"success\": true}"
    }
}

Security Considerations

  • Always validate paths stay within working_directory to prevent directory traversal
  • The LLM is instructed to use relative paths for file operations
  • Reject paths that attempt to escape (e.g., ../../../etc/passwd)
  • If _context is absent, decide whether to require it or accept absolute paths

Context Fields

Field Type Description
working_directory string Absolute path to the user's selected folder

Porting Existing Tools

From MCP (Model Context Protocol)

MCP tools map directly to Osaurus tools:

MCP Concept Osaurus Equivalent
Tool name id in manifest
Input schema parameters (JSON Schema)
Tool handler run() method in tool struct
Response JSON string return value

Example MCP tool conversion:

// MCP tool definition
{
  "name": "get_weather",
  "description": "Get weather for a location",
  "inputSchema": {
    "type": "object",
    "properties": {
      "location": { "type": "string" }
    },
    "required": ["location"]
  }
}

Becomes this Osaurus manifest entry:

{
  "id": "get_weather",
  "description": "Get weather for a location",
  "parameters": {
    "type": "object",
    "properties": {
      "location": { "type": "string" }
    },
    "required": ["location"]
  },
  "requirements": [],
  "permission_policy": "ask"
}

From CLI Tools

Wrap command-line tools using Process/subprocess:

func run(args: String) -> String {
    guard let input = parseArgs(args) else {
        return "{\"error\": \"Invalid arguments\"}"
    }
    
    let process = Process()
    process.executableURL = URL(fileURLWithPath: "/usr/bin/some-cli")
    process.arguments = [input.flag, input.value]
    
    let pipe = Pipe()
    process.standardOutput = pipe
    process.standardError = pipe
    
    do {
        try process.run()
        process.waitUntilExit()
        
        let data = pipe.fileHandleForReading.readDataToEndOfFile()
        let output = String(data: data, encoding: .utf8) ?? ""
        
        if process.terminationStatus == 0 {
            return "{\"output\": \"\(output.escapedForJSON)\"}"
        } else {
            return "{\"error\": \"Command failed: \(output.escapedForJSON)\"}"
        }
    } catch {
        return "{\"error\": \"\(error.localizedDescription)\"}"
    }
}

From Web APIs

Make HTTP requests to wrap external APIs:

func run(args: String) -> String {
    guard let input = parseArgs(args) else {
        return "{\"error\": \"Invalid arguments\"}"
    }
    
    // Use synchronous URLSession for plugin context
    let semaphore = DispatchSemaphore(value: 0)
    var result = "{\"error\": \"Request failed\"}"
    
    let url = URL(string: "https://api.example.com/endpoint")!
    var request = URLRequest(url: url)
    request.httpMethod = "POST"
    request.setValue("application/json", forHTTPHeaderField: "Content-Type")
    request.httpBody = try? JSONEncoder().encode(input)
    
    URLSession.shared.dataTask(with: request) { data, response, error in
        defer { semaphore.signal() }
        
        if let data = data,
           let json = try? JSONSerialization.jsonObject(with: data) {
            result = String(data: try! JSONSerialization.data(withJSONObject: json), encoding: .utf8)!
        }
    }.resume()
    
    semaphore.wait()
    return result
}

Testing Workflow

1. Build the Plugin

swift build -c release

2. Verify Manifest

Extract and validate the manifest JSON:

osaurus manifest extract .build/release/libosaurus-xlsx.dylib

Check for:

  • Valid JSON structure
  • All tools have unique id values
  • Parameters use valid JSON Schema
  • Version follows semver (e.g., "0.1.0")

3. Test Locally

Package and install for local testing:

# Package the plugin
osaurus tools package dev.example.osaurus-xlsx 0.1.0

# Install locally
osaurus tools install ./dev.example.osaurus-xlsx-0.1.0.zip

# Verify installation
osaurus tools verify

4. Test in Osaurus

  1. Open Osaurus app
  2. Go to Tools settings (Cmd+Shift+M → Tools)
  3. Verify your plugin appears
  4. Test each tool by asking the AI to use it

5. Iterate

After making changes:

swift build -c release && osaurus tools package dev.example.osaurus-xlsx 0.1.0 && osaurus tools install ./dev.example.osaurus-xlsx-0.1.0.zip

Best Practices

JSON Schema for Parameters

  • Always specify type for each property
  • Use description to help the AI understand parameter purpose
  • Mark truly required fields in required array
  • Use appropriate types: string, number, integer, boolean, array, object
{
  "type": "object",
  "properties": {
    "query": {
      "type": "string",
      "description": "Search query text"
    },
    "limit": {
      "type": "integer",
      "description": "Maximum results to return",
      "default": 10
    },
    "filters": {
      "type": "array",
      "items": { "type": "string" },
      "description": "Optional filter tags"
    }
  },
  "required": ["query"]
}

Error Handling

Always return valid JSON, even for errors:

{"error": "Clear description of what went wrong"}

For detailed errors:

{"error": "Validation failed", "details": {"field": "query", "message": "Cannot be empty"}}

Tool Naming

  • Use snake_case for tool IDs: get_weather, search_files
  • Be descriptive but concise
  • Prefix related tools: github_create_issue, github_list_repos

Permission Policies

Policy When to Use
ask Default. User confirms each execution
auto Safe, read-only operations
deny Dangerous operations (use sparingly)

System Requirements

Add to requirements array when your tool needs:

Requirement Use Case
automation AppleScript, controlling other apps
accessibility UI automation, input simulation
calendar Reading/writing calendar events
contacts Accessing contact information
location Getting user's location
disk Full disk access (Messages, Safari data)
reminders Reading/writing reminders
notes Accessing Notes app
maps Controlling Maps app

Submission Checklist

Before submitting to the Osaurus plugin registry:

  • Plugin builds without warnings
  • osaurus manifest extract returns valid JSON
  • All tools have clear descriptions
  • Parameters use proper JSON Schema
  • Error cases return valid JSON errors
  • Version follows semver (X.Y.Z)
  • plugin_id follows reverse-domain format (com.yourname.pluginname)
  • README.md documents all tools
  • Code is signed with Developer ID (for distribution)

Code Signing (Required for Distribution)

codesign --force --options runtime --timestamp \
  --sign "Developer ID Application: Your Name (TEAMID)" \
  .build/release/libosaurus-xlsx.dylib

Registry Submission

  1. Fork the osaurus-tools repository
  2. Add plugins/<your-plugin-id>.json with metadata
  3. Submit a pull request

Common Issues

Plugin not loading

  • Check osaurus manifest extract for errors
  • Verify the dylib is properly signed
  • Check Console.app for loading errors

Tool not appearing

  • Ensure tool is in manifest capabilities.tools array
  • Verify invoke() handles the tool ID
  • Check tool ID matches exactly (case-sensitive)

JSON parsing errors

  • Validate JSON escaping in strings
  • Use proper encoding for special characters
  • Test with echo '{"param":"value"}' | osaurus manifest extract ...