Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,7 @@ jobs:

- uses: actions/setup-go@v7
with:
go-version: "1.25.12"
go-version: "1.25.13"
cache: true
cache-dependency-path: go.sum

Expand Down Expand Up @@ -181,7 +181,7 @@ jobs:

- uses: actions/setup-go@v7
with:
go-version: "1.25.12"
go-version: "1.25.13"
cache: true
cache-dependency-path: go.sum

Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/govulncheck.yml
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ jobs:

- uses: actions/setup-go@v7
with:
go-version: "1.25.12"
go-version: "1.25.13"
cache: true
cache-dependency-path: go.sum

Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/lint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ jobs:
uses: actions/setup-go@v7
with:
# Pinned to match go.mod. Bump both together when upgrading.
go-version: "1.25.12"
go-version: "1.25.13"
# Module cache only — golangci-lint-action below brings its
# own analysis cache that subsumes ~/.cache/go-build for
# this job.
Expand Down
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -548,7 +548,7 @@ any drift.

**sqlc pinned to v1.29.0.** v1.30+ declares `go >= 1.26` in its
go.mod, which would force `go run` to fetch a newer toolchain than
this repo builds under (go 1.25.12). If you bump sqlc, update
this repo builds under (go 1.25.13). If you bump sqlc, update
`SQLC_VERSION` in both `Makefile` and `.github/workflows/check.yml` in
the same commit. CI caches a small sqlc binary for `make check-go` and
passes it via the `SQLC` make override; local development defaults to
Expand Down
300 changes: 300 additions & 0 deletions apps/parsar/internal/cli/plugin.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,300 @@
package cli

import (
"context"
"encoding/json"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"text/tabwriter"
)

func runPlugin(ctx *runContext, args []string) error {
if len(args) == 0 {
printPluginHelp(ctx.stdout)
return fmt.Errorf("plugin: missing subcommand")
}
if args[0] == "-h" || args[0] == "--help" || args[0] == "help" {
printPluginHelp(ctx.stdout)
return nil
}
for _, sc := range pluginSubcommands {
if sc.name == args[0] {
return sc.run(ctx, args[1:])
}
}
printPluginHelp(ctx.stderr)
return fmt.Errorf("plugin: unknown subcommand %q", args[0])
}

var pluginSubcommands = []command{
{name: "add", summary: "Install a plugin bundle from a local directory", run: runPluginAdd},
{name: "list", summary: "List installed plugin bundles", run: runPluginList},
{name: "remove", summary: "Remove an installed plugin bundle", run: runPluginRemove},
}

func printPluginHelp(w io.Writer) {
fmt.Fprintln(w, "Usage: parsar plugin <subcommand> [flags]")
fmt.Fprintln(w)
fmt.Fprintln(w, "Subcommands:")
for _, sc := range pluginSubcommands {
fmt.Fprintf(w, " %-9s %s\n", sc.name, sc.summary)
}
}

// ----- plugin add -----------------------------------------------------------

// pluginManifest mirrors the user-authored manifest.json in a plugin directory.
// Only the fields needed for Phase 0 are defined.
type pluginManifest struct {
Name string `json:"name"`
Version string `json:"version"`
Description string `json:"description,omitempty"`
Author string `json:"author,omitempty"`
Server *pluginManifestEntry `json:"server,omitempty"`
Client *pluginManifestEntry `json:"client,omitempty"`
Skills []string `json:"skills,omitempty"`
Tools []string `json:"tools,omitempty"`
Hooks []string `json:"hooks,omitempty"`
Credentials []string `json:"credentials,omitempty"`
}

type pluginManifestEntry struct {
Entry string `json:"entry,omitempty"`
Tools []string `json:"tools,omitempty"`
}

// bundleSkillPayload mirrors canonical.BundleSkill for the API request.
type bundleSkillPayload struct {
Slug string `json:"slug"`
Instruction string `json:"instruction"`
}

