Skip to content
Merged
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
29 changes: 29 additions & 0 deletions .github/workflows/checks.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
name: Checks

on:
push:
branches: [main]
pull_request:

jobs:
checks:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- uses: actions/setup-go@v5
with:
go-version-file: go.mod
cache: true

- name: Formatting
run: make fmt-check

- name: Vet
run: make vet

- name: Tests
run: make test-race

- name: Build
run: make build
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# Anchored, or it also matches cmd/sourceant and the entry point is
# never committed.
/sourceant
coverage.out
coverage.html
64 changes: 64 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
.PHONY: help deps build test test-race test-coverage fmt fmt-check vet lint lint-install clean qa

BINARY_NAME=sourceant
VERSION?=$(shell cat VERSION 2>/dev/null || echo "dev")
BUILD_TIME=$(shell date -u '+%Y-%m-%d_%H:%M:%S')
GIT_COMMIT=$(shell git rev-parse --short HEAD 2>/dev/null || echo "unknown")
LDFLAGS=-ldflags "-X github.com/sourceant/cli/internal/command.Version=$(VERSION) -X github.com/sourceant/cli/internal/command.BuildTime=$(BUILD_TIME) -X github.com/sourceant/cli/internal/command.GitCommit=$(GIT_COMMIT)"

help:
@echo "SourceAnt CLI - build commands"
@echo ""
@echo "make deps - Download dependencies"
@echo "make build - Build the CLI binary"
@echo "make test - Run unit tests"
@echo "make test-race - Run unit tests under the race detector"
@echo "make test-coverage - Run tests with a coverage report"
@echo "make fmt - Format code with gofmt"
@echo "make fmt-check - Check gofmt formatting"
@echo "make vet - Run go vet"
@echo "make lint - Run golangci-lint"
@echo "make qa - Run fmt-check, vet, lint, and tests"
@echo "make clean - Clean build artifacts"

deps:
go mod download
go mod tidy

build:
go build $(LDFLAGS) -o $(BINARY_NAME) ./cmd/sourceant

test:
go test ./...

test-race:
go test -race ./...

test-coverage:
go test -coverprofile=coverage.out ./...
go tool cover -html=coverage.out -o coverage.html
@echo "Coverage report generated: coverage.html"

fmt:
go fmt ./...

fmt-check:
@test -z "$$(gofmt -l .)" || (echo "Run gofmt on:" && gofmt -l . && exit 1)

vet:
go vet ./...

lint:
@command -v golangci-lint > /dev/null 2>&1 && golangci-lint run ./... || \
(test -x "$$(go env GOPATH)/bin/golangci-lint" && "$$(go env GOPATH)/bin/golangci-lint" run ./... || \
(echo "golangci-lint not found. Run 'make lint-install' first." && exit 1))

lint-install:
go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@latest

qa: fmt-check vet lint test

clean:
rm -f $(BINARY_NAME)
rm -f coverage.out coverage.html
go clean
79 changes: 79 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
# SourceAnt CLI

The command a person types. It reads the code graph the SourceAnt agent keeps on this machine.

```
$ sourceant repos
REPOSITORY PATH
acme/billing /home/you/work/billing

$ sourceant graph acme/billing
2215 nodes, 2006 links

KIND COUNT
function 895
import 854
class 257
python 179

EDGE COUNT
defines 1152
imports 854
```

## How the pieces fit

Three processes, each with one job:

| | |
|---|---|
| `sourceant` | this CLI, which talks only to the agent |
| `sourceant-agent` | always running: supervises the indexer, keeps the graph current, serves it |
| the SourceAnt core | Python, owns the grammars and the graph |

The CLI never reaches past the agent. The agent is the process that is always up and the one that knows where the core is listening; going around it would mean learning both.

## Installing

```bash
make build
./sourceant install
```

`install` puts a core on this machine and writes down which one, so the agent knows what to start.

Two ways to have it. `--runtime docker` pulls the published image, and is what works today. `--runtime python` builds a virtual environment and pip installs the core, for when the core is published as a package; until then it says so rather than recording something that will not start.

Both put the index in the same place, `$XDG_DATA_HOME/sourceant`, so it does not matter which one indexed it. The container runs as whoever installed, so what it writes there belongs to them.

