mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-09 18:08:31 -10:00
* feat(skills): install a skill's bundled files and point the skill tool at them A skill was only its SKILL.md: install copied that one file, and the skill tool returned only the markdown body, so a skill whose instructions run its own scripts was broken by installing it, and a hand-placed skill directory gave the model no way to find the files beside it. Install now copies the whole skill directory, leaving out hidden entries (.git, .env) and symlinks, which it neither follows nor copies but reports. A script stays executable; everything else is installed 0644. A tree over 1,000 files or 32 MiB is refused. skills.lock records a treeHash beside the SKILL.md hash; skill info reports drift in bundled files, and an entry from before this is still checked on SKILL.md alone. A reinstall that changed only a bundled file is reported as an update. Both skill tools end their output with the skill's directory and a list of up to 50 bundled files when there are any; a SKILL.md-only skill is returned unchanged. Closes #584 * fix(skills): compare a reinstall over an old lock entry with SKILL.md alone A lock entry written before tree hashes has no treeHash, so the reinstall compared the new tree hash with an empty string and always counted as an update. Re-adding an unchanged skill that is only a SKILL.md printed "Updated" and "the files bundled with it changed" when there were none. That entry comes from an install that copied SKILL.md alone, so compare with that: it is an update when SKILL.md changed or the reinstall now brings bundled files. InstallResult gains previousTreeHash, and the CLI uses it to say the bundled files are now installed rather than changed on the first reinstall over such an entry. * fix(skills): match the tree hash when recovering an interrupted install lockReconciler matched both trees of an interrupted workspace on the SKILL.md hash alone. A reinstall that changes only a bundled file keeps SKILL.md, so a kill between the tree swap and the lockfile publish left two trees carrying the recorded hash, and recovery kept the new one and retired the backup the lockfile still described. A tree now matches an entry with a tree hash only when both hashes match. An entry from before tree hashes is still matched on SKILL.md alone. A tree hash that cannot be computed is returned as an error, the way an unreadable SKILL.md already is, so recovery decides nothing about a tree it could not read. Also bring the package comment up to date: install copies the skill directory, not SKILL.md alone.
742 lines
23 KiB
Go
742 lines
23 KiB
Go
package cli
|
|
|
|
// Distribution command handlers: `zero skill {add,info,remove}`,
|
|
// `zero plugin {add,remove}`, and `zero tools {make,list}`. These wire the
|
|
// install/scaffold logic in internal/skills, internal/plugins, and
|
|
// internal/tools into the CLI. List for skills/plugins reuses the existing
|
|
// listing handlers (runSkillsList / runPlugins list).
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
|
|
"github.com/Twigpine/zero/internal/plugins"
|
|
"github.com/Twigpine/zero/internal/redaction"
|
|
"github.com/Twigpine/zero/internal/skills"
|
|
"github.com/Twigpine/zero/internal/tools"
|
|
)
|
|
|
|
// distributionAddOptions are the flags shared by `skill add` / `plugin add`.
|
|
type distributionAddOptions struct {
|
|
force bool
|
|
json bool
|
|
}
|
|
|
|
// parseDistributionAddArgs splits a positional source from the add flags.
|
|
func parseDistributionAddArgs(args []string, label string) (string, distributionAddOptions, bool, error) {
|
|
options := distributionAddOptions{}
|
|
source := ""
|
|
for _, arg := range args {
|
|
switch arg {
|
|
case "-h", "--help", "help":
|
|
return "", options, true, nil
|
|
case "--force", "-f":
|
|
options.force = true
|
|
case "--json":
|
|
options.json = true
|
|
default:
|
|
if strings.HasPrefix(arg, "-") {
|
|
return "", options, false, execUsageError{fmt.Sprintf("unknown %s add flag %q", label, arg)}
|
|
}
|
|
if source != "" {
|
|
return "", options, false, execUsageError{fmt.Sprintf("%s add takes a single source (git URL or path)", label)}
|
|
}
|
|
source = arg
|
|
}
|
|
}
|
|
return source, options, false, nil
|
|
}
|
|
|
|
// --- skill add / info / remove ---
|
|
|
|
func runSkillAdd(args []string, dir string, stdout io.Writer, stderr io.Writer) int {
|
|
source, options, help, err := parseDistributionAddArgs(args, "skill")
|
|
if err != nil {
|
|
return writeExecUsageError(stderr, err.Error())
|
|
}
|
|
if help {
|
|
if err := writeSkillAddHelp(stdout); err != nil {
|
|
return exitCrash
|
|
}
|
|
return exitSuccess
|
|
}
|
|
if source == "" {
|
|
return writeExecUsageError(stderr, "usage: zero skill add <git-url|path> [--force] [--json]")
|
|
}
|
|
if dir == "" {
|
|
return writeAppError(stderr, "could not resolve the skills directory", exitCrash)
|
|
}
|
|
|
|
result, err := skills.Install(context.Background(), skills.InstallOptions{
|
|
Source: source,
|
|
Dir: dir,
|
|
Force: options.force,
|
|
})
|
|
if err != nil {
|
|
if errors.Is(err, skills.ErrNameClash) {
|
|
return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitUsage)
|
|
}
|
|
return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash)
|
|
}
|
|
if options.json {
|
|
if err := writePrettyJSON(stdout, redaction.RedactValue(result, redaction.Options{})); err != nil {
|
|
return exitCrash
|
|
}
|
|
return exitSuccess
|
|
}
|
|
lines := []string{}
|
|
if result.Updated {
|
|
lines = append(lines, fmt.Sprintf("Updated skill %q.", result.Name))
|
|
switch {
|
|
case result.PreviousHash != result.Hash:
|
|
lines = append(lines, fmt.Sprintf(" hash: %s -> %s", shortHash(result.PreviousHash), shortHash(result.Hash)))
|
|
case result.PreviousTreeHash == "":
|
|
// The install this replaced predates tree hashes and copied SKILL.md
|
|
// alone, so the bundled files are new rather than changed.
|
|
lines = append(lines, " SKILL.md is unchanged; the files bundled with it are now installed too")
|
|
default:
|
|
lines = append(lines, " SKILL.md is unchanged; the files bundled with it changed")
|
|
}
|
|
} else {
|
|
lines = append(lines, fmt.Sprintf("Installed skill %q.", result.Name))
|
|
lines = append(lines, " hash: "+result.Hash)
|
|
}
|
|
lines = append(lines, " source: "+result.Source)
|
|
lines = append(lines, " path: "+result.Path)
|
|
if len(result.SkippedLinks) > 0 {
|
|
lines = append(lines, " not copied (symlinks): "+strings.Join(result.SkippedLinks, ", "))
|
|
}
|
|
if _, err := fmt.Fprintln(stdout, redaction.RedactString(strings.Join(lines, "\n"), redaction.Options{})); err != nil {
|
|
return exitCrash
|
|
}
|
|
return exitSuccess
|
|
}
|
|
|
|
func runSkillInfo(args []string, dir string, stdout io.Writer, stderr io.Writer) int {
|
|
name, asJSON, help, err := parseNameCommandArgs(args, "skill info")
|
|
if err != nil {
|
|
return writeExecUsageError(stderr, err.Error())
|
|
}
|
|
if help {
|
|
if err := writeSkillInfoHelp(stdout); err != nil {
|
|
return exitCrash
|
|
}
|
|
return exitSuccess
|
|
}
|
|
if name == "" {
|
|
return writeExecUsageError(stderr, "usage: zero skill info <name> [--json]")
|
|
}
|
|
// Resolve across primary + ~/.agents/skills; lock metadata only from primary.
|
|
info, ok := skills.InfoFromRoots(dir, skills.GlobalRoots(dir), name)
|
|
if !ok {
|
|
return writeAppError(stderr, fmt.Sprintf("skill %q not found", name), exitUsage)
|
|
}
|
|
if asJSON {
|
|
if err := writePrettyJSON(stdout, redaction.RedactValue(info, redaction.Options{})); err != nil {
|
|
return exitCrash
|
|
}
|
|
return exitSuccess
|
|
}
|
|
lines := []string{info.Skill.Name}
|
|
if info.Skill.Description != "" {
|
|
lines = append(lines, " "+info.Skill.Description)
|
|
}
|
|
if info.Source != "" {
|
|
lines = append(lines, " source: "+info.Source)
|
|
}
|
|
if info.Hash != "" {
|
|
lines = append(lines, " hash: "+info.Hash)
|
|
lines = append(lines, fmt.Sprintf(" hash drift: %t", info.HashDrift))
|
|
}
|
|
lines = append(lines, " path: "+info.Skill.Path)
|
|
if _, err := fmt.Fprintln(stdout, redaction.RedactString(strings.Join(lines, "\n"), redaction.Options{})); err != nil {
|
|
return exitCrash
|
|
}
|
|
return exitSuccess
|
|
}
|
|
|
|
func runSkillRemove(args []string, dir string, stdout io.Writer, stderr io.Writer) int {
|
|
name, asJSON, help, err := parseNameCommandArgs(args, "skill remove")
|
|
if err != nil {
|
|
return writeExecUsageError(stderr, err.Error())
|
|
}
|
|
if help {
|
|
if err := writeSkillRemoveHelp(stdout); err != nil {
|
|
return exitCrash
|
|
}
|
|
return exitSuccess
|
|
}
|
|
if asJSON {
|
|
return writeExecUsageError(stderr, "skill remove does not support --json")
|
|
}
|
|
if name == "" {
|
|
return writeExecUsageError(stderr, "usage: zero skill remove <name>")
|
|
}
|
|
if err := skills.Remove(dir, name); err != nil {
|
|
return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitUsage)
|
|
}
|
|
if _, err := fmt.Fprintf(stdout, "Removed skill %q.\n", name); err != nil {
|
|
return exitCrash
|
|
}
|
|
return exitSuccess
|
|
}
|
|
|
|
// --- plugin add / remove ---
|
|
|
|
func runPluginAdd(args []string, dir string, stdout io.Writer, stderr io.Writer) int {
|
|
source, options, help, err := parseDistributionAddArgs(args, "plugin")
|
|
if err != nil {
|
|
return writeExecUsageError(stderr, err.Error())
|
|
}
|
|
if help {
|
|
if err := writePluginAddHelp(stdout); err != nil {
|
|
return exitCrash
|
|
}
|
|
return exitSuccess
|
|
}
|
|
if source == "" {
|
|
return writeExecUsageError(stderr, "usage: zero plugin add <git-url|path> [--force] [--json]")
|
|
}
|
|
if dir == "" {
|
|
return writeAppError(stderr, "could not resolve the plugins directory", exitCrash)
|
|
}
|
|
|
|
result, err := plugins.Install(context.Background(), plugins.InstallOptions{
|
|
Source: source,
|
|
Dir: dir,
|
|
Force: options.force,
|
|
})
|
|
if err != nil {
|
|
if errors.Is(err, plugins.ErrNameClash) {
|
|
return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitUsage)
|
|
}
|
|
return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash)
|
|
}
|
|
if options.json {
|
|
if err := writePrettyJSON(stdout, redaction.RedactValue(result, redaction.Options{})); err != nil {
|
|
return exitCrash
|
|
}
|
|
return exitSuccess
|
|
}
|
|
lines := []string{}
|
|
if result.Updated {
|
|
lines = append(lines, fmt.Sprintf("Updated plugin %s@%s.", result.ID, result.Version))
|
|
lines = append(lines, fmt.Sprintf(" hash: %s -> %s", shortHash(result.PreviousHash), shortHash(result.Hash)))
|
|
} else {
|
|
lines = append(lines, fmt.Sprintf("Installed plugin %s@%s.", result.ID, result.Version))
|
|
lines = append(lines, " hash: "+result.Hash)
|
|
}
|
|
lines = append(lines, " source: "+result.Source)
|
|
lines = append(lines, " path: "+result.ManifestPath)
|
|
lines = append(lines, "Activation gates each tool through the normal permission flow; no install script was run.")
|
|
if _, err := fmt.Fprintln(stdout, redaction.RedactString(strings.Join(lines, "\n"), redaction.Options{})); err != nil {
|
|
return exitCrash
|
|
}
|
|
return exitSuccess
|
|
}
|
|
|
|
func runPluginRemove(args []string, dir string, stdout io.Writer, stderr io.Writer) int {
|
|
id, asJSON, help, err := parseNameCommandArgs(args, "plugin remove")
|
|
if err != nil {
|
|
return writeExecUsageError(stderr, err.Error())
|
|
}
|
|
if help {
|
|
if err := writePluginRemoveHelp(stdout); err != nil {
|
|
return exitCrash
|
|
}
|
|
return exitSuccess
|
|
}
|
|
if asJSON {
|
|
return writeExecUsageError(stderr, "plugin remove does not support --json")
|
|
}
|
|
if id == "" {
|
|
return writeExecUsageError(stderr, "usage: zero plugin remove <id>")
|
|
}
|
|
if err := plugins.Remove(dir, id); err != nil {
|
|
return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitUsage)
|
|
}
|
|
if _, err := fmt.Fprintf(stdout, "Removed plugin %q.\n", id); err != nil {
|
|
return exitCrash
|
|
}
|
|
return exitSuccess
|
|
}
|
|
|
|
func runPluginInfo(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
|
|
id, options, help, err := parsePluginInfoArgs(args)
|
|
if err != nil {
|
|
return writeExecUsageError(stderr, err.Error())
|
|
}
|
|
if help {
|
|
if err := writePluginInfoHelp(stdout); err != nil {
|
|
return exitCrash
|
|
}
|
|
return exitSuccess
|
|
}
|
|
if id == "" {
|
|
return writeExecUsageError(stderr, "usage: zero plugins info <id> [--user] [--json]")
|
|
}
|
|
|
|
cwd, err := deps.getwd()
|
|
if err != nil {
|
|
return writeAppError(stderr, "failed to resolve workspace: "+err.Error(), exitCrash)
|
|
}
|
|
info, err := plugins.Info(plugins.InfoOptions{
|
|
LoadOptions: pluginLoadOptions(deps, cwd, options.user),
|
|
}, id)
|
|
if err != nil {
|
|
if errors.Is(err, plugins.ErrNotInstalled) {
|
|
return writeExecUsageError(stderr, err.Error())
|
|
}
|
|
return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash)
|
|
}
|
|
|
|
if options.json {
|
|
if err := writePrettyJSON(stdout, redaction.RedactValue(info, redaction.Options{})); err != nil {
|
|
return exitCrash
|
|
}
|
|
return exitSuccess
|
|
}
|
|
|
|
plugin := info.Plugin
|
|
lines := []string{fmt.Sprintf("%s (%s) %s", plugin.ID, plugin.Name, plugin.Version)}
|
|
state := "enabled"
|
|
if !plugin.Enabled {
|
|
state = "disabled"
|
|
}
|
|
lines = append(lines, " state: "+state)
|
|
lines = append(lines, " source: "+string(plugin.Source))
|
|
if len(plugin.Tools) > 0 {
|
|
lines = append(lines, fmt.Sprintf(" tools: %d", len(plugin.Tools)))
|
|
}
|
|
if len(plugin.Hooks) > 0 {
|
|
lines = append(lines, fmt.Sprintf(" hooks: %d", len(plugin.Hooks)))
|
|
}
|
|
if len(plugin.Skills) > 0 {
|
|
lines = append(lines, fmt.Sprintf(" skills: %d", len(plugin.Skills)))
|
|
}
|
|
if len(plugin.Prompts) > 0 {
|
|
lines = append(lines, fmt.Sprintf(" prompts: %d", len(plugin.Prompts)))
|
|
}
|
|
if info.LockSource != "" {
|
|
lines = append(lines, " lock source: "+info.LockSource)
|
|
}
|
|
if info.LockHash != "" {
|
|
lines = append(lines, " lock hash: "+info.LockHash)
|
|
lines = append(lines, fmt.Sprintf(" hash drift: %t", info.HashDrift))
|
|
}
|
|
lines = append(lines, " manifest: "+plugin.ManifestPath)
|
|
if _, err := fmt.Fprintln(stdout, redaction.RedactString(strings.Join(lines, "\n"), redaction.Options{})); err != nil {
|
|
return exitCrash
|
|
}
|
|
return exitSuccess
|
|
}
|
|
|
|
type pluginInfoOptions struct {
|
|
json bool
|
|
user bool
|
|
}
|
|
|
|
func parsePluginInfoArgs(args []string) (string, pluginInfoOptions, bool, error) {
|
|
options := pluginInfoOptions{}
|
|
id := ""
|
|
for _, arg := range args {
|
|
switch arg {
|
|
case "-h", "--help", "help":
|
|
return "", options, true, nil
|
|
case "--json":
|
|
options.json = true
|
|
case "--user":
|
|
options.user = true
|
|
default:
|
|
if strings.HasPrefix(arg, "-") {
|
|
return "", options, false, execUsageError{fmt.Sprintf("unknown plugins info flag %q", arg)}
|
|
}
|
|
if id != "" {
|
|
return "", options, false, execUsageError{"plugins info takes a single id"}
|
|
}
|
|
id = arg
|
|
}
|
|
}
|
|
return id, options, false, nil
|
|
}
|
|
|
|
func pluginLoadOptions(deps appDeps, cwd string, userOnly bool) plugins.LoadOptions {
|
|
opts := plugins.LoadOptions{Cwd: cwd, ExcludeProject: userOnly}
|
|
roots := []plugins.Root{{Source: plugins.SourceUser, Path: deps.pluginsDir()}}
|
|
if !userOnly {
|
|
roots = append(roots, plugins.Root{Source: plugins.SourceProject, Path: filepath.Join(cwd, ".zero", "plugins")})
|
|
}
|
|
opts.Roots = roots
|
|
return opts
|
|
}
|
|
|
|
// --- tools make / list ---
|
|
|
|
func runTools(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
|
|
if len(args) == 0 {
|
|
return writeExecUsageError(stderr, "tools subcommand required. Use `zero tools make <name>` or `zero tools list`.")
|
|
}
|
|
switch args[0] {
|
|
case "-h", "--help", "help":
|
|
if err := writeToolsHelp(stdout); err != nil {
|
|
return exitCrash
|
|
}
|
|
return exitSuccess
|
|
case "make", "new", "scaffold":
|
|
return runToolsMake(args[1:], deps.toolsDir(), stdout, stderr)
|
|
case "list", "ls":
|
|
return runToolsList(args[1:], deps.toolsDir(), stdout, stderr)
|
|
default:
|
|
return writeExecUsageError(stderr, fmt.Sprintf("unknown tools subcommand %q", args[0]))
|
|
}
|
|
}
|
|
|
|
type toolsMakeOptions struct {
|
|
runtime string
|
|
description string
|
|
json bool
|
|
}
|
|
|
|
func runToolsMake(args []string, dir string, stdout io.Writer, stderr io.Writer) int {
|
|
name := ""
|
|
options := toolsMakeOptions{}
|
|
for i := 0; i < len(args); i++ {
|
|
arg := args[i]
|
|
switch {
|
|
case arg == "-h" || arg == "--help" || arg == "help":
|
|
if err := writeToolsMakeHelp(stdout); err != nil {
|
|
return exitCrash
|
|
}
|
|
return exitSuccess
|
|
case arg == "--json":
|
|
options.json = true
|
|
case arg == "--runtime":
|
|
value, next, err := nextFlagValue(args, i, arg)
|
|
if err != nil {
|
|
return writeExecUsageError(stderr, err.Error())
|
|
}
|
|
options.runtime, i = value, next
|
|
case strings.HasPrefix(arg, "--runtime="):
|
|
options.runtime = strings.TrimSpace(strings.TrimPrefix(arg, "--runtime="))
|
|
case arg == "--description":
|
|
value, next, err := nextFlagValue(args, i, arg)
|
|
if err != nil {
|
|
return writeExecUsageError(stderr, err.Error())
|
|
}
|
|
options.description, i = value, next
|
|
case strings.HasPrefix(arg, "--description="):
|
|
options.description = strings.TrimSpace(strings.TrimPrefix(arg, "--description="))
|
|
case strings.HasPrefix(arg, "-"):
|
|
return writeExecUsageError(stderr, fmt.Sprintf("unknown tools make flag %q", arg))
|
|
default:
|
|
if name != "" {
|
|
return writeExecUsageError(stderr, "tools make takes a single tool name")
|
|
}
|
|
name = arg
|
|
}
|
|
}
|
|
if name == "" {
|
|
return writeExecUsageError(stderr, "usage: zero tools make <name> [--runtime shell|node|python] [--description text]")
|
|
}
|
|
if dir == "" {
|
|
return writeAppError(stderr, "could not resolve the toolbox directory", exitCrash)
|
|
}
|
|
|
|
result, err := tools.Scaffold(tools.ScaffoldOptions{
|
|
Name: name,
|
|
Dir: dir,
|
|
Description: options.description,
|
|
Runtime: tools.ScaffoldRuntime(strings.TrimSpace(options.runtime)),
|
|
})
|
|
if err != nil {
|
|
return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitUsage)
|
|
}
|
|
if options.json {
|
|
if err := writePrettyJSON(stdout, redaction.RedactValue(result, redaction.Options{})); err != nil {
|
|
return exitCrash
|
|
}
|
|
return exitSuccess
|
|
}
|
|
lines := []string{
|
|
fmt.Sprintf("Scaffolded tool %q at %s.", result.Name, result.PluginDir),
|
|
" manifest: " + result.ManifestPath,
|
|
" entry: " + result.EntryPath,
|
|
"",
|
|
"Next steps:",
|
|
" 1. Implement the TODO in the entry script.",
|
|
" 2. Run `zero tools list` to confirm it loads.",
|
|
" 3. The tool activates through the normal permission flow.",
|
|
}
|
|
if _, err := fmt.Fprintln(stdout, redaction.RedactString(strings.Join(lines, "\n"), redaction.Options{})); err != nil {
|
|
return exitCrash
|
|
}
|
|
return exitSuccess
|
|
}
|
|
|
|
func runToolsList(args []string, dir string, stdout io.Writer, stderr io.Writer) int {
|
|
asJSON := false
|
|
for _, arg := range args {
|
|
switch arg {
|
|
case "-h", "--help", "help":
|
|
if err := writeToolsListHelp(stdout); err != nil {
|
|
return exitCrash
|
|
}
|
|
return exitSuccess
|
|
case "--json":
|
|
asJSON = true
|
|
default:
|
|
return writeExecUsageError(stderr, fmt.Sprintf("unknown tools list flag %q", arg))
|
|
}
|
|
}
|
|
if dir == "" {
|
|
return writeAppError(stderr, "could not resolve the toolbox directory", exitCrash)
|
|
}
|
|
|
|
// Scaffolded tools are plugins, so list them through the plugin loader scoped
|
|
// to the toolbox dir, then surface their tool extensions.
|
|
result, err := plugins.Load(plugins.LoadOptions{Roots: []plugins.Root{{Source: plugins.SourceUser, Path: dir}}})
|
|
if err != nil {
|
|
return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash)
|
|
}
|
|
items := toolboxItems(result.Plugins)
|
|
if asJSON {
|
|
payload := struct {
|
|
Tools []toolboxItem `json:"tools"`
|
|
Diagnostics []plugins.Diagnostic `json:"diagnostics"`
|
|
}{Tools: items, Diagnostics: result.Diagnostics}
|
|
if err := writePrettyJSON(stdout, redaction.RedactValue(payload, redaction.Options{})); err != nil {
|
|
return exitCrash
|
|
}
|
|
return exitSuccess
|
|
}
|
|
if _, err := fmt.Fprintln(stdout, redaction.RedactString(formatToolboxList(items, result.Diagnostics, dir), redaction.Options{})); err != nil {
|
|
return exitCrash
|
|
}
|
|
return exitSuccess
|
|
}
|
|
|
|
// toolboxItem is a flattened view of a plugin tool for `tools list`.
|
|
type toolboxItem struct {
|
|
Plugin string `json:"plugin"`
|
|
Name string `json:"name"`
|
|
Description string `json:"description,omitempty"`
|
|
Command string `json:"command"`
|
|
Permission string `json:"permission"`
|
|
}
|
|
|
|
func toolboxItems(loaded []plugins.LoadedPlugin) []toolboxItem {
|
|
items := []toolboxItem{}
|
|
for _, plugin := range loaded {
|
|
for _, tool := range plugin.Tools {
|
|
command := tool.Command
|
|
if len(tool.Args) > 0 {
|
|
command = strings.TrimSpace(command + " " + strings.Join(tool.Args, " "))
|
|
}
|
|
items = append(items, toolboxItem{
|
|
Plugin: plugin.ID,
|
|
Name: tool.Name,
|
|
Description: tool.Description,
|
|
Command: command,
|
|
Permission: string(tool.Permission),
|
|
})
|
|
}
|
|
}
|
|
sort.Slice(items, func(left int, right int) bool {
|
|
if items[left].Plugin != items[right].Plugin {
|
|
return items[left].Plugin < items[right].Plugin
|
|
}
|
|
return items[left].Name < items[right].Name
|
|
})
|
|
return items
|
|
}
|
|
|
|
func formatToolboxList(items []toolboxItem, diagnostics []plugins.Diagnostic, dir string) string {
|
|
lines := []string{}
|
|
if len(items) == 0 {
|
|
lines = append(lines, fmt.Sprintf("No Zero plugin-tools found in %s.", dir))
|
|
} else {
|
|
lines = append(lines, "Zero Tools:")
|
|
for _, item := range items {
|
|
line := " " + item.Name + " (" + item.Plugin + ")"
|
|
if item.Description != "" {
|
|
line += " - " + item.Description
|
|
}
|
|
lines = append(lines, line)
|
|
lines = append(lines, fmt.Sprintf(" command: %s [%s]", item.Command, item.Permission))
|
|
}
|
|
}
|
|
lines = append(lines, formatToolboxDiagnostics(diagnostics)...)
|
|
return strings.Join(lines, "\n")
|
|
}
|
|
|
|
// formatToolboxDiagnostics renders loader diagnostics the same way the plugins
|
|
// listing does, so a broken toolbox plugin surfaces a warning instead of
|
|
// silently vanishing from the listing.
|
|
func formatToolboxDiagnostics(diagnostics []plugins.Diagnostic) []string {
|
|
if len(diagnostics) == 0 {
|
|
return nil
|
|
}
|
|
lines := []string{"Tool diagnostics:"}
|
|
for _, diagnostic := range diagnostics {
|
|
line := fmt.Sprintf(" [%s] %s", diagnostic.Kind, diagnostic.Message)
|
|
lines = append(lines, line)
|
|
}
|
|
return lines
|
|
}
|
|
|
|
// parseNameCommandArgs parses a single positional name plus an optional --json
|
|
// flag for the info/remove style commands.
|
|
func parseNameCommandArgs(args []string, label string) (string, bool, bool, error) {
|
|
name := ""
|
|
asJSON := false
|
|
for _, arg := range args {
|
|
switch arg {
|
|
case "-h", "--help", "help":
|
|
return "", false, true, nil
|
|
case "--json":
|
|
asJSON = true
|
|
default:
|
|
if strings.HasPrefix(arg, "-") {
|
|
return "", false, false, execUsageError{fmt.Sprintf("unknown %s flag %q", label, arg)}
|
|
}
|
|
if name != "" {
|
|
return "", false, false, execUsageError{fmt.Sprintf("%s takes a single name", label)}
|
|
}
|
|
name = arg
|
|
}
|
|
}
|
|
return name, asJSON, false, nil
|
|
}
|
|
|
|
// shortHash trims a "sha256:<hex>" fingerprint to a readable prefix for status
|
|
// lines while leaving the algorithm tag intact.
|
|
func shortHash(hash string) string {
|
|
if hash == "" {
|
|
return "(none)"
|
|
}
|
|
algo, hex, found := strings.Cut(hash, ":")
|
|
if !found {
|
|
if len(hash) > 12 {
|
|
return hash[:12]
|
|
}
|
|
return hash
|
|
}
|
|
if len(hex) > 12 {
|
|
hex = hex[:12]
|
|
}
|
|
return algo + ":" + hex
|
|
}
|
|
|
|
func writeSkillAddHelp(w io.Writer) error {
|
|
_, err := fmt.Fprint(w, `Usage:
|
|
zero skill add <git-url|path> [flags]
|
|
|
|
Installs a skill from a git URL or local path into the skills directory and
|
|
records a content hash in skills.lock. Fetched content is never executed.
|
|
|
|
Flags:
|
|
-f, --force Overwrite a skill installed from a different source
|
|
--json Print the install result as JSON
|
|
-h, --help Show this help
|
|
`)
|
|
return err
|
|
}
|
|
|
|
func writeSkillInfoHelp(w io.Writer) error {
|
|
_, err := fmt.Fprint(w, `Usage:
|
|
zero skill info <name> [--json]
|
|
|
|
Shows a skill's frontmatter plus its recorded source and pinned hash.
|
|
`)
|
|
return err
|
|
}
|
|
|
|
func writeSkillRemoveHelp(w io.Writer) error {
|
|
_, err := fmt.Fprint(w, `Usage:
|
|
zero skill remove <name>
|
|
|
|
Removes an installed skill directory and its skills.lock entry.
|
|
`)
|
|
return err
|
|
}
|
|
|
|
func writePluginAddHelp(w io.Writer) error {
|
|
_, err := fmt.Fprint(w, `Usage:
|
|
zero plugin add <git-url|path> [flags]
|
|
|
|
Installs a plugin from a git URL or local path. The manifest is validated and a
|
|
content hash is recorded in plugins.lock. No install script is run; the plugin
|
|
still activates through the normal permission flow.
|
|
|
|
Flags:
|
|
-f, --force Overwrite a plugin installed from a different source
|
|
--json Print the install result as JSON
|
|
-h, --help Show this help
|
|
`)
|
|
return err
|
|
}
|
|
|
|
func writePluginRemoveHelp(w io.Writer) error {
|
|
_, err := fmt.Fprint(w, `Usage:
|
|
zero plugin remove <id>
|
|
|
|
Removes an installed plugin directory and its plugins.lock entry.
|
|
`)
|
|
return err
|
|
}
|
|
|
|
func writePluginInfoHelp(w io.Writer) error {
|
|
_, err := fmt.Fprint(w, `Usage:
|
|
zero plugins info <id> [flags]
|
|
|
|
Flags:
|
|
--user Target only user plugins (ignore ./.zero/plugins)
|
|
--json Print plugin info as JSON
|
|
-h, --help Show this help
|
|
`)
|
|
return err
|
|
}
|
|
|
|
func writeToolsHelp(w io.Writer) error {
|
|
_, err := fmt.Fprint(w, `Usage:
|
|
zero tools <command>
|
|
|
|
Commands:
|
|
make <name> Scaffold a new plugin-tool skeleton in the toolbox dir
|
|
list List plugin-tools in the toolbox dir
|
|
`)
|
|
return err
|
|
}
|
|
|
|
func writeToolsMakeHelp(w io.Writer) error {
|
|
_, err := fmt.Fprint(w, `Usage:
|
|
zero tools make <name> [flags]
|
|
|
|
Scaffolds a plugin-tool skeleton (a plugin manifest plus a runnable entry stub)
|
|
in the toolbox directory. After plugin activation the tool is loadable.
|
|
|
|
Flags:
|
|
--runtime <shell|node|python> Entry stub language (default shell)
|
|
--description <text> Tool description
|
|
--json Print the scaffold result as JSON
|
|
-h, --help Show this help
|
|
`)
|
|
return err
|
|
}
|
|
|
|
func writeToolsListHelp(w io.Writer) error {
|
|
_, err := fmt.Fprint(w, `Usage:
|
|
zero tools list [flags]
|
|
|
|
Flags:
|
|
--json Print plugin-tools as JSON
|
|
-h, --help Show this help
|
|
`)
|
|
return err
|
|
}
|