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
23 changes: 23 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -144,19 +144,42 @@ onecli auth status Check current auth state

Authentication is only required when the server enforces it. In local mode, commands work without logging in.

### Run

```
onecli run -- <command> [args...] Run a command with gateway access
onecli run --agent my-agent -- claude One-off run as a specific agent
```

`onecli run` launches any command behind the OneCLI gateway — proxy and CA
trust are configured automatically, and credentials are injected at the
gateway. The agent identity resolves as `--agent` flag > `ONECLI_AGENT` env >
`onecli config set agent <identifier>`; when none is set, the project's
default agent is used.

### Config

```
onecli config get <key> Read config value
onecli config set <key> <value> Write config value
```

Keys:

| Key | Description |
|-----|-------------|
| `api-host` | API base URL (default: `https://api.onecli.sh`) |
| `project` | Project slug used when no `--project` flag is given |
| `agent` | Agent identifier `onecli run` uses when no `--agent` flag is given (pins this machine to an agent) |

## Environment Variables

| Variable | Description |
|----------|-------------|
| `ONECLI_API_KEY` | API key (overrides stored key) |
| `ONECLI_API_HOST` | API base URL (default: `https://api.onecli.sh`) |
| `ONECLI_PROJECT` | Project slug (overrides the `project` config key) |
| `ONECLI_AGENT` | Agent identifier for `onecli run` (overrides the `agent` config key) |
| `ONECLI_ENV` | `dev` or `production` |

