Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 14 additions & 2 deletions cmd/analyze.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,12 @@ import (

"github.com/supermodeltools/cli/internal/analyze"
"github.com/supermodeltools/cli/internal/config"
"github.com/supermodeltools/cli/internal/files"
)

func init() {
var opts analyze.Options
var noFiles bool

c := &cobra.Command{
Use: "analyze [path]",
Expand All @@ -17,7 +19,10 @@ func init() {
call graph generation, dependency analysis, and domain classification.

Results are cached locally by content hash. Subsequent commands
(dead-code, blast-radius, graph) reuse the cache automatically.`,
(dead-code, blast-radius, graph) reuse the cache automatically.

By default, .graph.* sidecar files are written next to each source file.
Use --no-files to skip writing graph files.`,
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
cfg, err := config.Load()
Expand All @@ -31,12 +36,19 @@ Results are cached locally by content hash. Subsequent commands
if len(args) > 0 {
dir = args[0]
}
return analyze.Run(cmd.Context(), cfg, dir, opts)
if err := analyze.Run(cmd.Context(), cfg, dir, opts); err != nil {
return err
}
if cfg.FilesEnabled() && !noFiles {
return files.Generate(cmd.Context(), cfg, dir, files.GenerateOptions{})
}
return nil
},
}

c.Flags().BoolVar(&opts.Force, "force", false, "re-analyze even if a cached result exists")
c.Flags().StringVarP(&opts.Output, "output", "o", "", "output format: human|json")
c.Flags().BoolVar(&noFiles, "no-files", false, "skip writing .graph.* sidecar files")

rootCmd.AddCommand(c)
}
32 changes: 32 additions & 0 deletions cmd/clean.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package cmd

import (
"github.com/spf13/cobra"

"github.com/supermodeltools/cli/internal/config"
"github.com/supermodeltools/cli/internal/files"
)

func init() {
var dryRun bool

c := &cobra.Command{
Use: "clean [path]",
Short: "Remove all .graph.* files from the repository",
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
cfg, err := config.Load()
if err != nil {
return err
}
dir := "."
if len(args) > 0 {
dir = args[0]
}
return files.Clean(cmd.Context(), cfg, dir, dryRun)
},
}

c.Flags().BoolVar(&dryRun, "dry-run", false, "show what would be removed without removing")
rootCmd.AddCommand(c)
}
24 changes: 24 additions & 0 deletions cmd/hook.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package cmd

import (
"github.com/spf13/cobra"

"github.com/supermodeltools/cli/internal/files"
)

func init() {
var port int

c := &cobra.Command{
Use: "hook",
Short: "Forward Claude Code file-change events to the watch daemon",
Long: `Reads a Claude Code PostToolUse JSON payload from stdin and forwards the file path to the running watch daemon via UDP. Install as a PostToolUse hook in .claude/settings.json.`,
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
return files.Hook(port)
},
}

c.Flags().IntVar(&port, "port", 7734, "UDP port of the watch daemon")
rootCmd.AddCommand(c)
}
47 changes: 47 additions & 0 deletions cmd/watch.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
package cmd

import (
"time"

"github.com/spf13/cobra"

"github.com/supermodeltools/cli/internal/config"
"github.com/supermodeltools/cli/internal/files"
)

