Skip to content

Commit 4d31552

Browse files
waverideraiclaude
andcommitted
Fix .gitignore anchoring so source dirs aren't excluded
The `foundry`, `cast/`, and `manuscript/` rules were meant to ignore the compiled binary and the runtime working directories at the repo root, but without a leading slash they also matched the `cmd/foundry/` and `internal/cast/` source directories. That silently dropped the CLI entrypoint and the OpenAPI/CLI casting package from the tree, so a fresh checkout failed to build (CI: "no required module provides package .../internal/cast"). Anchor the working-directory rules to the repo root and add the previously excluded internal/cast sources. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent e257bc8 commit 4d31552

4 files changed

Lines changed: 638 additions & 3 deletions

File tree

‎.gitignore‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
manuscript/
2-
cast/
3-
.foundry-work/
1+
/manuscript/
2+
/cast/
3+
/.foundry-work/
44
/foundry
55
*.plugin
66
.DS_Store

‎internal/cast/cli.go‎

Lines changed: 260 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,260 @@
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+
}

‎internal/cast/discover.go‎

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
package cast
2+
3+
import (
4+
"encoding/json"
5+
"os"
6+
"path/filepath"
7+
"sort"
8+
"strings"
9+
"time"
10+
)
11+
12+
// FoundSpec describes a specification already present on disk. The foundry
13+
// looks for these before doing any work, so a spec cast in an earlier session
14+
// can be handed straight to the press without re-striking anything.
15+
type FoundSpec struct {
16+
Path string `json:"path"`
17+
Kind string `json:"kind"` // openapi | swagger | graphql | postman | unknown
18+
Title string `json:"title,omitempty"`
19+
Version string `json:"version,omitempty"`
20+
PathCount int `json:"path_count"`
21+
ByFoundry bool `json:"by_foundry"` // did we cast this ourselves
22+
ModifiedAt time.Time `json:"modified_at"`
23+
SizeBytes int64 `json:"size_bytes"`
24+
}
25+
26+
var specNames = map[string]bool{
27+
"openapi.json": true, "openapi.yaml": true, "openapi.yml": true,
28+
"swagger.json": true, "swagger.yaml": true, "swagger.yml": true,
29+
"api.json": true, "spec.json": true, "schema.graphql": true,
30+
"postman_collection.json": true,
31+
}
32+
33+
// FindSpecs walks dirs (up to two levels) looking for existing specifications.
34+
// Results are sorted newest first so the most recent cast is the default.
35+
func FindSpecs(dirs ...string) []FoundSpec {
36+
var out []FoundSpec
37+
seen := map[string]bool{}
38+
39+
for _, dir := range dirs {
40+
if dir == "" {
41+
continue
42+
}
43+
root := filepath.Clean(dir)
44+
_ = filepath.Walk(root, func(p string, fi os.FileInfo, err error) error {
45+
if err != nil {
46+
return nil
47+
}
48+
// Keep the walk shallow: two levels below the root.
49+
if fi.IsDir() {
50+
rel, _ := filepath.Rel(root, p)
51+
if strings.Count(rel, string(os.PathSeparator)) >= 2 && rel != "." {
52+
return filepath.SkipDir
53+
}
54+
base := filepath.Base(p)
55+
if base == "node_modules" || base == ".git" || base == ".foundry-work" {
56+
return filepath.SkipDir
57+
}
58+
return nil
59+
}
60+
name := strings.ToLower(filepath.Base(p))
61+
if !specNames[name] && !strings.HasSuffix(name, ".openapi.json") {
62+
return nil
63+
}
64+
abs, _ := filepath.Abs(p)
65+
if seen[abs] {
66+
return nil
67+
}
68+
seen[abs] = true
69+
if fs := inspectSpec(p, fi); fs != nil {
70+
out = append(out, *fs)
71+
}
72+
return nil
73+
})
74+
}
75+
76+
sort.Slice(out, func(i, j int) bool { return out[i].ModifiedAt.After(out[j].ModifiedAt) })
77+
return out
78+
}
79+
80+
func inspectSpec(path string, fi os.FileInfo) *FoundSpec {
81+
fs := &FoundSpec{
82+
Path: path,
83+
Kind: "unknown",
84+
ModifiedAt: fi.ModTime(),
85+
SizeBytes: fi.Size(),
86+
}
87+
if strings.HasSuffix(strings.ToLower(path), ".graphql") {
88+
fs.Kind = "graphql"
89+
return fs
90+
}
91+
b, err := os.ReadFile(path)
92+
if err != nil {
93+
return fs
94+
}
95+
var doc map[string]interface{}
96+
if err := json.Unmarshal(b, &doc); err != nil {
97+
// YAML specs are still worth reporting; we just cannot introspect them here.
98+
if strings.HasSuffix(path, ".yaml") || strings.HasSuffix(path, ".yml") {
99+
fs.Kind = "openapi"
100+
}
101+
return fs
102+
}
103+
switch {
104+
case doc["openapi"] != nil:
105+
fs.Kind = "openapi"
106+
case doc["swagger"] != nil:
107+
fs.Kind = "swagger"
108+
case doc["info"] != nil && doc["item"] != nil:
109+
fs.Kind = "postman"
110+
}
111+
if info, ok := doc["info"].(map[string]interface{}); ok {
112+
if t, ok := info["title"].(string); ok {
113+
fs.Title = t
114+
}
115+
if v, ok := info["version"].(string); ok {
116+
fs.Version = v
117+
}
118+
if d, ok := info["description"].(string); ok && strings.Contains(d, "Type Foundry") {
119+
fs.ByFoundry = true
120+
}
121+
}
122+
if paths, ok := doc["paths"].(map[string]interface{}); ok {
123+
fs.PathCount = len(paths)
124+
}
125+
return fs
126+
}

0 commit comments

Comments
 (0)