Files
2026-07-13 13:00:08 +08:00

161 lines
6.9 KiB
Go

package installsource
import (
"encoding/json"
"reasonix/internal/config"
)
// request mirrors the public schema. Fields stay exported because Execute
// unmarshals directly into this struct; the struct comment is the source of
// truth for the field semantics surfaced to the model.
type request struct {
Op string `json:"op"` // "install" (default) | "uninstall"
Source string `json:"source"` // required for install; ignored for uninstall
Kind string `json:"kind"` // auto|skill|mcp|plugin (install only)
Apply bool `json:"apply"` // install only: actually write/connect
Scope string `json:"scope"` // project|global
Mode string `json:"mode"` // auto|copy|link|register (skill install)
Name string `json:"name"` // override the discovered name
Transport string `json:"transport"` // auto|stdio|http|sse
Command string `json:"command"`
Args []string `json:"args"`
Env map[string]string `json:"env"`
Headers map[string]string `json:"headers"`
Tier string `json:"tier"`
Replace bool `json:"replace"` // overwrite existing entries
Strict *bool `json:"strict"` // nil -> true; require skill frontmatter
// PlanID is echoed back on a confirm-apply call so the host can refuse
// to apply a plan that does not match the one it approved.
PlanID string `json:"planId"`
scopeExplicit bool
}
// response is the JSON shape returned to the model. Status is one of
// "planned" (apply=false, no writes), "done" (all actions succeeded),
// "partial" (some actions succeeded), "failed" (no actions succeeded), or
// "blocked" (no plan could be produced). The `Kind` field is preserved for
// back-compat with callers that only handle a single capability; `Kinds`
// reports per-kind counts.
type response struct {
OK bool `json:"ok"`
Status string `json:"status"`
Op string `json:"op"`
Applied bool `json:"applied"`
Source string `json:"source"`
Name string `json:"name,omitempty"`
Kind string `json:"kind"` // legacy single kind, "" when no plan
Kinds kindTally `json:"kinds,omitempty"` // counts per kind
Scope string `json:"scope"`
Mode string `json:"mode"`
PlanID string `json:"planId,omitempty"`
Actions []action `json:"actions"`
Warnings []string `json:"warnings,omitempty"`
Next string `json:"next,omitempty"`
}
// kindTally reports per-kind counts. It is a struct (not a map) so the JSON
// shape stays stable: missing fields read as zero, and we can add new kinds
// without breaking old clients.
type kindTally struct {
Skill int `json:"skill"`
MCP int `json:"mcp"`
Plugin int `json:"plugin"`
}
// action is the per-install-step DTO. The Kind/Action pair drives the apply
// dispatcher; RiskLevel/RiskReasons help the calling skill decide whether to
// ask the user before apply=true.
type action struct {
Kind string `json:"kind"` // "skill" | "mcp" | "plugin"
Action string `json:"action"` // copy_skill|link_skill|register_skill_root|install_mcp_server|remove_skill|remove_skill_root|remove_mcp_server
Status string `json:"status"` // planned|done|failed
RiskLevel RiskLevel `json:"riskLevel"` // low|medium|high
RiskReasons []string `json:"riskReasons,omitempty"`
Name string `json:"name,omitempty"`
Source string `json:"source,omitempty"`
Target string `json:"target,omitempty"`
ConfigPath string `json:"configPath,omitempty"`
Scope string `json:"scope,omitempty"`
Mode string `json:"mode,omitempty"`
Transport string `json:"transport,omitempty"`
URL string `json:"url,omitempty"`
Command string `json:"command,omitempty"`
Args []string `json:"args,omitempty"`
Env map[string]string `json:"env,omitempty"`
Headers map[string]string `json:"headers,omitempty"`
Skills []string `json:"skills,omitempty"`
SkillCount int `json:"skillCount,omitempty"`
Commands []string `json:"commands,omitempty"`
CommandCount int `json:"commandCount,omitempty"`
Commit string `json:"commit,omitempty"` // resolved git snapshot the plan describes; apply pins to it
Layout string `json:"layout,omitempty"` // canonical_dir|flat_compat|registered_root
InstallRoot string `json:"installRoot,omitempty"` // skills root or registered custom root
CanonicalPath string `json:"canonicalPath,omitempty"` // <skill-name>/SKILL.md when applicable
Discoverable bool `json:"discoverable,omitempty"` // Store.Read can load it after apply/register
Indexed bool `json:"indexed,omitempty"` // Store.List includes it for the skills index
ToolCount int `json:"toolCount,omitempty"`
HookCount int `json:"hookCount,omitempty"`
ManifestKind string `json:"manifestKind,omitempty"`
Version string `json:"version,omitempty"`
Warnings []string `json:"warnings,omitempty"`
Error string `json:"error,omitempty"`
Next string `json:"next,omitempty"`
// Internal state used by apply. Stripped by publicActions before
// serializing to JSON.
entry config.PluginEntry
skill skillCandidate
disconnect func() // optional MCP rollback; nil when not connected
}
func actionPlanKey(a action) string {
return a.Kind + ":" + a.Name + ":" + a.Action + ":" + a.Source + ":" + a.Target + ":" + a.ConfigPath
}
// skillCandidate is a parsed skill file/directory ready to install. The
// caller decides whether to copy, link, or register the source path.
type skillCandidate struct {
Name string
Description string
SourcePath string
RootPath string
IsDir bool
Content string
}
// summarizeKind returns a one-word kind label for callers that only want the
// dominant capability. "mixed" is returned when the plan contains both
// skills and MCP servers; counts live in response.Kinds.
func summarizeKind(actions []action) string {
seen := map[string]bool{}
for _, a := range actions {
seen[a.Kind] = true
}
if len(seen) > 1 {
return "mixed"
}
for k := range seen {
return k
}
return ""
}
// publicActions strips unexported state from each action so the JSON
// response does not leak internal config types.
func publicActions(in []action) []action {
out := make([]action, len(in))
for i := range in {
out[i] = in[i]
out[i].entry = config.PluginEntry{}
out[i].skill = skillCandidate{}
}
return out
}
func marshalJSON(v any) string {
b, _ := json.Marshal(v)
return string(b)
}