Add GSP init and AI usage tooling

This commit is contained in:
2026-05-06 19:40:55 +08:00
parent 67a1bf2600
commit 1478972e53
17 changed files with 870 additions and 8 deletions

View File

@@ -40,8 +40,8 @@ project/
严格字段规范参考:
```text
../language/gsp.schema.json
../language/gsp.manifest.schema.json
../specs/versions/0.1/gsp.schema.json
../specs/versions/0.1/gsp.manifest.schema.json
```
项目级入口配置:
@@ -165,7 +165,10 @@ manifest 可用于声明:
## 4. 第一版命令
```bash
gsp init
gsp validate
gsp ai-init
gsp version
gsp index
gsp trace <id>
gsp flatten <id>
@@ -180,6 +183,7 @@ gsp stage-check --stage implement
```bash
go build -o ../bin/gsp ./cmd/gsp
../bin/gsp version
../bin/gsp validate --root ../examples/lottery
../bin/gsp flatten page.lottery.main --root ../examples/lottery --depth -1 --out ../.gsp/flattened.json
../bin/gsp graph page.lottery.main --root ../examples/lottery --format mermaid --out ../.gsp/graph.mmd

View File

@@ -24,7 +24,18 @@ func run(args []string) error {
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 "validate":
return runValidate(args[1:])
case "index":
@@ -51,6 +62,9 @@ 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 validate [--root .] [--out report.json]
gsp index [--root .] [--out index.json]
gsp trace <id> [--root .] [--depth 3] [--out trace.json]
@@ -61,6 +75,88 @@ Usage:
`)
}
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")
}
@@ -236,6 +332,8 @@ func normalizeFlagArgs(args []string) []string {
"-budget": true, "--budget": true,
"-format": true, "--format": true,
"-stage": true, "--stage": true,
"-name": true, "--name": true,
"-entry": true, "--entry": true,
}
var flags []string
var positionals []string

View File

@@ -0,0 +1,154 @@
package gsp
import (
"fmt"
"path/filepath"
"strings"
)
type AIInitOptions struct {
Agents bool
Skill string
All bool
Force bool
}
func InitAIUsage(root string, options AIInitOptions) (InitResult, error) {
project, err := LoadProject(root)
if err != nil {
return InitResult{}, err
}
if project.Manifest == nil {
return InitResult{}, fmt.Errorf("gsp.manifest not found; run gsp init first")
}
result := InitResult{Root: filepath.ToSlash(project.Root)}
projectName := project.Manifest.Project
if projectName == "" {
projectName = filepath.Base(project.Root)
}
entry := "project.entry"
if len(project.Manifest.Entry) > 0 && project.Manifest.Entry[0] != "" {
entry = project.Manifest.Entry[0]
}
scan := "design"
if len(project.Manifest.Scan) > 0 && project.Manifest.Scan[0] != "" {
scan = project.Manifest.Scan[0]
}
files := []initFile{
{
Path: "README.md",
Data: projectReadme(projectName, scan, entry),
},
{
Path: "AI_USAGE.md",
Data: aiUsage(project.Manifest.GSPVersion, scan),
},
}
if options.All || options.Agents {
files = append(files, initFile{
Path: "AGENTS.md",
Data: agentsUsage(),
})
}
skill := strings.TrimSpace(options.Skill)
if options.All && skill == "" {
skill = "generic"
}
if skill != "" {
path, data, err := skillUsage(skill)
if err != nil {
return InitResult{}, err
}
files = append(files, initFile{Path: path, Data: data})
}
for _, file := range files {
path := filepath.Join(project.Root, filepath.FromSlash(file.Path))
if err := writeInitFile(project.Root, path, []byte(file.Data), options.Force, &result); err != nil {
return InitResult{}, err
}
}
return result, nil
}
type initFile struct {
Path string
Data string
}
func projectReadme(projectName, scan, entry string) string {
return fmt.Sprintf(`# %s
This is a GSP project.
- Manifest: `+"`gsp.manifest`"+`
- GSP source: `+"`%s/`"+`
- Entry GSP: `+"`%s`"+`
- AI rules: `+"`AI_USAGE.md`"+`
`, projectName, scan, entry)
}
func aiUsage(gspVersion, scan string) string {
if gspVersion == "" {
gspVersion = DefaultGSPVersion
}
return fmt.Sprintf(`# AI Usage
- Read `+"`gsp.manifest`"+` first.
- Use GSP version `+"`%s`"+`.
- Treat `+"`%s/`"+` as the default GSP source directory.
- `+"`.gsp`"+` files use YAML.
- Preserve `+"`id`"+`; do not rename it unless explicitly requested.
- `+"`id`"+` is the unique identity of a GSP unit.
- Use only fields valid for the declared GSP version.
- `+"`with`"+` means related design context.
- `+"`refines`"+` means single-source refinement.
- Empty `+"`context`"+` means placeholder.
- Do not invent missing referenced GSPs silently.
- Use `+"`gsp validate`"+` after editing GSP files.
- Use `+"`gsp index`"+` to locate GSP units.
- Use `+"`gsp trace <id>`"+` to inspect relations.
- Use `+"`gsp flatten <id>`"+` before implementation or task splitting.
- Use `+"`gsp pack <id>`"+` when a compact AI context is needed.
- Use `+"`gsp stage-check --stage <stage>`"+` before stage handoff.
`, gspVersion, scan)
}
func agentsUsage() string {
return `# Agent Instructions
Read ` + "`AI_USAGE.md`" + ` before working on this GSP project.
`
}
func skillUsage(kind string) (string, string, error) {
switch strings.ToLower(strings.TrimSpace(kind)) {
case "generic":
return "skills/gsp/SKILL.md", `# GSP
Use this skill when working in a GSP project.
Read ` + "`gsp.manifest`" + `, then ` + "`AI_USAGE.md`" + `.
Use ` + "`gsp validate`" + `, ` + "`gsp index`" + `, and ` + "`gsp flatten <id>`" + ` when handling GSP files.
`, nil
case "codex":
return ".codex/skills/gsp/SKILL.md", `# GSP
Use this skill when working in a GSP project.
Read ` + "`gsp.manifest`" + `, then ` + "`AI_USAGE.md`" + `.
Use GSP Toolkit commands before editing, handoff, implementation, or review.
`, nil
default:
return "", "", fmt.Errorf("unsupported skill %q; use generic or codex", kind)
}
}

View File

@@ -0,0 +1,179 @@
package gsp
import (
"fmt"
"os"
"path/filepath"
"strings"
)
type InitOptions struct {
Name string
Entry string
Force bool
GSPVersion string
ToolkitVersion string
DefaultOutputFolder string
}
type InitResult struct {
Root string `json:"root"`
Created []string `json:"created,omitempty"`
Updated []string `json:"updated,omitempty"`
}
func InitProject(root string, options InitOptions) (InitResult, error) {
absRoot, err := filepath.Abs(root)
if err != nil {
return InitResult{}, err
}
if err := os.MkdirAll(absRoot, 0755); err != nil {
return InitResult{}, err
}
name := strings.TrimSpace(options.Name)
if name == "" {
name = filepath.Base(absRoot)
}
entry := strings.TrimSpace(options.Entry)
if entry == "" {
entry = "project.entry"
}
gspVersion := strings.TrimSpace(options.GSPVersion)
if gspVersion == "" {
gspVersion = DefaultGSPVersion
}
toolkitVersion := strings.TrimSpace(options.ToolkitVersion)
if toolkitVersion == "" {
toolkitVersion = ToolkitVersion
}
output := strings.TrimSpace(options.DefaultOutputFolder)
if output == "" {
output = ".gsp"
}
result := InitResult{Root: filepath.ToSlash(absRoot)}
designDir := filepath.Join(absRoot, "design")
if err := os.MkdirAll(designDir, 0755); err != nil {
return InitResult{}, err
}
result.Created = append(result.Created, relPath(absRoot, designDir)+"/")
manifest := fmt.Sprintf(`gspVersion: %s
toolkitVersion: %s
project: %s
entry:
- %s
scan:
- design
stageRules:
design:
minResolution: L0
integrate:
minResolution: L2
implement:
minResolution: L3
bind:
minResolution: L4
release:
minResolution: L5
types:
- concept
- style
- feedback
- interaction
- mechanic
- page
output: %s
`, gspVersion, toolkitVersion, yamlScalar(name), yamlScalar(entry), yamlScalar(output))
entryFile := filepath.Join(designDir, safeFileName(entry)+".gsp")
entryContent := fmt.Sprintf(`id: %s
type: concept
resolution: L0
context: Project entry GSP.
`, yamlScalar(entry))
if err := writeInitFile(absRoot, filepath.Join(absRoot, "gsp.manifest"), []byte(manifest), options.Force, &result); err != nil {
return InitResult{}, err
}
if err := writeInitFile(absRoot, entryFile, []byte(entryContent), options.Force, &result); err != nil {
return InitResult{}, err
}
return result, nil
}
func writeInitFile(root, path string, data []byte, force bool, result *InitResult) error {
existed := false
if _, err := os.Stat(path); err == nil {
existed = true
if !force {
return fmt.Errorf("%s already exists; use --force to overwrite generated init files", relPath(root, path))
}
} else if err != nil && !os.IsNotExist(err) {
return err
}
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
return err
}
if err := os.WriteFile(path, data, 0644); err != nil {
return err
}
rel := relPath(root, path)
if existed {
result.Updated = append(result.Updated, rel)
return nil
}
result.Created = append(result.Created, rel)
return nil
}
func safeFileName(value string) string {
var builder strings.Builder
for _, r := range value {
switch {
case r >= 'a' && r <= 'z':
builder.WriteRune(r)
case r >= 'A' && r <= 'Z':
builder.WriteRune(r)
case r >= '0' && r <= '9':
builder.WriteRune(r)
case r == '.', r == '-', r == '_':
builder.WriteRune(r)
default:
builder.WriteRune('_')
}
}
name := strings.Trim(builder.String(), "._-")
if name == "" {
return "project.entry"
}
return name
}
func yamlScalar(value string) string {
value = strings.TrimSpace(value)
if value == "" {
return `""`
}
if isPlainYAMLScalar(value) {
return value
}
escaped := strings.ReplaceAll(value, `\`, `\\`)
escaped = strings.ReplaceAll(escaped, `"`, `\"`)
return `"` + escaped + `"`
}
func isPlainYAMLScalar(value string) bool {
for _, r := range value {
switch {
case r >= 'a' && r <= 'z':
case r >= 'A' && r <= 'Z':
case r >= '0' && r <= '9':
case r == '.', r == '-', r == '_':
default:
return false
}
}
return true
}

