diff --git a/CHANGELOG.md b/CHANGELOG.md index a2ed34a..119484d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## [2.1.0](https://github.com/onecli/onecli-cli/compare/v2.0.1...v2.1.0) (2026-05-20) + + +### Features + +* add org subcommands for apps, connections, rules, and secrets ([#65](https://github.com/onecli/onecli-cli/issues/65)) ([5411e02](https://github.com/onecli/onecli-cli/commit/5411e029e6ace0d31fc81f484af8f8778c5eb119)) + ## [2.0.1](https://github.com/onecli/onecli-cli/compare/v2.0.0...v2.0.1) (2026-05-19) diff --git a/cmd/onecli/help.go b/cmd/onecli/help.go index 8835f82..a99ea39 100644 --- a/cmd/onecli/help.go +++ b/cmd/onecli/help.go @@ -137,6 +137,63 @@ func (cmd *HelpCmd) Run(out *output.Writer) error { {Name: "projects delete", Description: "Delete a project.", Args: []ArgInfo{ {Name: "--id", Required: true, Description: "ID of the project to delete."}, }}, + {Name: "org secrets list", Description: "List all org-scoped secrets."}, + {Name: "org secrets create", Description: "Create a new org-scoped secret.", Args: []ArgInfo{ + {Name: "--name", Required: true, Description: "Display name for the secret."}, + {Name: "--type", Required: true, Description: "Secret type: 'anthropic', 'openai', or 'generic'."}, + {Name: "--value", Required: true, Description: "Secret value (e.g. API key)."}, + {Name: "--host-pattern", Required: true, Description: "Host pattern to match."}, + }}, + {Name: "org secrets update", Description: "Update an org-scoped secret.", Args: []ArgInfo{ + {Name: "--id", Required: true, Description: "ID of the secret to update."}, + }}, + {Name: "org secrets delete", Description: "Delete an org-scoped secret.", Args: []ArgInfo{ + {Name: "--id", Required: true, Description: "ID of the secret to delete."}, + }}, + {Name: "org rules list", Description: "List all org-scoped policy rules."}, + {Name: "org rules get", Description: "Get a single org-scoped policy rule.", Args: []ArgInfo{ + {Name: "--id", Required: true, Description: "ID of the rule to retrieve."}, + }}, + {Name: "org rules create", Description: "Create a new org-scoped policy rule.", Args: []ArgInfo{ + {Name: "--name", Required: true, Description: "Display name for the rule."}, + {Name: "--host-pattern", Required: true, Description: "Host pattern to match."}, + {Name: "--action", Required: true, Description: "Action: 'block' or 'rate_limit'."}, + }}, + {Name: "org rules update", Description: "Update an org-scoped policy rule.", Args: []ArgInfo{ + {Name: "--id", Required: true, Description: "ID of the rule to update."}, + }}, + {Name: "org rules delete", Description: "Delete an org-scoped policy rule.", Args: []ArgInfo{ + {Name: "--id", Required: true, Description: "ID of the rule to delete."}, + }}, + {Name: "org rules permissions get", Description: "Get tool permissions for a provider.", Args: []ArgInfo{ + {Name: "--provider", Required: true, Description: "Provider name (e.g. 'github', 'gmail')."}, + }}, + {Name: "org rules permissions set", Description: "Set tool permissions for a provider.", Args: []ArgInfo{ + {Name: "--provider", Required: true, Description: "Provider name (e.g. 'github', 'gmail')."}, + {Name: "--json", Required: true, Description: "JSON payload with 'changes' array."}, + }}, + {Name: "org connections list", Description: "List all org-scoped connections.", Args: []ArgInfo{ + {Name: "--provider", Description: "Filter by provider name."}, + }}, + {Name: "org connections delete", Description: "Delete an org-scoped connection.", Args: []ArgInfo{ + {Name: "--id", Required: true, Description: "ID of the connection to delete."}, + }}, + {Name: "org apps configured", Description: "List providers with org-level credentials configured."}, + {Name: "org apps get", Description: "Get app config status for a provider.", Args: []ArgInfo{ + {Name: "--provider", Required: true, Description: "Provider name (e.g. 'github', 'gmail')."}, + }}, + {Name: "org apps configure", Description: "Save BYOC credentials at the org level.", Args: []ArgInfo{ + {Name: "--provider", Required: true, Description: "Provider name (e.g. 'github', 'gmail')."}, + {Name: "--client-id", Required: true, Description: "OAuth client ID."}, + {Name: "--client-secret", Required: true, Description: "OAuth client secret."}, + }}, + {Name: "org apps remove", Description: "Remove BYOC credentials at the org level.", Args: []ArgInfo{ + {Name: "--provider", Required: true, Description: "Provider name (e.g. 'github', 'gmail')."}, + }}, + {Name: "org apps toggle", Description: "Enable or disable an app config at the org level.", Args: []ArgInfo{ + {Name: "--provider", Required: true, Description: "Provider name (e.g. 'github', 'gmail')."}, + {Name: "--enabled", Required: true, Description: "Set to true to enable, false to disable."}, + }}, {Name: "auth login", Description: "Store API key for authentication."}, {Name: "auth logout", Description: "Remove stored API key."}, {Name: "auth status", Description: "Show authentication status."}, diff --git a/cmd/onecli/main.go b/cmd/onecli/main.go index ab833f2..074565a 100644 --- a/cmd/onecli/main.go +++ b/cmd/onecli/main.go @@ -29,6 +29,7 @@ type CLI struct { Apps AppsCmd `cmd:"" help:"Manage app connections."` Rules RulesCmd `cmd:"" help:"Manage policy rules."` Projects ProjectsCmd `cmd:"" help:"Manage projects."` + Org OrgCmd `cmd:"" help:"Organization-scoped management (secrets, rules, connections, apps)."` Auth AuthCmd `cmd:"" help:"Manage authentication."` Config ConfigCmd `cmd:"" help:"Manage configuration settings."` Migrate MigrateCmd `cmd:"" help:"Migrate data to OneCLI Cloud."` @@ -145,6 +146,8 @@ func hintForCommand(cmd, host string) string { return "Manage your policy rules \u2192 " + host case "projects": return "Manage your projects \u2192 " + host + case "org": + return "Manage organization-level resources \u2192 " + host case "auth": return "Manage authentication \u2192 " + host case "config": diff --git a/cmd/onecli/org.go b/cmd/onecli/org.go new file mode 100644 index 0000000..e6e0af2 --- /dev/null +++ b/cmd/onecli/org.go @@ -0,0 +1,9 @@ +package main + +// OrgCmd is the `onecli org` command group for organization-scoped operations. +type OrgCmd struct { + Secrets OrgSecretsCmd `cmd:"" help:"Manage org-scoped secrets."` + Rules OrgRulesCmd `cmd:"" help:"Manage org-scoped policy rules."` + Connections OrgConnectionsCmd `cmd:"" help:"Manage org-scoped connections."` + Apps OrgAppsCmd `cmd:"" help:"Manage org-scoped app configuration."` +} diff --git a/cmd/onecli/org_apps.go b/cmd/onecli/org_apps.go new file mode 100644 index 0000000..e823356 --- /dev/null +++ b/cmd/onecli/org_apps.go @@ -0,0 +1,153 @@ +package main + +import ( + "encoding/json" + "fmt" + + "github.com/onecli/onecli-cli/internal/api" + "github.com/onecli/onecli-cli/pkg/output" + "github.com/onecli/onecli-cli/pkg/validate" +) + +// OrgAppsCmd is the `onecli org apps` command group. +type OrgAppsCmd struct { + Configured OrgAppsConfiguredCmd `cmd:"" help:"List providers with org-level credentials configured."` + Get OrgAppsGetCmd `cmd:"" help:"Get app config status for a provider."` + Configure OrgAppsConfigureCmd `cmd:"" help:"Save BYOC credentials for a provider at the org level."` + Remove OrgAppsRemoveCmd `cmd:"" help:"Remove BYOC credentials for a provider at the org level."` + Toggle OrgAppsToggleCmd `cmd:"" help:"Enable or disable an app config at the org level."` +} + +// OrgAppsConfiguredCmd is `onecli org apps configured`. +type OrgAppsConfiguredCmd struct { + Fields string `optional:"" help:"Comma-separated list of fields to include in output."` +} + +func (c *OrgAppsConfiguredCmd) Run(out *output.Writer) error { + client, err := newClient() + if err != nil { + return err + } + providers, err := client.ListConfiguredProviders(newContext()) + if err != nil { + return err + } + return out.WriteFiltered(providers, c.Fields) +} + +// OrgAppsGetCmd is `onecli org apps get`. +type OrgAppsGetCmd 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 *OrgAppsGetCmd) 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 + } + config, err := client.GetOrgAppConfig(newContext(), c.Provider) + if err != nil { + return err + } + return out.WriteFiltered(config, c.Fields) +} + +// OrgAppsConfigureCmd is `onecli org apps configure`. +type OrgAppsConfigureCmd 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."` +} + +func (c *OrgAppsConfigureCmd) 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) + } + + if c.DryRun { + preview := map[string]string{ + "provider": c.Provider, + "clientId": input.ClientID, + "clientSecret": "***", + } + return out.WriteDryRun("Would configure org app", preview) + } + + client, err := newClient() + if err != nil { + return err + } + if err := client.UpsertOrgAppConfig(newContext(), c.Provider, input); err != nil { + return err + } + return out.Write(map[string]string{"status": "configured", "provider": c.Provider}) +} + +// OrgAppsRemoveCmd is `onecli org apps remove`. +type OrgAppsRemoveCmd struct { + Provider string `required:"" help:"Provider name (e.g. 'github', 'gmail')."` + DryRun bool `optional:"" name:"dry-run" help:"Validate the request without executing it."` +} + +func (c *OrgAppsRemoveCmd) Run(out *output.Writer) error { + if err := validate.ResourceID(c.Provider); err != nil { + return fmt.Errorf("invalid provider: %w", err) + } + if c.DryRun { + return out.WriteDryRun("Would remove org app config", map[string]string{"provider": c.Provider}) + } + client, err := newClient() + if err != nil { + return err + } + if err := client.DeleteOrgAppConfig(newContext(), c.Provider); err != nil { + return err + } + return out.Write(map[string]string{"status": "removed", "provider": c.Provider}) +} + +// OrgAppsToggleCmd is `onecli org apps toggle`. +type OrgAppsToggleCmd struct { + Provider string `required:"" help:"Provider name (e.g. 'github', 'gmail')."` + Enabled bool `required:"" help:"Set to true to enable, false to disable."` + DryRun bool `optional:"" name:"dry-run" help:"Validate the request without executing it."` +} + +func (c *OrgAppsToggleCmd) Run(out *output.Writer) error { + if err := validate.ResourceID(c.Provider); err != nil { + return fmt.Errorf("invalid provider: %w", err) + } + if c.DryRun { + return out.WriteDryRun("Would toggle org app config", map[string]any{"provider": c.Provider, "enabled": c.Enabled}) + } + client, err := newClient() + if err != nil { + return err + } + if err := client.ToggleOrgAppConfig(newContext(), c.Provider, c.Enabled); err != nil { + return err + } + status := "disabled" + if c.Enabled { + status = "enabled" + } + return out.Write(map[string]string{"status": status, "provider": c.Provider}) +} diff --git a/cmd/onecli/org_connections.go b/cmd/onecli/org_connections.go new file mode 100644 index 0000000..6281171 --- /dev/null +++ b/cmd/onecli/org_connections.go @@ -0,0 +1,81 @@ +package main + +import ( + "fmt" + + "github.com/onecli/onecli-cli/pkg/output" + "github.com/onecli/onecli-cli/pkg/validate" +) + +// OrgConnectionsCmd is the `onecli org connections` command group. +type OrgConnectionsCmd struct { + List OrgConnectionsListCmd `cmd:"" help:"List all org-scoped connections."` + Delete OrgConnectionsDeleteCmd `cmd:"" help:"Delete an org-scoped connection."` +} + +// OrgConnectionsListCmd is `onecli org connections list`. +type OrgConnectionsListCmd 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 *OrgConnectionsListCmd) Run(out *output.Writer) error { + client, err := newClient() + if err != nil { + return err + } + + if c.Provider != "" { + if err := validate.ResourceID(c.Provider); err != nil { + return fmt.Errorf("invalid provider: %w", err) + } + connections, err := client.ListOrgConnectionsByProvider(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) + } + + connections, err := client.ListOrgConnections(newContext()) + 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) +} + +// OrgConnectionsDeleteCmd is `onecli org connections delete`. +type OrgConnectionsDeleteCmd struct { + ID string `required:"" help:"ID of the connection to delete."` + DryRun bool `optional:"" name:"dry-run" help:"Validate the request without executing it."` +} + +func (c *OrgConnectionsDeleteCmd) Run(out *output.Writer) error { + if err := validate.ResourceID(c.ID); err != nil { + return fmt.Errorf("invalid connection ID: %w", err) + } + if c.DryRun { + return out.WriteDryRun("Would delete org connection", map[string]string{"id": c.ID}) + } + client, err := newClient() + if err != nil { + return err + } + if err := client.DeleteOrgConnection(newContext(), c.ID); err != nil { + return err + } + return out.Write(map[string]string{"status": "deleted", "id": c.ID}) +} diff --git a/cmd/onecli/org_rules.go b/cmd/onecli/org_rules.go new file mode 100644 index 0000000..ae3c5dc --- /dev/null +++ b/cmd/onecli/org_rules.go @@ -0,0 +1,307 @@ +package main + +import ( + "encoding/json" + "fmt" + + "github.com/onecli/onecli-cli/internal/api" + "github.com/onecli/onecli-cli/pkg/output" + "github.com/onecli/onecli-cli/pkg/validate" +) + +// OrgRulesCmd is the `onecli org rules` command group. +type OrgRulesCmd struct { + List OrgRulesListCmd `cmd:"" help:"List all org-scoped policy rules."` + Get OrgRulesGetCmd `cmd:"" help:"Get a single org-scoped policy rule by ID."` + Create OrgRulesCreateCmd `cmd:"" help:"Create a new org-scoped policy rule."` + Update OrgRulesUpdateCmd `cmd:"" help:"Update an org-scoped policy rule."` + Delete OrgRulesDeleteCmd `cmd:"" help:"Delete an org-scoped policy rule."` + Permissions OrgRulesPermissionsCmd `cmd:"" help:"Manage app-level tool permissions."` +} + +// OrgRulesListCmd is `onecli org rules list`. +type OrgRulesListCmd 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."` + Max int `optional:"" default:"20" help:"Maximum number of results to return."` +} + +func (c *OrgRulesListCmd) Run(out *output.Writer) error { + client, err := newClient() + if err != nil { + return err + } + rules, err := client.ListOrgRules(newContext()) + if err != nil { + return err + } + if c.Max > 0 && len(rules) > c.Max { + rules = rules[:c.Max] + } + if c.Quiet != "" { + return out.WriteQuiet(rules, c.Quiet) + } + return out.WriteFiltered(rules, c.Fields) +} + +// OrgRulesGetCmd is `onecli org rules get`. +type OrgRulesGetCmd struct { + ID string `required:"" help:"ID of the rule to retrieve."` + Fields string `optional:"" help:"Comma-separated list of fields to include in output."` +} + +func (c *OrgRulesGetCmd) Run(out *output.Writer) error { + if err := validate.ResourceID(c.ID); err != nil { + return fmt.Errorf("invalid rule ID: %w", err) + } + client, err := newClient() + if err != nil { + return err + } + rule, err := client.GetOrgRule(newContext(), c.ID) + if err != nil { + return err + } + return out.WriteFiltered(rule, c.Fields) +} + +// OrgRulesCreateCmd is `onecli org rules create`. +type OrgRulesCreateCmd struct { + Name string `required:"" help:"Display name for the rule."` + HostPattern string `required:"" name:"host-pattern" help:"Host pattern to match (e.g. 'api.anthropic.com')."` + Action string `required:"" help:"Action to take: 'block' or 'rate_limit'."` + PathPattern string `optional:"" name:"path-pattern" help:"Path pattern to match (e.g. '/v1/*')."` + Method string `optional:"" help:"HTTP method to match (GET, POST, PUT, PATCH, DELETE)."` + AgentID string `optional:"" name:"agent-id" help:"Agent ID to scope this rule to. Omit for all agents."` + RateLimit *int `optional:"" name:"rate-limit" help:"Max requests per window (required for rate_limit action)."` + RateLimitWindow string `optional:"" name:"rate-limit-window" help:"Time window: 'minute', 'hour', or 'day'."` + Enabled bool `optional:"" default:"true" help:"Enable rule immediately."` + Json string `optional:"" help:"Raw JSON payload. Overrides individual flags."` + DryRun bool `optional:"" name:"dry-run" help:"Validate the request without executing it."` +} + +func (c *OrgRulesCreateCmd) Run(out *output.Writer) error { + var input api.CreateRuleInput + if c.Json != "" { + if err := json.Unmarshal([]byte(c.Json), &input); err != nil { + return fmt.Errorf("invalid JSON payload: %w", err) + } + } else { + input = api.CreateRuleInput{ + Name: c.Name, + HostPattern: c.HostPattern, + PathPattern: c.PathPattern, + Method: c.Method, + Action: c.Action, + Enabled: c.Enabled, + AgentID: c.AgentID, + RateLimit: c.RateLimit, + RateLimitWindow: c.RateLimitWindow, + } + } + + if err := validateRuleInput(input.HostPattern, input.PathPattern, input.Method, input.AgentID, input.Action); err != nil { + return err + } + + if input.Action == "rate_limit" && (input.RateLimit == nil || input.RateLimitWindow == "") { + return fmt.Errorf("--rate-limit and --rate-limit-window are required when action is 'rate_limit'") + } + + if c.DryRun { + return out.WriteDryRun("Would create org rule", input) + } + + client, err := newClient() + if err != nil { + return err + } + rule, err := client.CreateOrgRule(newContext(), input) + if err != nil { + return err + } + return out.Write(rule) +} + +// OrgRulesUpdateCmd is `onecli org rules update`. +type OrgRulesUpdateCmd struct { + ID string `required:"" help:"ID of the rule to update."` + Name string `optional:"" help:"New display name."` + HostPattern string `optional:"" name:"host-pattern" help:"New host pattern."` + PathPattern string `optional:"" name:"path-pattern" help:"New path pattern."` + Method string `optional:"" help:"New HTTP method."` + Action string `optional:"" help:"New action: 'block' or 'rate_limit'."` + Enabled *bool `optional:"" help:"Enable or disable the rule."` + AgentID string `optional:"" name:"agent-id" help:"New agent ID scope."` + RateLimit *int `optional:"" name:"rate-limit" help:"New max requests per window."` + RateLimitWindow string `optional:"" name:"rate-limit-window" help:"New time window."` + Json string `optional:"" help:"Raw JSON payload. Overrides individual flags."` + DryRun bool `optional:"" name:"dry-run" help:"Validate the request without executing it."` +} + +func (c *OrgRulesUpdateCmd) Run(out *output.Writer) error { + if err := validate.ResourceID(c.ID); err != nil { + return fmt.Errorf("invalid rule ID: %w", err) + } + + var input api.UpdateRuleInput + if c.Json != "" { + if err := json.Unmarshal([]byte(c.Json), &input); err != nil { + return fmt.Errorf("invalid JSON payload: %w", err) + } + } else { + if c.Name != "" { + input.Name = &c.Name + } + if c.HostPattern != "" { + input.HostPattern = &c.HostPattern + } + if c.PathPattern != "" { + input.PathPattern = &c.PathPattern + } + if c.Method != "" { + input.Method = &c.Method + } + if c.Action != "" { + input.Action = &c.Action + } + if c.Enabled != nil { + input.Enabled = c.Enabled + } + if c.AgentID != "" { + input.AgentID = &c.AgentID + } + if c.RateLimit != nil { + input.RateLimit = c.RateLimit + } + if c.RateLimitWindow != "" { + input.RateLimitWindow = &c.RateLimitWindow + } + } + + var hostPattern, pathPattern, method, agentID, action string + if input.HostPattern != nil { + hostPattern = *input.HostPattern + } + if input.PathPattern != nil { + pathPattern = *input.PathPattern + } + if input.Method != nil { + method = *input.Method + } + if input.AgentID != nil { + agentID = *input.AgentID + } + if input.Action != nil { + action = *input.Action + } + if err := validateRuleInput(hostPattern, pathPattern, method, agentID, action); err != nil { + return err + } + + if c.DryRun { + return out.WriteDryRun("Would update org rule", map[string]any{"id": c.ID, "input": input}) + } + + client, err := newClient() + if err != nil { + return err + } + rule, err := client.UpdateOrgRule(newContext(), c.ID, input) + if err != nil { + return err + } + return out.Write(rule) +} + +// OrgRulesDeleteCmd is `onecli org rules delete`. +type OrgRulesDeleteCmd struct { + ID string `required:"" help:"ID of the rule to delete."` + DryRun bool `optional:"" name:"dry-run" help:"Validate the request without executing it."` +} + +func (c *OrgRulesDeleteCmd) Run(out *output.Writer) error { + if err := validate.ResourceID(c.ID); err != nil { + return fmt.Errorf("invalid rule ID: %w", err) + } + if c.DryRun { + return out.WriteDryRun("Would delete org rule", map[string]string{"id": c.ID}) + } + client, err := newClient() + if err != nil { + return err + } + if err := client.DeleteOrgRule(newContext(), c.ID); err != nil { + return err + } + return out.Write(map[string]string{"status": "deleted", "id": c.ID}) +} + +// OrgRulesPermissionsCmd is `onecli org rules permissions`. +type OrgRulesPermissionsCmd struct { + Get OrgRulesPermissionsGetCmd `cmd:"" help:"Get tool permissions for a provider."` + Set OrgRulesPermissionsSetCmd `cmd:"" help:"Set tool permissions for a provider."` +} + +// OrgRulesPermissionsGetCmd is `onecli org rules permissions get`. +type OrgRulesPermissionsGetCmd 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 *OrgRulesPermissionsGetCmd) 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 + } + states, err := client.GetAppPermissions(newContext(), c.Provider) + if err != nil { + return err + } + return out.WriteFiltered(states, c.Fields) +} + +// OrgRulesPermissionsSetCmd is `onecli org rules permissions set`. +type OrgRulesPermissionsSetCmd struct { + Provider string `required:"" help:"Provider name (e.g. 'github', 'gmail')."` + Json string `required:"" help:"JSON payload with 'changes' array of {toolId, permission} objects."` + DryRun bool `optional:"" name:"dry-run" help:"Validate the request without executing it."` +} + +func (c *OrgRulesPermissionsSetCmd) Run(out *output.Writer) error { + if err := validate.ResourceID(c.Provider); err != nil { + return fmt.Errorf("invalid provider: %w", err) + } + + var input api.SetPermissionsInput + if err := json.Unmarshal([]byte(c.Json), &input); err != nil { + return fmt.Errorf("invalid JSON payload: %w", err) + } + if len(input.Changes) == 0 { + return fmt.Errorf("'changes' array must contain at least one entry") + } + for _, ch := range input.Changes { + if ch.ToolID == "" { + return fmt.Errorf("each change must have a non-empty 'toolId'") + } + if ch.Permission != "allow" && ch.Permission != "manual_approval" && ch.Permission != "block" { + return fmt.Errorf("invalid permission %q for tool %q: must be 'allow', 'manual_approval', or 'block'", ch.Permission, ch.ToolID) + } + } + + if c.DryRun { + return out.WriteDryRun("Would set app permissions", map[string]any{"provider": c.Provider, "input": input}) + } + + client, err := newClient() + if err != nil { + return err + } + if err := client.SetAppPermissions(newContext(), c.Provider, input); err != nil { + return err + } + return out.Write(map[string]string{"status": "updated", "provider": c.Provider}) +} diff --git a/cmd/onecli/org_secrets.go b/cmd/onecli/org_secrets.go new file mode 100644 index 0000000..dde9805 --- /dev/null +++ b/cmd/onecli/org_secrets.go @@ -0,0 +1,196 @@ +package main + +import ( + "encoding/json" + "fmt" + + "github.com/onecli/onecli-cli/internal/api" + "github.com/onecli/onecli-cli/pkg/output" + "github.com/onecli/onecli-cli/pkg/validate" +) + +// OrgSecretsCmd is the `onecli org secrets` command group. +type OrgSecretsCmd struct { + List OrgSecretsListCmd `cmd:"" help:"List all org-scoped secrets."` + Create OrgSecretsCreateCmd `cmd:"" help:"Create a new org-scoped secret."` + Update OrgSecretsUpdateCmd `cmd:"" help:"Update an org-scoped secret."` + Delete OrgSecretsDeleteCmd `cmd:"" help:"Delete an org-scoped secret."` +} + +// OrgSecretsListCmd is `onecli org secrets list`. +type OrgSecretsListCmd 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."` + Max int `optional:"" default:"20" help:"Maximum number of results to return."` +} + +func (c *OrgSecretsListCmd) Run(out *output.Writer) error { + client, err := newClient() + if err != nil { + return err + } + secrets, err := client.ListOrgSecrets(newContext()) + if err != nil { + return err + } + if c.Max > 0 && len(secrets) > c.Max { + secrets = secrets[:c.Max] + } + if c.Quiet != "" { + return out.WriteQuiet(secrets, c.Quiet) + } + return out.WriteFiltered(secrets, c.Fields) +} + +// OrgSecretsCreateCmd is `onecli org secrets create`. +type OrgSecretsCreateCmd struct { + Name string `required:"" help:"Display name for the secret."` + Type string `required:"" help:"Secret type: 'anthropic', 'openai', or 'generic'."` + Value string `required:"" help:"Secret value (e.g. API key)."` + HostPattern string `required:"" name:"host-pattern" help:"Host pattern to match (e.g. 'api.anthropic.com')."` + PathPattern string `optional:"" name:"path-pattern" help:"Path pattern to match (e.g. '/v1/*')."` + HeaderName string `optional:"" name:"header-name" help:"Header name for injection (e.g. 'Authorization')."` + ValueFormat string `optional:"" name:"value-format" help:"Value format template for header injection (default: '{value}')."` + ParamName string `optional:"" name:"param-name" help:"URL query parameter name for injection (e.g. 'key')."` + ParamFormat string `optional:"" name:"param-format" help:"Value format template for param injection (default: '{value}')."` + Json string `optional:"" help:"Raw JSON payload. Overrides individual flags."` + DryRun bool `optional:"" name:"dry-run" help:"Validate the request without executing it."` +} + +func (c *OrgSecretsCreateCmd) Run(out *output.Writer) error { + var input api.CreateSecretInput + if c.Json != "" { + if err := json.Unmarshal([]byte(c.Json), &input); err != nil { + return fmt.Errorf("invalid JSON payload: %w", err) + } + } else { + if c.HeaderName != "" && c.ParamName != "" { + return fmt.Errorf("--header-name and --param-name are mutually exclusive") + } + input = api.CreateSecretInput{ + Name: c.Name, + Type: c.Type, + Value: c.Value, + HostPattern: c.HostPattern, + PathPattern: c.PathPattern, + } + if c.HeaderName != "" { + input.InjectionConfig = &api.InjectionConfig{ + HeaderName: c.HeaderName, + ValueFormat: c.ValueFormat, + } + } else if c.ParamName != "" { + input.InjectionConfig = &api.InjectionConfig{ + ParamName: c.ParamName, + ParamFormat: c.ParamFormat, + } + } + } + + if input.Type != "anthropic" && input.Type != "openai" && input.Type != "generic" { + return fmt.Errorf("invalid type %q: must be 'anthropic', 'openai', or 'generic'", input.Type) + } + + if c.DryRun { + preview := input + preview.Value = "***" + return out.WriteDryRun("Would create org secret", preview) + } + + client, err := newClient() + if err != nil { + return err + } + secret, err := client.CreateOrgSecret(newContext(), input) + if err != nil { + return err + } + return out.Write(secret) +} + +// OrgSecretsUpdateCmd is `onecli org secrets update`. +type OrgSecretsUpdateCmd struct { + ID string `required:"" help:"ID of the secret to update."` + Value string `optional:"" help:"New secret value."` + HostPattern string `optional:"" name:"host-pattern" help:"New host pattern."` + PathPattern string `optional:"" name:"path-pattern" help:"New path pattern."` + HeaderName string `optional:"" name:"header-name" help:"New header name for injection."` + ValueFormat string `optional:"" name:"value-format" help:"New value format template for header injection."` + ParamName string `optional:"" name:"param-name" help:"New URL query parameter name for injection."` + ParamFormat string `optional:"" name:"param-format" help:"New value format template for param injection."` + Json string `optional:"" help:"Raw JSON payload. Overrides individual flags."` + DryRun bool `optional:"" name:"dry-run" help:"Validate the request without executing it."` +} + +func (c *OrgSecretsUpdateCmd) Run(out *output.Writer) error { + if err := validate.ResourceID(c.ID); err != nil { + return fmt.Errorf("invalid secret ID: %w", err) + } + + var input api.UpdateSecretInput + if c.Json != "" { + if err := json.Unmarshal([]byte(c.Json), &input); err != nil { + return fmt.Errorf("invalid JSON payload: %w", err) + } + } else { + if c.HeaderName != "" && c.ParamName != "" { + return fmt.Errorf("--header-name and --param-name are mutually exclusive") + } + if c.Value != "" { + input.Value = &c.Value + } + if c.HostPattern != "" { + input.HostPattern = &c.HostPattern + } + if c.PathPattern != "" { + input.PathPattern = &c.PathPattern + } + if c.HeaderName != "" { + input.InjectionConfig = &api.InjectionConfig{ + HeaderName: c.HeaderName, + ValueFormat: c.ValueFormat, + } + } else if c.ParamName != "" { + input.InjectionConfig = &api.InjectionConfig{ + ParamName: c.ParamName, + ParamFormat: c.ParamFormat, + } + } + } + + if c.DryRun { + return out.WriteDryRun("Would update org secret", map[string]any{"id": c.ID, "input": input}) + } + + client, err := newClient() + if err != nil { + return err + } + if err := client.UpdateOrgSecret(newContext(), c.ID, input); err != nil { + return err + } + return out.Write(map[string]string{"status": "updated", "id": c.ID}) +} + +// OrgSecretsDeleteCmd is `onecli org secrets delete`. +type OrgSecretsDeleteCmd struct { + ID string `required:"" help:"ID of the secret to delete."` + DryRun bool `optional:"" name:"dry-run" help:"Validate the request without executing it."` +} + +func (c *OrgSecretsDeleteCmd) Run(out *output.Writer) error { + if err := validate.ResourceID(c.ID); err != nil { + return fmt.Errorf("invalid secret ID: %w", err) + } + if c.DryRun { + return out.WriteDryRun("Would delete org secret", map[string]string{"id": c.ID}) + } + client, err := newClient() + if err != nil { + return err + } + if err := client.DeleteOrgSecret(newContext(), c.ID); err != nil { + return err + } + return out.Write(map[string]string{"status": "deleted", "id": c.ID}) +} diff --git a/internal/api/org_apps.go b/internal/api/org_apps.go new file mode 100644 index 0000000..93c66d2 --- /dev/null +++ b/internal/api/org_apps.go @@ -0,0 +1,62 @@ +package api + +import ( + "context" + "fmt" + "net/http" +) + +// OrgAppConfig is the config status for an org-scoped app provider. +type OrgAppConfig struct { + HasCredentials bool `json:"hasCredentials"` + Enabled bool `json:"enabled"` +} + +// ToggleInput is the request body for toggling app config. +type ToggleInput struct { + Enabled bool `json:"enabled"` +} + +// ListConfiguredProviders returns providers that have org-level credentials configured. +func (c *Client) ListConfiguredProviders(ctx context.Context) ([]any, error) { + var providers []any + if err := c.do(ctx, http.MethodGet, "/v1/org/app-config/configured", nil, &providers); err != nil { + return nil, fmt.Errorf("listing configured providers: %w", err) + } + return providers, nil +} + +// GetOrgAppConfig returns the app config for a provider at the org level. +func (c *Client) GetOrgAppConfig(ctx context.Context, provider string) (*OrgAppConfig, error) { + var config OrgAppConfig + if err := c.do(ctx, http.MethodGet, "/v1/org/app-config/"+provider, nil, &config); err != nil { + return nil, fmt.Errorf("getting org app config: %w", err) + } + return &config, nil +} + +// UpsertOrgAppConfig saves BYOC credentials for a provider at the org level. +func (c *Client) UpsertOrgAppConfig(ctx context.Context, provider string, input ConfigAppInput) error { + var resp SuccessResponse + if err := c.do(ctx, http.MethodPost, "/v1/org/app-config/"+provider, input, &resp); err != nil { + return fmt.Errorf("configuring org app: %w", err) + } + return nil +} + +// DeleteOrgAppConfig removes BYOC credentials for a provider at the org level. +func (c *Client) DeleteOrgAppConfig(ctx context.Context, provider string) error { + if err := c.do(ctx, http.MethodDelete, "/v1/org/app-config/"+provider, nil, nil); err != nil { + return fmt.Errorf("removing org app config: %w", err) + } + return nil +} + +// ToggleOrgAppConfig enables or disables an app config at the org level. +func (c *Client) ToggleOrgAppConfig(ctx context.Context, provider string, enabled bool) error { + var resp SuccessResponse + if err := c.do(ctx, http.MethodPatch, "/v1/org/app-config/"+provider+"/toggle", ToggleInput{Enabled: enabled}, &resp); err != nil { + return fmt.Errorf("toggling org app config: %w", err) + } + return nil +} diff --git a/internal/api/org_connections.go b/internal/api/org_connections.go new file mode 100644 index 0000000..49ece30 --- /dev/null +++ b/internal/api/org_connections.go @@ -0,0 +1,41 @@ +package api + +import ( + "context" + "fmt" + "net/http" +) + +// Connection represents an OAuth connection returned by the API. +type Connection struct { + ID string `json:"id"` + Provider string `json:"provider"` + Status string `json:"status"` + ConnectedAt string `json:"connectedAt"` +} + +// ListOrgConnections returns all connections for the organization. +func (c *Client) ListOrgConnections(ctx context.Context) ([]Connection, error) { + var connections []Connection + if err := c.do(ctx, http.MethodGet, "/v1/org/connections", nil, &connections); err != nil { + return nil, fmt.Errorf("listing org connections: %w", err) + } + return connections, nil +} + +// ListOrgConnectionsByProvider returns connections for a specific provider. +func (c *Client) ListOrgConnectionsByProvider(ctx context.Context, provider string) ([]Connection, error) { + var connections []Connection + if err := c.do(ctx, http.MethodGet, "/v1/org/connections/"+provider, nil, &connections); err != nil { + return nil, fmt.Errorf("listing org connections for provider: %w", err) + } + return connections, nil +} + +// DeleteOrgConnection removes an org-scoped connection by ID. +func (c *Client) DeleteOrgConnection(ctx context.Context, connectionID string) error { + if err := c.do(ctx, http.MethodDelete, "/v1/org/connections/"+connectionID, nil, nil); err != nil { + return fmt.Errorf("deleting org connection: %w", err) + } + return nil +} diff --git a/internal/api/org_rules.go b/internal/api/org_rules.go new file mode 100644 index 0000000..7b86d2f --- /dev/null +++ b/internal/api/org_rules.go @@ -0,0 +1,87 @@ +package api + +import ( + "context" + "fmt" + "net/http" +) + +// PermissionState describes the permission state for a single tool. +type PermissionState struct { + Permission string `json:"permission"` + Conditions []any `json:"conditions"` +} + +// PermissionChange is a single tool permission change for SetAppPermissions. +type PermissionChange struct { + ToolID string `json:"toolId"` + Permission string `json:"permission"` +} + +// SetPermissionsInput is the request body for setting app permissions. +type SetPermissionsInput struct { + Changes []PermissionChange `json:"changes"` + Conditions []any `json:"conditions,omitempty"` +} + +// ListOrgRules returns all policy rules scoped to the organization. +func (c *Client) ListOrgRules(ctx context.Context) ([]Rule, error) { + var rules []Rule + if err := c.do(ctx, http.MethodGet, "/v1/org/rules", nil, &rules); err != nil { + return nil, fmt.Errorf("listing org rules: %w", err) + } + return rules, nil +} + +// GetOrgRule returns a single org-scoped policy rule by ID. +func (c *Client) GetOrgRule(ctx context.Context, id string) (*Rule, error) { + var rule Rule + if err := c.do(ctx, http.MethodGet, "/v1/org/rules/"+id, nil, &rule); err != nil { + return nil, fmt.Errorf("getting org rule: %w", err) + } + return &rule, nil +} + +// CreateOrgRule creates a policy rule at the organization level. +func (c *Client) CreateOrgRule(ctx context.Context, input CreateRuleInput) (*Rule, error) { + var rule Rule + if err := c.do(ctx, http.MethodPost, "/v1/org/rules", input, &rule); err != nil { + return nil, fmt.Errorf("creating org rule: %w", err) + } + return &rule, nil +} + +// UpdateOrgRule updates an org-scoped policy rule. +func (c *Client) UpdateOrgRule(ctx context.Context, id string, input UpdateRuleInput) (*Rule, error) { + var rule Rule + if err := c.do(ctx, http.MethodPatch, "/v1/org/rules/"+id, input, &rule); err != nil { + return nil, fmt.Errorf("updating org rule: %w", err) + } + return &rule, nil +} + +// DeleteOrgRule deletes an org-scoped policy rule. +func (c *Client) DeleteOrgRule(ctx context.Context, id string) error { + if err := c.do(ctx, http.MethodDelete, "/v1/org/rules/"+id, nil, nil); err != nil { + return fmt.Errorf("deleting org rule: %w", err) + } + return nil +} + +// GetAppPermissions returns the tool-level permission states for a provider. +func (c *Client) GetAppPermissions(ctx context.Context, provider string) (map[string]PermissionState, error) { + var states map[string]PermissionState + if err := c.do(ctx, http.MethodGet, "/v1/org/rules/permissions/"+provider, nil, &states); err != nil { + return nil, fmt.Errorf("getting app permissions: %w", err) + } + return states, nil +} + +// SetAppPermissions updates tool-level permissions for a provider. +func (c *Client) SetAppPermissions(ctx context.Context, provider string, input SetPermissionsInput) error { + var resp any + if err := c.do(ctx, http.MethodPut, "/v1/org/rules/permissions/"+provider, input, &resp); err != nil { + return fmt.Errorf("setting app permissions: %w", err) + } + return nil +} diff --git a/internal/api/org_secrets.go b/internal/api/org_secrets.go new file mode 100644 index 0000000..ea1246c --- /dev/null +++ b/internal/api/org_secrets.go @@ -0,0 +1,42 @@ +package api + +import ( + "context" + "fmt" + "net/http" +) + +// ListOrgSecrets returns all secrets scoped to the organization. +func (c *Client) ListOrgSecrets(ctx context.Context) ([]Secret, error) { + var secrets []Secret + if err := c.do(ctx, http.MethodGet, "/v1/org/secrets", nil, &secrets); err != nil { + return nil, fmt.Errorf("listing org secrets: %w", err) + } + return secrets, nil +} + +// CreateOrgSecret creates a secret at the organization level. +func (c *Client) CreateOrgSecret(ctx context.Context, input CreateSecretInput) (*Secret, error) { + var secret Secret + if err := c.do(ctx, http.MethodPost, "/v1/org/secrets", input, &secret); err != nil { + return nil, fmt.Errorf("creating org secret: %w", err) + } + return &secret, nil +} + +// UpdateOrgSecret updates an org-scoped secret. +func (c *Client) UpdateOrgSecret(ctx context.Context, id string, input UpdateSecretInput) error { + var resp SuccessResponse + if err := c.do(ctx, http.MethodPatch, "/v1/org/secrets/"+id, input, &resp); err != nil { + return fmt.Errorf("updating org secret: %w", err) + } + return nil +} + +// DeleteOrgSecret deletes an org-scoped secret by ID. +func (c *Client) DeleteOrgSecret(ctx context.Context, id string) error { + if err := c.do(ctx, http.MethodDelete, "/v1/org/secrets/"+id, nil, nil); err != nil { + return fmt.Errorf("deleting org secret: %w", err) + } + return nil +}