mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-11 18:00:54 -10:00
* feat(providers): add `zero providers detect` for local-runtime onboarding Wire the previously-unwired provideronboarding detect/advice surface into the CLI. `zero providers detect` probes for running local OpenAI-compatible runtimes (Ollama, LM Studio) on their default ports and prints a no-key adopt command for each, followed by the next-step actions (use / check / set key) for every configured provider. Supports --json. This connects DetectLocalRuntimes, LocalRuntimeCandidates, DetectedLocalRuntime.SetupAction, ProviderActions, MissingCredentialAction, SetupCommand, and ProviderState.Actions — a fully built, tested feature that had no caller. Adds an injectable detectLocalRuntimes appDep for hermetic tests. * feat(sandbox): add `zero sandbox check` to evaluate a decision as JSON Wire the previously-unwired zerocommands sandbox-snapshot contract into the CLI. `zero sandbox check <tool>` evaluates what the sandbox engine would decide for a hypothetical tool action against the resolved policy, and emits the active plan (policy + backend + restrictions), the decision (allow/prompt/deny with risk and any violation), and any persistent grant matching the tool. Supports --json; grant reasons are redacted. This connects SandboxPlanSnapshotFromPlan, SandboxPolicySnapshotFromPolicy, SandboxBackendSnapshotFromBackend, SandboxDecisionSnapshotFromDecision, SandboxRiskSnapshotFromRisk, SandboxViolationSnapshotFromViolation, and SandboxGrantMatchSnapshotFromLookup — a fully built, tested JSON contract that had no production consumer. The existing `sandbox policy` output is unchanged. * test(providermodelcatalog): cover model predicates and remote fetch layer Add targeted tests for the audit-identified coverage gaps: the pure logic (LooksLikeCodingModelID, modelMatchesProvider, defaultedOpenGatewayURL, modelSortLabel/sortModels) and the HTTP fetch layer (fetchJSON, FetchModelsDev, FetchOpenGateway, FetchRemote routing) via httptest. Package coverage 48.9% -> 79.5%. cmd/zero (main shim) and browser.OpenURL (exec wrapper) are intentionally left to CI smoke — their only logic seams (openCommand) are already covered. * fix(sandbox): address CodeRabbit review on `sandbox check` - Pass the configured write-root scope (NewScope with AdditionalWriteRoots) into the check engine, so its decision matches real enforcement and a stale extra-root config surfaces here too instead of a workspace-only fallback. - Validate --side-effect and --autonomy against their advertised value sets and return a usage error on a typo, rather than letting the engine normalize it. - Reword the unmatched-grant line to "no grant matched this action" — Lookup only checks this tool/scope/autonomy, not whether any grant exists for the tool. - Add tests: invalid-flag rejection and a matched-grant reason-redaction regression. * test(sandbox): make the out-of-workspace check test path OS-portable Smoke (windows-latest) failed: "/etc/passwd" is not absolute on Windows, so the check joined it into the workspace and returned "prompt" instead of "deny". Build the out-of-workspace path under a separate temp dir so it is absolute and outside the workspace on every OS.
179 lines
5.5 KiB
Go
179 lines
5.5 KiB
Go
package cli
|
|
|
|
import (
|
|
"fmt"
|
|
"io"
|
|
"strings"
|
|
|
|
"github.com/Gitlawb/zero/internal/config"
|
|
"github.com/Gitlawb/zero/internal/provideronboarding"
|
|
)
|
|
|
|
type providerDetectOptions struct {
|
|
json bool
|
|
}
|
|
|
|
// providerDetectAction is the JSON/text shape of a single suggested next step.
|
|
type providerDetectAction struct {
|
|
Label string `json:"label"`
|
|
Command string `json:"command"`
|
|
Detail string `json:"detail,omitempty"`
|
|
}
|
|
|
|
type providerDetectRuntime struct {
|
|
CatalogID string `json:"catalogID"`
|
|
Name string `json:"name"`
|
|
BaseURL string `json:"baseURL"`
|
|
Models []string `json:"models,omitempty"`
|
|
Action providerDetectAction `json:"action"`
|
|
}
|
|
|
|
type providerDetectProvider struct {
|
|
Name string `json:"name"`
|
|
Active bool `json:"active"`
|
|
Actions []providerDetectAction `json:"actions,omitempty"`
|
|
}
|
|
|
|
type providerDetectReport struct {
|
|
DetectedRuntimes []providerDetectRuntime `json:"detectedRuntimes"`
|
|
Providers []providerDetectProvider `json:"providers"`
|
|
}
|
|
|
|
// runProvidersDetect probes the machine for running local, OpenAI-compatible
|
|
// model runtimes (Ollama, LM Studio) and prints a no-key adopt command for each
|
|
// one it finds, followed by the next-step actions for every already-configured
|
|
// provider. It is the onboarding-advice surface — "what can I do right now?" —
|
|
// and never errors on a machine with nothing running locally (it just reports an
|
|
// empty detected list).
|
|
func runProvidersDetect(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
|
|
options, help, err := parseProviderDetectArgs(args)
|
|
if err != nil {
|
|
return writeExecUsageError(stderr, err.Error())
|
|
}
|
|
if help {
|
|
if err := writeProvidersHelp(stdout); err != nil {
|
|
return exitCrash
|
|
}
|
|
return exitSuccess
|
|
}
|
|
|
|
resolved, exitCode := resolveCommandCenterConfig(stderr, deps)
|
|
if exitCode != exitSuccess {
|
|
return exitCode
|
|
}
|
|
|
|
ctx, stop := signalContext()
|
|
defer stop()
|
|
detected := deps.detectLocalRuntimes(ctx, provideronboarding.LocalDetectOptions{})
|
|
|
|
report := buildProviderDetectReport(resolved, detected)
|
|
|
|
if options.json {
|
|
if err := writePrettyJSON(stdout, report); err != nil {
|
|
return exitCrash
|
|
}
|
|
return exitSuccess
|
|
}
|
|
if _, err := fmt.Fprintln(stdout, formatProviderDetectReport(report)); err != nil {
|
|
return exitCrash
|
|
}
|
|
return exitSuccess
|
|
}
|
|
|
|
func parseProviderDetectArgs(args []string) (providerDetectOptions, bool, error) {
|
|
options := providerDetectOptions{}
|
|
for _, arg := range args {
|
|
switch {
|
|
case arg == "-h" || arg == "--help" || arg == "help":
|
|
return options, true, nil
|
|
case arg == "--json":
|
|
options.json = true
|
|
default:
|
|
return options, false, execUsageError{fmt.Sprintf("unexpected argument %q", arg)}
|
|
}
|
|
}
|
|
return options, false, nil
|
|
}
|
|
|
|
func buildProviderDetectReport(resolved config.ResolvedConfig, detected []provideronboarding.DetectedLocalRuntime) providerDetectReport {
|
|
report := providerDetectReport{
|
|
DetectedRuntimes: make([]providerDetectRuntime, 0, len(detected)),
|
|
Providers: make([]providerDetectProvider, 0, len(resolved.Providers)),
|
|
}
|
|
for _, runtime := range detected {
|
|
report.DetectedRuntimes = append(report.DetectedRuntimes, providerDetectRuntime{
|
|
CatalogID: runtime.CatalogID,
|
|
Name: runtime.Name,
|
|
BaseURL: runtime.BaseURL,
|
|
Models: runtime.Models,
|
|
Action: providerDetectActionFrom(runtime.SetupAction()),
|
|
})
|
|
}
|
|
for _, profile := range resolved.Providers {
|
|
active := strings.TrimSpace(profile.Name) != "" && profile.Name == resolved.ActiveProvider
|
|
state := provideronboarding.ProviderState{Profile: profile, Active: active}
|
|
actions := state.Actions()
|
|
entry := providerDetectProvider{
|
|
Name: profile.Name,
|
|
Active: active,
|
|
Actions: make([]providerDetectAction, 0, len(actions)),
|
|
}
|
|
for _, action := range actions {
|
|
entry.Actions = append(entry.Actions, providerDetectActionFrom(action))
|
|
}
|
|
report.Providers = append(report.Providers, entry)
|
|
}
|
|
return report
|
|
}
|
|
|
|
func providerDetectActionFrom(action provideronboarding.Action) providerDetectAction {
|
|
return providerDetectAction{Label: action.Label, Command: action.Command, Detail: action.Detail}
|
|
}
|
|
|
|
func formatProviderDetectReport(report providerDetectReport) string {
|
|
lines := []string{"Detected local runtimes:"}
|
|
if len(report.DetectedRuntimes) == 0 {
|
|
lines = append(lines, " (none running on default ports)")
|
|
}
|
|
for _, runtime := range report.DetectedRuntimes {
|
|
name := strings.TrimSpace(runtime.Name)
|
|
if name == "" {
|
|
name = runtime.CatalogID
|
|
}
|
|
lines = append(lines, " "+name+" — "+runtime.BaseURL)
|
|
if len(runtime.Models) > 0 {
|
|
lines = append(lines, " models: "+strings.Join(runtime.Models, ", "))
|
|
}
|
|
if command := strings.TrimSpace(runtime.Action.Command); command != "" {
|
|
lines = append(lines, " "+runtime.Action.Label+": "+command)
|
|
}
|
|
}
|
|
|
|
lines = append(lines, "", "Configured providers:")
|
|
if len(report.Providers) == 0 {
|
|
lines = append(lines, " (none configured — run `zero setup`)")
|
|
}
|
|
for _, provider := range report.Providers {
|
|
name := strings.TrimSpace(provider.Name)
|
|
if name == "" {
|
|
name = "(unnamed)"
|
|
}
|
|
marker := ""
|
|
if provider.Active {
|
|
marker = " (active)"
|
|
}
|
|
lines = append(lines, " "+name+marker)
|
|
if len(provider.Actions) == 0 {
|
|
lines = append(lines, " (ready)")
|
|
}
|
|
for _, action := range provider.Actions {
|
|
line := " " + action.Label
|
|
if command := strings.TrimSpace(action.Command); command != "" {
|
|
line += ": " + command
|
|
}
|
|
lines = append(lines, line)
|
|
}
|
|
}
|
|
return strings.Join(lines, "\n")
|
|
}
|