func runPluginAdd(ctx *runContext, args []string) error {
fs := newFlagSet("plugin add")
jsonOut := fs.Bool("json", false, "emit JSON of the created capability")
if err := fs.Parse(args); err != nil {
return fmt.Errorf("plugin add: parse flags: %w", err)
}
remaining := fs.Args()
if len(remaining) == 0 {
return fmt.Errorf("plugin add: path to plugin directory is required")
}
pluginDir := remaining[0]

// Read and parse manifest.json
manifestPath := filepath.Join(pluginDir, "manifest.json")
manifestData, err := os.ReadFile(manifestPath)
if err != nil {
return fmt.Errorf("plugin add: read manifest: %w", err)
}
var manifest pluginManifest
if err := json.Unmarshal(manifestData, &manifest); err != nil {
return fmt.Errorf("plugin add: parse manifest: %w", err)
}
if strings.TrimSpace(manifest.Name) == "" {
return fmt.Errorf("plugin add: manifest.name is required")
}
if strings.TrimSpace(manifest.Version) == "" {
return fmt.Errorf("plugin add: manifest.version is required")
}

// Read skill files and embed content
skills, err := readPluginSkills(pluginDir, manifest.Skills)
if err != nil {
return fmt.Errorf("plugin add: %w", err)
}

// Build the canonical_spec
bundleSpec := map[string]any{
"name": manifest.Name,
"version": manifest.Version,
}
if manifest.Description != "" {
bundleSpec["description"] = manifest.Description
}
if manifest.Author != "" {
bundleSpec["author"] = manifest.Author
}
if manifest.Server != nil && manifest.Server.Entry != "" {
bundleSpec["server_entry"] = manifest.Server.Entry
}
if manifest.Client != nil && manifest.Client.Entry != "" {
bundleSpec["client_entry"] = manifest.Client.Entry
}
if len(skills) > 0 {
bundleSpec["skills"] = skills
}
// Collect tools from manifest top-level or server.tools
tools := manifest.Tools
if manifest.Server != nil && len(manifest.Server.Tools) > 0 {
tools = append(tools, manifest.Server.Tools...)
}
if len(tools) > 0 {
bundleSpec["tools"] = tools
}
if len(manifest.Hooks) > 0 {
bundleSpec["hooks"] = manifest.Hooks
}
if len(manifest.Credentials) > 0 {
bundleSpec["credentials"] = manifest.Credentials
}

canonicalSpec := map[string]any{
"schema_version": 1,
"kind": "bundle",
"bundle": bundleSpec,
}

// Build the API request
reqBody := map[string]any{
"type": "bundle",
"name": manifest.Name,
"description": manifest.Description,
"visibility": "workspace",
"version": manifest.Version,
"canonical_spec": canonicalSpec,
}

cfg, err := ctx.resolveConfig()
if err != nil {
return fmt.Errorf("plugin add: %w", err)
}
if strings.TrimSpace(cfg.WorkspaceID) == "" {
return fmt.Errorf("plugin add: PARSAR_WORKSPACE_ID is required")
}
var result map[string]any
if err := newClient(cfg).do(context.Background(), "POST", "/api/v1/workspaces/"+cfg.WorkspaceID+"/capabilities/plugins/install", nil, reqBody, &result); err != nil {
return fmt.Errorf("plugin add: %w", err)
}

if *jsonOut {
return emitJSON(ctx.stdout, result)
}
name := manifest.Name
if id, ok := result["id"].(string); ok {
fmt.Fprintf(ctx.stdout, "plugin %q installed (capability_id=%s)\n", name, id)
} else {
fmt.Fprintf(ctx.stdout, "plugin %q installed\n", name)
}
return nil
}

// readPluginSkills reads skill markdown files from the plugin directory
// and returns them as inline payloads for the canonical_spec.
func readPluginSkills(pluginDir string, skillPaths []string) ([]bundleSkillPayload, error) {
if len(skillPaths) == 0 {
return nil, nil
}
var skills []bundleSkillPayload
for _, relPath := range skillPaths {
fullPath := filepath.Join(pluginDir, relPath)
content, err := os.ReadFile(fullPath)
if err != nil {
return nil, fmt.Errorf("read skill %s: %w", relPath, err)
}
// Derive slug from filename: "skills/customer-service.md" → "customer-service"
base := filepath.Base(relPath)
slug := strings.TrimSuffix(base, filepath.Ext(base))
skills = append(skills, bundleSkillPayload{
Slug: slug,
Instruction: string(content),
})
}
return skills, nil
}

// ----- plugin list ----------------------------------------------------------

