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
48 changes: 48 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
# AGENTS contribution workflow

This repository uses an implementation-first workflow for all feature work.

## Required sequence for any feature

1. **Design first**
- Describe problem, goals, non-goals, API/CLI UX, and edge cases.
- Add or update design docs in `docs/`.
2. **Document behavior**
- Update user-facing docs (`README.md`, `docs/index.md`) when commands or
capabilities change.
3. **Write tests**
- Add targeted unit tests for new logic before/with implementation.
4. **Implement code**
- Keep changes surgical and consistent with existing project style.
5. **Update skills**
- Update `skills/climate.md` and `skills/climate-generator/SKILL.md` when
command set or workflows change.
6. **Validate locally**
- Run:
- `go build ./...`
- `go test ./...`
- Run targeted tests during development for faster feedback.
7. **Validate CI health**
- Ensure PR checks are green before merge.
8. **Commit discipline**
- Small, meaningful commits with clear messages.
9. **Push and PR hygiene**
- Push branch updates, keep PR description/checklist current, and respond to
review comments with the commit hash that addresses each request.

## Quality rules

- Do not remove or weaken unrelated tests.
- Do not introduce breaking CLI changes without docs + migration notes.
- Prefer deterministic behavior (sorted output, stable iteration).
- Keep generated/manifest behavior backward compatible where practical.

## Feature checklist template

