598 lines
16 KiB
Go
598 lines
16 KiB
Go
package main
|
|
|
|
import (
|
|
"encoding/json"
|
|
"flag"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"gsp.toolkit/internal/gsp"
|
|
)
|
|
|
|
func main() {
|
|
if err := run(os.Args[1:]); err != nil {
|
|
fmt.Fprintln(os.Stderr, "error:", err)
|
|
os.Exit(1)
|
|
}
|
|
}
|
|
|
|
func run(args []string) error {
|
|
if len(args) == 0 {
|
|
printHelp()
|
|
return nil
|
|
}
|
|
|
|
if len(args) == 1 && (args[0] == "-v" || args[0] == "--version") {
|
|
printVersion(false)
|
|
return nil
|
|
}
|
|
|
|
switch args[0] {
|
|
case "init":
|
|
return runInit(args[1:])
|
|
case "ai-init":
|
|
return runAIInit(args[1:])
|
|
case "version":
|
|
return runVersion(args[1:])
|
|
case "completion":
|
|
return runCompletion(args[1:])
|
|
case "validate":
|
|
return runValidate(args[1:])
|
|
case "index":
|
|
return runIndex(args[1:])
|
|
case "trace":
|
|
return runTrace(args[1:])
|
|
case "flatten":
|
|
return runFlatten(args[1:])
|
|
case "pack":
|
|
return runPack(args[1:])
|
|
case "links":
|
|
return runLinks(args[1:])
|
|
case "impact":
|
|
return runImpact(args[1:])
|
|
case "message":
|
|
return runMessage(args[1:])
|
|
case "graph":
|
|
return runGraph(args[1:])
|
|
case "stage-check":
|
|
return runStageCheck(args[1:])
|
|
case "help", "-h", "--help":
|
|
printHelp()
|
|
return nil
|
|
default:
|
|
return fmt.Errorf("unknown command %q", args[0])
|
|
}
|
|
}
|
|
|
|
func printHelp() {
|
|
fmt.Print(`GSP Toolkit
|
|
|
|
Usage:
|
|
gsp init [path] [--name project-name] [--entry project.entry] [--force]
|
|
gsp ai-init [--root .] [--agents] [--skill generic|codex] [--all] [--force]
|
|
gsp version [--json]
|
|
gsp completion powershell|bash|zsh|fish
|
|
gsp completion install powershell
|
|
gsp validate [--root .] [--out report.json]
|
|
gsp index [--root .] [--out index.json]
|
|
gsp trace <id> [--root .] [--depth 3] [--out trace.json]
|
|
gsp flatten <id> [--root .] [--depth 3] [--include-type a,b] [--exclude-type a,b] [--out flattened.json]
|
|
gsp pack <id> [--root .] [--for implement] [--stage implement] [--depth 3] [--budget 12000] [--format json|md|canvas] [--out context-pack.json]
|
|
gsp links <id> [--root .] [--depth -1] [--format json|md] [--out links.json]
|
|
gsp impact <id> [--root .] [--depth -1] [--format json|md|canvas] [--out impact.json]
|
|
gsp message validate <file> [--root .] [--out message-report.json]
|
|
gsp graph [id] [--root .] [--depth 3] [--format json|mermaid|md|canvas] [--out graph.json]
|
|
gsp stage-check --stage implement [--root .] [--out stage-report.json]
|
|
`)
|
|
}
|
|
|
|
func runLinks(args []string) error {
|
|
fs := flag.NewFlagSet("links", flag.ContinueOnError)
|
|
root := commonRoot(fs)
|
|
out := commonOut(fs)
|
|
depth := fs.Int("depth", -1, "maximum relation depth; -1 means unlimited")
|
|
format := fs.String("format", "json", "json or md")
|
|
if err := fs.Parse(normalizeFlagArgs(args)); err != nil {
|
|
return err
|
|
}
|
|
if fs.NArg() != 1 {
|
|
return fmt.Errorf("links requires one GSP id")
|
|
}
|
|
project, err := gsp.LoadProject(*root)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
result := project.Links(fs.Arg(0), *depth)
|
|
switch *format {
|
|
case "json":
|
|
return writeJSON(*out, result)
|
|
case "md":
|
|
return writeText(*out, result.Markdown())
|
|
default:
|
|
return fmt.Errorf("unsupported links format %q", *format)
|
|
}
|
|
}
|
|
|
|
func runCompletion(args []string) error {
|
|
if len(args) == 0 {
|
|
return fmt.Errorf("completion requires powershell, bash, zsh, fish, or install powershell")
|
|
}
|
|
if args[0] == "install" {
|
|
if len(args) != 2 {
|
|
return fmt.Errorf("completion install requires one shell")
|
|
}
|
|
if args[1] != "powershell" {
|
|
return fmt.Errorf("completion install currently supports powershell")
|
|
}
|
|
return installPowerShellCompletion()
|
|
}
|
|
if len(args) != 1 {
|
|
return fmt.Errorf("completion accepts one shell")
|
|
}
|
|
switch args[0] {
|
|
case "powershell":
|
|
return writeText("", powerShellCompletionScript())
|
|
case "bash":
|
|
return writeText("", bashCompletionScript())
|
|
case "zsh":
|
|
return writeText("", zshCompletionScript())
|
|
case "fish":
|
|
return writeText("", fishCompletionScript())
|
|
default:
|
|
return fmt.Errorf("unsupported completion shell %q", args[0])
|
|
}
|
|
}
|
|
|
|
func installPowerShellCompletion() error {
|
|
home, err := os.UserHomeDir()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
completionDir := filepath.Join(home, ".gsp", "completion")
|
|
completionFile := filepath.Join(completionDir, "gsp.ps1")
|
|
if err := os.MkdirAll(completionDir, 0755); err != nil {
|
|
return err
|
|
}
|
|
if err := os.WriteFile(completionFile, []byte(powerShellCompletionScript()), 0644); err != nil {
|
|
return err
|
|
}
|
|
|
|
profilePath := filepath.Join(home, "Documents", "PowerShell", "Microsoft.PowerShell_profile.ps1")
|
|
if err := os.MkdirAll(filepath.Dir(profilePath), 0755); err != nil {
|
|
return err
|
|
}
|
|
sourceLine := fmt.Sprintf(". '%s'", strings.ReplaceAll(completionFile, "'", "''"))
|
|
existing, err := os.ReadFile(profilePath)
|
|
if err != nil && !os.IsNotExist(err) {
|
|
return err
|
|
}
|
|
if !strings.Contains(string(existing), sourceLine) {
|
|
var builder strings.Builder
|
|
if len(existing) > 0 {
|
|
builder.Write(existing)
|
|
if !strings.HasSuffix(string(existing), "\n") {
|
|
builder.WriteString("\n")
|
|
}
|
|
}
|
|
builder.WriteString(sourceLine)
|
|
builder.WriteString("\n")
|
|
if err := os.WriteFile(profilePath, []byte(builder.String()), 0644); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
fmt.Printf("Installed PowerShell completion to %s\n", completionFile)
|
|
fmt.Printf("Updated PowerShell profile %s\n", profilePath)
|
|
fmt.Println("Open a new PowerShell terminal or run the profile file to enable completion.")
|
|
return nil
|
|
}
|
|
|
|
func runAIInit(args []string) error {
|
|
fs := flag.NewFlagSet("ai-init", flag.ContinueOnError)
|
|
root := commonRoot(fs)
|
|
agents := fs.Bool("agents", false, "generate AGENTS.md")
|
|
skill := fs.String("skill", "", "generate a skill adapter: generic or codex")
|
|
all := fs.Bool("all", false, "generate README.md, AI_USAGE.md, AGENTS.md, and generic skill")
|
|
force := fs.Bool("force", false, "overwrite generated AI files when they already exist")
|
|
if err := fs.Parse(args); err != nil {
|
|
return err
|
|
}
|
|
if fs.NArg() != 0 {
|
|
return fmt.Errorf("ai-init does not accept positional arguments; use --root")
|
|
}
|
|
result, err := gsp.InitAIUsage(*root, gsp.AIInitOptions{
|
|
Agents: *agents,
|
|
Skill: *skill,
|
|
All: *all,
|
|
Force: *force,
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return writeJSON("", result)
|
|
}
|
|
|
|
func runInit(args []string) error {
|
|
fs := flag.NewFlagSet("init", flag.ContinueOnError)
|
|
name := fs.String("name", "", "project name")
|
|
entry := fs.String("entry", "project.entry", "entry GSP id")
|
|
force := fs.Bool("force", false, "overwrite generated init files when they already exist")
|
|
if err := fs.Parse(normalizeFlagArgs(args)); err != nil {
|
|
return err
|
|
}
|
|
if fs.NArg() > 1 {
|
|
return fmt.Errorf("init accepts zero or one path")
|
|
}
|
|
root := "."
|
|
if fs.NArg() == 1 {
|
|
root = fs.Arg(0)
|
|
}
|
|
result, err := gsp.InitProject(root, gsp.InitOptions{
|
|
Name: *name,
|
|
Entry: *entry,
|
|
Force: *force,
|
|
GSPVersion: gsp.DefaultGSPVersion,
|
|
ToolkitVersion: gsp.ToolkitVersion,
|
|
DefaultOutputFolder: ".gsp",
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return writeJSON("", result)
|
|
}
|
|
|
|
func runVersion(args []string) error {
|
|
fs := flag.NewFlagSet("version", flag.ContinueOnError)
|
|
asJSON := fs.Bool("json", false, "print version as JSON")
|
|
if err := fs.Parse(args); err != nil {
|
|
return err
|
|
}
|
|
printVersion(*asJSON)
|
|
return nil
|
|
}
|
|
|
|
func printVersion(asJSON bool) {
|
|
value := struct {
|
|
ToolkitVersion string `json:"toolkitVersion"`
|
|
DefaultGSPVersion string `json:"defaultGspVersion"`
|
|
SupportedGSPVersion []string `json:"supportedGspVersions"`
|
|
}{
|
|
ToolkitVersion: gsp.ToolkitVersion,
|
|
DefaultGSPVersion: gsp.DefaultGSPVersion,
|
|
SupportedGSPVersion: gsp.SupportedGSPVersions,
|
|
}
|
|
if asJSON {
|
|
data, _ := json.MarshalIndent(value, "", " ")
|
|
fmt.Println(string(data))
|
|
return
|
|
}
|
|
fmt.Printf("GSP Toolkit %s\nDefault GSP %s\nSupported GSP %s\n", gsp.ToolkitVersion, gsp.DefaultGSPVersion, strings.Join(gsp.SupportedGSPVersions, ", "))
|
|
}
|
|
|
|
func commonRoot(fs *flag.FlagSet) *string {
|
|
return fs.String("root", ".", "project root to scan")
|
|
}
|
|
|
|
func commonOut(fs *flag.FlagSet) *string {
|
|
return fs.String("out", "", "optional output file")
|
|
}
|
|
|
|
func runValidate(args []string) error {
|
|
fs := flag.NewFlagSet("validate", flag.ContinueOnError)
|
|
root := commonRoot(fs)
|
|
out := commonOut(fs)
|
|
if err := fs.Parse(args); err != nil {
|
|
return err
|
|
}
|
|
project, err := gsp.LoadProject(*root)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
report := project.Validate("")
|
|
return writeReport(*out, report)
|
|
}
|
|
|
|
func runIndex(args []string) error {
|
|
fs := flag.NewFlagSet("index", flag.ContinueOnError)
|
|
root := commonRoot(fs)
|
|
out := commonOut(fs)
|
|
if err := fs.Parse(args); err != nil {
|
|
return err
|
|
}
|
|
project, err := gsp.LoadProject(*root)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return writeJSON(*out, project.Index())
|
|
}
|
|
|
|
func runTrace(args []string) error {
|
|
fs := flag.NewFlagSet("trace", flag.ContinueOnError)
|
|
root := commonRoot(fs)
|
|
out := commonOut(fs)
|
|
depth := fs.Int("depth", 3, "maximum relation depth; -1 means unlimited")
|
|
if err := fs.Parse(normalizeFlagArgs(args)); err != nil {
|
|
return err
|
|
}
|
|
if fs.NArg() != 1 {
|
|
return fmt.Errorf("trace requires one GSP id")
|
|
}
|
|
project, err := gsp.LoadProject(*root)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
result := project.Trace(fs.Arg(0), *depth, gsp.Filter{})
|
|
return writeJSON(*out, result)
|
|
}
|
|
|
|
func runFlatten(args []string) error {
|
|
fs := flag.NewFlagSet("flatten", flag.ContinueOnError)
|
|
root := commonRoot(fs)
|
|
out := commonOut(fs)
|
|
depth := fs.Int("depth", 3, "maximum relation depth; -1 means unlimited")
|
|
includeType := fs.String("include-type", "", "comma-separated type allow-list")
|
|
excludeType := fs.String("exclude-type", "", "comma-separated type deny-list")
|
|
if err := fs.Parse(normalizeFlagArgs(args)); err != nil {
|
|
return err
|
|
}
|
|
if fs.NArg() != 1 {
|
|
return fmt.Errorf("flatten requires one GSP id")
|
|
}
|
|
project, err := gsp.LoadProject(*root)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
result := project.Flatten(fs.Arg(0), *depth, gsp.Filter{
|
|
IncludeTypes: splitCSV(*includeType),
|
|
ExcludeTypes: splitCSV(*excludeType),
|
|
})
|
|
return writeJSON(*out, result)
|
|
}
|
|
|
|
func runPack(args []string) error {
|
|
fs := flag.NewFlagSet("pack", flag.ContinueOnError)
|
|
root := commonRoot(fs)
|
|
out := commonOut(fs)
|
|
depth := fs.Int("depth", 3, "maximum relation depth; -1 means unlimited")
|
|
budget := fs.Int("budget", 0, "approximate JSON character budget; 0 means unlimited")
|
|
intent := fs.String("for", "", "context purpose: design, implement, review, test, acceptance, handoff, or inspect")
|
|
stage := fs.String("stage", "", "optional project stage")
|
|
format := fs.String("format", "json", "json, md, or canvas")
|
|
includeType := fs.String("include-type", "", "comma-separated type allow-list")
|
|
excludeType := fs.String("exclude-type", "", "comma-separated type deny-list")
|
|
if err := fs.Parse(normalizeFlagArgs(args)); err != nil {
|
|
return err
|
|
}
|
|
if fs.NArg() != 1 {
|
|
return fmt.Errorf("pack requires one GSP id")
|
|
}
|
|
project, err := gsp.LoadProject(*root)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
result := project.PackFor(fs.Arg(0), *intent, *stage, *depth, *budget, gsp.Filter{
|
|
IncludeTypes: splitCSV(*includeType),
|
|
ExcludeTypes: splitCSV(*excludeType),
|
|
})
|
|
switch *format {
|
|
case "json":
|
|
return writeJSON(*out, result)
|
|
case "md":
|
|
return writeText(*out, result.Markdown())
|
|
case "canvas":
|
|
data, err := project.Graph(fs.Arg(0), *depth).Canvas()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return writeBytes(*out, data)
|
|
default:
|
|
return fmt.Errorf("unsupported pack format %q", *format)
|
|
}
|
|
}
|
|
|
|
func runImpact(args []string) error {
|
|
fs := flag.NewFlagSet("impact", flag.ContinueOnError)
|
|
root := commonRoot(fs)
|
|
out := commonOut(fs)
|
|
depth := fs.Int("depth", -1, "maximum reverse relation depth; -1 means unlimited")
|
|
format := fs.String("format", "json", "json, md, or canvas")
|
|
if err := fs.Parse(normalizeFlagArgs(args)); err != nil {
|
|
return err
|
|
}
|
|
if fs.NArg() != 1 {
|
|
return fmt.Errorf("impact requires one GSP id")
|
|
}
|
|
project, err := gsp.LoadProject(*root)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
result := project.Impact(fs.Arg(0), *depth)
|
|
switch *format {
|
|
case "json":
|
|
return writeJSON(*out, result)
|
|
case "md":
|
|
return writeText(*out, result.Markdown())
|
|
case "canvas":
|
|
data, err := project.ImpactGraph(result).Canvas()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return writeBytes(*out, data)
|
|
default:
|
|
return fmt.Errorf("unsupported impact format %q", *format)
|
|
}
|
|
}
|
|
|
|
func runMessage(args []string) error {
|
|
if len(args) == 0 {
|
|
return fmt.Errorf("message requires validate")
|
|
}
|
|
switch args[0] {
|
|
case "validate":
|
|
return runMessageValidate(args[1:])
|
|
default:
|
|
return fmt.Errorf("unknown message command %q", args[0])
|
|
}
|
|
}
|
|
|
|
func runMessageValidate(args []string) error {
|
|
fs := flag.NewFlagSet("message validate", flag.ContinueOnError)
|
|
root := commonRoot(fs)
|
|
out := commonOut(fs)
|
|
if err := fs.Parse(normalizeFlagArgs(args)); err != nil {
|
|
return err
|
|
}
|
|
if fs.NArg() != 1 {
|
|
return fmt.Errorf("message validate requires one file")
|
|
}
|
|
project, err := gsp.LoadProject(*root)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
message, err := gsp.ReadMessage(project.Root, fs.Arg(0))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
report := project.ValidateMessage(message)
|
|
return writeReport(*out, report)
|
|
}
|
|
|
|
func runGraph(args []string) error {
|
|
fs := flag.NewFlagSet("graph", flag.ContinueOnError)
|
|
root := commonRoot(fs)
|
|
out := commonOut(fs)
|
|
depth := fs.Int("depth", 3, "maximum relation depth when id is provided; -1 means unlimited")
|
|
format := fs.String("format", "json", "json, mermaid, md, or canvas")
|
|
if err := fs.Parse(normalizeFlagArgs(args)); err != nil {
|
|
return err
|
|
}
|
|
id := ""
|
|
if fs.NArg() > 1 {
|
|
return fmt.Errorf("graph accepts zero or one GSP id")
|
|
}
|
|
if fs.NArg() == 1 {
|
|
id = fs.Arg(0)
|
|
}
|
|
project, err := gsp.LoadProject(*root)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
graph := project.Graph(id, *depth)
|
|
switch *format {
|
|
case "mermaid":
|
|
return writeText(*out, graph.Mermaid())
|
|
case "md":
|
|
return writeText(*out, graph.Markdown())
|
|
case "canvas":
|
|
data, err := graph.Canvas()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return writeBytes(*out, data)
|
|
case "json":
|
|
return writeJSON(*out, graph)
|
|
default:
|
|
return fmt.Errorf("unsupported graph format %q", *format)
|
|
}
|
|
}
|
|
|
|
func runStageCheck(args []string) error {
|
|
fs := flag.NewFlagSet("stage-check", flag.ContinueOnError)
|
|
root := commonRoot(fs)
|
|
out := commonOut(fs)
|
|
stage := fs.String("stage", "", "design, integrate, implement, bind, or release")
|
|
if err := fs.Parse(args); err != nil {
|
|
return err
|
|
}
|
|
if *stage == "" {
|
|
return fmt.Errorf("stage-check requires --stage")
|
|
}
|
|
project, err := gsp.LoadProject(*root)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
report := project.StageCheck(*stage)
|
|
return writeReport(*out, report)
|
|
}
|
|
|
|
func splitCSV(value string) map[string]bool {
|
|
result := map[string]bool{}
|
|
for _, part := range strings.Split(value, ",") {
|
|
part = strings.TrimSpace(part)
|
|
if part != "" {
|
|
result[part] = true
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
func normalizeFlagArgs(args []string) []string {
|
|
valueFlags := map[string]bool{
|
|
"-root": true, "--root": true,
|
|
"-out": true, "--out": true,
|
|
"-depth": true, "--depth": true,
|
|
"-include-type": true, "--include-type": true,
|
|
"-exclude-type": true, "--exclude-type": true,
|
|
"-budget": true, "--budget": true,
|
|
"-format": true, "--format": true,
|
|
"-stage": true, "--stage": true,
|
|
"-for": true, "--for": true,
|
|
"-name": true, "--name": true,
|
|
"-entry": true, "--entry": true,
|
|
}
|
|
var flags []string
|
|
var positionals []string
|
|
for i := 0; i < len(args); i++ {
|
|
arg := args[i]
|
|
if !strings.HasPrefix(arg, "-") || arg == "-" {
|
|
positionals = append(positionals, arg)
|
|
continue
|
|
}
|
|
flags = append(flags, arg)
|
|
name := arg
|
|
if index := strings.Index(arg, "="); index >= 0 {
|
|
name = arg[:index]
|
|
}
|
|
if valueFlags[name] && !strings.Contains(arg, "=") && i+1 < len(args) {
|
|
flags = append(flags, args[i+1])
|
|
i++
|
|
}
|
|
}
|
|
return append(flags, positionals...)
|
|
}
|
|
|
|
func writeJSON(path string, value any) error {
|
|
data, err := json.MarshalIndent(value, "", " ")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
data = append(data, '\n')
|
|
return writeBytes(path, data)
|
|
}
|
|
|
|
func writeReport(path string, report gsp.Report) error {
|
|
if err := writeJSON(path, report); err != nil {
|
|
return err
|
|
}
|
|
if !report.OK {
|
|
return fmt.Errorf("GSP check failed")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func writeText(path string, value string) error {
|
|
return writeBytes(path, []byte(value))
|
|
}
|
|
|
|
func writeBytes(path string, data []byte) error {
|
|
if path == "" {
|
|
_, err := os.Stdout.Write(data)
|
|
return err
|
|
}
|
|
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
|
|
return err
|
|
}
|
|
return os.WriteFile(path, data, 0644)
|
|
}
|