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
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ pkg/validate/ # Input hardening at command boundaries
## Shared Packages

- `pkg/output` — all output goes through `output.Writer`. Never use `fmt.Print` or `os.Stdout`.
- `pkg/exitcode` — exit codes and string codes. Map API 401 → AuthRequired, 404 → NotFound, 409 → Conflict.
- `pkg/exitcode` — exit codes and string codes. Map API 401 → AuthRequired, 403 → Forbidden, 404 → NotFound, 409 → Conflict.
- `pkg/validate` — validate resource IDs, URLs, API keys at command boundaries.

## Go Conventions
Expand Down
102 changes: 102 additions & 0 deletions cmd/onecli/agents.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,115 @@ import (
type AgentsCmd struct {
List AgentsListCmd `cmd:"" help:"List all agents."`
GetDefault AgentsGetDefaultCmd `cmd:"" name:"get-default" help:"Get the default agent."`
SetDefault AgentsSetDefaultCmd `cmd:"" name:"set-default" help:"Mark an agent as the project default."`
Create AgentsCreateCmd `cmd:"" help:"Create a new agent."`
Delete AgentsDeleteCmd `cmd:"" help:"Delete an agent."`
Rename AgentsRenameCmd `cmd:"" help:"Rename an agent."`
RegenerateToken AgentsRegenerateTokenCmd `cmd:"" name:"regenerate-token" help:"Regenerate an agent's access token."`
Secrets AgentsSecretsCmd `cmd:"" help:"List secrets assigned to an agent."`
SetSecrets AgentsSetSecretsCmd `cmd:"" name:"set-secrets" help:"Set secrets assigned to an agent."`
SetSecretMode AgentsSetSecretModeCmd `cmd:"" name:"set-secret-mode" help:"Set an agent's secret mode."`
GranularAccess AgentsGranularAccessCmd `cmd:"" name:"granular-access" help:"Show per-agent granular-access policies across the project."`
Connections AgentsConnectionsCmd `cmd:"" help:"Manage an agent's app-connection assignments."`
}

// AgentsSetDefaultCmd is `onecli agents set-default`.
type AgentsSetDefaultCmd struct {
ID string `required:"" help:"ID of the agent."`
DryRun bool `optional:"" name:"dry-run" help:"Validate the request without executing it."`
}

func (c *AgentsSetDefaultCmd) Run(out *output.Writer) error {
if err := validate.ResourceID(c.ID); err != nil {
return fmt.Errorf("invalid agent ID: %w", err)
}
if c.DryRun {
return out.WriteDryRun("Would set default agent", map[string]string{"id": c.ID})
}
client, err := newClient()
if err != nil {
return err
}
if err := client.SetDefaultAgent(newContext(), c.ID); err != nil {
return err
}
return out.Write(map[string]string{"status": "default", "id": c.ID})
}

// AgentsGranularAccessCmd is `onecli agents granular-access`.
type AgentsGranularAccessCmd struct {
Fields string `optional:"" help:"Comma-separated list of fields to include in output."`
Quiet string `optional:"" name:"quiet" help:"Output only the specified field, one per line."`
}

func (c *AgentsGranularAccessCmd) Run(out *output.Writer) error {
client, err := newClient()
if err != nil {
return err
}
entries, err := client.ListGranularAccess(newContext())
if err != nil {
return err
}
if c.Quiet != "" {
return out.WriteQuiet(entries, c.Quiet)
}
return out.WriteFiltered(entries, c.Fields)
}

// AgentsConnectionsCmd is the `onecli agents connections` command group.
type AgentsConnectionsCmd struct {
Get AgentsConnectionsGetCmd `cmd:"" help:"Get an agent's app-connection assignments."`
Set AgentsConnectionsSetCmd `cmd:"" help:"Replace an agent's app-connection assignments (raw JSON)."`
}

// AgentsConnectionsGetCmd is `onecli agents connections get`.
type AgentsConnectionsGetCmd struct {
ID string `required:"" help:"ID of the agent."`
Fields string `optional:"" help:"Comma-separated list of fields to include in output."`
}

func (c *AgentsConnectionsGetCmd) Run(out *output.Writer) error {
if err := validate.ResourceID(c.ID); err != nil {
return fmt.Errorf("invalid agent ID: %w", err)
}
client, err := newClient()
if err != nil {
return err
}
connections, err := client.GetAgentConnections(newContext(), c.ID)
if err != nil {
return err
}
return out.WriteFiltered(connections, c.Fields)
}

// AgentsConnectionsSetCmd is `onecli agents connections set`.
type AgentsConnectionsSetCmd struct {
ID string `required:"" help:"ID of the agent."`
Json string `required:"" help:"JSON array of connection assignments (the API's 'connections' array, incl. optional granular-access policies)."`
DryRun bool `optional:"" name:"dry-run" help:"Validate the request without executing it."`
}

func (c *AgentsConnectionsSetCmd) Run(out *output.Writer) error {
if err := validate.ResourceID(c.ID); err != nil {
return fmt.Errorf("invalid agent ID: %w", err)
}
var connections []any
if err := json.Unmarshal([]byte(c.Json), &connections); err != nil {
return fmt.Errorf("invalid JSON payload (expected an array): %w", err)
}
if c.DryRun {
return out.WriteDryRun("Would set agent connections", map[string]any{"id": c.ID, "connections": connections})
}
client, err := newClient()
if err != nil {
return err
}
if err := client.SetAgentConnections(newContext(), c.ID, connections); err != nil {
return err
}
return out.Write(map[string]any{"status": "updated", "id": c.ID, "connectionCount": len(connections)})
}

// AgentsListCmd is `onecli agents list`.
Expand Down
182 changes: 152 additions & 30 deletions cmd/onecli/apps.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package main
import (
"encoding/json"
"fmt"
"strings"

"github.com/onecli/onecli-cli/internal/api"
"github.com/onecli/onecli-cli/pkg/output"
Expand All @@ -17,11 +18,103 @@ type configureResult struct {

// AppsCmd is the `onecli apps` command group.
type AppsCmd struct {
List AppsListCmd `cmd:"" help:"List all apps with config and connection status."`
Get AppsGetCmd `cmd:"" help:"Get a single app with setup guidance."`
Configure AppsConfigureCmd `cmd:"" help:"Save OAuth credentials (BYOC) for a provider."`
Remove AppsRemoveCmd `cmd:"" help:"Remove OAuth credentials for a provider."`
Disconnect AppsDisconnectCmd `cmd:"" help:"Disconnect an app connection."`
List AppsListCmd `cmd:"" help:"List all apps with config and connection status."`
Get AppsGetCmd `cmd:"" help:"Get a single app with setup guidance."`
Configure AppsConfigureCmd `cmd:"" help:"Save credentials (BYOC) for a provider."`
Remove AppsRemoveCmd `cmd:"" help:"Remove BYOC credentials for a provider."`
Disconnect AppsDisconnectCmd `cmd:"" help:"Disconnect an app connection."`
Connections AppsConnectionsCmd `cmd:"" help:"Manage app connections."`
PermissionDefinition AppsPermissionDefinitionCmd `cmd:"" name:"permission-definition" help:"Show an app's tool catalog (groups + toolIds) for permission rules."`
Config AppsConfigCmd `cmd:"" help:"Inspect and toggle a provider's BYOC config."`
Configured AppsConfiguredCmd `cmd:"" help:"List providers with an enabled config."`
EnvDefaults AppsEnvDefaultsCmd `cmd:"" name:"env-defaults" help:"List providers with platform default credentials."`
Blocklist AppsBlocklistCmd `cmd:"" help:"Manage an app's endpoint blocklist."`
}

// AppsConnectionsCmd is the `onecli apps connections` command group.
type AppsConnectionsCmd struct {
List AppsConnectionsListCmd `cmd:"" help:"List app connections, optionally filtered by provider."`
Rename AppsConnectionsRenameCmd `cmd:"" help:"Rename an app connection."`
}

// AppsConnectionsListCmd is `onecli apps connections list`.
type AppsConnectionsListCmd struct {
Provider string `optional:"" help:"Filter by provider name (e.g. 'github', 'gmail')."`
Fields string `optional:"" help:"Comma-separated list of fields to include in output."`
Quiet string `optional:"" name:"quiet" help:"Output only the specified field, one per line."`
Max int `optional:"" default:"20" help:"Maximum number of results to return."`
}

func (c *AppsConnectionsListCmd) Run(out *output.Writer) error {
if c.Provider != "" {
if err := validate.ResourceID(c.Provider); err != nil {
return fmt.Errorf("invalid provider: %w", err)
}
}
client, err := newClient()
if err != nil {
return err
}
connections, err := client.ListConnections(newContext(), c.Provider)
if err != nil {
return err
}
if c.Max > 0 && len(connections) > c.Max {
connections = connections[:c.Max]
}
if c.Quiet != "" {
return out.WriteQuiet(connections, c.Quiet)
}
return out.WriteFiltered(connections, c.Fields)
}

// AppsConnectionsRenameCmd is `onecli apps connections rename`.
type AppsConnectionsRenameCmd struct {
ID string `required:"" help:"ID of the connection to rename."`
Label string `required:"" help:"New display label."`
DryRun bool `optional:"" name:"dry-run" help:"Validate the request without executing it."`
}

func (c *AppsConnectionsRenameCmd) Run(out *output.Writer) error {
if err := validate.ResourceID(c.ID); err != nil {
return fmt.Errorf("invalid connection ID: %w", err)
}
if c.Label == "" {
return fmt.Errorf("label must not be empty")
}
if c.DryRun {
return out.WriteDryRun("Would rename connection", map[string]string{"id": c.ID, "label": c.Label})
}
client, err := newClient()
if err != nil {
return err
}
conn, err := client.RenameConnection(newContext(), c.ID, c.Label)
if err != nil {
return err
}
return out.Write(conn)
}

// AppsPermissionDefinitionCmd is `onecli apps permission-definition`.
type AppsPermissionDefinitionCmd struct {
Provider string `required:"" help:"Provider name (e.g. 'github', 'gmail')."`
Fields string `optional:"" help:"Comma-separated list of fields to include in output."`
}

func (c *AppsPermissionDefinitionCmd) Run(out *output.Writer) error {
if err := validate.ResourceID(c.Provider); err != nil {
return fmt.Errorf("invalid provider: %w", err)
}
client, err := newClient()
if err != nil {
return err
}
def, err := client.GetPermissionDefinition(newContext(), c.Provider)
if err != nil {
return err
}
return out.WriteFiltered(def, c.Fields)
}

// AppsListCmd is `onecli apps list`.
Expand Down Expand Up @@ -77,46 +170,75 @@ func (c *AppsGetCmd) Run(out *output.Writer) error {
return out.WriteFiltered(app, c.Fields)
}

// buildConfigFields assembles the credential fields for an app configure
// command from, in precedence order: a raw --json object, repeated
// --field key=value flags, and the --client-id/--client-secret sugar for
// OAuth-style apps. Field names must match the app's own configurable field
// definitions (e.g. github-app uses appId/appSlug/privateKey).
func buildConfigFields(jsonPayload string, fieldFlags []string, clientID, clientSecret string) (api.ConfigFields, error) {
fields := api.ConfigFields{}
if jsonPayload != "" {
if err := json.Unmarshal([]byte(jsonPayload), &fields); err != nil {
return nil, fmt.Errorf("invalid JSON payload (expected an object of string fields): %w", err)
}
}
for _, f := range fieldFlags {
key, value, ok := strings.Cut(f, "=")
if !ok || key == "" {
return nil, fmt.Errorf("invalid --field %q: expected key=value", f)
}
fields[key] = value
}
if clientID != "" {
fields["clientId"] = clientID
}
if clientSecret != "" {
fields["clientSecret"] = clientSecret
}
if len(fields) == 0 {
return nil, fmt.Errorf("no credential fields provided: use --field key=value (repeatable), --json, or --client-id/--client-secret")
}
return fields, nil
}

// maskedFieldPreview lists the field names being set with masked values, so
// dry-run output never echoes secrets.
func maskedFieldPreview(provider string, fields api.ConfigFields) map[string]string {
preview := map[string]string{"provider": provider}
for key := range fields {
preview[key] = "***"
}
return preview
}

// AppsConfigureCmd is `onecli apps configure`.
type AppsConfigureCmd struct {
Provider string `required:"" help:"Provider name (e.g. 'github', 'gmail')."`
ClientID string `required:"" name:"client-id" help:"OAuth client ID."`
ClientSecret string `required:"" name:"client-secret" help:"OAuth client secret."`
Json string `optional:"" help:"Raw JSON payload. Overrides individual flags."`
DryRun bool `optional:"" name:"dry-run" help:"Validate the request without executing it."`
Provider string `required:"" help:"Provider name (e.g. 'github', 'gmail')."`
Field []string `optional:"" help:"Credential field as key=value (repeatable); names per the app's field definitions."`
ClientID string `optional:"" name:"client-id" help:"OAuth client ID (shorthand for --field clientId=...)."`
ClientSecret string `optional:"" name:"client-secret" help:"OAuth client secret (shorthand for --field clientSecret=...)."`
Json string `optional:"" help:"Raw JSON object of credential fields. Merged first; flags override."`
DryRun bool `optional:"" name:"dry-run" help:"Validate the request without executing it."`
}

func (c *AppsConfigureCmd) Run(out *output.Writer) error {
var input api.ConfigAppInput
if c.Json != "" {
if err := json.Unmarshal([]byte(c.Json), &input); err != nil {
return fmt.Errorf("invalid JSON payload: %w", err)
}
} else {
input = api.ConfigAppInput{
ClientID: c.ClientID,
ClientSecret: c.ClientSecret,
}
}

if err := validate.ResourceID(c.Provider); err != nil {
return fmt.Errorf("invalid provider: %w", err)
}
fields, err := buildConfigFields(c.Json, c.Field, c.ClientID, c.ClientSecret)
if err != nil {
return err
}

if c.DryRun {
preview := map[string]string{
"provider": c.Provider,
"clientId": input.ClientID,
"clientSecret": "***",
}
return out.WriteDryRun("Would configure app", preview)
return out.WriteDryRun("Would configure app", maskedFieldPreview(c.Provider, fields))
}

client, err := newClient()
if err != nil {
return err
}
if err := client.ConfigureApp(newContext(), c.Provider, input); err != nil {
if err := client.ConfigureApp(newContext(), c.Provider, fields); err != nil {
return err
}

Expand Down Expand Up @@ -176,7 +298,7 @@ func (c *AppsDisconnectCmd) Run(out *output.Writer) error {

connectionID := c.ConnectionID
if connectionID == "" {
connections, err := client.ListConnectionsByProvider(newContext(), c.Provider)
connections, err := client.ListConnections(newContext(), c.Provider)
if err != nil {
return err
}
Expand Down
25 changes: 25 additions & 0 deletions cmd/onecli/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,35 @@ type AuthCmd struct {
Login AuthLoginCmd `cmd:"" help:"Store API key for authentication."`
Logout AuthLogoutCmd `cmd:"" help:"Remove stored API key."`
Status AuthStatusCmd `cmd:"" help:"Show authentication status."`
Update AuthUpdateCmd `cmd:"" help:"Update your profile (display name)."`
ApiKey AuthApiKeyCmd `cmd:"api-key" help:"Show your current API key."`
RegenerateApiKey AuthRegenerateApiKeyCmd `cmd:"regenerate-api-key" help:"Regenerate your API key."`
}

// AuthUpdateCmd is `onecli auth update`.
type AuthUpdateCmd struct {
Name string `required:"" help:"New display name."`
DryRun bool `optional:"" name:"dry-run" help:"Validate the request without executing it."`
}

func (c *AuthUpdateCmd) Run(out *output.Writer) error {
if c.Name == "" {
return fmt.Errorf("name must not be empty")
}
if c.DryRun {
return out.WriteDryRun("Would update profile", map[string]string{"name": c.Name})
}
client, err := newClient()
if err != nil {
return err
}
user, err := client.UpdateProfile(newContext(), c.Name)
if err != nil {
return err
}
return out.Write(user)
}

// AuthLoginCmd is `onecli auth login`.
type AuthLoginCmd struct {
APIKey string `optional:"" name:"api-key" help:"API key to store (oc_... format)."`
Expand Down
Loading
Loading