## Output
Expand Down
4 changes: 2 additions & 2 deletions cmd/onecli/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ type ConfigCmd struct {

// ConfigGetCmd is `onecli config get <key>`.
type ConfigGetCmd struct {
Key string `arg:"" required:"" help:"Config key to read. Keys: api-host."`
Key string `arg:"" required:"" help:"Config key to read. Keys: api-host, project, agent."`
}

// ConfigGetResponse is the JSON output of config get.
Expand All @@ -32,7 +32,7 @@ func (c *ConfigGetCmd) Run(out *output.Writer) error {

// ConfigSetCmd is `onecli config set <key> <value>`.
type ConfigSetCmd struct {
Key string `arg:"" required:"" help:"Config key to set. Keys: api-host."`
Key string `arg:"" required:"" help:"Config key to set. Keys: api-host, project, agent."`
Value string `arg:"" required:"" help:"Value to set."`
}

Expand Down
7 changes: 4 additions & 3 deletions cmd/onecli/help.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,9 +42,10 @@ func (cmd *HelpCmd) Run(out *output.Writer) error {
{Name: "run", Description: "Run a command with OneCLI gateway access.", Args: []ArgInfo{
{Name: "<command>", Required: true, Description: "Command to execute (e.g. claude, cursor, codex)."},
{Name: "--project, -p", Description: "Project slug."},
{Name: "--agent", Description: "OneCLI agent identifier (uses default if omitted)."},
{Name: "--agent", Description: "OneCLI agent identifier (default: ONECLI_AGENT env, then 'onecli config set agent', then the project's default agent)."},
{Name: "--gateway", Description: "Gateway host:port override (default: derived from API host)."},
{Name: "--no-ca", Description: "Skip CA cert write and CA trust env injection."},
{Name: "--enforce", Description: "OS-enforced governance: route the agent's sandboxed egress through the gateway so it cannot be bypassed (Claude Code only)."},
{Name: "--dry-run", Description: "Print resolved env and command without executing."},
}},
{Name: "agents list", Description: "List all agents.", Args: []ArgInfo{
Expand Down Expand Up @@ -449,8 +450,8 @@ func (cmd *HelpCmd) Run(out *output.Writer) error {
}},
{Name: "auth api-key", Description: "Show your current API key."},
{Name: "auth regenerate-api-key", Description: "Regenerate your API key."},
{Name: "config get <key>", Description: "Get a config value."},
{Name: "config set <key> <value>", Description: "Set a config value."},
{Name: "config get <key>", Description: "Get a config value. Keys: api-host, project, agent."},
{Name: "config set <key> <value>", Description: "Set a config value. Keys: api-host, project, agent."},
{Name: "migrate", Description: "Migrate data to OneCLI Cloud.", Args: []ArgInfo{
{Name: "--cloud-key", Required: true, Description: "OneCLI Cloud API key."},
}},
Expand Down
18 changes: 18 additions & 0 deletions cmd/onecli/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,24 @@ func resolveProject(flag string) (string, error) {
return v, nil
}

// resolveAgent returns the agent identifier from the flag value, falling back
// to config (ONECLI_AGENT env var > config file). Empty means the project's
// server-side default agent. Returns an error if the resolved value fails
// input validation.
func resolveAgent(flag string) (string, error) {
v := flag
if v == "" {
v = config.Agent()
}
if v == "" {
return "", nil
}
if err := validate.ResourceID(v); err != nil {
return "", fmt.Errorf("invalid agent identifier: %w", err)
}
return v, nil
}

// hintForCommand returns a contextual hint message based on the active command group.
func hintForCommand(cmd, host string) string {
group := strings.SplitN(cmd, " ", 2)[0]
Expand Down
85 changes: 85 additions & 0 deletions cmd/onecli/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ package main

import (
"testing"

"github.com/onecli/onecli-cli/internal/config"
)

func TestResolveProjectFlagTakesPrecedence(t *testing.T) {
Expand Down Expand Up @@ -68,3 +70,86 @@ func TestResolveProjectRejectsInvalidEnvValue(t *testing.T) {
t.Error("resolveProject should reject invalid env value")
}
}

func TestResolveAgentFlagTakesPrecedence(t *testing.T) {
t.Setenv("ONECLI_AGENT", "env-agent")
got, err := resolveAgent("flag-agent")
if err != nil {
t.Fatal(err)
}
if got != "flag-agent" {
t.Errorf("resolveAgent(flag-agent) = %q, want flag-agent", got)
}
}

func TestResolveAgentFallsBackToEnv(t *testing.T) {
t.Setenv("ONECLI_AGENT", "env-agent")
got, err := resolveAgent("")
if err != nil {
t.Fatal(err)
}
if got != "env-agent" {
t.Errorf("resolveAgent(\"\") = %q, want env-agent", got)
}
}

func TestResolveAgentFallsBackToConfigFile(t *testing.T) {
dir := t.TempDir()
t.Setenv("HOME", dir)
t.Setenv("ONECLI_ENV", "")
t.Setenv("ONECLI_AGENT", "")

if err := config.SetConfigValue("agent", "pinned-agent"); err != nil {
t.Fatal(err)
}
got, err := resolveAgent("")
if err != nil {
t.Fatal(err)
}
if got != "pinned-agent" {
t.Errorf("resolveAgent(\"\") = %q, want pinned-agent", got)
}
}

func TestResolveAgentEmptyWhenUnset(t *testing.T) {
dir := t.TempDir()
t.Setenv("HOME", dir)
t.Setenv("ONECLI_AGENT", "")
t.Setenv("ONECLI_ENV", "")

got, err := resolveAgent("")
if err != nil {
t.Fatal(err)
}
if got != "" {
t.Errorf("resolveAgent(\"\") = %q, want empty (server default)", got)
}
}

func TestResolveAgentRejectsInvalidValues(t *testing.T) {
tests := []struct {
name string
flag string
}{
{"path traversal", "../etc/passwd"},
{"query injection", "agent?foo=bar"},
{"percent encoding", "agent%2e"},
{"control chars", "agent\x00"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := resolveAgent(tt.flag)
if err == nil {
t.Errorf("resolveAgent(%q) should return error", tt.flag)
}
})
}
}

func TestResolveAgentRejectsInvalidEnvValue(t *testing.T) {
t.Setenv("ONECLI_AGENT", "bad agent")
_, err := resolveAgent("")
if err == nil {
t.Error("resolveAgent should reject invalid env value")
}
}
16 changes: 8 additions & 8 deletions cmd/onecli/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,6 @@ import (
"github.com/onecli/onecli-cli/internal/api"
"github.com/onecli/onecli-cli/internal/config"
"github.com/onecli/onecli-cli/pkg/output"
"github.com/onecli/onecli-cli/pkg/validate"

"gopkg.in/yaml.v3"
)
Expand All @@ -43,7 +42,7 @@ var caShimSource string
// RunCmd is `onecli run -- <command> [args...]`.
type RunCmd struct {
Project string `optional:"" short:"p" help:"Project slug."`
Agent string `optional:"" name:"agent" help:"OneCLI agent identifier (uses default agent if omitted)."`
Agent string `optional:"" name:"agent" help:"OneCLI agent identifier (default: ONECLI_AGENT env, then 'onecli config set agent', then the project's default agent)."`
Gateway string `optional:"" name:"gateway" help:"Gateway host:port override (default: derived from API host)."`
NoCA bool `optional:"" name:"no-ca" help:"Skip writing the CA cert and CA trust env injection."`
Enforce bool `optional:"" name:"enforce" help:"OS-enforced governance: route the agent's sandboxed egress through the gateway so it cannot be bypassed (Claude Code only)."`
Expand All @@ -56,11 +55,12 @@ func (c *RunCmd) Run(out *output.Writer) error {
return fmt.Errorf("no command specified: use 'onecli run -- <command> [args...]'")
}

// Validate agent identifier if provided.
if c.Agent != "" {
if err := validate.ResourceID(c.Agent); err != nil {
return fmt.Errorf("invalid agent identifier: %w", err)
}
// Resolve the agent identity: --agent flag > ONECLI_AGENT env >
// `onecli config set agent ...` (the machine-local pin); empty means the
// project's server-side default agent. Validated at the boundary.
agent, err := resolveAgent(c.Agent)
if err != nil {
return err
}

// Resolve the binary path early — fail fast before the API round-trip.
Expand All @@ -74,7 +74,7 @@ func (c *RunCmd) Run(out *output.Writer) error {
if err != nil {
return err
}
cfg, err := client.GetContainerConfig(newContext(), c.Agent)
cfg, err := client.GetContainerConfig(newContext(), agent)
if err != nil {
return err
}
Expand Down
23 changes: 23 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,16 @@ import (
"fmt"
"os"
"path/filepath"

"github.com/onecli/onecli-cli/pkg/validate"
)

const (
envVar = "ONECLI_ENV"
apiKeyVar = "ONECLI_API_KEY"
apiHostVar = "ONECLI_API_HOST"
projectVar = "ONECLI_PROJECT"
agentVar = "ONECLI_AGENT"

envProduction = "production"
envDev = "dev"
Expand Down Expand Up @@ -78,6 +81,22 @@ func Project() string {
return cfg["project"]
}

// Agent returns the configured agent identifier, or empty string if not set.
// This is the machine-local agent pin ("this terminal runs as writer-bot"),
// used by `onecli run` when no --agent flag is given; empty means the
// project's server-side default agent.
// Precedence: ONECLI_AGENT env var > config file > empty.
func Agent() string {
if v := os.Getenv(agentVar); v != "" {
return v
}
cfg, err := readConfig()
if err != nil {
return ""
}
return cfg["agent"]
}

// KeychainService returns the keychain service name for API key storage.
func KeychainService() string {
if IsDev() {
Expand Down Expand Up @@ -115,12 +134,14 @@ var ErrInvalidConfigValue = errors.New("invalid config value")
var validKeys = map[string]func(string) error{
"api-host": validateURL,
"project": nil,
"agent": validate.ResourceID,
}

// configDefaults maps each config key to its default value.
var configDefaults = map[string]string{
"api-host": defaultAPIHost,
"project": "",
"agent": "",
}

func validateURL(u string) error {
Expand Down Expand Up @@ -200,6 +221,8 @@ func GetConfigValue(key string) (string, error) {
return APIHost(), nil
case "project":
return Project(), nil
case "agent":
return Agent(), nil
}

cfg, err := readConfig()
Expand Down
75 changes: 75 additions & 0 deletions internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,81 @@ func TestGetConfigValueProjectRespectsEnv(t *testing.T) {
}
}

func TestAgentDefault(t *testing.T) {
dir := t.TempDir()
t.Setenv("HOME", dir)
t.Setenv("ONECLI_AGENT", "")

if got := Agent(); got != "" {
t.Errorf("Agent() = %q, want empty", got)
}
}

func TestAgentEnvOverride(t *testing.T) {
t.Setenv("ONECLI_AGENT", "env-agent")
if got := Agent(); got != "env-agent" {
t.Errorf("Agent() = %q, want env-agent", got)
}
}

func TestAgentFromConfigFile(t *testing.T) {
dir := t.TempDir()
t.Setenv("HOME", dir)
t.Setenv("ONECLI_ENV", "")
t.Setenv("ONECLI_AGENT", "")

if err := SetConfigValue("agent", "file-agent"); err != nil {
t.Fatal(err)
}
if got := Agent(); got != "file-agent" {
t.Errorf("Agent() = %q, want file-agent", got)
}
}

func TestAgentEnvTakesPrecedenceOverFile(t *testing.T) {
dir := t.TempDir()
t.Setenv("HOME", dir)
t.Setenv("ONECLI_ENV", "")

_ = SetConfigValue("agent", "file-agent")
t.Setenv("ONECLI_AGENT", "env-agent")

if got := Agent(); got != "env-agent" {
t.Errorf("Agent() = %q, env var should take precedence", got)
}
}

func TestGetConfigValueAgentRespectsEnv(t *testing.T) {
dir := t.TempDir()
t.Setenv("HOME", dir)
t.Setenv("ONECLI_ENV", "")

_ = SetConfigValue("agent", "file-agent")
t.Setenv("ONECLI_AGENT", "env-agent")

val, err := GetConfigValue("agent")
if err != nil {
t.Fatal(err)
}
if val != "env-agent" {
t.Errorf("GetConfigValue(agent) = %q, env var should take precedence", val)
}
}

func TestSetConfigValueAgentRejectsInvalidIdentifier(t *testing.T) {
dir := t.TempDir()
t.Setenv("HOME", dir)
t.Setenv("ONECLI_ENV", "")

// The agent value reaches URLs and shell contexts downstream — reject
// hardened-input violations at the boundary, like the --agent flag does.
for _, bad := range []string{"", "two words", "a?b", "a%20b", "../etc"} {
if err := SetConfigValue("agent", bad); !errors.Is(err, ErrInvalidConfigValue) {
t.Errorf("SetConfigValue(agent, %q) = %v, want ErrInvalidConfigValue", bad, err)
}
}
}

func TestGetConfigValueDefaultWhenNoFile(t *testing.T) {
dir := t.TempDir()
t.Setenv("HOME", dir)
Expand Down
Loading