func runPluginList(ctx *runContext, args []string) error {
fs := newFlagSet("plugin list")
jsonOut := fs.Bool("json", false, "emit JSON instead of the table")
if err := fs.Parse(args); err != nil {
return fmt.Errorf("plugin list: parse flags: %w", err)
}
cfg, err := ctx.resolveConfig()
if err != nil {
return fmt.Errorf("plugin list: %w", err)
}
if strings.TrimSpace(cfg.WorkspaceID) == "" {
return fmt.Errorf("plugin list: PARSAR_WORKSPACE_ID is required")
}
var result struct {
Capabilities []struct {
ID string `json:"id"`
Name string `json:"name"`
Type string `json:"type"`
Description string `json:"description"`
Version string `json:"latest_version"`
} `json:"capabilities"`
}
if err := newClient(cfg).do(context.Background(), "GET", "/api/v1/workspaces/"+cfg.WorkspaceID+"/capabilities?type=bundle", nil, nil, &result); err != nil {
return fmt.Errorf("plugin list: %w", err)
}
if *jsonOut {
return emitJSON(ctx.stdout, result.Capabilities)
}
if len(result.Capabilities) == 0 {
fmt.Fprintln(ctx.stdout, "(no plugins installed)")
return nil
}
tw := tabwriter.NewWriter(ctx.stdout, 0, 2, 2, ' ', 0)
fmt.Fprintln(tw, "NAME\tVERSION\tDESCRIPTION")
for _, c := range result.Capabilities {
fmt.Fprintf(tw, "%s\t%s\t%s\n", c.Name, c.Version, truncate(c.Description, 50))
}
return tw.Flush()
}

// ----- plugin remove --------------------------------------------------------

func runPluginRemove(ctx *runContext, args []string) error {
fs := newFlagSet("plugin remove")
if err := fs.Parse(args); err != nil {
return fmt.Errorf("plugin remove: parse flags: %w", err)
}
remaining := fs.Args()
if len(remaining) == 0 {
return fmt.Errorf("plugin remove: plugin name is required")
}
name := remaining[0]
cfg, err := ctx.resolveConfig()
if err != nil {
return fmt.Errorf("plugin remove: %w", err)
}
if strings.TrimSpace(cfg.WorkspaceID) == "" {
return fmt.Errorf("plugin remove: PARSAR_WORKSPACE_ID is required")
}

// Resolve plugin name to capability_id via the list endpoint.
var listResult struct {
Capabilities []struct {
ID string `json:"id"`
Name string `json:"name"`
Type string `json:"type"`
} `json:"capabilities"`
}
c := newClient(cfg)
if err := c.do(context.Background(), "GET", "/api/v1/workspaces/"+cfg.WorkspaceID+"/capabilities?type=bundle", nil, nil, &listResult); err != nil {
return fmt.Errorf("plugin remove: list plugins: %w", err)
}
var capabilityID string
for _, cap := range listResult.Capabilities {
if cap.Name == name {
capabilityID = cap.ID
break
}
}
if capabilityID == "" {
return fmt.Errorf("plugin remove: plugin %q not found", name)
}

// Delete by capability ID using the existing endpoint.
if err := c.do(context.Background(), "DELETE", "/api/v1/workspaces/"+cfg.WorkspaceID+"/capabilities/"+capabilityID, nil, nil, nil); err != nil {
return fmt.Errorf("plugin remove: %w", err)
}
fmt.Fprintf(ctx.stdout, "plugin %q removed\n", name)
return nil
}
1 change: 1 addition & 0 deletions apps/parsar/internal/cli/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ var commands = []command{
{name: "memory", summary: "Manage user / workspace memories (list / add / edit / rm)", run: runMemory},
{name: "inject", summary: "Print the injection bundle hook scripts stitch into the prompt", run: runInject},
{name: "sync", summary: "Human-readable dump of the current injection snapshot (debug)", run: runSync},
{name: "plugin", summary: "Manage plugin bundles (add / list / remove)", run: runPlugin},
{name: "version", summary: "Print the CLI version and exit", run: runVersion},
}

Expand Down
1 change: 1 addition & 0 deletions apps/web/src/i18n/locales/en-US/admin.json
Original file line number Diff line number Diff line change
Expand Up @@ -1426,6 +1426,7 @@
"mcp": "MCP",
"skill": "Skill",
"plugin": "Plugin",
"bundle": "Plugin Bundle",
"system_prompt": "System Prompt"
}
},
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/i18n/locales/zh-CN/admin.json
Original file line number Diff line number Diff line change
Expand Up @@ -1426,6 +1426,7 @@
"mcp": "MCP",
"skill": "Skill",
"plugin": "Plugin",
"bundle": "插件包",
"system_prompt": "System Prompt"
}
},
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/lib/api-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -245,7 +245,7 @@ export interface DeleteAgentResponse {

/* --- Capabilities -------------------------------------------------------- */

export type CapabilityType = "skill" | "mcp" | "plugin" | "system_prompt"
export type CapabilityType = "skill" | "mcp" | "plugin" | "system_prompt" | "bundle"

export interface RequiredCredential {
kind: string
Expand Down
Loading