Files
zero/internal/tools/scaffold.go
KRATOS 5e68c78824 wedge stage 10: distribution (skill/plugin install, tools make) (#184)
* stage 10: distribution (skill/plugin install, tools make)

Add a file-backed distribution surface so skills and plugins can be
installed from a git URL or local path, and new plugin-tools can be
scaffolded. No central registry: a source is just a git URL or path.

- internal/skills/install.go: Install/Remove/Info + skills.lock. Validates
  SKILL.md frontmatter via the existing parser, copies it into the skills
  dir, and records a sha256 content hash. Reinstall surfaces the hash
  change; a clash with a different source is refused without --force.
- internal/plugins/install.go: same flow validated against the plugin
  manifest schema, copying the whole plugin tree (minus .git) so the
  plugin is activatable. plugins.lock records source + hash.
- internal/tools/scaffold.go: generate a prompt-gated plugin-tool skeleton
  (manifest + runnable shell/node/python entry stub) in the toolbox dir.
- CLI: zero skill {add,info,remove}, zero plugin {add,remove}, and a new
  zero tools {make,list}; wired in app.go/extensions.go/skills.go.

Safety: fetch honors the process environment (proxy/egress) and disables
git credential prompts; install never executes fetched content (skills are
markdown; plugin install scripts are never run) and installed plugins still
go through normal activation with permission gating.

* stage 10: make no-usable-name install test cross-platform

The test used a whitespace-only source dir name to force the no-usable-name rejection, which is not creatable on Windows (Smoke failure). Switched to a dir base that validSkillName rejects on every platform (contains "..") while staying creatable on Windows; the blank frontmatter still falls back to the unusable dir base, exercising the same rejection.

* stage 10: address review (tree hash, tools-list diagnostics, --json guards)

* stage 10: make canonical-source tests cross-platform (Windows)

Two Windows Smoke failures from the canonical-source review fix:
- TestInstallSameLocalSourceDifferentSpellingIsNotAClash (skills + plugins) used filepath.Rel(cwd, tempdir), which errors on Windows when the temp dir (C:) and repo (D:) are on different drives. Switched to a same-directory alternate spelling (redundant /./), which still exercises canonicalSource without a cross-drive relative path.
- TestRunSkillAddInfoRemove asserted the raw source path, but canonicalSource resolves symlinks/8.3-short-paths, so the stored+displayed source differs on Windows. Normalize the source via EvalSymlinks up front so the assertion matches on every platform.

* stage 10: address CodeRabbit nitpicks (variadic append, header comment)
2026-06-13 10:43:31 +05:30

247 lines
7.6 KiB
Go

package tools
// Distribution: scaffold a new plugin-tool skeleton in the user's toolbox dir.
// It writes a plugin manifest (schemaVersion 1, one prompt-gated tool with an
// empty parameter schema and a command pointing at the entry stub) plus a
// runnable stub carrying a clear TODO. After plugin activation the scaffolded
// tool is loadable like any other local plugin. Generated content is data only —
// scaffolding never executes anything.
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"regexp"
"strings"
)
// ScaffoldRuntime selects the entry-stub language. The default is a POSIX shell
// stub, which has no interpreter install dependency; node/python are offered for
// authors who prefer them. Runtimes are deliberately model-provider-agnostic.
type ScaffoldRuntime string
const (
RuntimeShell ScaffoldRuntime = "shell"
RuntimeNode ScaffoldRuntime = "node"
RuntimePython ScaffoldRuntime = "python"
)
// scaffoldNamePattern matches a safe single-segment tool name (also a valid
// plugin id and directory component).
var scaffoldNamePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]*$`)
// ScaffoldOptions configures a single tool scaffold.
type ScaffoldOptions struct {
// Name is the tool/plugin id (a single path segment).
Name string
// Dir is the toolbox directory to create the plugin under (typically the user
// plugins root).
Dir string
// Description is an optional human description; a default is used when empty.
Description string
// Runtime selects the entry-stub language; defaults to RuntimeShell.
Runtime ScaffoldRuntime
}
// ScaffoldResult reports the generated layout.
type ScaffoldResult struct {
Name string `json:"name"`
PluginDir string `json:"pluginDir"`
ManifestPath string `json:"manifestPath"`
EntryPath string `json:"entryPath"`
}
// runtimeSpec describes how to invoke a stub of a given runtime and what stub
// body/filename to emit.
type runtimeSpec struct {
entryFile string
command string
// argsBefore are command args that precede the entry path (e.g. interpreter
// scripts pass the script path as an arg; an executable shell script is the
// command itself and needs no preceding args).
usesScriptArg bool
mode os.FileMode
body func(name string, description string) string
}
func runtimeSpecFor(runtime ScaffoldRuntime) (runtimeSpec, error) {
switch runtime {
case "", RuntimeShell:
return runtimeSpec{
entryFile: "run.sh",
command: "sh",
usesScriptArg: true,
mode: 0o755,
body: shellStub,
}, nil
case RuntimeNode:
return runtimeSpec{
entryFile: "run.mjs",
command: "node",
usesScriptArg: true,
mode: 0o644,
body: nodeStub,
}, nil
case RuntimePython:
return runtimeSpec{
entryFile: "run.py",
command: "python3",
usesScriptArg: true,
mode: 0o644,
body: pythonStub,
}, nil
default:
return runtimeSpec{}, fmt.Errorf("unknown runtime %q (use shell, node, or python)", runtime)
}
}
// Scaffold generates a plugin-tool skeleton under options.Dir/<name>/ and returns
// the created paths. It refuses an invalid name, a missing dir, or an existing
// target so it never clobbers prior work.
func Scaffold(options ScaffoldOptions) (ScaffoldResult, error) {
name := strings.TrimSpace(options.Name)
if name == "" {
return ScaffoldResult{}, fmt.Errorf("a tool name is required")
}
if name != filepath.Base(name) || !scaffoldNamePattern.MatchString(name) || strings.Contains(name, "..") {
return ScaffoldResult{}, fmt.Errorf("invalid tool name %q (use letters, numbers, dots, dashes, or underscores)", name)
}
dir := strings.TrimSpace(options.Dir)
if dir == "" {
return ScaffoldResult{}, fmt.Errorf("a toolbox directory is required")
}
spec, err := runtimeSpecFor(options.Runtime)
if err != nil {
return ScaffoldResult{}, err
}
pluginDir := filepath.Join(dir, name)
if _, err := os.Stat(pluginDir); err == nil {
return ScaffoldResult{}, fmt.Errorf("a tool already exists at %s", pluginDir)
} else if !os.IsNotExist(err) {
return ScaffoldResult{}, fmt.Errorf("stat %s: %w", pluginDir, err)
}
description := strings.TrimSpace(options.Description)
if description == "" {
description = "TODO: describe what the " + name + " tool does."
}
if err := os.MkdirAll(pluginDir, 0o755); err != nil {
return ScaffoldResult{}, fmt.Errorf("create tool dir: %w", err)
}
entryPath := filepath.Join(pluginDir, spec.entryFile)
if err := os.WriteFile(entryPath, []byte(spec.body(name, description)), spec.mode); err != nil {
return ScaffoldResult{}, fmt.Errorf("write entry stub: %w", err)
}
// The command points at the stub. Args reference the entry by its path
// RELATIVE to the plugin dir, matching how the loader resolves plugin tool
// args (plugin paths stay inside the plugin directory).
args := []string{}
if spec.usesScriptArg {
args = append(args, spec.entryFile)
}
manifest := map[string]any{
"schemaVersion": 1,
"id": name,
"name": name,
"version": "0.1.0",
"description": description,
"tools": []map[string]any{{
"name": name,
"description": description,
"command": spec.command,
"args": args,
"inputSchema": map[string]any{
"type": "object",
"properties": map[string]any{},
"additionalProperties": false,
},
// Prompt-gated by default: a freshly scaffolded tool must never be
// auto-approved. Activation applies the normal permission flow.
"permission": "prompt",
}},
}
data, err := json.MarshalIndent(manifest, "", " ")
if err != nil {
return ScaffoldResult{}, fmt.Errorf("encode manifest: %w", err)
}
manifestPath := filepath.Join(pluginDir, "plugin.json")
if err := os.WriteFile(manifestPath, append(data, '\n'), 0o644); err != nil {
return ScaffoldResult{}, fmt.Errorf("write manifest: %w", err)
}
return ScaffoldResult{
Name: name,
PluginDir: pluginDir,
ManifestPath: manifestPath,
EntryPath: entryPath,
}, nil
}
func shellStub(name string, description string) string {
return `#!/bin/sh
# ` + name + ` — Zero plugin tool entry point.
# ` + description + `
#
# Zero invokes this script with the tool-call arguments as a single JSON object
# on stdin and expects the tool result on stdout.
#
# TODO: read the JSON arguments from stdin, do the work, and print a result.
set -eu
input="$(cat)"
echo "TODO: implement ${0##*/}. Received arguments: ${input}"
`
}
func nodeStub(name string, description string) string {
return `// ` + name + ` — Zero plugin tool entry point.
// ` + description + `
//
// Zero invokes this script with the tool-call arguments as a single JSON object
// on stdin and expects the tool result on stdout.
import process from "node:process";
let raw = "";
process.stdin.on("data", (chunk) => {
raw += chunk;
});
process.stdin.on("end", () => {
// TODO: parse the JSON arguments and do the work.
const args = raw.trim() ? JSON.parse(raw) : {};
process.stdout.write("TODO: implement ` + name + `. Received: " + JSON.stringify(args) + "\n");
});
`
}
func pythonStub(name string, description string) string {
return `#!/usr/bin/env python3
"""` + name + ` — Zero plugin tool entry point.
` + description + `
Zero invokes this script with the tool-call arguments as a single JSON object on
stdin and expects the tool result on stdout.
"""
import json
import sys
def main() -> None:
raw = sys.stdin.read()
# TODO: parse the JSON arguments and do the work.
args = json.loads(raw) if raw.strip() else {}
print("TODO: implement ` + name + `. Received:", json.dumps(args))
if __name__ == "__main__":
main()
`
}