diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index bf6e03be..988e0775 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -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 @@ -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 diff --git a/.github/workflows/govulncheck.yml b/.github/workflows/govulncheck.yml index 17ee96c8..10a0ea02 100644 --- a/.github/workflows/govulncheck.yml +++ b/.github/workflows/govulncheck.yml @@ -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 diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 50775476..c28051f1 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -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. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index deb67158..67bae237 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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 diff --git a/apps/parsar/internal/cli/plugin.go b/apps/parsar/internal/cli/plugin.go new file mode 100644 index 00000000..0a7da054 --- /dev/null +++ b/apps/parsar/internal/cli/plugin.go @@ -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 [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 +} diff --git a/apps/parsar/internal/cli/root.go b/apps/parsar/internal/cli/root.go index 0e843f0b..0e4fb86b 100644 --- a/apps/parsar/internal/cli/root.go +++ b/apps/parsar/internal/cli/root.go @@ -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}, } diff --git a/apps/web/src/i18n/locales/en-US/admin.json b/apps/web/src/i18n/locales/en-US/admin.json index 55cc57eb..5ee45cb6 100644 --- a/apps/web/src/i18n/locales/en-US/admin.json +++ b/apps/web/src/i18n/locales/en-US/admin.json @@ -1426,6 +1426,7 @@ "mcp": "MCP", "skill": "Skill", "plugin": "Plugin", + "bundle": "Plugin Bundle", "system_prompt": "System Prompt" } }, diff --git a/apps/web/src/i18n/locales/zh-CN/admin.json b/apps/web/src/i18n/locales/zh-CN/admin.json index e27bd317..022cf14c 100644 --- a/apps/web/src/i18n/locales/zh-CN/admin.json +++ b/apps/web/src/i18n/locales/zh-CN/admin.json @@ -1426,6 +1426,7 @@ "mcp": "MCP", "skill": "Skill", "plugin": "Plugin", + "bundle": "插件包", "system_prompt": "System Prompt" } }, diff --git a/apps/web/src/lib/api-types.ts b/apps/web/src/lib/api-types.ts index 805201ab..c1c53694 100644 --- a/apps/web/src/lib/api-types.ts +++ b/apps/web/src/lib/api-types.ts @@ -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 diff --git a/apps/web/src/pages/admin/capabilities/index.tsx b/apps/web/src/pages/admin/capabilities/index.tsx index 07884cc1..611dbd41 100644 --- a/apps/web/src/pages/admin/capabilities/index.tsx +++ b/apps/web/src/pages/admin/capabilities/index.tsx @@ -84,7 +84,7 @@ interface AgentInstallation { latest: boolean } -type CapabilityTypeFilter = "mcp" | "skill" +type CapabilityTypeFilter = "mcp" | "skill" | "bundle" type PageTab = "workspace" | "marketplace" export function CapabilitiesPage() { @@ -140,7 +140,11 @@ export function CapabilitiesPage() { // Tab is URL-driven; default lands on workspace. Marketplace tab also // owns the selected-detail state via the `item` URL param. const pageTab: PageTab = routeTab === "marketplace" || itemParam ? "marketplace" : "workspace" + const marketplaceTypeFilter: "mcp" | "skill" = typeFilter === "skill" ? "skill" : "mcp" const setPageTab = (next: PageTab) => { + if (next === "marketplace" && typeFilter === "bundle") { + setTypeFilter("mcp") + } navigate("capabilities", { tab: next === "marketplace" ? "marketplace" : null, item: null }) } const marketplaceItem = pageTab === "marketplace" ? itemParam : null @@ -263,8 +267,9 @@ export function CapabilitiesPage() { )} @@ -279,7 +284,7 @@ export function CapabilitiesPage() { navigate("capabilities", { tab: "marketplace", item })} @@ -520,11 +525,13 @@ function CapabilitiesFilterBar({ onQueryChange, typeFilter, onTypeFilterChange, + showBundle, }: { query: string onQueryChange: (value: string) => void typeFilter: CapabilityTypeFilter onTypeFilterChange: (value: CapabilityTypeFilter) => void + showBundle: boolean }) { const { t } = useTranslation("admin") return ( @@ -533,6 +540,7 @@ function CapabilitiesFilterBar({ MCP Skill + {showBundle && Plugin}
@@ -1078,6 +1086,7 @@ export function CapabilityDetailPage({ id }: { id: string }) { export function CapabilityTypeBadge({ type }: { type: Capability["type"] }) { if (type === "skill") return Skill if (type === "plugin") return Plugin + if (type === "bundle") return Plugin Bundle if (type === "system_prompt") return System Prompt return MCP } diff --git a/docs/openapi/openapi.yaml b/docs/openapi/openapi.yaml index 956928fa..ccc6b1d8 100644 --- a/docs/openapi/openapi.yaml +++ b/docs/openapi/openapi.yaml @@ -631,6 +631,8 @@ definitions: type: string type: array type: object + dev.installPluginBody: + type: object dev.installSkillRequest: properties: registry: @@ -6494,6 +6496,54 @@ paths: summary: List workspace marketplace installs tags: - capabilities + /api/v1/workspaces/{workspaceID}/capabilities/plugins/install: + post: + consumes: + - application/json + description: Creates a KindBundle capability and its first version. The canonical_spec + must have kind=bundle with inline skills embedded. Owner/admin only. + operationId: installPluginBundle + parameters: + - description: Workspace UUID + in: path + name: workspaceID + required: true + type: string + - description: Plugin install payload + in: body + name: body + required: true + schema: + $ref: '#/definitions/dev.installPluginBody' + produces: + - application/json + responses: + "201": + description: Created capability and version + schema: + additionalProperties: true + type: object + "400": + description: Missing name/version, invalid canonical_spec + schema: + additionalProperties: + type: string + type: object + "403": + description: Caller is not workspace owner/admin + schema: + additionalProperties: + type: string + type: object + "503": + description: Database-backed capability APIs are disabled + schema: + additionalProperties: + type: string + type: object + summary: Install a plugin bundle + tags: + - capabilities /api/v1/workspaces/{workspaceID}/capabilities/uninstall: post: consumes: diff --git a/examples/plugins/README.md b/examples/plugins/README.md new file mode 100644 index 00000000..731d9d3d --- /dev/null +++ b/examples/plugins/README.md @@ -0,0 +1,35 @@ +# Example Plugins + +Demo plugins for validating the Parsar Plugin (KindBundle) system. + +## customer-service-skin + +A "skin change" plugin that transforms an Agent's personality into a +professional customer service representative. Contains only a skill +(no server/client components) — the simplest possible plugin. + +### Install + +```bash +export PARSAR_SERVER_URL=http://localhost:8080 +export PARSAR_RUNNER_TOKEN= +export PARSAR_WORKSPACE_ID= + +parsar plugin add ./examples/plugins/customer-service-skin +``` + +### What It Does + +When bound to an Agent, the skill markdown is injected as a system prompt +addition (append mode). The Agent will adopt a warm, solution-oriented +customer service communication style in all responses. + +### Verify + +1. Install the plugin +2. Bind it to an Agent via the admin UI (Capability page → enable on Agent) +3. Start a new conversation with that Agent +4. The Agent should respond with customer-service-style phrasing: + - Warm greeting + - Acknowledge → Respond → Follow-up structure + - "Is there anything else I can help with?" diff --git a/examples/plugins/customer-service-skin/manifest.json b/examples/plugins/customer-service-skin/manifest.json new file mode 100644 index 00000000..a6824119 --- /dev/null +++ b/examples/plugins/customer-service-skin/manifest.json @@ -0,0 +1,7 @@ +{ + "name": "@internal/customer-service-skin", + "version": "1.0.0", + "description": "Transforms the Agent into a professional customer service representative with warm, solution-oriented communication style.", + "author": "FDE Team", + "skills": ["./skills/customer-service.md"] +} diff --git a/examples/plugins/customer-service-skin/skills/customer-service.md b/examples/plugins/customer-service-skin/skills/customer-service.md new file mode 100644 index 00000000..772fe6d7 --- /dev/null +++ b/examples/plugins/customer-service-skin/skills/customer-service.md @@ -0,0 +1,29 @@ +# Customer Service Persona + +You are a professional customer service representative. Follow these guidelines in every response: + +## Tone and Style + +- Always greet the user warmly and address them by name when available. +- Use polite, empathetic language. Acknowledge the user's feelings before offering solutions. +- Keep responses concise but thorough. Avoid jargon; use plain language. +- End every response with a follow-up question or offer of further assistance. + +## Response Structure + +1. **Acknowledge**: Start by acknowledging what the user said or asked. +2. **Respond**: Provide a clear, helpful answer or solution. +3. **Follow up**: Ask if there's anything else you can help with. + +## Example Phrases + +- "I understand how frustrating that must be. Let me help you with that." +- "Great question! Here's what I'd recommend..." +- "Is there anything else I can assist you with today?" + +## Rules + +- Never blame the user for an issue. +- If you don't know the answer, say so honestly and offer to escalate. +- Always prioritize the user's satisfaction over being technically correct. +- Use positive framing: say "Here's what we can do" instead of "We can't do that". diff --git a/go.mod b/go.mod index ab1492c3..bc8cc4fa 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/MiniMax-AI-Dev/parsar -go 1.25.12 +go 1.25.13 require ( github.com/BurntSushi/toml v1.6.0 diff --git a/go.work b/go.work index 16cea402..463cff59 100644 --- a/go.work +++ b/go.work @@ -1,3 +1,3 @@ -go 1.25.12 +go 1.25.13 use . diff --git a/server/internal/capability/canonical/bundle.go b/server/internal/capability/canonical/bundle.go new file mode 100644 index 00000000..d53cd66c --- /dev/null +++ b/server/internal/capability/canonical/bundle.go @@ -0,0 +1,95 @@ +package canonical + +import ( + "fmt" + "strings" +) + +// BundleSpec is the body for Spec{Kind: KindBundle} — a Plugin Bundle that +// packages server tools, client UI, skills, and hooks as a single deployable +// unit. Installed via `parsarctl plugin add`, the bundle lives on the local +// filesystem under the server's plugins/ directory. +type BundleSpec struct { + // Name is the unique plugin identifier, e.g. "@internal/jira-integration". + Name string `json:"name"` + + // Version follows semver, e.g. "1.2.0". + Version string `json:"version"` + + // Description is a human-readable summary for the admin UI. + Description string `json:"description,omitempty"` + + // Author identifies the FDE team or individual who built this plugin. + Author string `json:"author,omitempty"` + + // ServerEntry is the relative path to the Node.js server entry point, + // e.g. "./server/index.js". Empty means the plugin has no server component. + ServerEntry string `json:"server_entry,omitempty"` + + // ClientEntry is the relative path to the built client bundle, + // e.g. "./client/index.js". Empty means the plugin has no UI component. + ClientEntry string `json:"client_entry,omitempty"` + + // Skills holds inline skill definitions. Each entry has a slug (identifier) + // and instruction (markdown body). These are injected as system prompt + // additions (append mode) when the bundle is bound to an agent. + // The `parsarctl plugin add` command reads skill files from disk and + // embeds the content here at install time. + Skills []BundleSkill `json:"skills,omitempty"` + + // Tools lists tool names the server entry exposes via MCP. Informational + // for the admin UI; the actual tool registration happens at runtime. + Tools []string `json:"tools,omitempty"` + + // Hooks lists event hook names the server entry registers. Informational. + Hooks []string `json:"hooks,omitempty"` + + // Credentials lists credential kind codes the plugin requires. + // The admin fills these via the existing credential_ref flow. + Credentials []string `json:"credentials,omitempty"` +} + +// BundleSkill is one skill embedded in a BundleSpec. The content is read +// from the plugin's skills/ directory at install time and stored inline +// in the canonical_spec so the resolver needs no filesystem access. +type BundleSkill struct { + // Slug is the short identifier for the skill, e.g. "jira-workflow". + Slug string `json:"slug"` + + // Instruction is the raw markdown body of the skill. + Instruction string `json:"instruction"` +} + +// maxBundleNameLen limits the name field to a reasonable length. +const maxBundleNameLen = 256 + +// Validate enforces structural sanity. Pure: no DB / network access. +func (b BundleSpec) Validate() error { + name := strings.TrimSpace(b.Name) + if name == "" { + return fmt.Errorf("%w: name is required", ErrInvalidBundle) + } + if len(name) > maxBundleNameLen { + return fmt.Errorf("%w: name is too long (%d bytes, max %d)", ErrInvalidBundle, len(name), maxBundleNameLen) + } + if strings.TrimSpace(b.Version) == "" { + return fmt.Errorf("%w: version is required", ErrInvalidBundle) + } + // At least one of server, client, or skills must be present — otherwise + // the bundle does nothing. + hasServer := strings.TrimSpace(b.ServerEntry) != "" + hasClient := strings.TrimSpace(b.ClientEntry) != "" + hasSkills := len(b.Skills) > 0 + if !hasServer && !hasClient && !hasSkills { + return fmt.Errorf("%w: at least one of server_entry, client_entry, or skills must be set", ErrInvalidBundle) + } + for i, skill := range b.Skills { + if strings.TrimSpace(skill.Slug) == "" { + return fmt.Errorf("%w: skills[%d].slug is required", ErrInvalidBundle, i) + } + if strings.TrimSpace(skill.Instruction) == "" { + return fmt.Errorf("%w: skills[%d].instruction is required", ErrInvalidBundle, i) + } + } + return nil +} diff --git a/server/internal/capability/canonical/bundle_test.go b/server/internal/capability/canonical/bundle_test.go new file mode 100644 index 00000000..b29f0d3c --- /dev/null +++ b/server/internal/capability/canonical/bundle_test.go @@ -0,0 +1,134 @@ +package canonical + +import ( + "encoding/json" + "errors" + "testing" +) + +func TestBundleSpec_Validate_Valid(t *testing.T) { + t.Parallel() + specs := []BundleSpec{ + {Name: "@internal/hotel-ops", Version: "1.0.0", Skills: []BundleSkill{{Slug: "greeting", Instruction: "Hello!"}}}, + {Name: "my-plugin", Version: "0.1.0", ServerEntry: "./server/index.js"}, + {Name: "ui-only", Version: "2.0.0", ClientEntry: "./client/index.js"}, + {Name: "full", Version: "1.0.0", ServerEntry: "./server/index.js", ClientEntry: "./client/index.js", Skills: []BundleSkill{{Slug: "s1", Instruction: "do x"}}}, + } + for _, spec := range specs { + if err := spec.Validate(); err != nil { + t.Errorf("Validate(%q) = %v, want nil", spec.Name, err) + } + } +} + +func TestBundleSpec_Validate_MissingName(t *testing.T) { + t.Parallel() + spec := BundleSpec{Version: "1.0.0", Skills: []BundleSkill{{Slug: "s", Instruction: "x"}}} + err := spec.Validate() + if err == nil || !errors.Is(err, ErrInvalidBundle) { + t.Fatalf("expected ErrInvalidBundle for missing name, got %v", err) + } +} + +func TestBundleSpec_Validate_MissingVersion(t *testing.T) { + t.Parallel() + spec := BundleSpec{Name: "test", Skills: []BundleSkill{{Slug: "s", Instruction: "x"}}} + err := spec.Validate() + if err == nil || !errors.Is(err, ErrInvalidBundle) { + t.Fatalf("expected ErrInvalidBundle for missing version, got %v", err) + } +} + +func TestBundleSpec_Validate_NoComponents(t *testing.T) { + t.Parallel() + spec := BundleSpec{Name: "empty", Version: "1.0.0"} + err := spec.Validate() + if err == nil || !errors.Is(err, ErrInvalidBundle) { + t.Fatalf("expected ErrInvalidBundle for no components, got %v", err) + } +} + +func TestBundleSpec_Validate_SkillMissingSlug(t *testing.T) { + t.Parallel() + spec := BundleSpec{Name: "test", Version: "1.0.0", Skills: []BundleSkill{{Slug: "", Instruction: "x"}}} + err := spec.Validate() + if err == nil || !errors.Is(err, ErrInvalidBundle) { + t.Fatalf("expected ErrInvalidBundle for missing slug, got %v", err) + } +} + +func TestBundleSpec_Validate_SkillMissingInstruction(t *testing.T) { + t.Parallel() + spec := BundleSpec{Name: "test", Version: "1.0.0", Skills: []BundleSkill{{Slug: "s", Instruction: ""}}} + err := spec.Validate() + if err == nil || !errors.Is(err, ErrInvalidBundle) { + t.Fatalf("expected ErrInvalidBundle for missing instruction, got %v", err) + } +} + +func TestSpec_KindBundle_RoundTrip(t *testing.T) { + t.Parallel() + spec := Spec{ + SchemaVersion: SchemaVersionCurrent, + Kind: KindBundle, + Bundle: &BundleSpec{ + Name: "@internal/jira", + Version: "1.2.0", + Skills: []BundleSkill{{Slug: "jira-workflow", Instruction: "Create issues..."}}, + Tools: []string{"jira_create_issue"}, + }, + } + if err := spec.Validate(); err != nil { + t.Fatalf("Validate: %v", err) + } + data, err := json.Marshal(spec) + if err != nil { + t.Fatalf("Marshal: %v", err) + } + var decoded Spec + if err := json.Unmarshal(data, &decoded); err != nil { + t.Fatalf("Unmarshal: %v", err) + } + if decoded.Kind != KindBundle { + t.Fatalf("decoded.Kind = %q, want %q", decoded.Kind, KindBundle) + } + if decoded.Bundle == nil { + t.Fatal("decoded.Bundle is nil") + } + if decoded.Bundle.Name != "@internal/jira" { + t.Fatalf("decoded.Bundle.Name = %q", decoded.Bundle.Name) + } + if len(decoded.Bundle.Skills) != 1 || decoded.Bundle.Skills[0].Slug != "jira-workflow" { + t.Fatalf("decoded.Bundle.Skills = %+v", decoded.Bundle.Skills) + } +} + +func TestSpec_KindBundle_RejectsCrossBody(t *testing.T) { + t.Parallel() + spec := Spec{ + SchemaVersion: SchemaVersionCurrent, + Kind: KindBundle, + Bundle: &BundleSpec{Name: "x", Version: "1.0.0", Skills: []BundleSkill{{Slug: "s", Instruction: "i"}}}, + MCP: &MCPSpec{}, + } + if err := spec.Validate(); err == nil { + t.Fatal("expected error for cross-body, got nil") + } +} + +func TestSpec_KindBundle_RejectsMissingBody(t *testing.T) { + t.Parallel() + spec := Spec{SchemaVersion: SchemaVersionCurrent, Kind: KindBundle} + if err := spec.Validate(); err == nil { + t.Fatal("expected error for nil body, got nil") + } +} + +func TestSpec_UnmarshalJSON_KindBundleEmptyBody(t *testing.T) { + t.Parallel() + raw := []byte(`{"schema_version":1,"kind":"bundle"}`) + var spec Spec + if err := json.Unmarshal(raw, &spec); err == nil { + t.Fatal("expected error for empty bundle body, got nil") + } +} diff --git a/server/internal/capability/canonical/errors.go b/server/internal/capability/canonical/errors.go index 09b32129..3f07739b 100644 --- a/server/internal/capability/canonical/errors.go +++ b/server/internal/capability/canonical/errors.go @@ -10,5 +10,6 @@ var ( ErrInvalidSkill = errors.New("canonical: invalid skill spec") ErrInvalidPlugin = errors.New("canonical: invalid plugin spec") ErrInvalidSystemPrompt = errors.New("canonical: invalid system_prompt spec") + ErrInvalidBundle = errors.New("canonical: invalid bundle spec") ErrInvalidEnvValue = errors.New("canonical: invalid env value") ) diff --git a/server/internal/capability/canonical/spec.go b/server/internal/capability/canonical/spec.go index 78ee696c..cd654d55 100644 --- a/server/internal/capability/canonical/spec.go +++ b/server/internal/capability/canonical/spec.go @@ -27,11 +27,12 @@ const ( KindSkill Kind = "skill" KindPlugin Kind = "plugin" KindSystemPrompt Kind = "system_prompt" + KindBundle Kind = "bundle" ) // Spec is the top-level canonical capability description. Exactly one of -// MCP / Skill / Plugin / SystemPrompt is non-nil; the populated branch must -// match Kind. +// MCP / Skill / Plugin / SystemPrompt / Bundle is non-nil; the populated +// branch must match Kind. type Spec struct { SchemaVersion int16 `json:"schema_version"` Kind Kind `json:"kind"` @@ -39,6 +40,7 @@ type Spec struct { Skill *SkillSpec `json:"skill,omitempty"` Plugin *PluginSpec `json:"plugin,omitempty"` SystemPrompt *SystemPromptSpec `json:"system_prompt,omitempty"` + Bundle *BundleSpec `json:"bundle,omitempty"` } // Validate performs structural sanity checks. It does NOT consult external @@ -53,7 +55,7 @@ func (s Spec) Validate() error { if s.MCP == nil { return fmt.Errorf("%w: kind=mcp but mcp body is nil", ErrInvalidSpec) } - if s.Skill != nil || s.Plugin != nil || s.SystemPrompt != nil { + if s.Skill != nil || s.Plugin != nil || s.SystemPrompt != nil || s.Bundle != nil { return fmt.Errorf("%w: kind=mcp but another body is set", ErrInvalidSpec) } return s.MCP.Validate() @@ -61,7 +63,7 @@ func (s Spec) Validate() error { if s.Skill == nil { return fmt.Errorf("%w: kind=skill but skill body is nil", ErrInvalidSpec) } - if s.MCP != nil || s.Plugin != nil || s.SystemPrompt != nil { + if s.MCP != nil || s.Plugin != nil || s.SystemPrompt != nil || s.Bundle != nil { return fmt.Errorf("%w: kind=skill but another body is set", ErrInvalidSpec) } return s.Skill.Validate() @@ -69,7 +71,7 @@ func (s Spec) Validate() error { if s.Plugin == nil { return fmt.Errorf("%w: kind=plugin but plugin body is nil", ErrInvalidSpec) } - if s.MCP != nil || s.Skill != nil || s.SystemPrompt != nil { + if s.MCP != nil || s.Skill != nil || s.SystemPrompt != nil || s.Bundle != nil { return fmt.Errorf("%w: kind=plugin but another body is set", ErrInvalidSpec) } return s.Plugin.Validate() @@ -77,10 +79,18 @@ func (s Spec) Validate() error { if s.SystemPrompt == nil { return fmt.Errorf("%w: kind=system_prompt but system_prompt body is nil", ErrInvalidSpec) } - if s.MCP != nil || s.Skill != nil || s.Plugin != nil { + if s.MCP != nil || s.Skill != nil || s.Plugin != nil || s.Bundle != nil { return fmt.Errorf("%w: kind=system_prompt but another body is set", ErrInvalidSpec) } return s.SystemPrompt.Validate() + case KindBundle: + if s.Bundle == nil { + return fmt.Errorf("%w: kind=bundle but bundle body is nil", ErrInvalidSpec) + } + if s.MCP != nil || s.Skill != nil || s.Plugin != nil || s.SystemPrompt != nil { + return fmt.Errorf("%w: kind=bundle but another body is set", ErrInvalidSpec) + } + return s.Bundle.Validate() default: return fmt.Errorf("%w: unknown kind %q", ErrInvalidSpec, s.Kind) } @@ -96,6 +106,7 @@ type specWire struct { Skill json.RawMessage `json:"skill,omitempty"` Plugin json.RawMessage `json:"plugin,omitempty"` SystemPrompt json.RawMessage `json:"system_prompt,omitempty"` + Bundle json.RawMessage `json:"bundle,omitempty"` } // UnmarshalJSON only decodes the body matching Kind so a malformed inactive @@ -144,6 +155,15 @@ func (s *Spec) UnmarshalJSON(data []byte) error { return fmt.Errorf("decode system_prompt body: %w", err) } s.SystemPrompt = &body + case KindBundle: + if len(w.Bundle) == 0 { + return fmt.Errorf("%w: kind=bundle but no bundle body", ErrInvalidSpec) + } + var body BundleSpec + if err := json.Unmarshal(w.Bundle, &body); err != nil { + return fmt.Errorf("decode bundle body: %w", err) + } + s.Bundle = &body case "": return fmt.Errorf("%w: missing kind", ErrInvalidSpec) default: diff --git a/server/internal/capability/render/bundle.go b/server/internal/capability/render/bundle.go new file mode 100644 index 00000000..b6ed6165 --- /dev/null +++ b/server/internal/capability/render/bundle.go @@ -0,0 +1,40 @@ +package render + +import ( + "encoding/json" + "fmt" + + "github.com/MiniMax-AI-Dev/parsar/server/internal/capability/canonical" +) + +// bundleDocument is the wire shape emitted by every scaffold's renderer +// for KindBundle. Like systemPromptDocument, the daemon never consumes +// it — the connector-side resolveBundleCapability reads the spec +// directly. The renderer call exists only so the renderer factory's +// Supports() returns true and the default switch doesn't reject the kind. +type bundleDocument struct { + Name string `json:"name"` + Version string `json:"version"` + Skills []string `json:"skills,omitempty"` + Tools []string `json:"tools,omitempty"` +} + +func renderBundle(b *canonical.BundleSpec) (Output, error) { + if b == nil { + return Output{}, fmt.Errorf("render: nil bundle spec") + } + skills := make([]string, 0, len(b.Skills)) + for _, s := range b.Skills { + skills = append(skills, s.Slug) + } + body, err := json.Marshal(bundleDocument{ + Name: b.Name, + Version: b.Version, + Skills: skills, + Tools: b.Tools, + }) + if err != nil { + return Output{}, fmt.Errorf("render: marshal bundle: %w", err) + } + return Output{Content: body}, nil +} diff --git a/server/internal/capability/render/claudecode.go b/server/internal/capability/render/claudecode.go index 1c8d095a..25039c6a 100644 --- a/server/internal/capability/render/claudecode.go +++ b/server/internal/capability/render/claudecode.go @@ -54,7 +54,7 @@ type claudeCodePluginDocument struct { func (claudeCodeRenderer) Supports(kind canonical.Kind) bool { switch kind { - case canonical.KindMCP, canonical.KindSkill, canonical.KindPlugin, canonical.KindSystemPrompt: + case canonical.KindMCP, canonical.KindSkill, canonical.KindPlugin, canonical.KindSystemPrompt, canonical.KindBundle: return true default: return false @@ -74,6 +74,8 @@ func (claudeCodeRenderer) Render(_ context.Context, spec canonical.Spec) (Output return renderClaudeCodePlugin(spec.Plugin) case canonical.KindSystemPrompt: return renderSystemPrompt(spec.SystemPrompt) + case canonical.KindBundle: + return renderBundle(spec.Bundle) default: return Output{}, fmt.Errorf("claudecode render: unknown kind %q", spec.Kind) } diff --git a/server/internal/capability/render/codex.go b/server/internal/capability/render/codex.go index 5fcb6711..308ec008 100644 --- a/server/internal/capability/render/codex.go +++ b/server/internal/capability/render/codex.go @@ -45,7 +45,7 @@ type codexMCPServer struct { } func (codexRenderer) Supports(kind canonical.Kind) bool { - return kind == canonical.KindMCP || kind == canonical.KindSystemPrompt + return kind == canonical.KindMCP || kind == canonical.KindSystemPrompt || kind == canonical.KindBundle } func (codexRenderer) Render(_ context.Context, spec canonical.Spec) (Output, error) { @@ -65,6 +65,8 @@ func (codexRenderer) Render(_ context.Context, spec canonical.Spec) (Output, err return Output{}, ErrUnsupported case canonical.KindSystemPrompt: return renderSystemPrompt(spec.SystemPrompt) + case canonical.KindBundle: + return renderBundle(spec.Bundle) default: return Output{}, fmt.Errorf("codex render: unknown kind %q", spec.Kind) } diff --git a/server/internal/capability/render/opencode.go b/server/internal/capability/render/opencode.go index ce630332..dfa85d14 100644 --- a/server/internal/capability/render/opencode.go +++ b/server/internal/capability/render/opencode.go @@ -35,7 +35,7 @@ type openCodeMCPServer struct { } func (openCodeRenderer) Supports(kind canonical.Kind) bool { - return kind == canonical.KindMCP || kind == canonical.KindSystemPrompt + return kind == canonical.KindMCP || kind == canonical.KindSystemPrompt || kind == canonical.KindBundle } func (openCodeRenderer) Render(_ context.Context, spec canonical.Spec) (Output, error) { @@ -54,6 +54,8 @@ func (openCodeRenderer) Render(_ context.Context, spec canonical.Spec) (Output, return Output{}, ErrUnsupported case canonical.KindSystemPrompt: return renderSystemPrompt(spec.SystemPrompt) + case canonical.KindBundle: + return renderBundle(spec.Bundle) default: return Output{}, fmt.Errorf("opencode render: unknown kind %q", spec.Kind) } diff --git a/server/internal/capability/render/pi.go b/server/internal/capability/render/pi.go index 42df3af6..8aeae488 100644 --- a/server/internal/capability/render/pi.go +++ b/server/internal/capability/render/pi.go @@ -22,7 +22,7 @@ type piRenderer struct{} func (piRenderer) Target() Target { return TargetPi } func (piRenderer) Supports(kind canonical.Kind) bool { - return kind == canonical.KindSkill || kind == canonical.KindSystemPrompt + return kind == canonical.KindSkill || kind == canonical.KindSystemPrompt || kind == canonical.KindBundle } func (piRenderer) Render(_ context.Context, spec canonical.Spec) (Output, error) { @@ -34,6 +34,8 @@ func (piRenderer) Render(_ context.Context, spec canonical.Spec) (Output, error) return renderClaudeCodeSkill(spec.Skill) case canonical.KindSystemPrompt: return renderSystemPrompt(spec.SystemPrompt) + case canonical.KindBundle: + return renderBundle(spec.Bundle) case canonical.KindMCP: return Output{}, ErrUnsupported case canonical.KindPlugin: diff --git a/server/internal/connector/agentdaemon/bundle_capability_test.go b/server/internal/connector/agentdaemon/bundle_capability_test.go new file mode 100644 index 00000000..1c4bf421 --- /dev/null +++ b/server/internal/connector/agentdaemon/bundle_capability_test.go @@ -0,0 +1,183 @@ +package agentdaemon + +import ( + "context" + "encoding/json" + "testing" + + "github.com/MiniMax-AI-Dev/parsar/server/internal/capability/canonical" + "github.com/MiniMax-AI-Dev/parsar/server/internal/store" +) + +func TestResolveCapabilityAdditions_BundleSkillsInjected(t *testing.T) { + t.Parallel() + spec := canonical.Spec{ + SchemaVersion: canonical.SchemaVersionCurrent, + Kind: canonical.KindBundle, + Bundle: &canonical.BundleSpec{ + Name: "@internal/customer-service", + Version: "1.0.0", + Skills: []canonical.BundleSkill{ + {Slug: "greeting", Instruction: "Always greet warmly."}, + {Slug: "closing", Instruction: "End with a follow-up question."}, + }, + }, + } + raw, err := json.Marshal(spec) + if err != nil { + t.Fatalf("marshal: %v", err) + } + row := store.EnabledCapabilityRead{ + CapabilityID: "cap-bundle-1", + Name: "customer-service", + Type: "bundle", + CanonicalSpec: raw, + } + c := &Connector{ + capabilities: stubCapabilityStore{rows: []store.EnabledCapabilityRead{row}}, + log: discardLogger(), + } + got, err := c.resolveCapabilityAdditions(context.Background(), defaultPromptInput(), "claude_code") + if err != nil { + t.Fatalf("resolveCapabilityAdditions: %v", err) + } + if len(got.SystemPrompts) != 2 { + t.Fatalf("want 2 system_prompts from bundle skills, got %d: %+v", len(got.SystemPrompts), got.SystemPrompts) + } + // Verify first skill + if got.SystemPrompts[0].Content != "Always greet warmly." { + t.Fatalf("skill[0].Content = %q", got.SystemPrompts[0].Content) + } + if got.SystemPrompts[0].Mode != canonical.SystemPromptModeAppend { + t.Fatalf("skill[0].Mode = %q, want append", got.SystemPrompts[0].Mode) + } + if got.SystemPrompts[0].Name != "bundle:@internal/customer-service/greeting" { + t.Fatalf("skill[0].Name = %q", got.SystemPrompts[0].Name) + } + // Verify second skill + if got.SystemPrompts[1].Content != "End with a follow-up question." { + t.Fatalf("skill[1].Content = %q", got.SystemPrompts[1].Content) + } +} + +func TestResolveCapabilityAdditions_BundleEmptyCanonicalSpecSkipped(t *testing.T) { + t.Parallel() + row := store.EnabledCapabilityRead{ + CapabilityID: "cap-bundle-empty", + Name: "ghost-bundle", + Type: "bundle", + CanonicalSpec: nil, // empty + } + c := &Connector{ + capabilities: stubCapabilityStore{rows: []store.EnabledCapabilityRead{row}}, + log: discardLogger(), + } + got, err := c.resolveCapabilityAdditions(context.Background(), defaultPromptInput(), "claude_code") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(got.SystemPrompts) != 0 { + t.Fatalf("expected no system_prompts, got %d", len(got.SystemPrompts)) + } +} + +func TestResolveCapabilityAdditions_BundleKindMismatchErrors(t *testing.T) { + t.Parallel() + // Type=bundle but canonical_spec.kind=mcp — should error. + mismatched := canonical.Spec{ + SchemaVersion: canonical.SchemaVersionCurrent, + Kind: canonical.KindMCP, + MCP: &canonical.MCPSpec{Servers: []canonical.MCPServer{{Name: "x", Command: "true"}}}, + } + raw, err := json.Marshal(mismatched) + if err != nil { + t.Fatalf("marshal: %v", err) + } + row := store.EnabledCapabilityRead{ + CapabilityID: "bad-bundle", + Name: "bad", + Type: "bundle", + CanonicalSpec: raw, + } + c := &Connector{ + capabilities: stubCapabilityStore{rows: []store.EnabledCapabilityRead{row}}, + log: discardLogger(), + } + _, err = c.resolveCapabilityAdditions(context.Background(), defaultPromptInput(), "claude_code") + if err == nil { + t.Fatal("expected error for kind mismatch, got nil") + } +} + +func TestResolveCapabilityAdditions_BundleNoSkillsNoPrompts(t *testing.T) { + t.Parallel() + // A bundle with only server_entry (no skills) — Phase 0 produces no prompts. + spec := canonical.Spec{ + SchemaVersion: canonical.SchemaVersionCurrent, + Kind: canonical.KindBundle, + Bundle: &canonical.BundleSpec{ + Name: "@internal/tools-only", + Version: "1.0.0", + ServerEntry: "./server/index.js", + }, + } + raw, err := json.Marshal(spec) + if err != nil { + t.Fatalf("marshal: %v", err) + } + row := store.EnabledCapabilityRead{ + CapabilityID: "cap-tools-only", + Name: "tools-only", + Type: "bundle", + CanonicalSpec: raw, + } + c := &Connector{ + capabilities: stubCapabilityStore{rows: []store.EnabledCapabilityRead{row}}, + log: discardLogger(), + } + got, err := c.resolveCapabilityAdditions(context.Background(), defaultPromptInput(), "claude_code") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(got.SystemPrompts) != 0 { + t.Fatalf("expected 0 prompts for tool-only bundle, got %d", len(got.SystemPrompts)) + } +} + +func TestResolveCapabilityAdditions_BundleWorksOnAllEngines(t *testing.T) { + t.Parallel() + spec := canonical.Spec{ + SchemaVersion: canonical.SchemaVersionCurrent, + Kind: canonical.KindBundle, + Bundle: &canonical.BundleSpec{ + Name: "@internal/universal", + Version: "1.0.0", + Skills: []canonical.BundleSkill{{Slug: "rule", Instruction: "Be helpful."}}, + }, + } + raw, err := json.Marshal(spec) + if err != nil { + t.Fatalf("marshal: %v", err) + } + row := store.EnabledCapabilityRead{ + CapabilityID: "cap-u", + Name: "universal", + Type: "bundle", + CanonicalSpec: raw, + } + for _, engine := range []string{"claude_code", "opencode", "codex", "pi"} { + t.Run(engine, func(t *testing.T) { + c := &Connector{ + capabilities: stubCapabilityStore{rows: []store.EnabledCapabilityRead{row}}, + log: discardLogger(), + } + got, err := c.resolveCapabilityAdditions(context.Background(), defaultPromptInput(), engine) + if err != nil { + t.Fatalf("engine=%s: %v", engine, err) + } + if len(got.SystemPrompts) != 1 { + t.Fatalf("engine=%s: want 1 prompt, got %d", engine, len(got.SystemPrompts)) + } + }) + } +} diff --git a/server/internal/connector/agentdaemon/capability_runtime.go b/server/internal/connector/agentdaemon/capability_runtime.go index d5407a54..06d96903 100644 --- a/server/internal/connector/agentdaemon/capability_runtime.go +++ b/server/internal/connector/agentdaemon/capability_runtime.go @@ -392,6 +392,21 @@ func (c *Connector) resolveCapabilityAdditions(ctx context.Context, in connector continue } result.SystemPrompts = append(result.SystemPrompts, *sp) + case "bundle": + prompts, err := c.resolveBundleCapability(ctx, cap, renderer) + if err != nil { + if errors.Is(err, render.ErrUnsupported) { + c.log.Warn("agent_daemon: bundle capability not supported by agent_kind, skipping", + "capability_id", cap.CapabilityID, + "capability_name", cap.Name, + "agent_kind", agentKind, + "target", string(target)) + result.Disabled = append(result.Disabled, disabledForUnsupportedCapability(cap)) + continue + } + return result, err + } + result.SystemPrompts = append(result.SystemPrompts, prompts...) default: c.log.Warn("agent_daemon: skip unknown capability type", "capability_id", cap.CapabilityID, @@ -1043,6 +1058,58 @@ func (c *Connector) resolveSystemPromptCapability( }, nil } +// resolveBundleCapability extracts inline skills from a KindBundle capability +// and returns them as ResolvedSystemPrompt entries in append mode. Phase 0 +// only injects skills; server tools, client UI, and hooks are handled in +// later phases. +func (c *Connector) resolveBundleCapability( + ctx context.Context, + cap store.EnabledCapabilityRead, + renderer render.Renderer, +) ([]ResolvedSystemPrompt, error) { + resolved := resolveVersionFields(cap) + if len(resolved.CanonicalSpec) == 0 { + c.log.Warn("agent_daemon: bundle capability has empty canonical_spec, skipping", + "capability_id", cap.CapabilityID, + "capability_name", cap.Name) + return nil, nil + } + var spec canonical.Spec + if err := json.Unmarshal(resolved.CanonicalSpec, &spec); err != nil { + return nil, fmt.Errorf("agent_daemon: bundle capability %s canonical_spec decode: %w", cap.CapabilityID, err) + } + if spec.Kind != canonical.KindBundle { + return nil, fmt.Errorf("agent_daemon: capability %s has type=bundle but canonical_spec.kind=%q", cap.CapabilityID, spec.Kind) + } + if spec.Bundle == nil { + return nil, fmt.Errorf("agent_daemon: capability %s canonical_spec.bundle is nil", cap.CapabilityID) + } + // Render call for wire-shape consistency (matches other resolve* funcs). + if _, err := renderer.Render(ctx, spec); err != nil { + return nil, fmt.Errorf("agent_daemon: render bundle %s: %w", cap.CapabilityID, err) + } + // Inject each inline skill as a system prompt in append mode. + var prompts []ResolvedSystemPrompt + for _, skill := range spec.Bundle.Skills { + instruction := strings.TrimSpace(skill.Instruction) + if instruction == "" { + continue + } + prompts = append(prompts, ResolvedSystemPrompt{ + Name: fmt.Sprintf("bundle:%s/%s", spec.Bundle.Name, skill.Slug), + Mode: canonical.SystemPromptModeAppend, + Content: instruction, + }) + } + if len(prompts) > 0 { + c.log.Info("agent_daemon: bundle capability resolved skills", + "capability_id", cap.CapabilityID, + "bundle_name", spec.Bundle.Name, + "skill_count", len(prompts)) + } + return prompts, nil +} + // mergeSkillsIntoOptions folds resolved skill descriptors into // opts["skills"]. Override-wins on collision (mirrors mcp_servers / // plugins precedence). diff --git a/server/internal/dev/capability_routes.go b/server/internal/dev/capability_routes.go index 62333081..5624ad35 100644 --- a/server/internal/dev/capability_routes.go +++ b/server/internal/dev/capability_routes.go @@ -398,7 +398,7 @@ func listMarketplaceCapabilities(runtimeStore RuntimeStore) http.HandlerFunc { func isListedCapabilityType(capabilityType string) bool { switch strings.ToLower(strings.TrimSpace(capabilityType)) { - case "mcp", "skill": + case "mcp", "skill", "bundle": return true default: return false @@ -683,7 +683,7 @@ func createWorkspaceCapability(runtimeStore RuntimeStore) http.HandlerFunc { } body.Type = strings.ToLower(strings.TrimSpace(body.Type)) if !isListedCapabilityType(body.Type) { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "type must be mcp or skill"}) + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "type must be mcp, skill, or bundle"}) return } input := store.CreateCapabilityInput{WorkspaceID: workspaceID, Type: body.Type, Name: body.Name, Description: body.Description, Visibility: capabilityVisibility(body.Visibility, body.Scope), CreatorID: actorID} diff --git a/server/internal/dev/plugin_install_routes.go b/server/internal/dev/plugin_install_routes.go new file mode 100644 index 00000000..5621363f --- /dev/null +++ b/server/internal/dev/plugin_install_routes.go @@ -0,0 +1,116 @@ +package dev + +import ( + "encoding/json" + "errors" + "fmt" + "net/http" + "strings" + + "github.com/MiniMax-AI-Dev/parsar/server/internal/capability/canonical" + "github.com/MiniMax-AI-Dev/parsar/server/internal/store" +) + +type installPluginBody struct { + Name string `json:"name"` + Description string `json:"description"` + Visibility string `json:"visibility"` + Version string `json:"version"` + CanonicalSpec json.RawMessage `json:"canonical_spec"` +} + +// installPlugin handles POST /api/v1/workspaces/{workspaceID}/capabilities/plugins/install. +// Creates a KindBundle capability + version from the provided canonical_spec. +// The CLI (`parsar plugin add`) reads a local plugin directory, builds the +// canonical_spec with inline skill content, and POSTs it here. +// +// @Summary Install a plugin bundle +// @Description Creates a KindBundle capability and its first version. The canonical_spec must have kind=bundle with inline skills embedded. Owner/admin only. +// @Tags capabilities +// @ID installPluginBundle +// @Accept json +// @Produce json +// @Param workspaceID path string true "Workspace UUID" +// @Param body body installPluginBody true "Plugin install payload" +// @Success 201 {object} map[string]interface{} "Created capability and version" +// @Failure 400 {object} map[string]string "Missing name/version, invalid canonical_spec" +// @Failure 403 {object} map[string]string "Caller is not workspace owner/admin" +// @Failure 503 {object} map[string]string "Database-backed capability APIs are disabled" +// @Router /api/v1/workspaces/{workspaceID}/capabilities/plugins/install [post] +func installPlugin(runtimeStore RuntimeStore) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + workspaceID, ok := requireWorkspaceCapabilityAdmin(w, r, runtimeStore) + if !ok { + return + } + actorID, ok := devActorID(w, r) + if !ok { + return + } + var body installPluginBody + if err := decodeBody(r, &body); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json"}) + return + } + if strings.TrimSpace(body.Name) == "" { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "name is required"}) + return + } + if strings.TrimSpace(body.Version) == "" { + body.Version = "1.0.0" + } + + // Decode and validate the canonical_spec. + if len(body.CanonicalSpec) == 0 { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "canonical_spec is required"}) + return + } + var spec canonical.Spec + if err := json.Unmarshal(body.CanonicalSpec, &spec); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": fmt.Sprintf("canonical_spec decode: %s", err)}) + return + } + if spec.Kind != canonical.KindBundle { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": fmt.Sprintf("canonical_spec.kind must be \"bundle\", got %q", spec.Kind)}) + return + } + if err := spec.Validate(); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": fmt.Sprintf("canonical_spec validation: %s", err)}) + return + } + + visibility := strings.TrimSpace(body.Visibility) + if visibility == "" { + visibility = "workspace" + } + + sourcePayload := json.RawMessage(`{}`) + + result, err := runtimeStore.ImportCapability(r.Context(), store.ImportCapabilityInput{ + WorkspaceID: workspaceID, + Name: strings.TrimSpace(body.Name), + Description: strings.TrimSpace(body.Description), + Visibility: visibility, + Type: "bundle", + CreatorID: actorID, + Version: strings.TrimSpace(body.Version), + SourcePayload: sourcePayload, + Spec: spec, + }) + if err != nil { + if errors.Is(err, store.ErrCapabilityNameTaken) { + writeJSON(w, http.StatusConflict, map[string]string{"error": "a plugin with this name already exists in the workspace"}) + return + } + writeCapabilityError(w, err, "failed to install plugin") + return + } + writeJSON(w, http.StatusCreated, map[string]any{ + "id": result.Capability.ID, + "name": result.Capability.Name, + "type": result.Capability.Type, + "capability_version": result.CapabilityVersion.ID, + "version": result.CapabilityVersion.Version, + }) + } +} diff --git a/server/internal/dev/routes.go b/server/internal/dev/routes.go index 1ca80232..789d8bc8 100644 --- a/server/internal/dev/routes.go +++ b/server/internal/dev/routes.go @@ -647,6 +647,7 @@ func RegisterRoutesWithStore(r chi.Router, runtimeStore RuntimeStore, opts ...Ro r.Post("/workspaces/{workspaceID}/capabilities/import/preview", previewCapabilityImport(runtimeStore, cfg.blobStore)) r.Post("/workspaces/{workspaceID}/capabilities/import/commit", commitCapabilityImport(runtimeStore, cfg.blobStore)) r.Post("/workspaces/{workspaceID}/skills/install", installSkillFromRegistry(runtimeStore, cfg.blobStore, cfg.skillInstallRunner, cfg.skillInstallHTTPClient)) + r.Post("/workspaces/{workspaceID}/capabilities/plugins/install", installPlugin(runtimeStore)) // Plugin upload presign — browser PUTs the zip directly to // the blob backend, then calls import/commit with the returned // ossKey. presign-download checks ossKey belongs to the calling diff --git a/server/internal/store/capabilities.go b/server/internal/store/capabilities.go index 53ad6491..bfe50d94 100644 --- a/server/internal/store/capabilities.go +++ b/server/internal/store/capabilities.go @@ -977,7 +977,7 @@ func pgNullableText(value string) pgtype.Text { // canonical.Kind MUST be added to this allowlist as well. func normalizeCapabilityType(value string) string { switch strings.TrimSpace(value) { - case "skill", "mcp", "plugin", "system_prompt": + case "skill", "mcp", "plugin", "system_prompt", "bundle": return strings.TrimSpace(value) default: return "mcp" diff --git a/server/internal/store/capability_import.go b/server/internal/store/capability_import.go index 94e21905..60d859c3 100644 --- a/server/internal/store/capability_import.go +++ b/server/internal/store/capability_import.go @@ -682,6 +682,9 @@ func validateImportSpecPreCommit(s canonical.Spec) error { return fmt.Errorf("kind=plugin but another body is set") } return s.Validate() + case canonical.KindBundle: + // Bundle has no inline secrets or env map; full Validate() is sufficient. + return s.Validate() case "": return fmt.Errorf("missing kind") default: