Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 84958c7ef3 |
@@ -18,6 +18,7 @@ import (
|
||||
"time"
|
||||
|
||||
"gitea.alexandru.macocian.me/amacocian/sherlock/internal/agent"
|
||||
"gitea.alexandru.macocian.me/amacocian/sherlock/internal/config"
|
||||
"gitea.alexandru.macocian.me/amacocian/sherlock/internal/keyring"
|
||||
"gitea.alexandru.macocian.me/amacocian/sherlock/internal/mcp"
|
||||
)
|
||||
@@ -58,6 +59,13 @@ func main() {
|
||||
// `go install` even if the operator never touched their shell rc.
|
||||
agent.EnsurePathContainsSiblings()
|
||||
|
||||
// Register operator-defined custom agents from config.toml so they
|
||||
// dispatch just like the built-ins below.
|
||||
if err := registerCustomAgents(); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "sherlock:", err)
|
||||
os.Exit(exitUsage)
|
||||
}
|
||||
|
||||
switch sub {
|
||||
case "status":
|
||||
runStatus(store, rest)
|
||||
@@ -239,6 +247,28 @@ func knownMCPs() (mcp.Servers, error) {
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// registerCustomAgents loads the operator config (if any) and registers
|
||||
// each `[agents.<name>]` entry as a dispatchable agent. A missing config
|
||||
// file is not an error — sherlock simply offers only the built-ins. A
|
||||
// malformed agent entry (unknown backend, name collision) is fatal, in
|
||||
// keeping with the config package's "refuse to start rather than
|
||||
// misbehave" philosophy.
|
||||
func registerCustomAgents() error {
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
if errors.Is(err, config.ErrNotFound) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
for name, a := range cfg.Agents {
|
||||
if err := agent.RegisterCustom(name, a.Command, a.Backend); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func humanizeUntil(t time.Time) string {
|
||||
d := time.Until(t).Round(time.Second)
|
||||
if d < 0 {
|
||||
|
||||
@@ -46,3 +46,16 @@ base_url = "https://terminal.example.com"
|
||||
[services.searxng]
|
||||
provider = "sherlock-cli"
|
||||
base_url = "https://search.example.com"
|
||||
|
||||
# ── Custom agents ────────────────────────────────────────────────────
|
||||
# A [agents.<name>] block exposes an operator-owned wrapper command as
|
||||
# `sherlock <name>`, reusing a built-in backend's MCP/-env conventions.
|
||||
# `backend` must be "copilot" or "claude" — it tells sherlock how to
|
||||
# format the MCP config for the wrapped CLI. `command` is the binary to
|
||||
# exec and defaults to <name> when omitted.
|
||||
#
|
||||
# Example: surface a `copilot-local` wrapper (Copilot CLI pointed at a
|
||||
# self-hosted model via BYOK) as `sherlock copilot-local`.
|
||||
# [agents.copilot-local]
|
||||
# command = "copilot-local"
|
||||
# backend = "copilot"
|
||||
|
||||
+28
-4
@@ -23,7 +23,31 @@ behavior.
|
||||
|
||||
## Changing agents
|
||||
|
||||
Adding an agent is a code change under `internal/agent/`. Implement the small
|
||||
agent interface, register it in `init`, and use `internal/mcp` for config
|
||||
rendering. The exact API lives in `internal/agent/agent.go`; shared spawn
|
||||
helpers live in `internal/agent/exec.go`.
|
||||
Adding a built-in agent is a code change under `internal/agent/`. Implement
|
||||
the small agent interface, register it in `init`, and use `internal/mcp`
|
||||
for config rendering. The exact API lives in `internal/agent/agent.go`;
|
||||
shared spawn helpers live in `internal/agent/exec.go`, and the per-CLI
|
||||
MCP/-env conventions ("backends") live in `internal/agent/backend.go`.
|
||||
|
||||
## Custom agents (config)
|
||||
|
||||
Operators can register their own agents from `config.toml` without
|
||||
touching code — handy for wrapper commands such as a `copilot-local`
|
||||
script that points the Copilot CLI at a self-hosted model:
|
||||
|
||||
```toml
|
||||
[agents.copilot-local]
|
||||
command = "copilot-local" # binary to exec; defaults to the name if omitted
|
||||
backend = "copilot" # which CLI's MCP/-env conventions to use
|
||||
```
|
||||
|
||||
`backend` must be one of the built-in backends (`copilot` or `claude`);
|
||||
it tells sherlock how to format the MCP config (`--additional-mcp-config
|
||||
@<path>` vs `--mcp-config <path>`) and what env to scrub. The rendered
|
||||
MCP file is named after the agent (`<name>.mcp.json`).
|
||||
|
||||
`sherlock copilot-local [args...]` then dispatches exactly like a
|
||||
built-in. A custom agent whose name collides with a built-in agent, or
|
||||
that names an unknown backend, is a hard startup error. (Names matching a
|
||||
reserved subcommand — `status`, `logout`, `run`, `update`, `version` —
|
||||
are shadowed by that subcommand; reach them via `sherlock run <name>`.)
|
||||
|
||||
+11
-5
@@ -1,9 +1,15 @@
|
||||
// Package agent dispatches `sherlock <name>` invocations to a small
|
||||
// set of supported CLIs (Copilot, Claude Code, …). Each agent is a
|
||||
// concrete type in its own file that implements the Agent interface;
|
||||
// they register themselves at init time. Adding a new agent is a
|
||||
// matter of dropping a new file in this package — there is no TOML
|
||||
// schema and no user-facing extensibility surface, by design.
|
||||
// set of supported CLIs (Copilot, Claude Code, …). Each built-in agent
|
||||
// is a concrete type in its own file that implements the Agent
|
||||
// interface; they register themselves at init time. Adding a new
|
||||
// built-in is a matter of dropping a new file in this package.
|
||||
//
|
||||
// Operators can also declare custom agents in config.toml's
|
||||
// `[agents.<name>]` table — a wrapper command plus a backend type
|
||||
// ("copilot" or "claude") that selects the MCP-config and env
|
||||
// conventions. Those are added at runtime via RegisterCustom. The
|
||||
// per-CLI quirks shared by built-in and custom agents live in
|
||||
// backend.go.
|
||||
//
|
||||
// The reusable bits live in this package too (exec.go for env/exec
|
||||
// helpers; the mcp sibling package for MCP-config rendering). Agent
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
|
||||
"gitea.alexandru.macocian.me/amacocian/sherlock/internal/mcp"
|
||||
)
|
||||
|
||||
// backend captures the per-CLI quirks shared by every agent that speaks
|
||||
// to a given coding-assistant CLI: how the rendered MCP-config file is
|
||||
// passed on the command line, and which env vars must be scrubbed from
|
||||
// the child process.
|
||||
//
|
||||
// Built-in agents (copilot, claude) and operator-defined custom agents
|
||||
// from config.toml both spawn through a backend, so a `copilot-local`
|
||||
// wrapper formats its MCP config exactly like the real `copilot`.
|
||||
type backend struct {
|
||||
// mcpArgs returns the CLI flags that point the agent at the rendered
|
||||
// MCP-config file at path.
|
||||
mcpArgs func(path string) []string
|
||||
// forbidEnv lists env vars stripped from the child (e.g. a personal
|
||||
// ANTHROPIC_API_KEY that must not override broker-managed behavior).
|
||||
forbidEnv []string
|
||||
}
|
||||
|
||||
// backends is the set of known backend types. The keys are the values
|
||||
// operators put in `[agents.<name>].backend`.
|
||||
var backends = map[string]backend{
|
||||
"copilot": {
|
||||
// The `@` prefix tells Copilot to treat the argument as a file
|
||||
// path rather than inline JSON.
|
||||
mcpArgs: func(p string) []string { return []string{"--additional-mcp-config", "@" + p} },
|
||||
},
|
||||
"claude": {
|
||||
mcpArgs: func(p string) []string { return []string{"--mcp-config", p} },
|
||||
forbidEnv: []string{"ANTHROPIC_API_KEY"},
|
||||
},
|
||||
}
|
||||
|
||||
// backendNames returns the known backend type names, sorted, for error
|
||||
// messages.
|
||||
func backendNames() []string {
|
||||
out := make([]string, 0, len(backends))
|
||||
for n := range backends {
|
||||
out = append(out, n)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// spawn is the shared spawn path used by both built-in and custom
|
||||
// agents. name selects the rendered MCP-config file name; command is the
|
||||
// binary to exec. On success it does not return (the process is
|
||||
// replaced); any returned error is a setup failure.
|
||||
func (b backend) spawn(name, command string, ctx Context, args []string) error {
|
||||
bin, err := LookPath(command)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
mcpPath, err := mcp.Render(name, ctx.Servers)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
argv := append([]string{bin}, b.mcpArgs(mcpPath)...)
|
||||
argv = append(argv, args...)
|
||||
return DefaultExecer.Exec(bin, argv, BuildEnv(b.forbidEnv, nil))
|
||||
}
|
||||
|
||||
// resolveBackend returns the backend for a type name, with a friendly
|
||||
// error listing the valid choices when it is unknown.
|
||||
func resolveBackend(name string) (backend, error) {
|
||||
b, ok := backends[name]
|
||||
if !ok {
|
||||
return backend{}, fmt.Errorf("unknown backend %q (want one of: %v)", name, backendNames())
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
@@ -1,9 +1,5 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"gitea.alexandru.macocian.me/amacocian/sherlock/internal/mcp"
|
||||
)
|
||||
|
||||
func init() { Register(&claude{}) }
|
||||
|
||||
// claude integrates Anthropic's Claude Code CLI (npm package
|
||||
@@ -14,27 +10,14 @@ func init() { Register(&claude{}) }
|
||||
// We also forbid ANTHROPIC_API_KEY in the child env so an
|
||||
// inadvertently-set personal key can't override the broker-managed
|
||||
// session. (Phase 2 will surface a proper Anthropic auth grant.)
|
||||
//
|
||||
// The per-CLI quirks (flag shape, env scrubbing) live in the shared
|
||||
// "claude" backend; this type is just the built-in registration.
|
||||
type claude struct{}
|
||||
|
||||
func (claude) Name() string { return "claude" }
|
||||
func (claude) Description() string { return "Anthropic Claude Code CLI" }
|
||||
|
||||
func (c claude) Spawn(ctx Context, args []string) error {
|
||||
bin, err := LookPath("claude")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
mcpPath, err := mcp.Render(c.Name(), ctx.Servers)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
argv := c.buildArgv(bin, mcpPath, args)
|
||||
env := BuildEnv([]string{"ANTHROPIC_API_KEY"}, nil)
|
||||
return DefaultExecer.Exec(bin, argv, env)
|
||||
}
|
||||
|
||||
func (claude) buildArgv(bin, mcpPath string, userArgs []string) []string {
|
||||
argv := []string{bin, "--mcp-config", mcpPath}
|
||||
argv = append(argv, userArgs...)
|
||||
return argv
|
||||
return backends["claude"].spawn(c.Name(), "claude", ctx, args)
|
||||
}
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"gitea.alexandru.macocian.me/amacocian/sherlock/internal/mcp"
|
||||
)
|
||||
|
||||
func init() { Register(&copilot{}) }
|
||||
|
||||
// copilot integrates the GitHub Copilot CLI (npm package `@github/copilot`
|
||||
@@ -17,26 +13,14 @@ func init() { Register(&copilot{}) }
|
||||
// any `--additional-mcp-config` files on top. The merge is additive,
|
||||
// so sherlock-injected MCPs coexist with whatever the operator has
|
||||
// configured globally.
|
||||
//
|
||||
// The per-CLI quirks (flag shape, env scrubbing) live in the shared
|
||||
// "copilot" backend; this type is just the built-in registration.
|
||||
type copilot struct{}
|
||||
|
||||
func (copilot) Name() string { return "copilot" }
|
||||
func (copilot) Description() string { return "GitHub Copilot CLI" }
|
||||
|
||||
func (c copilot) Spawn(ctx Context, args []string) error {
|
||||
bin, err := LookPath("copilot")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
mcpPath, err := mcp.Render(c.Name(), ctx.Servers)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
argv := c.buildArgv(bin, mcpPath, args)
|
||||
return DefaultExecer.Exec(bin, argv, BuildEnv(nil, nil))
|
||||
}
|
||||
|
||||
func (copilot) buildArgv(bin, mcpPath string, userArgs []string) []string {
|
||||
argv := []string{bin, "--additional-mcp-config", "@" + mcpPath}
|
||||
argv = append(argv, userArgs...)
|
||||
return argv
|
||||
return backends["copilot"].spawn(c.Name(), "copilot", ctx, args)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
package agent
|
||||
|
||||
import "fmt"
|
||||
|
||||
// custom is an operator-defined agent declared in config.toml's
|
||||
// `[agents.<name>]` table. It wraps an arbitrary command — typically a
|
||||
// shell wrapper such as `copilot-local` that points a CLI at a
|
||||
// self-hosted model — and reuses a built-in backend's MCP-config and
|
||||
// env conventions so MCPs are formatted exactly as the underlying CLI
|
||||
// expects.
|
||||
type custom struct {
|
||||
name string
|
||||
desc string
|
||||
command string
|
||||
backend backend
|
||||
}
|
||||
|
||||
func (c custom) Name() string { return c.name }
|
||||
func (c custom) Description() string { return c.desc }
|
||||
|
||||
func (c custom) Spawn(ctx Context, args []string) error {
|
||||
return c.backend.spawn(c.name, c.command, ctx, args)
|
||||
}
|
||||
|
||||
// RegisterCustom adds an operator-defined agent to the registry.
|
||||
//
|
||||
// - name is the subcommand: `sherlock <name>`.
|
||||
// - command is the binary to exec; it defaults to name when empty.
|
||||
// - backendName selects the MCP/-env conventions ("copilot" or
|
||||
// "claude").
|
||||
//
|
||||
// Unlike Register, it returns an error (rather than panicking) on a bad
|
||||
// backend or a name that collides with an already-registered agent,
|
||||
// since the input comes from operator config rather than program code.
|
||||
func RegisterCustom(name, command, backendName string) error {
|
||||
if name == "" {
|
||||
return fmt.Errorf("agent: custom agent needs a name")
|
||||
}
|
||||
b, err := resolveBackend(backendName)
|
||||
if err != nil {
|
||||
return fmt.Errorf("agent: custom agent %q: %w", name, err)
|
||||
}
|
||||
if command == "" {
|
||||
command = name
|
||||
}
|
||||
if _, dup := registry[name]; dup {
|
||||
return fmt.Errorf("agent: custom agent %q conflicts with an existing agent", name)
|
||||
}
|
||||
registry[name] = custom{
|
||||
name: name,
|
||||
desc: fmt.Sprintf("custom %s agent (%s)", backendName, command),
|
||||
command: command,
|
||||
backend: b,
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"os"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRegisterCustom_CopilotBackendSpawn(t *testing.T) {
|
||||
fakeBin := makeFakeBinary(t, "copilot-local")
|
||||
t.Setenv("PATH", fakeBin+":"+os.Getenv("PATH"))
|
||||
t.Setenv("XDG_RUNTIME_DIR", t.TempDir())
|
||||
|
||||
if err := RegisterCustom("copilot-local", "copilot-local", "copilot"); err != nil {
|
||||
t.Fatalf("RegisterCustom: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { delete(registry, "copilot-local") })
|
||||
|
||||
a, ok := Get("copilot-local")
|
||||
if !ok {
|
||||
t.Fatal("custom agent not registered")
|
||||
}
|
||||
|
||||
orig := DefaultExecer
|
||||
defer func() { DefaultExecer = orig }()
|
||||
rec := &recordExecer{}
|
||||
DefaultExecer = rec
|
||||
|
||||
if err := a.Spawn(Context{}, []string{"-p", "hi"}); err != nil {
|
||||
t.Fatalf("Spawn: %v", err)
|
||||
}
|
||||
if !strings.HasSuffix(rec.argv0, "/copilot-local") {
|
||||
t.Fatalf("argv0 = %s", rec.argv0)
|
||||
}
|
||||
// Copilot backend formatting: --additional-mcp-config @<path>.
|
||||
if !slices.Contains(rec.argv, "--additional-mcp-config") {
|
||||
t.Fatalf("argv missing copilot mcp flag: %v", rec.argv)
|
||||
}
|
||||
foundAt := false
|
||||
for _, a := range rec.argv {
|
||||
if strings.HasPrefix(a, "@") && strings.HasSuffix(a, "/copilot-local.mcp.json") {
|
||||
foundAt = true
|
||||
}
|
||||
}
|
||||
if !foundAt {
|
||||
t.Fatalf("argv missing @-prefixed mcp path named after the custom agent: %v", rec.argv)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterCustom_ClaudeBackendForbidsKeyAndDefaultsCommand(t *testing.T) {
|
||||
fakeBin := makeFakeBinary(t, "claude-local")
|
||||
t.Setenv("PATH", fakeBin+":"+os.Getenv("PATH"))
|
||||
t.Setenv("ANTHROPIC_API_KEY", "leaked")
|
||||
t.Setenv("XDG_RUNTIME_DIR", t.TempDir())
|
||||
|
||||
// Empty command must default to the agent name.
|
||||
if err := RegisterCustom("claude-local", "", "claude"); err != nil {
|
||||
t.Fatalf("RegisterCustom: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { delete(registry, "claude-local") })
|
||||
|
||||
orig := DefaultExecer
|
||||
defer func() { DefaultExecer = orig }()
|
||||
rec := &recordExecer{}
|
||||
DefaultExecer = rec
|
||||
|
||||
a, _ := Get("claude-local")
|
||||
if err := a.Spawn(Context{}, nil); err != nil {
|
||||
t.Fatalf("Spawn: %v", err)
|
||||
}
|
||||
if !strings.HasSuffix(rec.argv0, "/claude-local") {
|
||||
t.Fatalf("argv0 should default to the agent name: %s", rec.argv0)
|
||||
}
|
||||
if !slices.Contains(rec.argv, "--mcp-config") {
|
||||
t.Fatalf("argv missing claude mcp flag: %v", rec.argv)
|
||||
}
|
||||
if hasEnv(rec.env, "ANTHROPIC_API_KEY") {
|
||||
t.Fatal("ANTHROPIC_API_KEY leaked to claude-backed custom agent")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterCustom_UnknownBackend(t *testing.T) {
|
||||
err := RegisterCustom("weird", "weird", "gemini")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for unknown backend")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "gemini") {
|
||||
t.Fatalf("error should mention the bad backend: %v", err)
|
||||
}
|
||||
if _, ok := Get("weird"); ok {
|
||||
t.Fatal("agent with bad backend should not be registered")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterCustom_NameCollision(t *testing.T) {
|
||||
// "copilot" is a built-in; a custom agent must not clobber it.
|
||||
if err := RegisterCustom("copilot", "copilot-local", "copilot"); err == nil {
|
||||
t.Fatal("expected error on collision with built-in agent")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterCustom_EmptyName(t *testing.T) {
|
||||
if err := RegisterCustom("", "cmd", "copilot"); err == nil {
|
||||
t.Fatal("expected error for empty name")
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,10 @@
|
||||
// provider = "sherlock-cli"
|
||||
// base_url = "https://grafana.example"
|
||||
//
|
||||
// [agents.copilot-local] # a custom wrapper agent
|
||||
// command = "copilot-local" # binary to exec (defaults to name)
|
||||
// backend = "copilot" # MCP/-env conventions to follow
|
||||
//
|
||||
// A service either references a [providers.<name>] block via `provider`
|
||||
// or carries its own inline issuer/client_id (as gitea does, since it
|
||||
// authenticates against its own OAuth server rather than Authentik).
|
||||
@@ -76,11 +80,26 @@ type OAuth struct {
|
||||
Browser string `toml:"browser"`
|
||||
}
|
||||
|
||||
// Agent is an operator-defined custom agent: a wrapper command plus the
|
||||
// backend whose MCP-config and env conventions it follows. It lets an
|
||||
// operator expose, say, a `copilot-local` wrapper (which points the
|
||||
// Copilot CLI at a self-hosted model) as `sherlock copilot-local` while
|
||||
// reusing the built-in "copilot" backend's MCP formatting.
|
||||
//
|
||||
// Command is the binary sherlock execs; it defaults to the agent's name
|
||||
// when empty. Backend selects the MCP/-env conventions and must name a
|
||||
// backend the agent package knows ("copilot" or "claude").
|
||||
type Agent struct {
|
||||
Command string `toml:"command"`
|
||||
Backend string `toml:"backend"`
|
||||
}
|
||||
|
||||
// Config is the whole parsed file.
|
||||
type Config struct {
|
||||
OAuth OAuth `toml:"oauth"`
|
||||
Providers map[string]Provider `toml:"providers"`
|
||||
Services map[string]Service `toml:"services"`
|
||||
Agents map[string]Agent `toml:"agents"`
|
||||
|
||||
// path is where this config was loaded from, for error messages.
|
||||
path string
|
||||
|
||||
@@ -184,3 +184,47 @@ func TestDefaultPath_XDG(t *testing.T) {
|
||||
t.Fatalf("DefaultPath = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgents_Parsed(t *testing.T) {
|
||||
path := writeConfig(t, `
|
||||
[agents.copilot-local]
|
||||
command = "copilot-local"
|
||||
backend = "copilot"
|
||||
|
||||
[agents.claude-local]
|
||||
backend = "claude"
|
||||
`)
|
||||
c, err := LoadFrom(path)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadFrom: %v", err)
|
||||
}
|
||||
if len(c.Agents) != 2 {
|
||||
t.Fatalf("expected 2 agents, got %d: %+v", len(c.Agents), c.Agents)
|
||||
}
|
||||
cp := c.Agents["copilot-local"]
|
||||
if cp.Command != "copilot-local" || cp.Backend != "copilot" {
|
||||
t.Fatalf("copilot-local = %+v", cp)
|
||||
}
|
||||
// command is optional; absence leaves it empty for the agent layer
|
||||
// to default to the name.
|
||||
cl := c.Agents["claude-local"]
|
||||
if cl.Command != "" || cl.Backend != "claude" {
|
||||
t.Fatalf("claude-local = %+v", cl)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgents_AbsentIsEmpty(t *testing.T) {
|
||||
path := writeConfig(t, `
|
||||
[services.gitea]
|
||||
issuer = "https://gitea.example"
|
||||
client_id = "x"
|
||||
base_url = "https://gitea.example"
|
||||
`)
|
||||
c, err := LoadFrom(path)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadFrom: %v", err)
|
||||
}
|
||||
if len(c.Agents) != 0 {
|
||||
t.Fatalf("expected no agents, got %+v", c.Agents)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user