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
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ devkit is a Claude Code plugin: deterministic YAML workflow engine, thin-dispatc
| `src/cmd/guard.go` | Hook gatekeeper | What Bash/Edit/etc. is allowed per step type + enforce level |
| `src/cmd/workflow.go` | `devkit workflow` CLI command | Workflow invocation entry point |
| `src/cmd/mcp.go` | `devkit mcp` CLI command | MCP server bootstrap |
| `src/cmd/probe_local.go` | `devkit probe-local` CLI command | Local-inference endpoint health probe (human + `--json`) |
| `src/mcp/tools.go` | MCP tool schemas | `devkit_start`, `devkit_advance`, `devkit_list`, `devkit_status` contracts |
| `src/mcp/server.go` | MCP server implementation | Tool dispatching |
| `src/mcp/principles.go` | Principle injection | How `_principles.yml` reaches workflows |
Expand Down
61 changes: 61 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,67 @@ This shows which CLIs are installed, which agents are available, and which comma

---

## Local models (optional)

devkit can dispatch fast-tier work (doc generation, changelogs, summarization, test stubs) to any OpenAI-compatible local inference server. Cloud tiers — Claude, Codex, Gemini — still handle tri-review, architecture planning, and security review.

### Environment variables

| Var | Default | Purpose |
|---|---|---|
| `DEVKIT_LOCAL_ENABLED` | unset | Set to `1` to enable |
| `DEVKIT_LOCAL_ENDPOINT` | `http://localhost:11434/v1` | Base URL — must end in `/v1` |
| `DEVKIT_LOCAL_MODEL` | `qwen3:32b` | Model name sent in the request payload |
| `DEVKIT_LOCAL_API_KEY` | unset | Bearer token, if endpoint requires auth |
| `DEVKIT_LOCAL_TIMEOUT` | `600` | Per-request timeout (seconds) |
| `DEVKIT_LOCAL_DEBUG` | unset | Set to `1` to log probe failures to stderr |

### Default ports by stack

| Stack | Default port | Model-name source |
|---|---|---|
| llama-server (llama.cpp) | 8080 | loaded via `-m` at launch |
| llama-swap | 8080 | names in llama-swap YAML |
| Ollama | 11434 | `ollama list` |
| LM Studio | 1234 | GUI-loaded model |
| vLLM | 8000 | `--model` flag at launch |
| SGLang | 30000 | `--model-path` at launch |
| LocalAI | 8080 | models directory |

Port 8080 is the llama.cpp convention; llama-swap reuses it since it fronts llama-server processes.

### Examples

Single model, no router (llama-server, LM Studio, LocalAI, vLLM, SGLang):

```bash
export DEVKIT_LOCAL_ENABLED=1
export DEVKIT_LOCAL_ENDPOINT=http://<host>:<port>/v1
export DEVKIT_LOCAL_MODEL=<model-loaded-at-launch>
devkit-engine probe-local
```

Multi-model via router (Ollama, llama-swap, LocalAI):

```bash
export DEVKIT_LOCAL_ENABLED=1
export DEVKIT_LOCAL_ENDPOINT=http://<host>:<port>/v1
export DEVKIT_LOCAL_MODEL=<name-the-router-recognizes>
devkit-engine probe-local
```

### Verifying the setup

`devkit-engine probe-local` calls `$DEVKIT_LOCAL_ENDPOINT/models`, reports reachability and latency, and checks whether your configured model is present in the server's model list. Exit 0 on healthy, 1 otherwise. Add `--json` for structured output.

### Limits

- Fast tier only — local models have higher tool-call error rates than cloud tiers, so tri-review / architecture / security still go to the cloud.
- No function-calling (plain `/v1/chat/completions` only).
- No streaming.

---

## How It Works

