diff --git a/README.md b/README.md index ada1aca..89cec18 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ GSP 是一个通用游戏规格协议与配套工具链,用于在人类、AI ```text GSP/ - language/ GSP 语言定义与 schema + specs/ GSP 版本化协议规范与 schema toolkit/ GSP Toolkit Go CLI 源码 examples/ 示例 GSP 工程 ``` @@ -68,9 +68,12 @@ bin/gsp.exe | 文档 | 作用 | |---|---| -| `language/README.md` | GSP 语言说明。 | -| `language/gsp.schema.json` | GSP 核心字段 schema。 | -| `language/gsp.manifest.schema.json` | GSP 工程 manifest schema。 | +| `specs/README.md` | GSP 规范版本入口。 | +| `specs/versions/0.1/README.md` | GSP 0.1 语言说明。 | +| `specs/versions/0.1/gsp.schema.json` | GSP 0.1 核心字段 schema。 | +| `specs/versions/0.1/gsp.manifest.schema.json` | GSP 0.1 工程 manifest schema。 | +| `specs/versions/0.1/commands.md` | GSP 0.1 命令规范。 | +| `specs/versions/0.1/ai-usage.md` | GSP 0.1 AI 使用规则。 | | `toolkit/README.md` | GSP Toolkit 命令与实现说明。 | ## GSP 工程结构 @@ -85,3 +88,85 @@ project/ ``` Toolkit 默认从工程根目录的 `design/` 目录扫描 `.gsp` 文件。`gsp.manifest` 可声明语言版本、Toolkit 版本、入口 GSP、扫描范围、阶段规则和 type 列表。 + +## AI 使用入口 + +给当前 GSP 工程生成 AI 友好入口: + +```bash +gsp ai-init +``` + +默认生成: + +```text +README.md +AI_USAGE.md +``` + +可选生成代理说明或 skill 入口: + +```bash +gsp ai-init --agents +gsp ai-init --skill generic +gsp ai-init --skill codex +gsp ai-init --all +``` + +## 安装为命令 + +Windows: + +```powershell +powershell -ExecutionPolicy Bypass -File .\toolkit\scripts\install.ps1 +gsp version +``` + +macOS: + +```bash +sh ./toolkit/scripts/install.sh +gsp version +``` + +安装后可以在任意目录使用: + +```bash +gsp init +gsp ai-init +gsp version +gsp validate +gsp index +gsp flatten +gsp graph +gsp stage-check --stage implement +``` + +## 初始化 GSP 工程 + +在当前目录初始化: + +```bash +gsp init +``` + +在指定目录初始化: + +```bash +gsp init path/to/project --name project-name --entry project.entry +``` + +初始化结果: + +```text +project/ + gsp.manifest + design/ + project.entry.gsp +``` + +已有 `gsp.manifest` 或入口 `.gsp` 时,`gsp init` 默认不会覆盖。需要重建初始化文件时使用: + +```bash +gsp init path/to/project --force +``` diff --git a/examples/lottery/gsp.manifest b/examples/lottery/gsp.manifest index 5205a52..314a09b 100644 --- a/examples/lottery/gsp.manifest +++ b/examples/lottery/gsp.manifest @@ -1,5 +1,5 @@ gspVersion: 0.1 -toolkitVersion: 0.1 +toolkitVersion: 0.1.0 project: lottery-example entry: diff --git a/specs/README.md b/specs/README.md new file mode 100644 index 0000000..02ed5a9 --- /dev/null +++ b/specs/README.md @@ -0,0 +1,7 @@ +# GSP Specs + +GSP specifications are versioned under `versions/`. + +Current version: `0.1`. + +Each toolkit release declares its default GSP version and supported GSP versions. A GSP project declares its protocol version in `gsp.manifest`. diff --git a/language/README.md b/specs/versions/0.1/README.md similarity index 95% rename from language/README.md rename to specs/versions/0.1/README.md index 96b740f..b28205e 100644 --- a/language/README.md +++ b/specs/versions/0.1/README.md @@ -2,7 +2,7 @@ GSP = Game Specification Protocol。 -GSP 是 AI 游戏制作引擎的底层游戏规格协议模块,用于在人类、AI、工具、运行时、实现模块、测试与验收之间传递游戏设计信息。 +GSP 是通用游戏规格协议,用于在人类、AI、工具、运行时、实现模块、测试与验收之间传递游戏设计信息。 README 面向准备使用 GSP 语言的人类和 AI。它说明 GSP 的用途、边界和基本写法。严格字段规范以 `gsp.schema.json` 为准。 @@ -13,6 +13,8 @@ README 面向准备使用 GSP 语言的人类和 AI。它说明 GSP 的用途、 | `README.md` | GSP 语言使用前说明。给人类和 AI 阅读。 | | `gsp.schema.json` | GSP 第一版核心字段规范。使用 JSON Schema 表达,便于 AI、工具、编译器和实现模块识别。 | | `gsp.manifest.schema.json` | GSP 工程 manifest 字段规范。 | +| `commands.md` | GSP Toolkit 命令规范。 | +| `ai-usage.md` | GSP 项目 AI 使用规则。 | ## GSP 是什么 diff --git a/specs/versions/0.1/ai-usage.md b/specs/versions/0.1/ai-usage.md new file mode 100644 index 0000000..84ceb1d --- /dev/null +++ b/specs/versions/0.1/ai-usage.md @@ -0,0 +1,18 @@ +# AI Usage 0.1 + +- Read `gsp.manifest` first. +- Treat `design/` 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 ` to inspect relations. +- Use `gsp flatten ` before implementation or task splitting. +- Use `gsp pack ` when a compact AI context is needed. +- Use `gsp stage-check --stage ` before stage handoff. diff --git a/specs/versions/0.1/commands.md b/specs/versions/0.1/commands.md new file mode 100644 index 0000000..969e1e9 --- /dev/null +++ b/specs/versions/0.1/commands.md @@ -0,0 +1,106 @@ +# GSP Commands 0.1 + +## init + +Create a minimal GSP project. + +```bash +gsp init [path] [--name project-name] [--entry project.entry] [--force] +``` + +Generated structure: + +```text +project/ + gsp.manifest + design/ + project.entry.gsp +``` + +## ai-init + +Create AI-facing usage files for a GSP project. + +```bash +gsp ai-init [--root .] [--agents] [--skill generic|codex] [--all] [--force] +``` + +Default output: + +```text +README.md +AI_USAGE.md +``` + +Optional output: + +```text +AGENTS.md +skills/gsp/SKILL.md +.codex/skills/gsp/SKILL.md +``` + +## version + +Print toolkit and protocol versions. + +```bash +gsp version +gsp version --json +``` + +## validate + +Validate GSP files and references. + +```bash +gsp validate [--root .] [--out report.json] +``` + +## index + +Build a stable GSP index. + +```bash +gsp index [--root .] [--out index.json] +``` + +## trace + +Inspect relation chains from one GSP id. + +```bash +gsp trace [--root .] [--depth 3] [--out trace.json] +``` + +## flatten + +Expand one GSP id into a flat context. + +```bash +gsp flatten [--root .] [--depth 3] [--include-type a,b] [--exclude-type a,b] [--out flattened.json] +``` + +## pack + +Create a compact AI context pack. + +```bash +gsp pack [--root .] [--depth 3] [--budget 12000] [--out context-pack.json] +``` + +## graph + +Generate a relation graph. + +```bash +gsp graph [id] [--root .] [--depth 3] [--format json|mermaid] [--out graph.json] +``` + +## stage-check + +Check minimum resolution for a stage. + +```bash +gsp stage-check --stage implement [--root .] [--out stage-report.json] +``` diff --git a/language/gsp.manifest.schema.json b/specs/versions/0.1/gsp.manifest.schema.json similarity index 100% rename from language/gsp.manifest.schema.json rename to specs/versions/0.1/gsp.manifest.schema.json diff --git a/language/gsp.schema.json b/specs/versions/0.1/gsp.schema.json similarity index 100% rename from language/gsp.schema.json rename to specs/versions/0.1/gsp.schema.json diff --git a/toolkit/README.md b/toolkit/README.md index d0d64a6..a8cdc15 100644 --- a/toolkit/README.md +++ b/toolkit/README.md @@ -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 gsp flatten @@ -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 diff --git a/toolkit/cmd/gsp/main.go b/toolkit/cmd/gsp/main.go index 5758ae3..44a8a4e 100644 --- a/toolkit/cmd/gsp/main.go +++ b/toolkit/cmd/gsp/main.go @@ -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 [--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 diff --git a/toolkit/internal/gsp/ai_init.go b/toolkit/internal/gsp/ai_init.go new file mode 100644 index 0000000..70ab322 --- /dev/null +++ b/toolkit/internal/gsp/ai_init.go @@ -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 `"+` to inspect relations. +- Use `+"`gsp flatten `"+` before implementation or task splitting. +- Use `+"`gsp pack `"+` when a compact AI context is needed. +- Use `+"`gsp stage-check --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 `" + ` 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) + } +} diff --git a/toolkit/internal/gsp/init.go b/toolkit/internal/gsp/init.go new file mode 100644 index 0000000..c3497df --- /dev/null +++ b/toolkit/internal/gsp/init.go @@ -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 +} diff --git a/toolkit/internal/gsp/project.go b/toolkit/internal/gsp/project.go index 9a72a96..d015a82 100644 --- a/toolkit/internal/gsp/project.go +++ b/toolkit/internal/gsp/project.go @@ -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") diff --git a/toolkit/internal/gsp/project_test.go b/toolkit/internal/gsp/project_test.go index 8f03e9f..16e9d7a 100644 --- a/toolkit/internal/gsp/project_test.go +++ b/toolkit/internal/gsp/project_test.go @@ -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) diff --git a/toolkit/internal/gsp/version.go b/toolkit/internal/gsp/version.go new file mode 100644 index 0000000..e4d3481 --- /dev/null +++ b/toolkit/internal/gsp/version.go @@ -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 +} diff --git a/toolkit/scripts/install.ps1 b/toolkit/scripts/install.ps1 new file mode 100644 index 0000000..2dde215 --- /dev/null +++ b/toolkit/scripts/install.ps1 @@ -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 diff --git a/toolkit/scripts/install.sh b/toolkit/scripts/install.sh new file mode 100644 index 0000000..15ba8e4 --- /dev/null +++ b/toolkit/scripts/install.sh @@ -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