View File

@@ -19,6 +19,13 @@ func (p *Project) Validate(stage string) Report {
report.Notices = append(report.Notices, issue)
}
}
if p.Manifest != nil {
if p.Manifest.GSPVersion == "" {
report.addWarning("missing_gsp_version", "", p.Manifest.File, "manifest has no gspVersion")
} else if !SupportsGSPVersion(p.Manifest.GSPVersion) {
report.addError("unsupported_gsp_version", "", p.Manifest.File, fmt.Sprintf("GSP version %q is not supported by this toolkit", p.Manifest.GSPVersion))
}
}
for _, unit := range p.Units {
if unit.ID == "" {
report.addError("missing_id", "", unit.File, "GSP requires id")

View File

@@ -105,6 +105,125 @@ resolution: L2
}
}
func TestValidateUnsupportedGSPVersion(t *testing.T) {
root := t.TempDir()
design := filepath.Join(root, "design")
if err := os.MkdirAll(design, 0755); err != nil {
t.Fatal(err)
}
writeTestFile(t, root, "gsp.manifest", `gspVersion: 9.9
scan:
- design
`)
writeTestFile(t, design, "entry.gsp", `id: project.entry
`)
project, err := LoadProject(root)
if err != nil {
t.Fatal(err)
}
report := project.Validate("")
if report.OK {
t.Fatal("expected unsupported version failure")
}
if report.Errors[0].Code != "unsupported_gsp_version" {
t.Fatalf("expected unsupported_gsp_version, got %+v", report.Errors)
}
}
func TestInitProjectCreatesManifestAndEntry(t *testing.T) {
root := filepath.Join(t.TempDir(), "sample-project")
result, err := InitProject(root, InitOptions{
Name: "sample",
Entry: "page.sample.main",
GSPVersion: "0.1",
ToolkitVersion: "0.1.0",
})
if err != nil {
t.Fatal(err)
}
if len(result.Created) == 0 {
t.Fatal("expected created paths")
}
project, err := LoadProject(root)
if err != nil {
t.Fatal(err)
}
if project.Manifest == nil {
t.Fatal("expected manifest")
}
if project.Manifest.Entry[0] != "page.sample.main" {
t.Fatalf("manifest entry = %v", project.Manifest.Entry)
}
if _, ok := project.ByID["page.sample.main"]; !ok {
t.Fatal("expected initialized entry GSP")
}
report := project.Validate("")
if !report.OK {
t.Fatalf("expected valid initialized project, got %+v", report.Errors)
}
}
func TestInitProjectRefusesOverwrite(t *testing.T) {
root := t.TempDir()
if _, err := InitProject(root, InitOptions{}); err != nil {
t.Fatal(err)
}
if _, err := InitProject(root, InitOptions{}); err == nil {
t.Fatal("expected overwrite refusal")
}
}
func TestInitAIUsageCreatesCoreFiles(t *testing.T) {
root := t.TempDir()
if _, err := InitProject(root, InitOptions{
Name: "sample",
Entry: "page.sample.main",
GSPVersion: "0.1",
}); err != nil {
t.Fatal(err)
}
result, err := InitAIUsage(root, AIInitOptions{})
if err != nil {
t.Fatal(err)
}
if !sameStrings(result.Created, []string{"README.md", "AI_USAGE.md"}) {
t.Fatalf("created = %v", result.Created)
}
if _, err := os.Stat(filepath.Join(root, "README.md")); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(filepath.Join(root, "AI_USAGE.md")); err != nil {
t.Fatal(err)
}
}
func TestInitAIUsageOptionalAdapters(t *testing.T) {
root := t.TempDir()
if _, err := InitProject(root, InitOptions{}); err != nil {
t.Fatal(err)
}
if _, err := InitAIUsage(root, AIInitOptions{
Agents: true,
Skill: "generic",
}); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(filepath.Join(root, "AGENTS.md")); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(filepath.Join(root, "skills", "gsp", "SKILL.md")); err != nil {
t.Fatal(err)
}
}
func TestInitAIUsageRequiresManifest(t *testing.T) {
root := t.TempDir()
if _, err := InitAIUsage(root, AIInitOptions{}); err == nil {
t.Fatal("expected missing manifest error")
}
}
func writeTestFile(t *testing.T, root, name, content string) {
t.Helper()
path := filepath.Join(root, name)

View File

@@ -0,0 +1,18 @@
package gsp
const ToolkitVersion = "0.1.0"
const DefaultGSPVersion = "0.1"
var SupportedGSPVersions = []string{DefaultGSPVersion}
func SupportsGSPVersion(version string) bool {
if version == "" {
return true
}
for _, supported := range SupportedGSPVersions {
if version == supported {
return true
}
}
return false
}

View File

@@ -0,0 +1,38 @@
param(
[string]$InstallDir = "$HOME\.gsp\bin"
)
$ErrorActionPreference = "Stop"
$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
$ToolkitDir = Resolve-Path -LiteralPath (Join-Path $ScriptDir "..")
$TargetDir = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($InstallDir)
$TargetExe = Join-Path $TargetDir "gsp.exe"
New-Item -ItemType Directory -Force -Path $TargetDir | Out-Null
Push-Location $ToolkitDir
try {
go build -o $TargetExe .\cmd\gsp
}
finally {
Pop-Location
}
$userPath = [Environment]::GetEnvironmentVariable("Path", "User")
$parts = @()
if ($userPath) {
$parts = $userPath -split ";" | Where-Object { $_ -ne "" }
}
if ($parts -notcontains $TargetDir) {
$newPath = (($parts + $TargetDir) -join ";")
[Environment]::SetEnvironmentVariable("Path", $newPath, "User")
Write-Host "Installed gsp to $TargetExe"
Write-Host "Added $TargetDir to the user PATH. Open a new terminal before running gsp."
}
else {
Write-Host "Installed gsp to $TargetExe"
}
& $TargetExe version

View File

@@ -0,0 +1,27 @@
#!/usr/bin/env sh
set -eu
SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
TOOLKIT_DIR=$(CDPATH= cd -- "$SCRIPT_DIR/.." && pwd)
INSTALL_DIR=${GSP_INSTALL_DIR:-"$HOME/.gsp/bin"}
TARGET="$INSTALL_DIR/gsp"
mkdir -p "$INSTALL_DIR"
(
cd "$TOOLKIT_DIR"
go build -o "$TARGET" ./cmd/gsp
)
chmod +x "$TARGET"
echo "Installed gsp to $TARGET"
case ":$PATH:" in
*":$INSTALL_DIR:"*) ;;
*)
echo "Add this to your shell profile if gsp is not found:"
echo "export PATH=\"$INSTALL_DIR:\$PATH\""
;;
esac
"$TARGET" version