Skip to content
Closed
Show file tree
Hide file tree
Changes from all 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
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,7 @@ node_modules/
apps/openant-cli/bin/
libs/openant-core/parsers/go/go_parser/go_parser
# docs/
CLAUDE.md
.gitignore
.claude
.git
389 changes: 389 additions & 0 deletions WEBUI_SPEC.md

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions apps/openant-cli/cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ func init() {
rootCmd.PersistentFlags().StringVarP(&projectFlag, "project", "p", "", "Project to use (overrides active project, e.g. grafana/grafana)")

rootCmd.AddCommand(initCmd)
rootCmd.AddCommand(serveCmd)
rootCmd.AddCommand(scanCmd)
rootCmd.AddCommand(parseCmd)
rootCmd.AddCommand(enhanceCmd)
Expand Down
90 changes: 90 additions & 0 deletions apps/openant-cli/cmd/serve.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
package cmd

import (
"context"
"fmt"
"os"
"os/exec"
"os/signal"
"path/filepath"
"runtime"
"syscall"

"github.com/knostic/open-ant-cli/internal/output"
"github.com/knostic/open-ant-cli/internal/server"
"github.com/spf13/cobra"
)

var serveCmd = &cobra.Command{
Use: "serve",
Short: "Start the OpenAnt web UI",
Long: `Serve starts a local HTTP server (default http://localhost:8080) and opens
the browser automatically.

Scan outputs are stored under ~/.openant/webui/ and persist across restarts.
Use Ctrl+C to stop the server; all in-flight scans are cancelled gracefully.`,
Args: cobra.NoArgs,
Run: runServe,
}

var serveAddr string

func init() {
serveCmd.Flags().StringVar(&serveAddr, "addr", "127.0.0.1:8080", "Address to listen on")
}

func runServe(_ *cobra.Command, _ []string) {
rt, err := ensurePython()
if err != nil {
output.PrintError(err.Error())
os.Exit(2)
}

// Resolve output root: ~/.openant/webui/
home, err := os.UserHomeDir()
if err != nil {
output.PrintError("cannot determine home directory: " + err.Error())
os.Exit(2)
}
outDir := filepath.Join(home, ".openant", "webui")
if err := os.MkdirAll(outDir, 0750); err != nil {
output.PrintError("cannot create output directory: " + err.Error())
os.Exit(2)
}

srv, err := server.New(rt.Path, outDir)
if err != nil {
output.PrintError("failed to initialise server: " + err.Error())
os.Exit(2)
}

ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()

url, err := srv.Start(ctx, serveAddr)
if err != nil {
output.PrintError("failed to start server: " + err.Error())
os.Exit(2)
}

fmt.Println("OpenAnt web UI running at", url)
openBrowser(url)
fmt.Println("Press Ctrl+C to stop.")

<-ctx.Done()
fmt.Println("\nShutting down…")
}

// openBrowser opens url in the default system browser.
func openBrowser(url string) {
var cmd *exec.Cmd
switch runtime.GOOS {
case "darwin":
cmd = exec.Command("open", url)
case "windows":
cmd = exec.Command("rundll32", "url.dll,FileProtocolHandler", url)
default:
cmd = exec.Command("xdg-open", url)
}
_ = cmd.Start()
}
107 changes: 107 additions & 0 deletions apps/openant-cli/internal/python/invoke_ctx.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
package python

import (
"bufio"
"bytes"
"context"
"fmt"
"io"
"os"
"os/exec"
"syscall"
)

// InvokeCtx runs `python -m openant <args>` with context-cancellation support.
// Each stderr line is passed to onLog in real time. stdout is discarded.
// On context cancellation, SIGKILL is sent to the entire process group so all
// child processes (e.g. parallel workers) are killed.
// Returns the exit code and any error (cancelled runs return exit code -1, nil).
func InvokeCtx(ctx context.Context, pythonPath string, args []string, workDir, apiKey string, onLog func(string)) (int, error) {
_, code, err := invokeCtxInner(ctx, pythonPath, args, workDir, apiKey, onLog, false)
return code, err
}

// InvokeCtxCapture runs `python -m openant <args>` like InvokeCtx but also
// captures and returns the full stdout content (e.g. JSON output).
func InvokeCtxCapture(ctx context.Context, pythonPath string, args []string, workDir, apiKey string, onLog func(string)) (stdout string, exitCode int, err error) {
return invokeCtxInner(ctx, pythonPath, args, workDir, apiKey, onLog, true)
}

func invokeCtxInner(ctx context.Context, pythonPath string, args []string, workDir, apiKey string, onLog func(string), captureStdout bool) (string, int, error) {
cmdArgs := append([]string{"-m", "openant"}, args...)
cmd := exec.Command(pythonPath, cmdArgs...)

if workDir != "" {
cmd.Dir = workDir
}
cmd.Env = os.Environ()
if apiKey != "" {
cmd.Env = setEnv(cmd.Env, "ANTHROPIC_API_KEY", apiKey)
}
// New process group so we can kill all descendants at once.
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}

stderr, err := cmd.StderrPipe()
if err != nil {
return "", 0, fmt.Errorf("stderr pipe: %w", err)
}

var stdoutBuf bytes.Buffer
if captureStdout {
cmd.Stdout = &stdoutBuf
} else {
cmd.Stdout = nil // discard
}

if err := cmd.Start(); err != nil {
return "", 0, fmt.Errorf("start: %w", err)
}

// Kill process group when context is cancelled.
stopKiller := make(chan struct{})
go func() {
select {
case <-ctx.Done():
if cmd.Process != nil {
_ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
}
case <-stopKiller:
}
}()

// Stream stderr to onLog.
stderrDone := make(chan struct{})
go func() {
defer close(stderrDone)
sc := bufio.NewScanner(stderr)
for sc.Scan() {
if onLog != nil {
onLog(sc.Text())
}
}
}()

// If capturing stdout we need to drain it; cmd.Stdout = &stdoutBuf handles that.
// For non-capture, stdout goes to /dev/null via nil.
// Wait for stderr to finish before cmd.Wait().
<-stderrDone

// Also drain any remaining stdout bytes (belt-and-suspenders for the pipe).
if captureStdout {
_, _ = io.Copy(&stdoutBuf, bytes.NewReader(nil)) // no-op; already set via cmd.Stdout
}

exitErr := cmd.Wait()
close(stopKiller)

if exitErr != nil {
if ee, ok := exitErr.(*exec.ExitError); ok {
return stdoutBuf.String(), ee.ExitCode(), nil
}
if ctx.Err() != nil {
return "", -1, nil
}
return "", 0, fmt.Errorf("wait: %w", exitErr)
}
return stdoutBuf.String(), 0, nil
}
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@
<div>
<h1 class="text-2xl sm:text-3xl lg:text-4xl font-bold text-knostic-navy mb-1">{{.Title}}</h1>
<div class="flex flex-wrap items-center gap-x-4 gap-y-1 text-sm text-knostic-muted mt-2">
{{if .RepoName}}<span title="Repository"><strong class="text-knostic-navy">{{.RepoName}}</strong></span>{{end}}
{{if .RepoName}}{{if .RepoURL}}<a href="{{.RepoURL}}" target="_blank" rel="noopener" title="Repository" class="text-knostic-navy font-semibold hover:underline">{{.RepoName}}</a>{{else}}<span title="Repository"><strong class="text-knostic-navy">{{.RepoName}}</strong></span>{{end}}{{end}}
{{if .ShortCommit}}<code class="text-xs bg-knostic-card-bg border border-knostic-border-light px-2 py-0.5 rounded text-knostic-navy">{{.ShortCommit}}</code>{{end}}
{{if .Language}}<span class="capitalize">{{.Language}}</span>{{end}}
</div>
Expand Down
Loading
Loading