Then start the agent. See [sourceant/agent](https://github.com/sourceant/agent).

| Variable | Default | Meaning |
|---|---|---|
| `SOURCEANT_AGENT_URL` | `http://127.0.0.1:8930` | The agent to talk to |

`--agent`, `--timeout` and `--json` override it per command.

## Commands

| Command | What it does |
|---|---|
| `sourceant install` | Put a core on this machine |
| `sourceant status` | Whether the agent and the indexer are running |
| `sourceant repos` | Repositories indexed on this machine |
| `sourceant graph <repository>` | What the indexer found in one of them |
| `sourceant ui` | Open the graph in a browser |
| `sourceant version` | What this build is |

`--json` prints the agent's own answer, for anything that wants to read it rather than look at it.

## Building

```bash
make qa # fmt-check, vet, lint, test
make build
```

## Licence

MIT.
1 change: 1 addition & 0 deletions VERSION
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
0.1.0
12 changes: 12 additions & 0 deletions cmd/sourceant/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
// Command sourceant is the SourceAnt command line client.
package main

import (
"os"

"github.com/sourceant/cli/internal/command"
)

func main() {
os.Exit(command.Run(os.Args[1:], os.Stdout, os.Stderr))
}
10 changes: 10 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
module github.com/sourceant/cli

go 1.26.1

require github.com/spf13/cobra v1.10.2

require (
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/spf13/pflag v1.0.9 // indirect
)
10 changes: 10 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
171 changes: 171 additions & 0 deletions internal/agent/client.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
// Package agent talks to the SourceAnt agent running on this machine.
//
// The CLI never reaches past the agent to the Python core. The agent is the
// process that is always up and the one that knows where the core landed;
// going around it would mean learning both.
package agent

import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"time"
)

// Status is what the agent says about itself.
type Status struct {
Version string `json:"version"`
CoreURL string `json:"core_url"`
CoreUp bool `json:"core_up"`
CoreStarts int `json:"core_starts"`
LastExit string `json:"last_exit,omitempty"`
}

// Repository is one repository indexed on this machine.
type Repository struct {
Name string `json:"name"`
Path string `json:"path"`
}

// Node is one file, import or symbol.
type Node struct {
ID string `json:"id"`
Name string `json:"name"`
Kind string `json:"kind"`
Labels []string `json:"labels"`
Path string `json:"path"`
}

// Link is a typed edge between two nodes.
type Link struct {
Source string `json:"source"`
Target string `json:"target"`
Type string `json:"type"`
}

// Graph is one repository's whole scope.
type Graph struct {
Nodes []Node `json:"nodes"`
Links []Link `json:"links"`
Truncated bool `json:"truncated"`
}

// Error is a non-2xx answer from the agent.
type Error struct {
StatusCode int
Detail string
}

func (e *Error) Error() string {
if e.Detail == "" {
return fmt.Sprintf("the agent returned %d", e.StatusCode)
}
return e.Detail
}

// Unreachable says the agent is not running, or not where we looked.
type Unreachable struct {
BaseURL string
Cause error
}

func (e *Unreachable) Error() string {
return fmt.Sprintf("no agent answering at %s: %v", e.BaseURL, e.Cause)
}

func (e *Unreachable) Unwrap() error { return e.Cause }

// Client talks to one agent.
type Client struct {
baseURL string
http *http.Client
}

// New builds a client for the agent at baseURL.
func New(baseURL string, timeout time.Duration) *Client {
return &Client{
baseURL: strings.TrimRight(baseURL, "/"),
http: &http.Client{Timeout: timeout},
}
}

// BaseURL is the agent this client talks to.
func (c *Client) BaseURL() string { return c.baseURL }

// Status asks the agent how it and the core are doing.
func (c *Client) Status(ctx context.Context) (Status, error) {
return get[Status](ctx, c, "/health", nil)
}

// Repositories lists what is indexed on this machine.
func (c *Client) Repositories(ctx context.Context) ([]Repository, error) {
return get[[]Repository](ctx, c, "/api/repositories", nil)
}

// GraphOptions narrows what a drawing covers.
type GraphOptions struct {
PathPrefix string
IncludeTests bool
NodeLimit int
}

// Graph reads one repository's whole scope.
func (c *Client) Graph(ctx context.Context, repository string, opts GraphOptions) (Graph, error) {
query := url.Values{"repository": {repository}}
if opts.PathPrefix != "" {
query.Set("path_prefix", opts.PathPrefix)
}
if opts.IncludeTests {
query.Set("include_tests", "true")
}
if opts.NodeLimit > 0 {
query.Set("node_limit", strconv.Itoa(opts.NodeLimit))
}
return get[Graph](ctx, c, "/api/graph", query)
}

func get[T any](ctx context.Context, c *Client, path string, query url.Values) (T, error) {
var zero T
target := c.baseURL + path
if len(query) > 0 {
target += "?" + query.Encode()
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, target, nil)
if err != nil {
return zero, err
}
req.Header.Set("Accept", "application/json")

resp, err := c.http.Do(req)
if err != nil {
return zero, &Unreachable{BaseURL: c.baseURL, Cause: err}
}
defer func() { _ = resp.Body.Close() }()

body, err := io.ReadAll(resp.Body)
if err != nil {
return zero, err
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return zero, &Error{StatusCode: resp.StatusCode, Detail: detail(body)}
}
if err := json.Unmarshal(body, &zero); err != nil {
return zero, fmt.Errorf("the agent answered %s with something other than JSON: %w", path, err)
}
return zero, nil
}

func detail(body []byte) string {
var parsed struct {
Error string `json:"error"`
}
if err := json.Unmarshal(body, &parsed); err == nil && parsed.Error != "" {
return parsed.Error
}
return strings.TrimSpace(string(body))
}
Loading
Loading