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
1 change: 1 addition & 0 deletions ECOSYSTEM.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@
| SIN-Browser-Tools | `browser__*` (106 tools) | ask | ACTIVE |
| GitHub CLI (gh) | `gh_query`, `gh_health`, `gh_execute` | allow / allow / ask (M4) | ACTIVE |

| [OpenSIN-Code/autodev-cli](https://github.com/OpenSIN-Code/autodev-cli) | `autodev__*` (e.g. `autodev_status`, `autodev_lessons`, `autodev_init`, `autodev_run_experiment`, `autodev_swarm`, `autodev_session_log`) | allow (read-only) + ask (mutating) — split M4 policy |
## LLM Backends

| Repo | Integration | Status |
Expand Down
128 changes: 128 additions & 0 deletions cmd/sin-code/autodev_cmd.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
// SPDX-License-Identifier: MIT
// Purpose: `sin-code autodev` — Cobra constructor for the
// OpenSIN-Code/autodev-cli (Python, MIT, v0.4.0) bridge. Mirrors
// gh_cmd.go (M4 + v3.8.0+ Bridged-External pattern): own subcommand
// set, transparent stdout/stderr forwarding, no business logic,
// Python never vendored. Setup / doctor / version are pure shell-out
// to autodev-cli and rely on internal/autodev for binary discovery.
// Docs: autodev.doc.md
package main

import (
"context"
"fmt"
"os"
"os/exec"
"time"

"github.com/OpenSIN-Code/SIN-Code/cmd/sin-code/internal/autodev"
"github.com/spf13/cobra"
)

// NewAutodevCmd builds the `autodev` cobra subcommand. Pattern matches
// NewGhCmd / NewVaneCmd: returns *cobra.Command with setup / doctor /
// version attached. All verbs are one-line shell-out via autodev-cli;
// no reserialization, no interception, no caching.
func NewAutodevCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "autodev",
Short: "Bridge to OpenSIN-Code/autodev-cli (Python autoresearch loop, never vendored)",
Long: `sin-code autodev shells out to the user-installed autodev-cli
(https://github.com/OpenSIN-Code/autodev-cli, MIT, v0.4.0) without ever
vendoring its Python sources. This subcommand is the operator-facing
entry point: setup, doctor, version. Each verb forwards stdout and
stderr transparently — the calling agent loop sees exactly what
autodev-cli printed, with no reserialization or comment injection.

Binary resolution honors $AUTODEV_BIN (override) and falls back to
"autodev" on $PATH. Install autodev-cli separately (pipx / pip) —
this subcommand refuses to run if it cannot find the binary, with
the exact lookup error surfaced so the operator can fix PATH.`,
}
cmd.AddCommand(newAutodevSetupCmd())
cmd.AddCommand(newAutodevDoctorCmd())
cmd.AddCommand(newAutodevVersionCmd())
return cmd
}

// ── setup ─────────────────────────────────────────────────────────────

// newAutodevSetupCmd runs `autodev init --json .` in the operator's
// working directory. Idempotent: autodev-cli's init tolerates a
// pre-initialized project. Exit code propagates through cobra.
func newAutodevSetupCmd() *cobra.Command {
return &cobra.Command{
Use: "setup",
Short: "Initialize a project for autodev-cli (runs `autodev init --json .`)",
Long: `Idempotent. Forwards stdout/stderr from 'autodev init --json .'
verbatim. Non-zero exit propagates through cobra so CI / agent loops
can detect partial init. Set $AUTODEV_BIN to override the binary.`,
RunE: func(cmd *cobra.Command, _ []string) error {
ctx, cancel := context.WithTimeout(cmd.Context(), 2*time.Minute)
defer cancel()
return runPassthrough(ctx, autodev.DefaultBin(), "init", "--json", ".")
},
}
}

// ── doctor ────────────────────────────────────────────────────────────

// newAutodevDoctorCmd runs `autodev status --json`. Output is the
// canonical JSON blob autodev-cli's autodev-mcp tool consumes; echoing
// it byte-for-byte lets downstream MCP tools round-trip.
func newAutodevDoctorCmd() *cobra.Command {
return &cobra.Command{
Use: "doctor",
Short: "Show current autodev project state (runs `autodev status --json`)",
Long: `Runs 'autodev status --json' and forwards its JSON output
verbatim so MCP consumers receive the same document upstream produced.
Exit code propagates.`,
RunE: func(cmd *cobra.Command, _ []string) error {
ctx, cancel := context.WithTimeout(cmd.Context(), 30*time.Second)
defer cancel()
return runPassthrough(ctx, autodev.DefaultBin(), "status", "--json")
},
}
}

// ── version ───────────────────────────────────────────────────────────

// newAutodevVersionCmd shells out to `autodev --version` and prints
// the trimmed stdout on a single line. Errors surface the wrapped
// upstream message so the operator can tell whether the binary is
// absent (typical CI case) or upstream lacks the flag (transient).
func newAutodevVersionCmd() *cobra.Command {
return &cobra.Command{
Use: "version",
Short: "Report upstream autodev-cli --version",
Long: `Shells out to 'autodev --version' (per upstream contract) and prints the trimmed stdout line. Non-zero exit / stderr / empty stdout surface as a cobra error.`,
RunE: func(cmd *cobra.Command, _ []string) error {
v, err := autodev.Version()
if err != nil {
// Surface upstream's stderr verbatim so the operator
// can see WHY `--version` failed (e.g. "No such
// option --version" until upstream lands the flag).
fmt.Fprintln(cmd.ErrOrStderr(), "✗ autodev version probe failed:", err)
return err
}
fmt.Fprintln(cmd.OutOrStdout(), v)
return nil
},
}
}

// ── helpers ───────────────────────────────────────────────────────────