func init() {
var opts files.WatchOptions

c := &cobra.Command{
Use: "watch [path]",
Short: "Generate graph files on startup, then keep them updated as you code",
Long: `Runs a full generate on startup (using cached graph if available), then
enters daemon mode. Listens for file-change notifications from the
'supermodel hook' command and incrementally re-renders affected files.

Press Ctrl+C to stop and remove graph files.`,
Comment on lines +18 to +22
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Fix the shutdown help text.

Line 22 says Ctrl+C removes graph files, but the current shutdown path only stops the daemon. Cleanup is a separate flow, so this text is promising behavior that does not happen.

Suggested text
-Press Ctrl+C to stop and remove graph files.`,
+Press Ctrl+C to stop. Run `supermodel clean` if you want to remove graph files.`,
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
Long: `Runs a full generate on startup (using cached graph if available), then
enters daemon mode. Listens for file-change notifications from the
'supermodel hook' command and incrementally re-renders affected files.
Press Ctrl+C to stop and remove graph files.`,
Long: `Runs a full generate on startup (using cached graph if available), then
enters daemon mode. Listens for file-change notifications from the
'supermodel hook' command and incrementally re-renders affected files.
Press Ctrl+C to stop. Run `supermodel clean` if you want to remove graph files.`,
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@cmd/watch.go` around lines 18 - 22, The shutdown help text in the watch
command's Long description incorrectly claims "Press Ctrl+C to stop and remove
graph files"; update the Long string (in the Cobra command declaration, e.g.,
the watch command variable that sets Long in cmd/watch.go) to accurately reflect
actual behavior—change it to say something like "Press Ctrl+C to stop the
daemon." or otherwise remove the "and remove graph files" clause so it no longer
promises cleanup that doesn't occur in the current shutdown path.

Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
cfg, err := config.Load()
if err != nil {
return err
}
if err := cfg.RequireAPIKey(); err != nil {
return err
}
dir := "."
if len(args) > 0 {
dir = args[0]
}
return files.Watch(cmd.Context(), cfg, dir, opts)
},
}

c.Flags().StringVar(&opts.CacheFile, "cache-file", "", "override cache file path")
c.Flags().DurationVar(&opts.Debounce, "debounce", 2*time.Second, "debounce duration before processing changes")
c.Flags().IntVar(&opts.NotifyPort, "notify-port", 7734, "UDP port for hook notifications")
c.Flags().BoolVar(&opts.FSWatch, "fs-watch", false, "enable git-poll fallback")
c.Flags().DurationVar(&opts.PollInterval, "poll-interval", 3*time.Second, "git poll interval when --fs-watch is enabled")

rootCmd.AddCommand(c)
}
82 changes: 82 additions & 0 deletions internal/api/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,88 @@ func (c *Client) pollUntilComplete(ctx context.Context, zipPath, idempotencyKey
return job, nil
}

// AnalyzeSidecars uploads a repository ZIP and runs the full analysis pipeline,
// returning the complete SidecarIR response with full Node/Relationship data
// required for sidecar rendering (IDs, labels, properties preserved).
func (c *Client) AnalyzeSidecars(ctx context.Context, zipPath, idempotencyKey string) (*SidecarIR, error) {
job, err := c.pollUntilComplete(ctx, zipPath, idempotencyKey)
if err != nil {
return nil, err
}
var ir SidecarIR
if err := json.Unmarshal(job.Result, &ir); err != nil {
return nil, fmt.Errorf("decode sidecar result: %w", err)
}
return &ir, nil
}

// AnalyzeIncremental uploads a zip of changed files and requests an incremental
// graph update from the API. changedFiles is sent as a form field so the server
// can scope its analysis to only those files.
func (c *Client) AnalyzeIncremental(ctx context.Context, zipPath string, changedFiles []string, idempotencyKey string) (*SidecarIR, error) {
f, err := os.Open(zipPath)
if err != nil {
return nil, err
}
defer f.Close()

var buf bytes.Buffer
mw := multipart.NewWriter(&buf)

fw, err := mw.CreateFormFile("file", filepath.Base(zipPath))
if err != nil {
return nil, err
}
if _, err = io.Copy(fw, f); err != nil {
return nil, err
}

// Encode changed files as a JSON array in a form field.
changedJSON, err := json.Marshal(changedFiles)
if err != nil {
return nil, err
}
if err := mw.WriteField("changedFiles", string(changedJSON)); err != nil {
return nil, err
}
mw.Close()

var job JobResponse
if err := c.request(ctx, http.MethodPost, analyzeEndpoint, mw.FormDataContentType(), &buf, idempotencyKey, &job); err != nil {
return nil, err
}

// Poll until complete (reuse pollUntilComplete logic inline since we already have the first response).
for job.Status == "pending" || job.Status == "processing" {
wait := time.Duration(job.RetryAfter) * time.Second
if wait <= 0 {
wait = 5 * time.Second
}
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-time.After(wait):
}
nextJob, err := c.postZipTo(ctx, zipPath, idempotencyKey, analyzeEndpoint)
if err != nil {
return nil, err
}
job = *nextJob
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
if job.Error != nil {
return nil, fmt.Errorf("incremental analysis failed: %s", *job.Error)
}
if job.Status != "completed" {
return nil, fmt.Errorf("unexpected job status: %s", job.Status)
}

var ir SidecarIR
if err := json.Unmarshal(job.Result, &ir); err != nil {
return nil, fmt.Errorf("decode incremental sidecar result: %w", err)
}
return &ir, nil
}

// postZip sends the repository ZIP to the analyze endpoint and returns the
// raw job response (which may be pending, processing, or completed).
func (c *Client) postZip(ctx context.Context, zipPath, idempotencyKey string) (*JobResponse, error) {
Expand Down
35 changes: 35 additions & 0 deletions internal/api/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,41 @@ type IRSubdomain struct {
DescriptionSummary string `json:"descriptionSummary"`
}

// SidecarIR is the full structured response from /v1/graphs/supermodel used
// by the sidecars vertical slice. Unlike SupermodelIR (which uses simplified
// IRNode/IRRelationship stubs), SidecarIR preserves the complete node graph
// with IDs, labels, and properties required for sidecar rendering.
type SidecarIR struct {
Repo string `json:"repo"`
Summary map[string]any `json:"summary"`
Metadata IRMetadata `json:"metadata"`
Domains []SidecarDomain `json:"domains"`
Graph SidecarGraph `json:"graph"`
}

// SidecarGraph is the full node/relationship graph embedded in SidecarIR.
type SidecarGraph struct {
Nodes []Node `json:"nodes"`
Relationships []Relationship `json:"relationships"`
}

// SidecarDomain is a semantic domain from the API with file references.
type SidecarDomain struct {
Name string `json:"name"`
DescriptionSummary string `json:"descriptionSummary"`
KeyFiles []string `json:"keyFiles"`
Responsibilities []string `json:"responsibilities"`
Subdomains []SidecarSubdomain `json:"subdomains"`
}

// SidecarSubdomain is a named sub-area within a SidecarDomain.
type SidecarSubdomain struct {
Name string `json:"name"`
DescriptionSummary string `json:"descriptionSummary"`
Files []string `json:"files"`
KeyFiles []string `json:"keyFiles"`
}

// JobResponse is the async envelope returned by the API for long-running jobs.
type JobResponse struct {
Status string `json:"status"`
Expand Down
14 changes: 14 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ type Config struct {
APIKey string `yaml:"api_key,omitempty"`
APIBase string `yaml:"api_base,omitempty"`
Output string `yaml:"output,omitempty"` // "human" | "json"
Files *bool `yaml:"files,omitempty"`
}

// Dir returns the Supermodel config directory (~/.supermodel).
Expand Down Expand Up @@ -70,6 +71,14 @@ func (c *Config) Save() error {
return nil
}

// FilesEnabled reports whether file mode is on. Defaults to true.
func (c *Config) FilesEnabled() bool {
if c.Files != nil {
return *c.Files
}
return true
}

// RequireAPIKey returns an actionable error if no API key is configured.
func (c *Config) RequireAPIKey() error {
if c.APIKey == "" {
Expand Down Expand Up @@ -98,4 +107,9 @@ func (c *Config) applyEnv() {
if base := os.Getenv("SUPERMODEL_API_BASE"); base != "" {
c.APIBase = base
}
if os.Getenv("SUPERMODEL_FILES") == "false" {
c.Files = boolPtr(false)
}
}

func boolPtr(b bool) *bool { return &b }
Loading
Loading