feat: Frappe/ERPNext plugin + binary plugin protocol
- frappe/: Standalone Go binary plugin for ERPNext - 5 tools: frappe_list, frappe_get, frappe_create, frappe_report, frappe_rpc - JSON-over-stdin/stdout protocol - Per-request config (multi-tenant support) - Read-only safety mode - Tested against Xcite Technologies Inc - README.md: Plugin architecture docs - .gitignore: Exclude compiled binaries Plugin protocol: discover → tools list, execute → tool result, health → ping
This commit is contained in:
parent
320cc9a353
commit
39b9ea4641
|
|
@ -0,0 +1,10 @@
|
|||
# Compiled plugin binaries
|
||||
openabby-plugin-*
|
||||
*/openabby-plugin-*
|
||||
|
||||
# Go
|
||||
*.test
|
||||
*.prof
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
72
README.md
72
README.md
|
|
@ -1,3 +1,71 @@
|
|||
# openabby-plugins
|
||||
# OpenAbby Plugins
|
||||
|
||||
Official plugins for OpenAbby agent platform
|
||||
Official plugin repository for the [OpenAbby](https://git.mosaic.site/abby_hellofinance_ca/openabby) agent platform.
|
||||
|
||||
## Architecture
|
||||
|
||||
Each plugin is a **standalone binary** that communicates with the OpenAbby core via JSON over stdin/stdout. This means:
|
||||
|
||||
- **Zero coupling** — plugins compile independently, no shared Go version required
|
||||
- **Any language** — Go, Python, Rust, whatever compiles to a binary
|
||||
- **Hot-loadable** — drop into `plugins/` directory, restart agent
|
||||
- **Per-agent config** — each agent loads only the plugins it needs
|
||||
|
||||
## Plugin Protocol
|
||||
|
||||
OpenAbby sends a JSON request on stdin, plugin responds on stdout:
|
||||
|
||||
```
|
||||
→ {"action": "discover"}
|
||||
← {"status": "ok", "tools": [...]}
|
||||
|
||||
→ {"action": "execute", "tool": "frappe_list", "args": {...}, "config": {...}}
|
||||
← {"status": "ok", "result": "..."}
|
||||
|
||||
→ {"action": "health", "config": {...}}
|
||||
← {"status": "ok", "result": "pong"}
|
||||
```
|
||||
|
||||
## Available Plugins
|
||||
|
||||
| Plugin | Description | Status |
|
||||
|--------|-------------|--------|
|
||||
| [frappe](./frappe/) | Frappe/ERPNext connector — documents, reports, RPC | ✅ Ready |
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
# Clone
|
||||
git clone https://git.mosaic.site/abby_hellofinance_ca/openabby-plugins.git
|
||||
|
||||
# Build a plugin
|
||||
cd openabby-plugins/frappe
|
||||
go build -o openabby-plugin-frappe .
|
||||
|
||||
# Install — copy plugin dir to your agent's plugins path
|
||||
cp -r frappe/ ~/openabby/plugins/frappe/
|
||||
```
|
||||
|
||||
## Agent Config
|
||||
|
||||
```yaml
|
||||
plugins:
|
||||
dirs:
|
||||
- ~/openabby/plugins
|
||||
frappe:
|
||||
url: https://erp.example.com
|
||||
api_key: your_key
|
||||
api_secret: your_secret
|
||||
read_only: false # safety: set true for monitoring-only agents
|
||||
```
|
||||
|
||||
## Creating a Plugin
|
||||
|
||||
1. Create a directory with `plugin.yaml` manifest
|
||||
2. Build a binary that reads JSON from stdin, writes JSON to stdout
|
||||
3. Implement `discover`, `execute`, and optionally `health` actions
|
||||
4. See [frappe/](./frappe/) for a complete example
|
||||
|
||||
## License
|
||||
|
||||
Proprietary — Mosaic Cloud Systems Corp
|
||||
|
|
|
|||
|
|
@ -0,0 +1,3 @@
|
|||
module github.com/mosaiccloud/openabby-plugins/frappe
|
||||
|
||||
go 1.23
|
||||
|
|
@ -0,0 +1,390 @@
|
|||
// openabby-plugin-frappe is a standalone plugin binary for the OpenAbby agent platform.
|
||||
// It communicates with the core via JSON over stdin/stdout using the OpenAbby plugin protocol.
|
||||
//
|
||||
// Build: go build -o openabby-plugin-frappe .
|
||||
// Install: copy binary + plugin.yaml to your plugins/ directory
|
||||
//
|
||||
// Config is passed per-request via the "config" field:
|
||||
// {"url": "https://erp.example.com", "api_key": "...", "api_secret": "...", "read_only": true}
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// --- Plugin Protocol Types ---
|
||||
|
||||
type Request struct {
|
||||
Action string `json:"action"`
|
||||
Tool string `json:"tool,omitempty"`
|
||||
Args map[string]interface{} `json:"args,omitempty"`
|
||||
Config map[string]interface{} `json:"config,omitempty"`
|
||||
}
|
||||
|
||||
type Response struct {
|
||||
Status string `json:"status"`
|
||||
Tools []ToolDef `json:"tools,omitempty"`
|
||||
Result string `json:"result,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
type ToolDef struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Parameters map[string]interface{} `json:"parameters"`
|
||||
}
|
||||
|
||||
// --- Frappe Client ---
|
||||
|
||||
type FrappeClient struct {
|
||||
URL string
|
||||
APIKey string
|
||||
APISecret string
|
||||
ReadOnly bool
|
||||
HTTP *http.Client
|
||||
}
|
||||
|
||||
func NewClientFromConfig(cfg map[string]interface{}) (*FrappeClient, error) {
|
||||
c := &FrappeClient{
|
||||
HTTP: &http.Client{Timeout: 30 * time.Second},
|
||||
}
|
||||
|
||||
// Config from request takes priority, env vars as fallback
|
||||
if u, ok := cfg["url"].(string); ok {
|
||||
c.URL = u
|
||||
} else {
|
||||
c.URL = os.Getenv("FRAPPE_URL")
|
||||
}
|
||||
if k, ok := cfg["api_key"].(string); ok {
|
||||
c.APIKey = k
|
||||
} else {
|
||||
c.APIKey = os.Getenv("FRAPPE_API_KEY")
|
||||
}
|
||||
if s, ok := cfg["api_secret"].(string); ok {
|
||||
c.APISecret = s
|
||||
} else {
|
||||
c.APISecret = os.Getenv("FRAPPE_API_SECRET")
|
||||
}
|
||||
if ro, ok := cfg["read_only"].(bool); ok {
|
||||
c.ReadOnly = ro
|
||||
} else if os.Getenv("FRAPPE_READ_ONLY") == "true" {
|
||||
c.ReadOnly = true
|
||||
}
|
||||
|
||||
if c.URL == "" || c.APIKey == "" || c.APISecret == "" {
|
||||
return nil, fmt.Errorf("missing frappe config: url, api_key, and api_secret required")
|
||||
}
|
||||
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func (c *FrappeClient) doRequest(method, path string, body io.Reader) ([]byte, error) {
|
||||
u := strings.TrimRight(c.URL, "/") + path
|
||||
req, err := http.NewRequest(method, u, body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Authorization", fmt.Sprintf("token %s:%s", c.APIKey, c.APISecret))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
resp, err := c.HTTP.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
data, _ := io.ReadAll(resp.Body)
|
||||
|
||||
if resp.StatusCode >= 400 {
|
||||
return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(data))
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// --- Tool Implementations ---
|
||||
|
||||
func toolList(c *FrappeClient, args map[string]interface{}) (string, error) {
|
||||
doctype, _ := args["doctype"].(string)
|
||||
if doctype == "" {
|
||||
return "", fmt.Errorf("doctype is required")
|
||||
}
|
||||
|
||||
params := url.Values{}
|
||||
if f, ok := args["filters"].(string); ok && f != "" {
|
||||
params.Set("filters", f)
|
||||
}
|
||||
if f, ok := args["fields"].(string); ok && f != "" {
|
||||
fields := strings.Split(f, ",")
|
||||
for i := range fields {
|
||||
fields[i] = strings.TrimSpace(fields[i])
|
||||
}
|
||||
fj, _ := json.Marshal(fields)
|
||||
params.Set("fields", string(fj))
|
||||
}
|
||||
if o, ok := args["order_by"].(string); ok && o != "" {
|
||||
params.Set("order_by", o)
|
||||
}
|
||||
limit := 20
|
||||
if l, ok := args["limit"].(float64); ok && l > 0 {
|
||||
limit = int(l)
|
||||
if limit > 100 {
|
||||
limit = 100
|
||||
}
|
||||
}
|
||||
params.Set("limit_page_length", fmt.Sprintf("%d", limit))
|
||||
|
||||
path := fmt.Sprintf("/api/resource/%s?%s", url.PathEscape(doctype), params.Encode())
|
||||
data, err := c.doRequest("GET", path, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
var result struct{ Data interface{} `json:"data"` }
|
||||
json.Unmarshal(data, &result)
|
||||
out, _ := json.MarshalIndent(map[string]interface{}{"doctype": doctype, "data": result.Data}, "", " ")
|
||||
return string(out), nil
|
||||
}
|
||||
|
||||
func toolGet(c *FrappeClient, args map[string]interface{}) (string, error) {
|
||||
doctype, _ := args["doctype"].(string)
|
||||
name, _ := args["name"].(string)
|
||||
if doctype == "" || name == "" {
|
||||
return "", fmt.Errorf("doctype and name required")
|
||||
}
|
||||
|
||||
path := fmt.Sprintf("/api/resource/%s/%s", url.PathEscape(doctype), url.PathEscape(name))
|
||||
data, err := c.doRequest("GET", path, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
var result struct{ Data interface{} `json:"data"` }
|
||||
json.Unmarshal(data, &result)
|
||||
out, _ := json.MarshalIndent(result.Data, "", " ")
|
||||
return string(out), nil
|
||||
}
|
||||
|
||||
func toolCreate(c *FrappeClient, args map[string]interface{}) (string, error) {
|
||||
if c.ReadOnly {
|
||||
return "", fmt.Errorf("site is configured as read-only")
|
||||
}
|
||||
doctype, _ := args["doctype"].(string)
|
||||
dataStr, _ := args["data"].(string)
|
||||
if doctype == "" || dataStr == "" {
|
||||
return "", fmt.Errorf("doctype and data required")
|
||||
}
|
||||
|
||||
path := fmt.Sprintf("/api/resource/%s", url.PathEscape(doctype))
|
||||
data, err := c.doRequest("POST", path, strings.NewReader(dataStr))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
var result struct{ Data interface{} `json:"data"` }
|
||||
json.Unmarshal(data, &result)
|
||||
out, _ := json.MarshalIndent(map[string]interface{}{"status": "created", "data": result.Data}, "", " ")
|
||||
return string(out), nil
|
||||
}
|
||||
|
||||
func toolReport(c *FrappeClient, args map[string]interface{}) (string, error) {
|
||||
reportName, _ := args["report_name"].(string)
|
||||
if reportName == "" {
|
||||
return "", fmt.Errorf("report_name required")
|
||||
}
|
||||
|
||||
reqArgs := map[string]interface{}{"report_name": reportName}
|
||||
if f, ok := args["filters"].(string); ok && f != "" {
|
||||
var filters interface{}
|
||||
json.Unmarshal([]byte(f), &filters)
|
||||
reqArgs["filters"] = filters
|
||||
}
|
||||
|
||||
body, _ := json.Marshal(reqArgs)
|
||||
data, err := c.doRequest("POST", "/api/method/frappe.desk.query_report.run", strings.NewReader(string(body)))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
var result struct{ Message interface{} `json:"message"` }
|
||||
json.Unmarshal(data, &result)
|
||||
out, _ := json.MarshalIndent(result.Message, "", " ")
|
||||
s := string(out)
|
||||
if len(s) > 50000 {
|
||||
s = s[:50000] + "\n... (truncated)"
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func toolRPC(c *FrappeClient, args map[string]interface{}) (string, error) {
|
||||
method, _ := args["method"].(string)
|
||||
if method == "" {
|
||||
return "", fmt.Errorf("method required")
|
||||
}
|
||||
|
||||
var reqBody io.Reader
|
||||
if a, ok := args["args"].(string); ok && a != "" {
|
||||
reqBody = strings.NewReader(a)
|
||||
}
|
||||
|
||||
path := fmt.Sprintf("/api/method/%s", method)
|
||||
data, err := c.doRequest("POST", path, reqBody)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
var result struct{ Message interface{} `json:"message"` }
|
||||
json.Unmarshal(data, &result)
|
||||
out, _ := json.MarshalIndent(result.Message, "", " ")
|
||||
return string(out), nil
|
||||
}
|
||||
|
||||
// --- Tool Definitions ---
|
||||
|
||||
var toolDefs = []ToolDef{
|
||||
{
|
||||
Name: "frappe_list",
|
||||
Description: "List documents from ERPNext/Frappe. Supports any doctype with filters, field selection, ordering.",
|
||||
Parameters: map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"doctype": map[string]interface{}{"type": "string", "description": "Doctype (e.g. 'Sales Invoice', 'Customer')"},
|
||||
"filters": map[string]interface{}{"type": "string", "description": "JSON array of filters"},
|
||||
"fields": map[string]interface{}{"type": "string", "description": "Comma-separated fields"},
|
||||
"order_by": map[string]interface{}{"type": "string", "description": "Sort order"},
|
||||
"limit": map[string]interface{}{"type": "integer", "description": "Max results (default 20, max 100)"},
|
||||
},
|
||||
"required": []string{"doctype"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "frappe_get",
|
||||
Description: "Get a single document by doctype and name/ID.",
|
||||
Parameters: map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"doctype": map[string]interface{}{"type": "string", "description": "Doctype"},
|
||||
"name": map[string]interface{}{"type": "string", "description": "Document name/ID"},
|
||||
},
|
||||
"required": []string{"doctype", "name"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "frappe_create",
|
||||
Description: "Create a new document in ERPNext. Blocked in read-only mode.",
|
||||
Parameters: map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"doctype": map[string]interface{}{"type": "string", "description": "Doctype to create"},
|
||||
"data": map[string]interface{}{"type": "string", "description": "JSON object with document fields"},
|
||||
},
|
||||
"required": []string{"doctype", "data"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "frappe_report",
|
||||
Description: "Run ERPNext reports (General Ledger, AR, AP, Balance Sheet, P&L, etc.).",
|
||||
Parameters: map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"report_name": map[string]interface{}{"type": "string", "description": "Report name"},
|
||||
"filters": map[string]interface{}{"type": "string", "description": "JSON object with report filters"},
|
||||
},
|
||||
"required": []string{"report_name"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "frappe_rpc",
|
||||
Description: "Call a Frappe whitelisted RPC method.",
|
||||
Parameters: map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"method": map[string]interface{}{"type": "string", "description": "Dotted method path"},
|
||||
"args": map[string]interface{}{"type": "string", "description": "JSON object with arguments"},
|
||||
},
|
||||
"required": []string{"method"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// --- Main Entry Point ---
|
||||
|
||||
func main() {
|
||||
// Read request from stdin
|
||||
data, err := io.ReadAll(os.Stdin)
|
||||
if err != nil {
|
||||
writeError(fmt.Sprintf("read stdin: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
var req Request
|
||||
if err := json.Unmarshal(data, &req); err != nil {
|
||||
writeError(fmt.Sprintf("parse request: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
switch req.Action {
|
||||
case "discover":
|
||||
writeJSON(Response{Status: "ok", Tools: toolDefs})
|
||||
|
||||
case "execute":
|
||||
client, err := NewClientFromConfig(req.Config)
|
||||
if err != nil {
|
||||
writeError(err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
var result string
|
||||
switch req.Tool {
|
||||
case "frappe_list":
|
||||
result, err = toolList(client, req.Args)
|
||||
case "frappe_get":
|
||||
result, err = toolGet(client, req.Args)
|
||||
case "frappe_create":
|
||||
result, err = toolCreate(client, req.Args)
|
||||
case "frappe_report":
|
||||
result, err = toolReport(client, req.Args)
|
||||
case "frappe_rpc":
|
||||
result, err = toolRPC(client, req.Args)
|
||||
default:
|
||||
writeError(fmt.Sprintf("unknown tool: %s", req.Tool))
|
||||
return
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
writeError(err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(Response{Status: "ok", Result: result})
|
||||
|
||||
case "health":
|
||||
client, err := NewClientFromConfig(req.Config)
|
||||
if err != nil {
|
||||
writeError(err.Error())
|
||||
return
|
||||
}
|
||||
if _, err := client.doRequest("POST", "/api/method/frappe.ping", nil); err != nil {
|
||||
writeError(fmt.Sprintf("ping failed: %v", err))
|
||||
return
|
||||
}
|
||||
writeJSON(Response{Status: "ok", Result: "pong"})
|
||||
|
||||
default:
|
||||
writeError(fmt.Sprintf("unknown action: %s", req.Action))
|
||||
}
|
||||
}
|
||||
|
||||
func writeJSON(v interface{}) {
|
||||
enc := json.NewEncoder(os.Stdout)
|
||||
enc.Encode(v)
|
||||
}
|
||||
|
||||
func writeError(msg string) {
|
||||
writeJSON(Response{Status: "error", Error: msg})
|
||||
}
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
name: frappe
|
||||
description: "Frappe Framework / ERPNext connector. Provides bookkeeping, invoicing, reporting, and document management tools for any Frappe-based ERP instance."
|
||||
version: "0.1.0"
|
||||
author: "Mosaic Cloud Systems Corp"
|
||||
language: binary
|
||||
entry_point: openabby-plugin-frappe
|
||||
timeout: 60
|
||||
permissions:
|
||||
network: true
|
||||
env_vars:
|
||||
- FRAPPE_URL
|
||||
- FRAPPE_API_KEY
|
||||
- FRAPPE_API_SECRET
|
||||
- FRAPPE_READ_ONLY
|
||||
triggers:
|
||||
- frappe
|
||||
- erpnext
|
||||
- invoice
|
||||
- journal entry
|
||||
- ledger
|
||||
- accounts receivable
|
||||
- accounts payable
|
||||
- balance sheet
|
||||
- bookkeeping
|
||||
Loading…
Reference in New Issue