// runPassthrough executes bin <args...> with the given ctx and pipes
// stdout + stderr bit-for-bit to the parent process. Gates on
// autodev.ResolveAutodevBin() so the operator gets a clean install
// hint instead of a stack trace if autodev-cli is missing.
func runPassthrough(ctx context.Context, bin string, args ...string) error {
if err := autodev.ResolveAutodevBin(); err != nil {
return fmt.Errorf("autodev bridge: %w (set $AUTODEV_BIN or install %s)", err, bin)
}
c := exec.CommandContext(ctx, bin, args...)

Check failure

Code scanning / gosec

Subprocess launched with variable Error

Subprocess launched with variable
c.Stdout = os.Stdout // transparent: no reserialization
c.Stderr = os.Stderr // transparent: no reserialization
return c.Run()
}
121 changes: 121 additions & 0 deletions cmd/sin-code/internal/autodev/autodev.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
// SPDX-License-Identifier: MIT
// Purpose: autodev bridge — stdlib-only binary discovery + version probe
// for the OpenSIN-Code/autodev-cli (Python, MIT, v0.4.0) CLI/MCP. The
// bridge itself is the runtime subprocess boundary; Python sources are
// never vendored (M2: single static Go binary, CGO_ENABLED=0, and
// "NOT a place to vendor tool implementations that live in their own
// repos" — AGENTS.md §2). Stdlib-only imports: os, context, os/exec,
// errors, fmt, strings. No modelcontextprotocol/go-sdk here — that
// dependency belongs to mcpclient/registry.go callers, not the bridge.
// Docs: autodev.doc.md
package autodev

import (
"context"
"errors"
"fmt"
"os"
"os/exec"
"strings"
)

// Env names for binary overrides (highest priority). Set these to point
// the bridge at locally-built / pinned copies without rebuilding sin-code.
const (
envBin = "AUTODEV_BIN" // overrides DefaultBin()
envMCPBin = "AUTODEV_MCP_BIN" // overrides DefaultMCPBin()
)

// DefaultBin returns the autodev CLI binary path the bridge will
// invoke, honoring $AUTODEV_BIN when set and non-empty (trimmed).
// Falls back to "autodev" (PATH-resolved at exec time).
func DefaultBin() string {
if v := strings.TrimSpace(os.Getenv(envBin)); v != "" {
return v
}
return "autodev"
}

// DefaultMCPBin returns the autodev-mcp stdio server binary, honoring
// $AUTODEV_MCP_BIN. Falls back to "autodev-mcp".
func DefaultMCPBin() string {
if v := strings.TrimSpace(os.Getenv(envMCPBin)); v != "" {
return v
}
return "autodev-mcp"
}

// IsInstalled reports whether bin resolves on PATH (or as an absolute
// path). Safe on empty / whitespace-only input (returns false).
func IsInstalled(bin string) bool {
if strings.TrimSpace(bin) == "" {
return false
}
_, err := exec.LookPath(bin)
return err == nil
}

// ErrNotInstalled is returned by ResolveAutodevBin / ResolveAutodevMCPBin
// when the resolved binary is missing. Wrapped exec.ErrNotFound so
// callers can errors.Is() either layer.
var ErrNotInstalled = errors.New("autodev: binary not installed")

// ResolveAutodevBin returns ErrNotInstalled (wrapping exec.ErrNotFound)
// when DefaultBin() does not resolve on PATH or as an absolute path.
func ResolveAutodevBin() error { return resolve(DefaultBin()) }

// ResolveAutodevMCPBin returns ErrNotInstalled when DefaultMCPBin() is
// absent. Identical semantics to ResolveAutodevBin; split into two
// functions so callers can tick the right install gate independently.
func ResolveAutodevMCPBin() error { return resolve(DefaultMCPBin()) }

// Version shells out to `autodev --version` (per the upstream bridge
// contract) and returns the trimmed stdout on clean exit. Any non-zero
// exit code, non-empty stderr, or empty stdout becomes an error so the
// caller can branch on upstream presence. Stdlib-only — no JSON, no
// regex, no side imports.
func Version() (string, error) {
return versionWith(context.Background())
}

// versionWith is the context-aware test seam. Production code calls
// Version(); tests inject deadlines or background as needed.
func versionWith(ctx context.Context) (string, error) {
bin := DefaultBin()
cmd := exec.CommandContext(ctx, bin, "--version")

Check failure

Code scanning / gosec

Subprocess launched with variable Error

Subprocess launched with variable
var stdout, stderr strings.Builder
cmd.Stdout = &stdout
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
// Prefer upstream's stderr (rich diagnostics) over the wrapped
// exit error so the operator sees WHY upstream rejected the
// flag (e.g. "No such option --version" until upstream lands
// one — tracked upstream).
msg := strings.TrimSpace(stderr.String())
if msg == "" {
msg = strings.TrimSpace(stdout.String())
}
if msg == "" {
msg = err.Error()
}
return "", fmt.Errorf("autodev: %s --version failed: %w: %s", bin, err, msg)
}
out := strings.TrimSpace(stdout.String())
if out == "" {
return "", fmt.Errorf("autodev: %s --version returned empty stdout", bin)
}
return out, nil
}

// resolve is the shared body of the two Resolve* funcs. Centralised so
// the error-wrapping rule (ErrNotInstalled wraps exec.ErrNotFound) is
// impossible to forget in a new Resolve path.
func resolve(bin string) error {
if strings.TrimSpace(bin) == "" {
return fmt.Errorf("%w: empty bin name", ErrNotInstalled)
}
if _, err := exec.LookPath(bin); err != nil {
return fmt.Errorf("%w: %s: %w", ErrNotInstalled, bin, err)
}
return nil
}
Loading
Loading