Skip to content

Commit 8511c36

Browse files
greynewellclaude
andauthored
Add supermodel share command (#56)
* Add supermodel share command and POST /v1/share API method supermodel share runs the same health audit pipeline and uploads the rendered Markdown report to the Supermodel API, returning a public URL and a ready-to-paste README badge: Report: https://supermodeltools.com/s/abc123 Add this badge to your README: [![Supermodel](https://img.shields.io/badge/supermodel-HEALTHY-blueviolet)](…) Implementation: - api.ShareRequest / ShareResponse types in internal/api/types.go - api.Client.Share() method using the existing request() helper - jsonBody() helper for JSON POST bodies - cmd/share.go wires the command; reuses resolveAuditDir, shareAnalyze, and runImpactForShare (same pattern as cmd/audit.go) Note: requires POST /v1/share endpoint on the backend to return URLs. Closes #52 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Fix staticcheck SA5011: add return after t.Fatal in tests Staticcheck cannot see that t.Fatal halts goroutine execution, so it flags the nil-checked pointer accesses immediately after as potential nil dereferences. Adding unreachable return statements satisfies it. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 46ca8f6 commit 8511c36

5 files changed

Lines changed: 170 additions & 0 deletions

File tree

cmd/share.go

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
package cmd
2+
3+
import (
4+
"bytes"
5+
"context"
6+
"fmt"
7+
"os"
8+
"time"
9+
10+
"github.com/spf13/cobra"
11+
12+
"github.com/supermodeltools/cli/internal/api"
13+
"github.com/supermodeltools/cli/internal/audit"
14+
"github.com/supermodeltools/cli/internal/cache"
15+
"github.com/supermodeltools/cli/internal/config"
16+
)
17+
18+
func init() {
19+
var dir string
20+
21+
c := &cobra.Command{
22+
Use: "share",
23+
Short: "Upload your codebase health report and get a public URL",
24+
Long: `Runs a health audit and uploads the report to supermodeltools.com,
25+
returning a short public URL you can share or embed as a README badge.
26+
27+
Example:
28+
29+
supermodel share
30+
supermodel share --dir ./path/to/project`,
31+
RunE: func(cmd *cobra.Command, _ []string) error {
32+
return runShare(cmd, dir)
33+
},
34+
SilenceUsage: true,
35+
}
36+
37+
c.Flags().StringVar(&dir, "dir", "", "project directory (default: current working directory)")
38+
rootCmd.AddCommand(c)
39+
}
40+
41+
func runShare(cmd *cobra.Command, dir string) error {
42+
rootDir, projectName, err := resolveAuditDir(dir)
43+
if err != nil {
44+
return err
45+
}
46+
47+
cfg, err := config.Load()
48+
if err != nil {
49+
return err
50+
}
51+
52+
// Run the full audit pipeline.
53+
ir, err := shareAnalyze(cmd, cfg, rootDir, projectName)
54+
if err != nil {
55+
return err
56+
}
57+
58+
report := audit.Analyze(ir, projectName)
59+
60+
impact, err := runImpactForShare(cmd, cfg, rootDir)
61+
if err != nil {
62+
fmt.Fprintf(cmd.ErrOrStderr(), "Warning: impact analysis unavailable: %v\n", err)
63+
} else {
64+
audit.EnrichWithImpact(report, impact)
65+
}
66+
67+
// Render to Markdown.
68+
var buf bytes.Buffer
69+
audit.RenderHealth(&buf, report)
70+
71+
// Upload and get public URL.
72+
client := api.New(cfg)
73+
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
74+
defer cancel()
75+
76+
fmt.Fprintln(cmd.ErrOrStderr(), "Uploading report…")
77+
url, err := client.Share(ctx, api.ShareRequest{
78+
ProjectName: projectName,
79+
Status: string(report.Status),
80+
Content: buf.String(),
81+
})
82+
if err != nil {
83+
return fmt.Errorf("upload failed: %w", err)
84+
}
85+
86+
fmt.Fprintf(cmd.OutOrStdout(), "\n Report: %s\n\n", url)
87+
fmt.Fprintf(cmd.OutOrStdout(), " Add this badge to your README:\n\n")
88+
fmt.Fprintf(cmd.OutOrStdout(),
89+
" [![Supermodel](https://img.shields.io/badge/supermodel-%s-blueviolet)](%s)\n\n",
90+
report.Status, url)
91+
92+
return nil
93+
}
94+
95+
func shareAnalyze(cmd *cobra.Command, cfg *config.Config, rootDir, projectName string) (*api.SupermodelIR, error) {
96+
if err := cfg.RequireAPIKey(); err != nil {
97+
return nil, err
98+
}
99+
100+
fmt.Fprintln(cmd.ErrOrStderr(), "Creating repository archive…")
101+
zipPath, err := audit.CreateZip(rootDir)
102+
if err != nil {
103+
return nil, fmt.Errorf("create archive: %w", err)
104+
}
105+
defer func() { _ = os.Remove(zipPath) }()
106+
107+
hash, err := cache.HashFile(zipPath)
108+
if err != nil {
109+
return nil, fmt.Errorf("hash archive: %w", err)
110+
}
111+
112+
client := api.New(cfg)
113+
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
114+
defer cancel()
115+
116+
fmt.Fprintf(cmd.ErrOrStderr(), "Analyzing %s…\n", projectName)
117+
return client.AnalyzeDomains(ctx, zipPath, "share-"+hash[:16])
118+
}
119+
120+
func runImpactForShare(cmd *cobra.Command, cfg *config.Config, rootDir string) (*api.ImpactResult, error) {
121+
zipPath, err := audit.CreateZip(rootDir)
122+
if err != nil {
123+
return nil, err
124+
}
125+
defer func() { _ = os.Remove(zipPath) }()
126+
127+
hash, err := cache.HashFile(zipPath)
128+
if err != nil {
129+
return nil, err
130+
}
131+
132+
client := api.New(cfg)
133+
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
134+
defer cancel()
135+
136+
fmt.Fprintln(cmd.ErrOrStderr(), "Running impact analysis…")
137+
return client.Impact(ctx, zipPath, "share-impact-"+hash[:16], "", "")
138+
}
139+
140+
// resolveAuditDir and findGitRoot are defined in cmd/audit.go (same package).

internal/api/client.go

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -368,6 +368,22 @@ func (c *Client) DisplayGraph(ctx context.Context, repoID, idempotencyKey string
368368
return &g, nil
369369
}
370370

371+
// Share uploads a rendered report and returns a public URL.
372+
func (c *Client) Share(ctx context.Context, req ShareRequest) (string, error) {
373+
var resp ShareResponse
374+
if err := c.request(ctx, "POST", "/v1/share", "application/json",
375+
jsonBody(req), "", &resp); err != nil {
376+
return "", err
377+
}
378+
return resp.URL, nil
379+
}
380+
381+
// jsonBody encodes v as JSON and returns it as an io.Reader.
382+
func jsonBody(v any) io.Reader {
383+
b, _ := json.Marshal(v)
384+
return bytes.NewReader(b)
385+
}
386+
371387
func (c *Client) request(ctx context.Context, method, path, contentType string, body io.Reader, idempotencyKey string, out any) error {
372388
req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, body)
373389
if err != nil {

internal/api/types.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -323,6 +323,18 @@ type CrossDomainDependency struct {
323323
TargetDomain string `json:"targetDomain"`
324324
}
325325

326+
// ShareRequest is the payload for POST /v1/share.
327+
type ShareRequest struct {
328+
ProjectName string `json:"project_name"`
329+
Status string `json:"status"`
330+
Content string `json:"content"` // rendered Markdown report
331+
}
332+
333+
// ShareResponse is returned by POST /v1/share.
334+
type ShareResponse struct {
335+
URL string `json:"url"`
336+
}
337+
326338
// Error represents a non-2xx response from the API.
327339
type Error struct {
328340
StatusCode int `json:"-"`

internal/cache/cache_test.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,7 @@ func TestGetPut_RoundTrip(t *testing.T) {
107107
}
108108
if got == nil {
109109
t.Fatal("Get returned nil after Put")
110+
return
110111
}
111112
if len(got.Nodes) != 1 || got.Nodes[0].ID != "n1" {
112113
t.Errorf("round-trip nodes: got %v", got.Nodes)

internal/restore/restore_test.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -619,6 +619,7 @@ func TestFromSupermodelIR_Empty(t *testing.T) {
619619
g := FromSupermodelIR(&api.SupermodelIR{}, "empty")
620620
if g == nil {
621621
t.Fatal("returned nil")
622+
return
622623
}
623624
if g.Name != "empty" {
624625
t.Errorf("name: got %q", g.Name)

0 commit comments

Comments
 (0)