Devkit runs as an **MCP server** inside Claude Code. When a workflow starts, the engine takes control:
Expand Down
2 changes: 1 addition & 1 deletion ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@
- **Cross-domain dirty-bit enforcement** — Blocks completion without test evidence per domain
- **Language-universal hooks** — Consolidated language-specific hooks into `lang-review.sh` with Go, TypeScript, Rust, Python, and Shell support
- **Hook consolidation** — Merged 14 hooks into 10, reduced per-edit shell processes from 7 to 4
- **Local runner** — OpenAI-compatible HTTP runner for Ollama/llama-server/vLLM alongside Claude/Codex/Gemini CLI runners. Opt-in via `DEVKIT_LOCAL_ENABLED=1`; defaults to `qwen3:32b` at `localhost:11434/v1`. Intended for `fast`-tier dispatch; cloud runners stay default for `smart`/`general` given local tool-call reliability gap.
- **Local runner** — OpenAI-compatible HTTP runner for Ollama/llama-server/llama-swap/vLLM/SGLang/LM Studio/LocalAI alongside Claude/Codex/Gemini CLI runners. Opt-in via `DEVKIT_LOCAL_ENABLED=1`; defaults to `qwen3:32b` at `localhost:11434/v1`. Intended for `fast`-tier dispatch; cloud runners stay default for `smart`/`general` given local tool-call reliability gap. `devkit-engine probe-local` verifies endpoint reachability, latency, and configured-model presence with human or `--json` output (wired into `/devkit:health`).
- **Stealth scraping backends** — `scrape` skill adds Camoufox (patched Firefox with leak-fixed JA3/TLS fingerprints) between Playwright and Firecrawl with auto-fallback on Cloudflare/DataDome/Incapsula challenge-page signatures, plus Scweet as host-routed backend for `x.com`/`twitter.com` URLs when `SCWEET_AUTH_TOKEN` is set.

## Retired
Expand Down
15 changes: 15 additions & 0 deletions skills/health/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,21 @@ else
fi
```

### Local Runner

```bash
echo "=== Local Runner ==="
if [ "$DEVKIT_LOCAL_ENABLED" = "1" ]; then
if command -v devkit-engine >/dev/null 2>&1; then
devkit-engine probe-local 2>&1 || echo "probe failed — see output above"
else
echo "devkit-engine not in PATH — build with 'make build' from src/"
fi
else
echo "disabled (DEVKIT_LOCAL_ENABLED unset) — set to 1 to enable local inference"
fi
```

### Agent Availability

List all agents from `agents/` directory with their model and isolation settings.
Expand Down
209 changes: 209 additions & 0 deletions src/cmd/probe_local.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,209 @@
package cmd

import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strings"
"time"

"github.com/5uck1ess/devkit/runners"
"github.com/spf13/cobra"
)

var errProbeFailed = errors.New("probe failed")

type ProbeConfig struct {
Enabled bool
Endpoint string
Model string
APIKey string
Timeout time.Duration
}

type ProbeResult struct {
Endpoint string `json:"endpoint"`
Model string `json:"model"`
Enabled bool `json:"enabled"`
Reachable bool `json:"reachable"`
HTTPStatus int `json:"http_status"`
LatencyMS int64 `json:"latency_ms"`
ModelsSeen []string `json:"models_seen"`
ModelMatch bool `json:"model_match"`
ErrorMsg string `json:"error,omitempty"`
Hint string `json:"hint,omitempty"`
}

func runProbe(ctx context.Context, cfg ProbeConfig) ProbeResult {
r := ProbeResult{
Endpoint: cfg.Endpoint,
Model: cfg.Model,
Enabled: cfg.Enabled,
}
if !cfg.Enabled {
return r
}

reqCtx, cancel := context.WithTimeout(ctx, cfg.Timeout)
defer cancel()

req, err := http.NewRequestWithContext(reqCtx, http.MethodGet, cfg.Endpoint+"/models", nil)
if err != nil {
r.ErrorMsg = fmt.Sprintf("building request: %v", err)
r.Hint = "check DEVKIT_LOCAL_ENDPOINT format — must be a valid URL ending in /v1"
return r
}
if cfg.APIKey != "" {
req.Header.Set("Authorization", "Bearer "+cfg.APIKey)
}

client := &http.Client{Timeout: cfg.Timeout}
start := time.Now()
resp, err := client.Do(req)
r.LatencyMS = time.Since(start).Milliseconds()
if err != nil {
r.ErrorMsg = err.Error()
r.Hint = "endpoint unreachable — check it is running and DEVKIT_LOCAL_ENDPOINT is correct"
return r
}
defer resp.Body.Close()
r.HTTPStatus = resp.StatusCode

if resp.StatusCode >= 400 {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 256))
r.ErrorMsg = strings.TrimSpace(string(body))
switch resp.StatusCode {
case 401, 403:
r.Hint = "check DEVKIT_LOCAL_API_KEY — endpoint requires auth"
case 404:
r.Hint = "check DEVKIT_LOCAL_ENDPOINT — must end in /v1 (some stacks need the suffix)"
default:
r.Hint = "endpoint returned an error — check server logs"
}
return r
}

raw, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if err != nil {
r.ErrorMsg = fmt.Sprintf("reading response: %v", err)
return r
}

var parsed struct {
Data []struct {
ID string `json:"id"`
} `json:"data"`
}
if err := json.Unmarshal(raw, &parsed); err != nil {
r.ErrorMsg = fmt.Sprintf("parsing /models response: %v", err)
r.Hint = "endpoint did not return OpenAI /models JSON — confirm it speaks the OpenAI spec"
return r
}

r.Reachable = true
for _, m := range parsed.Data {
r.ModelsSeen = append(r.ModelsSeen, m.ID)
if m.ID == cfg.Model {
r.ModelMatch = true
}
}
if !r.ModelMatch {
r.Hint = fmt.Sprintf("configured model %q not in /models — check DEVKIT_LOCAL_MODEL matches a name the server exposes", cfg.Model)
}
return r
}

func formatHuman(r ProbeResult) string {
var b strings.Builder
fmt.Fprintf(&b, "endpoint: %s\n", r.Endpoint)
fmt.Fprintf(&b, "model: %s\n", r.Model)
if !r.Enabled {
fmt.Fprintln(&b, "enabled: no — disabled (set DEVKIT_LOCAL_ENABLED=1 to enable)")
return b.String()
}
fmt.Fprintln(&b, "enabled: yes")
if r.Reachable {
fmt.Fprintf(&b, "reachable: yes (HTTP %d, %dms)\n", r.HTTPStatus, r.LatencyMS)
if len(r.ModelsSeen) > 0 {
fmt.Fprintf(&b, "models seen: %s\n", strings.Join(r.ModelsSeen, ", "))
} else {
fmt.Fprintln(&b, "models seen: (none returned)")
}
if r.ModelMatch {
fmt.Fprintln(&b, "model match: OK (configured model present in /models)")
} else {
fmt.Fprintln(&b, "model match: MISSING")
if r.Hint != "" {
fmt.Fprintf(&b, "hint: %s\n", r.Hint)
}
}
return b.String()
}
if r.HTTPStatus > 0 {
fmt.Fprintf(&b, "reachable: NO (HTTP %d in %dms)\n", r.HTTPStatus, r.LatencyMS)
} else {
fmt.Fprintf(&b, "reachable: NO (%dms)\n", r.LatencyMS)
}
if r.Hint != "" {
fmt.Fprintf(&b, "hint: %s\n", r.Hint)
}
if r.ErrorMsg != "" {
fmt.Fprintf(&b, "body: %s\n", r.ErrorMsg)
}
return b.String()
}

func formatJSON(r ProbeResult) ([]byte, error) {
return json.MarshalIndent(r, "", " ")
}

var probeLocalJSON bool

var probeLocalCmd = &cobra.Command{
Use: "probe-local",
Short: "Probe the configured local inference endpoint",
Long: `Probe the OpenAI-compatible endpoint configured via DEVKIT_LOCAL_* env vars.