- [ ] Design doc added/updated
- [ ] README/docs updated
- [ ] Skills updated
- [ ] Tests added/updated
- [ ] `go build ./...` passes
- [ ] `go test ./...` passes
- [ ] CI checks green
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,8 @@ Demo: [disk0Dancer/github](https://github.com/disk0Dancer/github) — 1 100+ end
| Command | Purpose |
|---|---|
| `generate` | Create CLI from OpenAPI spec |
| `compose` | Merge multiple specs (with prefixes) into one facade CLI |
| `mock` | Run local mock HTTP server from OpenAPI spec |
| `list` | Show registered CLIs |
| `remove` | Delete a generated CLI |
| `upgrade` | Regenerate from updated spec |
Expand All @@ -69,6 +71,9 @@ Demo: [disk0Dancer/github](https://github.com/disk0Dancer/github) — 1 100+ end

- [Site](https://disk0dancer.github.io/climate/)
- [LLM index](https://disk0dancer.github.io/climate/llms.txt)
- [Compose design](docs/design-compose.md)
- [Mock design](docs/design-mock.md)
- [OpenAPI 3.0 support matrix](docs/openapi-3-support-matrix.md)

## Development

Expand Down
149 changes: 149 additions & 0 deletions cmd/climate/commands/compose.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
package commands

import (
"fmt"
"strings"

"github.com/disk0Dancer/climate/internal/compose"
"github.com/disk0Dancer/climate/internal/generator"
"github.com/disk0Dancer/climate/internal/manifest"
"github.com/spf13/cobra"
)

var (
composeName string
composeOutDir string
composeNoBuild bool
composeForce bool
composeTitle string
composeVersion string
composeDesc string
)

var composeCmd = &cobra.Command{
Use: "compose [flags] <spec1>:<prefix1> [<spec2>:<prefix2> ...]",
Short: "Compose multiple OpenAPI specs into a single gateway CLI",
Long: `Merge several OpenAPI 3.x specifications — each assigned a path prefix —
into one composite spec, then generate a CLI from the result.

This is the recommended workflow for microservice environments where each
service owns its own OpenAPI document. The resulting CLI acts as a single
facade: one binary, one authentication model, all services.

Each positional argument has the form:

<spec>:<prefix>

Where <spec> is a file path or URL and <prefix> is a non-empty path prefix
that starts with "/" (e.g. "/api/v1").

Examples:
climate compose orders.yaml:/api/orders users.yaml:/api/users
climate compose --name gateway --title "Gateway API" \
https://orders.svc/openapi.json:/orders \
https://users.svc/openapi.json:/users`,
Args: cobra.MinimumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
inputs, err := parseSpecInputs(args)
if err != nil {
exitError("Invalid spec:prefix arguments", err)
}

merged, rawBytes, err := compose.MergeToBytes(inputs, compose.Options{
Title: composeTitle,
Version: composeVersion,
Description: composeDesc,
})
if err != nil {
exitError("Failed to compose specs", err)
}

opts := generator.Options{
CLIName: composeName,
OutDir: composeOutDir,
NoBuild: composeNoBuild,
Force: composeForce,
SpecSource: buildSpecSourceLabel(inputs),
}

result, err := generator.Generate(merged, rawBytes, opts)
if err != nil {
exitError("Generation failed", err)
}

// Update manifest.
mf, err := manifest.Load()
if err != nil {
fmt.Fprintf(cmd.ErrOrStderr(), "Warning: could not load manifest: %v\n", err)
} else {
mf.Upsert(manifest.CLIEntry{
Name: result.CLIName,
BinaryPath: result.BinaryPath,
SourceDir: result.SourceDir,
Version: result.Version,
OpenAPIHash: result.OpenAPIHash,
OpenAPISpec: opts.SpecSource,
})
if saveErr := mf.Save(); saveErr != nil {
fmt.Fprintf(cmd.ErrOrStderr(), "Warning: could not save manifest: %v\n", saveErr)
}
}

writeJSON(result)
return nil
},
}

// parseSpecInputs splits each "spec:prefix" argument into a SpecInput.
func parseSpecInputs(args []string) ([]compose.SpecInput, error) {
inputs := make([]compose.SpecInput, 0, len(args))
for _, arg := range args {
// A URL contains "://" so we must find the colon that separates the
// spec from the prefix carefully: the prefix always starts with "/" so
// the last occurrence of ":/" is the boundary.
idx := strings.LastIndex(arg, ":/")
if idx < 0 {
// Plain colon split (local path with no scheme).
parts := strings.SplitN(arg, ":", 2)
if len(parts) != 2 {
return nil, fmt.Errorf("argument %q must have the form <spec>:<prefix>", arg)
}
inputs = append(inputs, compose.SpecInput{Source: parts[0], Prefix: parts[1]})
continue
}

// Check whether ":/" is the scheme separator ("://") or the
// spec/prefix boundary.
schemeIdx := strings.Index(arg, "://")
if schemeIdx >= 0 && schemeIdx == idx {
// The only ":/" is the scheme — there's no prefix separator.
return nil, fmt.Errorf("argument %q must have the form <spec>:<prefix> (e.g. https://host/spec.json:/prefix)", arg)
}

// The last ":/" is the boundary (handles https://…:/prefix).
source := arg[:idx]
prefix := arg[idx+1:] // keeps the leading "/"
inputs = append(inputs, compose.SpecInput{Source: source, Prefix: prefix})
}
return inputs, nil
}

// buildSpecSourceLabel returns a human-readable label listing all source specs.
func buildSpecSourceLabel(inputs []compose.SpecInput) string {
parts := make([]string, len(inputs))
for i, inp := range inputs {
parts[i] = inp.Source + "@" + inp.Prefix
}
return "compose:[" + strings.Join(parts, ",") + "]"
}

func init() {
composeCmd.Flags().StringVar(&composeName, "name", "", "Override the generated CLI name")
composeCmd.Flags().StringVar(&composeOutDir, "out-dir", "", "Directory for generated source code")
composeCmd.Flags().BoolVar(&composeNoBuild, "no-build", false, "Skip building the binary")
composeCmd.Flags().BoolVar(&composeForce, "force", false, "Overwrite existing output directory")
composeCmd.Flags().StringVar(&composeTitle, "title", "", "Title for the composed API (info.title)")
composeCmd.Flags().StringVar(&composeVersion, "api-version", "1.0.0", "Version for the composed API (info.version)")
composeCmd.Flags().StringVar(&composeDesc, "description", "", "Description for the composed API (info.description)")
rootCmd.AddCommand(composeCmd)
}
128 changes: 128 additions & 0 deletions cmd/climate/commands/mock.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
package commands

import (
"fmt"
"net/http"
"strings"
"time"

"github.com/disk0Dancer/climate/internal/mock"
"github.com/disk0Dancer/climate/internal/spec"
"github.com/spf13/cobra"
)

var (
mockPort int
mockLatency int
mockEmitURL string
mockEventPath string
mockEventMethod string
)

var mockCmd = &cobra.Command{
Use: "mock [flags] <openapi_spec>",
Short: "Start a local HTTP mock server from an OpenAPI spec",
Long: `Start a local HTTP mock server that serves synthetic responses for every
endpoint defined in an OpenAPI 3.x specification.

This is useful for local development and testing when the real service is
unavailable, produces side-effects, or you simply want to experiment with
the API surface without any credentials.

The server inspects each operation's first successful (2xx) response schema
and generates a plausible JSON value — objects with all declared properties
filled in, arrays with one example element, and scalars set to sensible
zero values.

The spec can be a local file path or an HTTP(S) URL.

Examples:
climate mock ./openapi.yaml
climate mock --port 9090 https://petstore3.swagger.io/api/v3/openapi.json
climate mock --latency 200 ./orders.yaml
climate mock --emit-url http://localhost:3001/webhook --event-path /events/order-created --event-method POST ./openapi.yaml`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
specSource := args[0]
method := strings.ToUpper(strings.TrimSpace(mockEventMethod))
if method == "" {
method = http.MethodPost
}
emitMode := strings.TrimSpace(mockEmitURL) != ""

if !emitMode && (cmd.Flags().Changed("event-path") || cmd.Flags().Changed("event-method")) {
exitError("--event-path or --event-method require --emit-url", nil)
}
if emitMode {
if strings.TrimSpace(mockEventPath) == "" {
exitError("Missing required flag --event-path when using --emit-url", nil)
}
if !isValidHTTPMethod(method) {
exitError(
"Invalid --event-method value",
fmt.Errorf("unsupported HTTP method %q (supported: GET, POST, PUT, PATCH, DELETE)", method),
)
}
}

openAPI, err := spec.Load(specSource)
if err != nil {
exitError("Failed to load spec", err)
}

if emitMode {
payload, err := mock.GenerateEventPayload(openAPI, mockEventPath, method)
if err != nil {
exitError("Failed to generate event payload", err)
}
statusCode, err := mock.EmitEvent(mockEmitURL, method, payload)
if err != nil {
exitError("Failed to emit event", err)
}
fmt.Fprintf(cmd.OutOrStdout(),
"Emitted %s event from %s to %s (status: %d)\n",
method, mockEventPath, mockEmitURL, statusCode)
return nil
}

addr := fmt.Sprintf(":%d", mockPort)
latency := time.Duration(mockLatency) * time.Millisecond
s := mock.NewServer(openAPI, addr, latency)

fmt.Fprintf(cmd.OutOrStdout(), "Mock server for %q listening on http://localhost%s\n",
openAPI.Info.Title, addr)
if mockLatency > 0 {
fmt.Fprintf(cmd.OutOrStdout(), "Artificial latency: %dms\n", mockLatency)
}
fmt.Fprintln(cmd.OutOrStdout(), "\nRoutes:")
fmt.Fprint(cmd.OutOrStdout(), s.Summary())
fmt.Fprintln(cmd.OutOrStdout(), "\nPress Ctrl+C to stop.")

if err := s.ListenAndServe(); err != nil {
exitError("Mock server error", err)
}
return nil
},
}

func init() {
mockCmd.Flags().IntVar(&mockPort, "port", 8080, "TCP port to listen on")
mockCmd.Flags().IntVar(&mockLatency, "latency", 0, "Artificial response latency in milliseconds")
mockCmd.Flags().StringVar(&mockEmitURL, "emit-url", "", "Send one synthetic webhook/event payload to this URL and exit")
mockCmd.Flags().StringVar(&mockEventPath, "event-path", "", "OpenAPI path to use for synthetic event payload generation (required with --emit-url)")
mockCmd.Flags().StringVar(&mockEventMethod, "event-method", "POST", "HTTP method to use for event emission with --emit-url")
rootCmd.AddCommand(mockCmd)
}

func isValidHTTPMethod(method string) bool {
switch method {
case http.MethodGet,
http.MethodPost,
http.MethodPut,
http.MethodPatch,
http.MethodDelete:
return true
default:
return false
}
}
Loading
Loading