|
| 1 | +package cast |
| 2 | + |
| 3 | +import ( |
| 4 | + "encoding/json" |
| 5 | + "fmt" |
| 6 | + "os" |
| 7 | + "os/exec" |
| 8 | + "path/filepath" |
| 9 | + "sort" |
| 10 | + "strings" |
| 11 | +) |
| 12 | + |
| 13 | +// PressResult describes how CLI generation was carried out. |
| 14 | +type PressResult struct { |
| 15 | + Mode string `json:"mode"` // printing-press | scaffold |
| 16 | + Spec string `json:"spec"` |
| 17 | + OutDir string `json:"out_dir"` |
| 18 | + Commands []string `json:"commands,omitempty"` // shell commands actually run |
| 19 | + Operations []string `json:"operations,omitempty"` // operation ids in a scaffolded CLI |
| 20 | + Notes []string `json:"notes"` |
| 21 | +} |
| 22 | + |
| 23 | +// PressAvailable reports whether cli-printing-press is installed. The foundry |
| 24 | +// does not reimplement the press: it casts type and hands it over. The built-in |
| 25 | +// scaffold exists only as a fallback. |
| 26 | +func PressAvailable() bool { |
| 27 | + _, err := exec.LookPath("cli-printing-press") |
| 28 | + return err == nil |
| 29 | +} |
| 30 | + |
| 31 | +// Press generates a CLI from a spec. If cli-printing-press is on PATH it is |
| 32 | +// invoked with the cast spec. Otherwise a minimal, self-contained Go scaffold |
| 33 | +// is emitted so the operator still has something runnable. |
| 34 | +func Press(specPath, outDir, name string, dryRun bool) (PressResult, error) { |
| 35 | + res := PressResult{Spec: specPath, OutDir: outDir} |
| 36 | + |
| 37 | + if PressAvailable() { |
| 38 | + res.Mode = "printing-press" |
| 39 | + args := []string{"--spec", specPath, "--out", outDir} |
| 40 | + if dryRun { |
| 41 | + res.Commands = append(res.Commands, "cli-printing-press "+strings.Join(args, " ")) |
| 42 | + res.Notes = append(res.Notes, "dry run: nothing executed") |
| 43 | + return res, nil |
| 44 | + } |
| 45 | + cmd := exec.Command("cli-printing-press", args...) |
| 46 | + cmd.Stdout, cmd.Stderr = os.Stdout, os.Stderr |
| 47 | + res.Commands = append(res.Commands, "cli-printing-press "+strings.Join(args, " ")) |
| 48 | + if err := cmd.Run(); err != nil { |
| 49 | + res.Notes = append(res.Notes, fmt.Sprintf("printing press exited with error: %v", err)) |
| 50 | + return res, err |
| 51 | + } |
| 52 | + return res, nil |
| 53 | + } |
| 54 | + |
| 55 | + res.Mode = "scaffold" |
| 56 | + res.Notes = append(res.Notes, |
| 57 | + "cli-printing-press is not installed, so a minimal scaffold was emitted instead.", |
| 58 | + "Install the press for the full treatment (SQLite sync, MCP server, agent-native flags): https://github.com/mvanhorn/cli-printing-press") |
| 59 | + if dryRun { |
| 60 | + return res, nil |
| 61 | + } |
| 62 | + ops, dir, err := scaffold(specPath, outDir, name) |
| 63 | + res.Operations = ops |
| 64 | + if dir != "" { |
| 65 | + res.OutDir = dir |
| 66 | + } |
| 67 | + return res, err |
| 68 | +} |
| 69 | + |
| 70 | +type specDoc struct { |
| 71 | + Info struct { |
| 72 | + Title string `json:"title"` |
| 73 | + } `json:"info"` |
| 74 | + Servers []struct { |
| 75 | + URL string `json:"url"` |
| 76 | + } `json:"servers"` |
| 77 | + Paths map[string]map[string]struct { |
| 78 | + OperationID string `json:"operationId"` |
| 79 | + Summary string `json:"summary"` |
| 80 | + } `json:"paths"` |
| 81 | +} |
| 82 | + |
| 83 | +// scaffold writes a small but real Go CLI: one subcommand per operation, with |
| 84 | +// --json output and bearer-token auth from the environment. |
| 85 | +func scaffold(specPath, outDir, name string) ([]string, string, error) { |
| 86 | + b, err := os.ReadFile(specPath) |
| 87 | + if err != nil { |
| 88 | + return nil, "", err |
| 89 | + } |
| 90 | + var doc specDoc |
| 91 | + if err := json.Unmarshal(b, &doc); err != nil { |
| 92 | + return nil, "", fmt.Errorf("scaffold needs a JSON spec: %w", err) |
| 93 | + } |
| 94 | + if name == "" { |
| 95 | + name = slug(doc.Info.Title) |
| 96 | + } |
| 97 | + if name == "" { |
| 98 | + name = "target" |
| 99 | + } |
| 100 | + |
| 101 | + base := "" |
| 102 | + if len(doc.Servers) > 0 { |
| 103 | + base = doc.Servers[0].URL |
| 104 | + } |
| 105 | + |
| 106 | + type op struct{ id, method, path, summary string } |
| 107 | + var ops []op |
| 108 | + for p, methods := range doc.Paths { |
| 109 | + for m, o := range methods { |
| 110 | + id := o.OperationID |
| 111 | + if id == "" { |
| 112 | + id = slug(m + p) |
| 113 | + } |
| 114 | + ops = append(ops, op{id, strings.ToUpper(m), p, o.Summary}) |
| 115 | + } |
| 116 | + } |
| 117 | + sort.Slice(ops, func(i, j int) bool { return ops[i].id < ops[j].id }) |
| 118 | + |
| 119 | + dir := filepath.Join(outDir, name+"-cli") |
| 120 | + if err := os.MkdirAll(dir, 0o755); err != nil { |
| 121 | + return nil, "", err |
| 122 | + } |
| 123 | + |
| 124 | + var sb strings.Builder |
| 125 | + sb.WriteString("// Code generated by Type Foundry from a reconstructed specification.\n") |
| 126 | + sb.WriteString("// The spec was inferred from observed artifacts, not published by the vendor.\n") |
| 127 | + sb.WriteString("// Treat every endpoint as unverified until you have exercised it yourself.\n\n") |
| 128 | + sb.WriteString("package main\n\nimport (\n\t\"encoding/json\"\n\t\"flag\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"os\"\n\t\"strings\"\n\t\"time\"\n)\n\n") |
| 129 | + fmt.Fprintf(&sb, "const baseURL = %q\n\n", base) |
| 130 | + sb.WriteString(`type op struct { |
| 131 | + Method string |
| 132 | + Path string |
| 133 | + Summary string |
| 134 | +} |
| 135 | +
|
| 136 | +var ops = map[string]op{ |
| 137 | +`) |
| 138 | + for _, o := range ops { |
| 139 | + fmt.Fprintf(&sb, "\t%q: {%q, %q, %q},\n", o.id, o.method, o.path, o.summary) |
| 140 | + } |
| 141 | + sb.WriteString("}\n\n") |
| 142 | + sb.WriteString(`func main() { |
| 143 | + jsonOut := flag.Bool("json", false, "emit raw JSON") |
| 144 | + dryRun := flag.Bool("dry-run", false, "print the request without sending it") |
| 145 | + flag.Parse() |
| 146 | +
|
| 147 | + if flag.NArg() == 0 { |
| 148 | + fmt.Println("commands:") |
| 149 | + for id, o := range ops { |
| 150 | + fmt.Printf(" %-40s %s %s\n", id, o.Method, o.Path) |
| 151 | + } |
| 152 | + os.Exit(2) |
| 153 | + } |
| 154 | + o, ok := ops[flag.Arg(0)] |
| 155 | + if !ok { |
| 156 | + fmt.Fprintf(os.Stderr, "unknown command %q\n", flag.Arg(0)) |
| 157 | + os.Exit(2) |
| 158 | + } |
| 159 | +
|
| 160 | + path := o.Path |
| 161 | + for _, kv := range flag.Args()[1:] { |
| 162 | + parts := strings.SplitN(kv, "=", 2) |
| 163 | + if len(parts) == 2 { |
| 164 | + path = strings.ReplaceAll(path, "{"+parts[0]+"}", parts[1]) |
| 165 | + } |
| 166 | + } |
| 167 | + url := baseURL + path |
| 168 | +
|
| 169 | + if *dryRun { |
| 170 | + fmt.Printf("%s %s\n", o.Method, url) |
| 171 | + return |
| 172 | + } |
| 173 | +
|
| 174 | + req, err := http.NewRequest(o.Method, url, nil) |
| 175 | + if err != nil { |
| 176 | + fmt.Fprintln(os.Stderr, err) |
| 177 | + os.Exit(3) |
| 178 | + } |
| 179 | + if tok := os.Getenv("FOUNDRY_TOKEN"); tok != "" { |
| 180 | + req.Header.Set("Authorization", "Bearer "+tok) |
| 181 | + } |
| 182 | + req.Header.Set("Accept", "application/json") |
| 183 | +
|
| 184 | + client := &http.Client{Timeout: 30 * time.Second} |
| 185 | + resp, err := client.Do(req) |
| 186 | + if err != nil { |
| 187 | + fmt.Fprintln(os.Stderr, err) |
| 188 | + os.Exit(4) |
| 189 | + } |
| 190 | + defer resp.Body.Close() |
| 191 | + body, _ := io.ReadAll(resp.Body) |
| 192 | +
|
| 193 | + if resp.StatusCode >= 400 { |
| 194 | + fmt.Fprintf(os.Stderr, "http %d\n%s\n", resp.StatusCode, body) |
| 195 | + os.Exit(5) |
| 196 | + } |
| 197 | + if *jsonOut { |
| 198 | + os.Stdout.Write(body) |
| 199 | + return |
| 200 | + } |
| 201 | + var v interface{} |
| 202 | + if json.Unmarshal(body, &v) == nil { |
| 203 | + enc := json.NewEncoder(os.Stdout) |
| 204 | + enc.SetIndent("", " ") |
| 205 | + _ = enc.Encode(v) |
| 206 | + return |
| 207 | + } |
| 208 | + os.Stdout.Write(body) |
| 209 | +} |
| 210 | +`) |
| 211 | + |
| 212 | + if err := os.WriteFile(filepath.Join(dir, "main.go"), []byte(sb.String()), 0o644); err != nil { |
| 213 | + return nil, "", err |
| 214 | + } |
| 215 | + mod := fmt.Sprintf("module %s-cli\n\ngo 1.22\n", name) |
| 216 | + if err := os.WriteFile(filepath.Join(dir, "go.mod"), []byte(mod), 0o644); err != nil { |
| 217 | + return nil, "", err |
| 218 | + } |
| 219 | + |
| 220 | + readme := fmt.Sprintf(`# %s-cli |
| 221 | +
|
| 222 | +Scaffolded by Type Foundry from a **reconstructed** specification. |
| 223 | +
|
| 224 | + go build -o %s-cli . |
| 225 | + FOUNDRY_TOKEN=... ./%s-cli <operation> [param=value ...] |
| 226 | + ./%s-cli --dry-run <operation> |
| 227 | +
|
| 228 | +%d operations. The spec was inferred from observed artifacts and is not an |
| 229 | +official interface: endpoints may change without notice and none of it is |
| 230 | +endorsed by the vendor. Use against accounts you control. |
| 231 | +`, name, name, name, name, len(ops)) |
| 232 | + if err := os.WriteFile(filepath.Join(dir, "README.md"), []byte(readme), 0o644); err != nil { |
| 233 | + return nil, "", err |
| 234 | + } |
| 235 | + |
| 236 | + out := make([]string, 0, len(ops)) |
| 237 | + for _, o := range ops { |
| 238 | + out = append(out, o.id) |
| 239 | + } |
| 240 | + return out, dir, nil |
| 241 | +} |
| 242 | + |
| 243 | +func slug(s string) string { |
| 244 | + s = strings.ToLower(s) |
| 245 | + var b strings.Builder |
| 246 | + prevDash := false |
| 247 | + for _, r := range s { |
| 248 | + switch { |
| 249 | + case r >= 'a' && r <= 'z', r >= '0' && r <= '9': |
| 250 | + b.WriteRune(r) |
| 251 | + prevDash = false |
| 252 | + default: |
| 253 | + if !prevDash && b.Len() > 0 { |
| 254 | + b.WriteByte('-') |
| 255 | + prevDash = true |
| 256 | + } |
| 257 | + } |
| 258 | + } |
| 259 | + return strings.Trim(b.String(), "-") |
| 260 | +} |
0 commit comments