chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,218 @@
|
||||
// Package command loads custom slash commands from Markdown files. A command is
|
||||
// a prompt template: invoking /name substitutes the arguments into the body and
|
||||
// sends the result as a chat turn. Loading is pure and dependency-free — a small
|
||||
// "key: value" frontmatter parser keeps Reasonix's single-(TOML)-dependency promise
|
||||
// rather than pulling in a YAML library.
|
||||
package command
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
fileencoding "reasonix/internal/fileutil/encoding"
|
||||
"reasonix/internal/frontmatter"
|
||||
)
|
||||
|
||||
// Command is a custom slash command loaded from a .md file.
|
||||
type Command struct {
|
||||
Name string // "review" or "git:commit", derived from the file path
|
||||
Description string // from frontmatter
|
||||
ArgHint string // from frontmatter (argument-hint)
|
||||
Body string // template with $ARGUMENTS / $1..$N / $$
|
||||
Source string // originating file path, for diagnostics
|
||||
Plugin string // installed plugin package name; empty for user/project commands
|
||||
ShortName string // original plugin command name before the package qualifier
|
||||
Hidden bool // compatibility-only short alias; invocable but omitted from listings
|
||||
}
|
||||
|
||||
// Root is one command directory and its optional plugin-package owner. Plugin
|
||||
// ownership is carried through loading so plugin commands can use stable,
|
||||
// package-qualified display names without losing unambiguous short-name
|
||||
// compatibility.
|
||||
type Root struct {
|
||||
Path string
|
||||
Plugin string
|
||||
}
|
||||
|
||||
// substRe matches the substitution tokens recognised in a command body.
|
||||
var substRe = regexp.MustCompile(`\$(\$|ARGUMENTS|[0-9]+)`)
|
||||
|
||||
// Render substitutes args into the command body: $ARGUMENTS is all args joined
|
||||
// by spaces, $1..$N are positional (empty when absent), and $$ is a literal $.
|
||||
func (c Command) Render(args []string) string {
|
||||
return substRe.ReplaceAllStringFunc(c.Body, func(m string) string {
|
||||
switch tok := m[1:]; tok {
|
||||
case "$":
|
||||
return "$"
|
||||
case "ARGUMENTS":
|
||||
return strings.Join(args, " ")
|
||||
default:
|
||||
n, _ := strconv.Atoi(tok) // regex guarantees digits
|
||||
if n >= 1 && n <= len(args) {
|
||||
return args[n-1]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Load reads every *.md command file under each dir, in order, so a later dir
|
||||
// overrides an earlier one on a name clash (pass the user dir first, project
|
||||
// dir last). Missing dirs are skipped. Individual file failures are collected
|
||||
// into the returned error but don't prevent the others from loading. The result
|
||||
// is sorted by name.
|
||||
func Load(dirs ...string) ([]Command, error) {
|
||||
roots := make([]Root, 0, len(dirs))
|
||||
for _, dir := range dirs {
|
||||
roots = append(roots, Root{Path: dir})
|
||||
}
|
||||
return LoadRoots(roots...)
|
||||
}
|
||||
|
||||
// LoadRoots is Load with optional plugin ownership. Every plugin command is
|
||||
// exposed canonically as /<plugin>:<name>. A short /<name> compatibility alias
|
||||
// is retained only when exactly one plugin contributes that name and no user or
|
||||
// project command owns it; the alias is hidden from completion and model-visible
|
||||
// listings. An explicit command occupying the qualified name still wins.
|
||||
func LoadRoots(roots ...Root) ([]Command, error) {
|
||||
byName := map[string]Command{}
|
||||
pluginCommands := map[string]map[string]Command{}
|
||||
var errs []string
|
||||
for _, spec := range roots {
|
||||
root, err := filepath.Abs(spec.Path)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
// A symlink-following walk (filepath.WalkDir does not follow links), so a
|
||||
// symlinked command directory or a symlinked <name>.md is picked up like a
|
||||
// real one. visited (keyed by resolved path) guards against symlink cycles.
|
||||
visited := map[string]bool{}
|
||||
if real, err := filepath.EvalSymlinks(root); err == nil {
|
||||
visited[real] = true
|
||||
} else {
|
||||
visited[root] = true
|
||||
}
|
||||
walkCommands(root, root, visited, func(path string) {
|
||||
c, perr := parseFile(root, path)
|
||||
if perr != nil {
|
||||
errs = append(errs, perr.Error())
|
||||
return
|
||||
}
|
||||
c.Plugin = strings.TrimSpace(spec.Plugin)
|
||||
if c.Plugin == "" {
|
||||
byName[c.Name] = c
|
||||
} else {
|
||||
if pluginCommands[c.Plugin] == nil {
|
||||
pluginCommands[c.Plugin] = map[string]Command{}
|
||||
}
|
||||
pluginCommands[c.Plugin][c.Name] = c
|
||||
}
|
||||
})
|
||||
}
|
||||
byShortName := map[string][]Command{}
|
||||
for plugin, commands := range pluginCommands {
|
||||
for shortName, pluginCommand := range commands {
|
||||
pluginCommand.ShortName = shortName
|
||||
byShortName[shortName] = append(byShortName[shortName], pluginCommand)
|
||||
qualified := plugin + ":" + shortName
|
||||
if _, occupied := byName[qualified]; occupied {
|
||||
continue
|
||||
}
|
||||
pluginCommand.Name = qualified
|
||||
byName[qualified] = pluginCommand
|
||||
}
|
||||
}
|
||||
for shortName, candidates := range byShortName {
|
||||
if _, occupied := byName[shortName]; occupied || len(candidates) != 1 {
|
||||
continue
|
||||
}
|
||||
compat := candidates[0]
|
||||
compat.Name = shortName
|
||||
compat.Hidden = true
|
||||
byName[shortName] = compat
|
||||
}
|
||||
cmds := make([]Command, 0, len(byName))
|
||||
for _, c := range byName {
|
||||
cmds = append(cmds, c)
|
||||
}
|
||||
sort.Slice(cmds, func(i, j int) bool { return cmds[i].Name < cmds[j].Name })
|
||||
if len(errs) > 0 {
|
||||
return cmds, fmt.Errorf("command load: %s", strings.Join(errs, "; "))
|
||||
}
|
||||
return cmds, nil
|
||||
}
|
||||
|
||||
// walkCommands recursively visits dir, following symlinks, and calls fn with the
|
||||
// path of every *.md file (including symlinked files and files under symlinked
|
||||
// directories). visited (resolved-path set) prevents infinite recursion through
|
||||
// a symlink cycle. Unreadable directories are skipped, never fatal.
|
||||
func walkCommands(root, dir string, visited map[string]bool, fn func(path string)) {
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
for _, e := range entries {
|
||||
full := filepath.Join(dir, e.Name())
|
||||
isDir := e.IsDir()
|
||||
isFile := e.Type().IsRegular()
|
||||
if e.Type()&os.ModeSymlink != 0 {
|
||||
info, serr := os.Stat(full) // follow the link
|
||||
if serr != nil {
|
||||
continue // broken link
|
||||
}
|
||||
isDir = info.IsDir()
|
||||
isFile = info.Mode().IsRegular()
|
||||
}
|
||||
switch {
|
||||
case isDir:
|
||||
real, rerr := filepath.EvalSymlinks(full)
|
||||
if rerr != nil {
|
||||
real = full
|
||||
}
|
||||
if visited[real] {
|
||||
continue
|
||||
}
|
||||
visited[real] = true
|
||||
walkCommands(root, full, visited, fn)
|
||||
case isFile && strings.EqualFold(filepath.Ext(e.Name()), ".md"):
|
||||
fn(full)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// parseFile reads one command file and derives its name from the path relative
|
||||
// to root: drop the .md suffix and turn subdirectories into ":" namespaces
|
||||
// (git/commit.md → git:commit).
|
||||
func parseFile(root, path string) (Command, error) {
|
||||
b, err := fileencoding.ReadFileUTF8(path)
|
||||
if err != nil {
|
||||
return Command{}, err
|
||||
}
|
||||
rel, err := filepath.Rel(root, path)
|
||||
if err != nil {
|
||||
rel = filepath.Base(path)
|
||||
}
|
||||
name := strings.ReplaceAll(strings.TrimSuffix(filepath.ToSlash(rel), ".md"), "/", ":")
|
||||
|
||||
// Normalise line endings and strip a leading UTF-8 BOM if present.
|
||||
content := strings.TrimPrefix(strings.ReplaceAll(string(b), "\r\n", "\n"), string(rune(0xFEFF)))
|
||||
fm, body := frontmatter.Split(content)
|
||||
return Command{
|
||||
Name: name,
|
||||
Description: fm["description"],
|
||||
ArgHint: fm["argument-hint"],
|
||||
Body: strings.TrimSpace(body),
|
||||
Source: path,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// splitFrontmatter is a thin wrapper kept for test compatibility; the real
|
||||
// parser lives in internal/frontmatter.
|
||||
func splitFrontmatter(s string) (map[string]string, string) {
|
||||
return frontmatter.Split(s)
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
package command
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// --- splitFrontmatter ---
|
||||
|
||||
func TestSplitFrontmatterNoFence(t *testing.T) {
|
||||
fm, body := splitFrontmatter("just body text\nno fence")
|
||||
if len(fm) != 0 {
|
||||
t.Errorf("expected empty fm, got %v", fm)
|
||||
}
|
||||
if !strings.Contains(body, "just body text") {
|
||||
t.Errorf("body = %q", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitFrontmatterUnclosed(t *testing.T) {
|
||||
fm, body := splitFrontmatter("---\nkey: val\n\nbody without closing")
|
||||
if len(fm) != 0 {
|
||||
t.Errorf("unclosed fence should return empty fm, got %v", fm)
|
||||
}
|
||||
if !strings.Contains(body, "---") {
|
||||
t.Errorf("body should contain original content: %q", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitFrontmatterEmptyBody(t *testing.T) {
|
||||
fm, body := splitFrontmatter("---\nkey: val\n---\n")
|
||||
if fm["key"] != "val" {
|
||||
t.Errorf("key = %q", fm["key"])
|
||||
}
|
||||
if strings.TrimSpace(body) != "" {
|
||||
t.Errorf("expected empty body, got %q", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitFrontmatterQuotedValues(t *testing.T) {
|
||||
fm, _ := splitFrontmatter("---\ndescription: \"quoted\"\n---\n")
|
||||
if fm["description"] != "quoted" {
|
||||
t.Errorf("description should be unquoted: %q", fm["description"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitFrontmatterCRLF(t *testing.T) {
|
||||
fm, body := splitFrontmatter("---\r\nkey: val\r\n---\r\nbody\r\n")
|
||||
if fm["key"] != "val" {
|
||||
t.Errorf("key = %q", fm["key"])
|
||||
}
|
||||
if !strings.Contains(body, "body") {
|
||||
t.Errorf("body = %q", body)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Render edge cases ---
|
||||
|
||||
func TestRenderManyPositionals(t *testing.T) {
|
||||
c := Command{Body: "$1 $2 $3 $4 $5 $6 $7 $8 $9"}
|
||||
args := []string{"a", "b", "c", "d", "e", "f", "g", "h", "i"}
|
||||
got := c.Render(args)
|
||||
if got != "a b c d e f g h i" {
|
||||
t.Errorf("Render = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderSpecialChars(t *testing.T) {
|
||||
c := Command{Body: "cmd: $1"}
|
||||
got := c.Render([]string{`"hello" world`})
|
||||
if !strings.Contains(got, `"hello" world`) {
|
||||
t.Errorf("Render with quotes = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderBackslash(t *testing.T) {
|
||||
c := Command{Body: "path: $1"}
|
||||
got := c.Render([]string{`C:\Users\test`})
|
||||
if !strings.Contains(got, `C:\Users\test`) {
|
||||
t.Errorf("Render with backslash = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderDollarDollar(t *testing.T) {
|
||||
c := Command{Body: "Price: $$100"}
|
||||
got := c.Render(nil)
|
||||
if got != "Price: $100" {
|
||||
t.Errorf("Render $$ = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderNoTokens(t *testing.T) {
|
||||
c := Command{Body: "no tokens here"}
|
||||
got := c.Render([]string{"arg1", "arg2"})
|
||||
if got != "no tokens here" {
|
||||
t.Errorf("Render = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Load edge cases ---
|
||||
|
||||
func TestLoadEmptyDir(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cmds, err := Load(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
if len(cmds) != 0 {
|
||||
t.Errorf("empty dir should return 0 commands, got %d", len(cmds))
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadNestedSubdirs(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
write(t, dir, "a/b/c/deep.md", "---\ndescription: deep\n---\nDeep command.")
|
||||
cmds, err := Load(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
if len(cmds) != 1 || cmds[0].Name != "a:b:c:deep" {
|
||||
t.Errorf("deep nesting: %+v", cmds)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadBOM(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
// Write a file with a UTF-8 BOM.
|
||||
bom := string(rune(0xFEFF))
|
||||
write(t, dir, "bom.md", bom+"---\ndescription: BOM test\n---\nBody with BOM.")
|
||||
cmds, err := Load(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
if len(cmds) != 1 {
|
||||
t.Fatalf("want 1 command, got %d", len(cmds))
|
||||
}
|
||||
if cmds[0].Description != "BOM test" {
|
||||
t.Errorf("BOM not stripped: description = %q", cmds[0].Description)
|
||||
}
|
||||
if strings.Contains(cmds[0].Body, bom) {
|
||||
t.Error("BOM should be stripped from body")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadMalformedFile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
// A file that can't be read (directory with .md extension — won't happen in
|
||||
// practice but tests the error path).
|
||||
os.MkdirAll(filepath.Join(dir, "bad.md"), 0o755)
|
||||
// walkCommands skips directories even if named .md, so no error.
|
||||
cmds, err := Load(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
if len(cmds) != 0 {
|
||||
t.Errorf("directory named .md should be skipped, got %d", len(cmds))
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadSortedByName(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
write(t, dir, "zebra.md", "Z")
|
||||
write(t, dir, "alpha.md", "A")
|
||||
write(t, dir, "middle.md", "M")
|
||||
cmds, err := Load(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
if len(cmds) != 3 {
|
||||
t.Fatalf("want 3, got %d", len(cmds))
|
||||
}
|
||||
if cmds[0].Name != "alpha" || cmds[1].Name != "middle" || cmds[2].Name != "zebra" {
|
||||
t.Errorf("not sorted: %v %v %v", cmds[0].Name, cmds[1].Name, cmds[2].Name)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
package command
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
fileencoding "reasonix/internal/fileutil/encoding"
|
||||
)
|
||||
|
||||
func TestRender(t *testing.T) {
|
||||
c := Command{Body: "Review $1 focusing on $ARGUMENTS. Cost: $$5. Missing: [$3]"}
|
||||
got := c.Render([]string{"main.go", "bugs"})
|
||||
want := "Review main.go focusing on main.go bugs. Cost: $5. Missing: []"
|
||||
if got != want {
|
||||
t.Errorf("Render = %q, want %q", got, want)
|
||||
}
|
||||
|
||||
// No args: $ARGUMENTS and $N collapse to empty.
|
||||
if got := (Command{Body: "x=$ARGUMENTS y=$1"}).Render(nil); got != "x= y=" {
|
||||
t.Errorf("empty-args Render = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func write(t *testing.T, dir, rel, content string) {
|
||||
t.Helper()
|
||||
p := filepath.Join(dir, rel)
|
||||
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(p, []byte(content), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoad(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
write(t, dir, "review.md", "---\ndescription: Review the diff\nargument-hint: [area]\n---\nReview, focus on $ARGUMENTS.")
|
||||
write(t, dir, "plain.md", "No frontmatter, just $1.")
|
||||
write(t, dir, "git/commit.md", "---\ndescription: Commit\n---\nWrite a commit message.")
|
||||
write(t, dir, "notes.txt", "ignored — not markdown")
|
||||
|
||||
cmds, err := Load(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
if len(cmds) != 3 {
|
||||
t.Fatalf("loaded %d commands, want 3 (%v)", len(cmds), names(cmds))
|
||||
}
|
||||
|
||||
byName := map[string]Command{}
|
||||
for _, c := range cmds {
|
||||
byName[c.Name] = c
|
||||
}
|
||||
|
||||
r, ok := byName["review"]
|
||||
if !ok || r.Description != "Review the diff" || r.ArgHint != "[area]" {
|
||||
t.Errorf("review parsed wrong: %+v", r)
|
||||
}
|
||||
if r.Body != "Review, focus on $ARGUMENTS." {
|
||||
t.Errorf("review body = %q", r.Body)
|
||||
}
|
||||
if p := byName["plain"]; p.Body != "No frontmatter, just $1." || p.Description != "" {
|
||||
t.Errorf("plain parsed wrong: %+v", p)
|
||||
}
|
||||
if _, ok := byName["git:commit"]; !ok {
|
||||
t.Errorf("subdir namespacing failed: %v", names(cmds))
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadDecodesGB18030CommandFile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
body := "---\ndescription: 中文命令\nargument-hint: [主题]\n---\n请总结 $ARGUMENTS。"
|
||||
path := filepath.Join(dir, "summary.md")
|
||||
if err := os.WriteFile(path, fileencoding.Encode(body, fileencoding.GB18030), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
cmds, err := Load(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
if len(cmds) != 1 || cmds[0].Description != "中文命令" || cmds[0].ArgHint != "[主题]" || cmds[0].Body != "请总结 $ARGUMENTS。" {
|
||||
t.Fatalf("decoded command = %+v", cmds)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadOverrideAndMissingDir(t *testing.T) {
|
||||
user := t.TempDir()
|
||||
project := t.TempDir()
|
||||
write(t, user, "review.md", "USER version")
|
||||
write(t, project, "review.md", "PROJECT version")
|
||||
|
||||
// Later dir (project) wins on a name clash; a non-existent dir is skipped.
|
||||
cmds, err := Load("/no/such/dir", user, project)
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
if len(cmds) != 1 || cmds[0].Body != "PROJECT version" {
|
||||
t.Errorf("override failed: %+v", cmds)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadRootsUsesCanonicalPluginNamesAndHiddenCompatibleShortName(t *testing.T) {
|
||||
pluginDir := t.TempDir()
|
||||
projectDir := t.TempDir()
|
||||
write(t, pluginDir, "plan.md", "---\ndescription: Plugin plan\n---\nPLUGIN $ARGUMENTS")
|
||||
write(t, pluginDir, "status.md", "PLUGIN STATUS")
|
||||
write(t, projectDir, "plan.md", "---\ndescription: Project plan\n---\nPROJECT $ARGUMENTS")
|
||||
|
||||
cmds, err := LoadRoots(
|
||||
Root{Path: pluginDir, Plugin: "planning-with-files"},
|
||||
Root{Path: projectDir},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadRoots: %v", err)
|
||||
}
|
||||
byName := map[string]Command{}
|
||||
for _, cmd := range cmds {
|
||||
byName[cmd.Name] = cmd
|
||||
}
|
||||
if got := byName["plan"]; got.Body != "PROJECT $ARGUMENTS" || got.Plugin != "" || got.ShortName != "" || got.Hidden {
|
||||
t.Fatalf("short-name winner = %+v, want project command", got)
|
||||
}
|
||||
canonical, ok := byName["planning-with-files:plan"]
|
||||
if !ok || canonical.Body != "PLUGIN $ARGUMENTS" || canonical.Plugin != "planning-with-files" || canonical.ShortName != "plan" || canonical.Hidden {
|
||||
t.Fatalf("canonical plugin command = %+v, %v", canonical, ok)
|
||||
}
|
||||
if got := byName["status"]; got.Plugin != "planning-with-files" || got.ShortName != "status" || !got.Hidden {
|
||||
t.Fatalf("short compatibility command = %+v, want hidden plugin alias", got)
|
||||
}
|
||||
if got := byName["planning-with-files:status"]; got.Plugin != "planning-with-files" || got.ShortName != "status" || got.Hidden {
|
||||
t.Fatalf("canonical status command = %+v", got)
|
||||
}
|
||||
if got := canonical.Render([]string{"feature"}); got != "PLUGIN feature" {
|
||||
t.Fatalf("canonical command render = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadRootsDoesNotReplaceAnExplicitQualifiedCommand(t *testing.T) {
|
||||
pluginDir := t.TempDir()
|
||||
projectDir := t.TempDir()
|
||||
write(t, pluginDir, "plan.md", "PLUGIN")
|
||||
write(t, projectDir, "plan.md", "PROJECT")
|
||||
write(t, projectDir, "planning-with-files/plan.md", "EXPLICIT QUALIFIED")
|
||||
|
||||
cmds, err := LoadRoots(
|
||||
Root{Path: pluginDir, Plugin: "planning-with-files"},
|
||||
Root{Path: projectDir},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadRoots: %v", err)
|
||||
}
|
||||
for _, cmd := range cmds {
|
||||
if cmd.Name == "planning-with-files:plan" {
|
||||
if cmd.Body != "EXPLICIT QUALIFIED" || cmd.Plugin != "" || cmd.ShortName != "" || cmd.Hidden {
|
||||
t.Fatalf("explicit qualified command was replaced: %+v", cmd)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatal("explicit qualified command missing")
|
||||
}
|
||||
|
||||
func TestLoadRootsOmitsAmbiguousShortPluginName(t *testing.T) {
|
||||
alpha := t.TempDir()
|
||||
beta := t.TempDir()
|
||||
write(t, alpha, "plan.md", "ALPHA")
|
||||
write(t, beta, "plan.md", "BETA")
|
||||
cmds, err := LoadRoots(Root{Path: alpha, Plugin: "alpha"}, Root{Path: beta, Plugin: "beta"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
byName := map[string]Command{}
|
||||
for _, cmd := range cmds {
|
||||
byName[cmd.Name] = cmd
|
||||
}
|
||||
if _, ok := byName["plan"]; ok {
|
||||
t.Fatal("ambiguous plugin short name must not remain invocable")
|
||||
}
|
||||
if byName["alpha:plan"].Body != "ALPHA" || byName["beta:plan"].Body != "BETA" {
|
||||
t.Fatalf("canonical plugin commands = %+v", cmds)
|
||||
}
|
||||
}
|
||||
|
||||
func names(cmds []Command) []string {
|
||||
out := make([]string, len(cmds))
|
||||
for i, c := range cmds {
|
||||
out[i] = c.Name
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
package command
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// CandidateStatus is the diagnostic disposition of one command file.
|
||||
type CandidateStatus string
|
||||
|
||||
const (
|
||||
CandidateWinner CandidateStatus = "winner"
|
||||
CandidateShadowed CandidateStatus = "shadowed"
|
||||
CandidateError CandidateStatus = "error"
|
||||
)
|
||||
|
||||
// Candidate is one command file considered during Load, including shadowed
|
||||
// sources that later directories override.
|
||||
type Candidate struct {
|
||||
Name string
|
||||
Description string
|
||||
Path string
|
||||
Root string
|
||||
Status CandidateStatus
|
||||
WinnerPath string
|
||||
Error string
|
||||
}
|
||||
|
||||
// RootInfo is one scanned commands directory.
|
||||
type RootInfo struct {
|
||||
Dir string
|
||||
Status string // ok | missing | not-directory | unreadable
|
||||
}
|
||||
|
||||
// Inspection is a read-only snapshot matching command.Load override semantics
|
||||
// (later dir wins) without changing Load itself.
|
||||
type Inspection struct {
|
||||
Roots []RootInfo
|
||||
Candidates []Candidate
|
||||
Winners []Command
|
||||
}
|
||||
|
||||
// Inspect walks dirs in order (same as Load) and records every candidate.
|
||||
// Missing dirs are listed as missing and produce no warnings.
|
||||
func Inspect(dirs ...string) Inspection {
|
||||
var roots []RootInfo
|
||||
// Per-name list of candidates in scan order; last becomes winner.
|
||||
byName := map[string][]Candidate{}
|
||||
winners := map[string]Command{}
|
||||
|
||||
for _, dir := range dirs {
|
||||
root, err := filepath.Abs(dir)
|
||||
if err != nil {
|
||||
roots = append(roots, RootInfo{Dir: dir, Status: "unreadable"})
|
||||
continue
|
||||
}
|
||||
st := rootStatus(root)
|
||||
roots = append(roots, RootInfo{Dir: root, Status: st})
|
||||
if st != "ok" {
|
||||
continue
|
||||
}
|
||||
visited := map[string]bool{}
|
||||
if real, err := filepath.EvalSymlinks(root); err == nil {
|
||||
visited[real] = true
|
||||
} else {
|
||||
visited[root] = true
|
||||
}
|
||||
walkCommands(root, root, visited, func(path string) {
|
||||
c, perr := parseFile(root, path)
|
||||
if perr != nil {
|
||||
name := guessName(root, path)
|
||||
byName[name] = append(byName[name], Candidate{
|
||||
Name: name, Path: path, Root: root,
|
||||
Status: CandidateError, Error: perr.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
byName[c.Name] = append(byName[c.Name], Candidate{
|
||||
Name: c.Name, Description: c.Description, Path: path, Root: root,
|
||||
Status: CandidateWinner, // provisional; finalized below
|
||||
})
|
||||
winners[c.Name] = c
|
||||
})
|
||||
}
|
||||
|
||||
var candidates []Candidate
|
||||
for name, list := range byName {
|
||||
// Find last non-error candidate as winner; errors stay as errors.
|
||||
winIdx := -1
|
||||
for i := len(list) - 1; i >= 0; i-- {
|
||||
if list[i].Status != CandidateError {
|
||||
winIdx = i
|
||||
break
|
||||
}
|
||||
}
|
||||
for i, c := range list {
|
||||
if c.Status == CandidateError {
|
||||
candidates = append(candidates, c)
|
||||
continue
|
||||
}
|
||||
if i == winIdx {
|
||||
c.Status = CandidateWinner
|
||||
c.WinnerPath = ""
|
||||
} else if winIdx >= 0 {
|
||||
c.Status = CandidateShadowed
|
||||
c.WinnerPath = list[winIdx].Path
|
||||
}
|
||||
candidates = append(candidates, c)
|
||||
}
|
||||
_ = name
|
||||
}
|
||||
|
||||
cmds := make([]Command, 0, len(winners))
|
||||
for _, c := range winners {
|
||||
cmds = append(cmds, c)
|
||||
}
|
||||
sort.Slice(cmds, func(i, j int) bool { return cmds[i].Name < cmds[j].Name })
|
||||
sort.SliceStable(candidates, func(i, j int) bool {
|
||||
if candidates[i].Name != candidates[j].Name {
|
||||
return candidates[i].Name < candidates[j].Name
|
||||
}
|
||||
order := map[CandidateStatus]int{CandidateWinner: 0, CandidateShadowed: 1, CandidateError: 2}
|
||||
return order[candidates[i].Status] < order[candidates[j].Status]
|
||||
})
|
||||
|
||||
return Inspection{Roots: roots, Candidates: candidates, Winners: cmds}
|
||||
}
|
||||
|
||||
func rootStatus(dir string) string {
|
||||
info, err := os.Stat(dir)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return "missing"
|
||||
}
|
||||
return "unreadable"
|
||||
}
|
||||
if !info.IsDir() {
|
||||
return "not-directory"
|
||||
}
|
||||
f, err := os.Open(dir)
|
||||
if err != nil {
|
||||
return "unreadable"
|
||||
}
|
||||
_ = f.Close()
|
||||
return "ok"
|
||||
}
|
||||
|
||||
func guessName(root, path string) string {
|
||||
rel, err := filepath.Rel(root, path)
|
||||
if err != nil {
|
||||
rel = filepath.Base(path)
|
||||
}
|
||||
return strings.ReplaceAll(strings.TrimSuffix(filepath.ToSlash(rel), ".md"), "/", ":")
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
package command
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"reasonix/internal/tool"
|
||||
)
|
||||
|
||||
// SlashEntry is one invocable slash command exposed to the model through the
|
||||
// slash_command tool. It is a uniform view over the two kinds the user can also
|
||||
// type at the prompt — custom commands and skills — so the tool need not know
|
||||
// which is which. Render turns positional args into the prompt text the command
|
||||
// expands to (the same text typing "/name args" would send).
|
||||
type SlashEntry struct {
|
||||
Name string // without the leading slash, e.g. "review" or "git:commit"
|
||||
Description string
|
||||
ArgHint string // optional argument hint, for the listing
|
||||
Render func(args []string) string // expands the template/playbook with args
|
||||
}
|
||||
|
||||
// slashCommandTool lets the model invoke a loaded slash command by name. Unlike a
|
||||
// tool that performs an action and returns a result, a slash command is a *prompt
|
||||
// template*: the tool returns the expanded prompt text, which the model then reads
|
||||
// and acts on within the same turn — mirroring what typing "/name" does for a
|
||||
// human. Calling with no name (or "list") returns the available commands.
|
||||
type slashCommandTool struct {
|
||||
entries map[string]SlashEntry
|
||||
names []string // sorted, for a stable listing
|
||||
}
|
||||
|
||||
// NewSlashCommandTool builds the tool from the invocable entries (custom commands
|
||||
// + skills, adapted by the caller). A later entry wins on a name clash, matching
|
||||
// the prompt's command>skill precedence when the caller orders them that way.
|
||||
func NewSlashCommandTool(entries []SlashEntry) tool.Tool {
|
||||
m := make(map[string]SlashEntry, len(entries))
|
||||
for _, e := range entries {
|
||||
name := strings.TrimPrefix(strings.TrimSpace(e.Name), "/")
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
e.Name = name
|
||||
m[name] = e
|
||||
}
|
||||
names := make([]string, 0, len(m))
|
||||
for n := range m {
|
||||
names = append(names, n)
|
||||
}
|
||||
sort.Strings(names)
|
||||
return &slashCommandTool{entries: m, names: names}
|
||||
}
|
||||
|
||||
func (*slashCommandTool) Name() string { return "slash_command" }
|
||||
|
||||
func (*slashCommandTool) ReadOnly() bool { return true }
|
||||
|
||||
func (t *slashCommandTool) Description() string {
|
||||
var b strings.Builder
|
||||
b.WriteString("Invoke a project slash command (a reusable prompt template or skill) by name. " +
|
||||
"Returns the command's expanded prompt text for you to act on in this turn — it does not run on its own. " +
|
||||
"Call with an empty command (or \"list\") to see what's available. ")
|
||||
if len(t.names) == 0 {
|
||||
b.WriteString("No slash commands are configured in this project.")
|
||||
return b.String()
|
||||
}
|
||||
b.WriteString("Available: ")
|
||||
for i, n := range t.names {
|
||||
if i > 0 {
|
||||
b.WriteString(", ")
|
||||
}
|
||||
b.WriteString(n)
|
||||
}
|
||||
b.WriteString(".")
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func (*slashCommandTool) Schema() json.RawMessage {
|
||||
return json.RawMessage(`{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"command": {"type": "string", "description": "Slash command name (with or without a leading slash). Empty or \"list\" returns the available commands."},
|
||||
"arguments": {"type": "string", "description": "Arguments passed to the command, as you'd type them after the name (space-separated)."}
|
||||
}
|
||||
}`)
|
||||
}
|
||||
|
||||
func (t *slashCommandTool) Execute(_ context.Context, raw json.RawMessage) (string, error) {
|
||||
var p struct {
|
||||
Command string `json:"command"`
|
||||
Arguments string `json:"arguments"`
|
||||
}
|
||||
if len(raw) > 0 {
|
||||
if err := json.Unmarshal(raw, &p); err != nil {
|
||||
return "", fmt.Errorf("invalid args: %w", err)
|
||||
}
|
||||
}
|
||||
name := strings.TrimPrefix(strings.TrimSpace(p.Command), "/")
|
||||
if name == "" || strings.EqualFold(name, "list") {
|
||||
return t.list(), nil
|
||||
}
|
||||
e, ok := t.entries[name]
|
||||
if !ok {
|
||||
return "", fmt.Errorf("no slash command %q; available: %s", name, strings.Join(t.names, ", "))
|
||||
}
|
||||
args := strings.Fields(p.Arguments)
|
||||
expanded := e.Render(args)
|
||||
// Frame the expansion so the model treats it as an instruction to follow now,
|
||||
// not as data to echo back.
|
||||
return fmt.Sprintf("Expanded /%s — follow these instructions now:\n\n%s", name, expanded), nil
|
||||
}
|
||||
|
||||
func (t *slashCommandTool) list() string {
|
||||
if len(t.names) == 0 {
|
||||
return "No slash commands are configured in this project."
|
||||
}
|
||||
var b strings.Builder
|
||||
b.WriteString("Available slash commands:\n")
|
||||
for _, n := range t.names {
|
||||
e := t.entries[n]
|
||||
line := "- /" + n
|
||||
if e.ArgHint != "" {
|
||||
line += " " + e.ArgHint
|
||||
}
|
||||
if e.Description != "" {
|
||||
line += " — " + e.Description
|
||||
}
|
||||
b.WriteString(line + "\n")
|
||||
}
|
||||
return strings.TrimRight(b.String(), "\n")
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
package command
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func runSlash(t *testing.T, tl interface {
|
||||
Execute(context.Context, json.RawMessage) (string, error)
|
||||
}, args map[string]any) (string, error) {
|
||||
t.Helper()
|
||||
raw, _ := json.Marshal(args)
|
||||
return tl.Execute(context.Background(), raw)
|
||||
}
|
||||
|
||||
func sampleTool() interface {
|
||||
Execute(context.Context, json.RawMessage) (string, error)
|
||||
Name() string
|
||||
ReadOnly() bool
|
||||
Description() string
|
||||
} {
|
||||
return NewSlashCommandTool([]SlashEntry{
|
||||
{Name: "review", Description: "review the diff", ArgHint: "[path]",
|
||||
Render: func(a []string) string { return "REVIEW " + strings.Join(a, ",") }},
|
||||
// Leading slash on Name should be tolerated.
|
||||
{Name: "/git:commit", Description: "commit",
|
||||
Render: func(a []string) string { return "COMMIT" }},
|
||||
}).(interface {
|
||||
Execute(context.Context, json.RawMessage) (string, error)
|
||||
Name() string
|
||||
ReadOnly() bool
|
||||
Description() string
|
||||
})
|
||||
}
|
||||
|
||||
func TestSlashToolBasics(t *testing.T) {
|
||||
tl := sampleTool()
|
||||
if tl.Name() != "slash_command" {
|
||||
t.Errorf("name = %q", tl.Name())
|
||||
}
|
||||
if !tl.ReadOnly() {
|
||||
t.Error("slash_command should be read-only")
|
||||
}
|
||||
if !strings.Contains(tl.Description(), "review") || !strings.Contains(tl.Description(), "git:commit") {
|
||||
t.Errorf("description should list available commands: %q", tl.Description())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlashToolExpandsWithArgs(t *testing.T) {
|
||||
tl := sampleTool()
|
||||
out, err := runSlash(t, tl, map[string]any{"command": "review", "arguments": "a b"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(out, "REVIEW a,b") {
|
||||
t.Errorf("args not passed to Render: %q", out)
|
||||
}
|
||||
if !strings.Contains(out, "follow these instructions now") {
|
||||
t.Errorf("expansion should be framed as an instruction: %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlashToolLeadingSlashAndName(t *testing.T) {
|
||||
tl := sampleTool()
|
||||
// Caller passes a leading slash; entry was also registered with one.
|
||||
out, err := runSlash(t, tl, map[string]any{"command": "/git:commit"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(out, "COMMIT") {
|
||||
t.Errorf("leading-slash command not resolved: %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlashToolList(t *testing.T) {
|
||||
tl := sampleTool()
|
||||
for _, cmd := range []string{"", "list", "LIST"} {
|
||||
out, err := runSlash(t, tl, map[string]any{"command": cmd})
|
||||
if err != nil {
|
||||
t.Fatalf("list(%q): %v", cmd, err)
|
||||
}
|
||||
if !strings.Contains(out, "/review") || !strings.Contains(out, "[path]") || !strings.Contains(out, "/git:commit") {
|
||||
t.Errorf("list(%q) missing entries: %q", cmd, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlashToolUnknown(t *testing.T) {
|
||||
tl := sampleTool()
|
||||
_, err := runSlash(t, tl, map[string]any{"command": "nope"})
|
||||
if err == nil {
|
||||
t.Fatal("unknown command should error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "review") {
|
||||
t.Errorf("error should list available commands: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlashToolEmptyRegistry(t *testing.T) {
|
||||
tl := NewSlashCommandTool(nil)
|
||||
out, err := tl.Execute(context.Background(), json.RawMessage(`{}`))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(out, "No slash commands") {
|
||||
t.Errorf("empty list = %q", out)
|
||||
}
|
||||
if !strings.Contains(tl.Description(), "No slash commands") {
|
||||
t.Errorf("empty description = %q", tl.Description())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlashToolNameClashCommandWins(t *testing.T) {
|
||||
// Skills added first, command second — command should win the name.
|
||||
tl := NewSlashCommandTool([]SlashEntry{
|
||||
{Name: "dup", Render: func([]string) string { return "FROM-SKILL" }},
|
||||
{Name: "dup", Render: func([]string) string { return "FROM-COMMAND" }},
|
||||
})
|
||||
out, err := tl.Execute(context.Background(), json.RawMessage(`{"command":"dup"}`))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(out, "FROM-COMMAND") {
|
||||
t.Errorf("later entry should win the clash: %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginSlashToolShowsOnlyCanonicalQualifiedName(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
write(t, dir, "plan.md", "---\ndescription: Plan work\n---\nPlan $ARGUMENTS")
|
||||
plain, err := Load(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
owned, err := LoadRoots(Root{Path: dir, Plugin: "pwf"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
entries := func(cmds []Command) []SlashEntry {
|
||||
out := make([]SlashEntry, 0, len(cmds))
|
||||
for _, cmd := range cmds {
|
||||
if cmd.Hidden {
|
||||
continue
|
||||
}
|
||||
cmd := cmd
|
||||
out = append(out, SlashEntry{Name: cmd.Name, Description: cmd.Description, ArgHint: cmd.ArgHint, Render: func(args []string) string { return cmd.Render(args) }})
|
||||
}
|
||||
return out
|
||||
}
|
||||
plainTool := NewSlashCommandTool(entries(plain))
|
||||
ownedTool := NewSlashCommandTool(entries(owned))
|
||||
if !strings.Contains(plainTool.Description(), "Available: plan.") {
|
||||
t.Fatalf("plain tool description = %q", plainTool.Description())
|
||||
}
|
||||
if !strings.Contains(ownedTool.Description(), "Available: pwf:plan.") || strings.Contains(ownedTool.Description(), "Available: plan,") {
|
||||
t.Fatalf("plugin tool should list one canonical name, got %q", ownedTool.Description())
|
||||
}
|
||||
if string(plainTool.Schema()) != string(ownedTool.Schema()) {
|
||||
t.Fatal("plugin qualification must not change the slash_command schema")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package command
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestLoadFollowsSymlinks verifies command discovery follows symlinked
|
||||
// directories and symlinked .md files (filepath.WalkDir would skip both).
|
||||
func TestLoadFollowsSymlinks(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("symlink creation needs privilege on Windows")
|
||||
}
|
||||
cmdDir := t.TempDir()
|
||||
target := t.TempDir()
|
||||
|
||||
// Real command files living outside the commands dir.
|
||||
mustWrite(t, filepath.Join(target, "pkg", "deploy.md"), "---\ndescription: d\n---\nrun deploy")
|
||||
mustWrite(t, filepath.Join(target, "flat.md"), "---\ndescription: f\n---\nflat body")
|
||||
|
||||
// Symlink a directory and a flat file into the commands dir.
|
||||
if err := os.Symlink(filepath.Join(target, "pkg"), filepath.Join(cmdDir, "pkg")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink(filepath.Join(target, "flat.md"), filepath.Join(cmdDir, "linked.md")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
cmds, err := Load(cmdDir)
|
||||
if err != nil {
|
||||
t.Fatalf("load: %v", err)
|
||||
}
|
||||
names := map[string]bool{}
|
||||
for _, c := range cmds {
|
||||
names[c.Name] = true
|
||||
}
|
||||
if !names["pkg:deploy"] {
|
||||
t.Errorf("command under symlinked directory not discovered; got %v", names)
|
||||
}
|
||||
if !names["linked"] {
|
||||
t.Errorf("symlinked flat command file not discovered; got %v", names)
|
||||
}
|
||||
}
|
||||
|
||||
func mustWrite(t *testing.T, path, body string) {
|
||||
t.Helper()
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user