Reports endpoint, model, reachability, and whether the configured model is
present in the server's /v1/models response. Exit 0 on healthy, 1 otherwise.`,
// Override root's PersistentPreRunE — this probe doesn't need a git repo or DB.
PersistentPreRunE: func(cmd *cobra.Command, args []string) error { return nil },
SilenceUsage: true,
SilenceErrors: true,
RunE: func(cmd *cobra.Command, args []string) error {
cfg := ProbeConfig{
Enabled: runners.LocalEnabled(),
Endpoint: runners.LocalEndpoint(),
Model: runners.LocalModel(),
APIKey: runners.LocalAPIKey(),
Timeout: 3 * time.Second,
}
result := runProbe(cmd.Context(), cfg)

if probeLocalJSON {
out, err := formatJSON(result)
if err != nil {
return fmt.Errorf("formatting JSON: %w", err)
}
fmt.Println(string(out))
} else {
fmt.Print(formatHuman(result))
}

if !cfg.Enabled {
return nil
}
if !result.Reachable || !result.ModelMatch {
return errProbeFailed
}
return nil
},
}

func init() {
probeLocalCmd.Flags().BoolVar(&probeLocalJSON, "json", false, "emit structured JSON instead of human-readable text")
rootCmd.AddCommand(probeLocalCmd)
}
Loading
Loading