From f4c9a1456a33b0f2479443ef05589b6e091b2d43 Mon Sep 17 00:00:00 2001 From: johnnyfish Date: Mon, 6 Apr 2026 00:33:40 +0300 Subject: [PATCH 1/2] feat: add apps connect/list/disconnect commands for OAuth app connections --- cmd/onecli/apps.go | 115 +++++++++++++++++++++++++++++++++++++++++++ cmd/onecli/help.go | 9 ++++ cmd/onecli/main.go | 3 ++ internal/api/apps.go | 49 ++++++++++++++++++ 4 files changed, 176 insertions(+) create mode 100644 cmd/onecli/apps.go create mode 100644 internal/api/apps.go diff --git a/cmd/onecli/apps.go b/cmd/onecli/apps.go new file mode 100644 index 0000000..f8cded0 --- /dev/null +++ b/cmd/onecli/apps.go @@ -0,0 +1,115 @@ +package main + +import ( + "fmt" + + "github.com/onecli/onecli-cli/internal/api" + "github.com/onecli/onecli-cli/pkg/output" + "github.com/onecli/onecli-cli/pkg/validate" +) + +// AppsCmd is the `onecli apps` command group. +type AppsCmd struct { + List AppsListCmd `cmd:"" help:"List all app connections."` + Connect AppsConnectCmd `cmd:"" help:"Connect an OAuth app (e.g. Google)."` + Disconnect AppsDisconnectCmd `cmd:"" help:"Disconnect an app."` +} + +// AppsListCmd is `onecli apps list`. +type AppsListCmd 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 *AppsListCmd) Run(out *output.Writer) error { + client, err := newClient() + if err != nil { + return err + } + apps, err := client.ListApps(newContext()) + if err != nil { + return err + } + if c.Quiet != "" { + return out.WriteQuiet(apps, c.Quiet) + } + return out.WriteFiltered(apps, c.Fields) +} + +// AppsConnectCmd is `onecli apps connect`. +type AppsConnectCmd struct { + Provider string `required:"" help:"Provider name (e.g. 'google')."` + ClientID string `required:"" name:"client-id" help:"OAuth client ID."` + ClientSecret string `required:"" name:"client-secret" help:"OAuth client secret."` + DryRun bool `optional:"" name:"dry-run" help:"Validate the request without executing it."` +} + +// connectResponse wraps the API response with agent-facing guidance. +type connectResponse struct { + ID string `json:"id"` + Provider string `json:"provider"` + Status string `json:"status"` + CreatedAt string `json:"createdAt"` +} + +const docsBaseURL = "https://onecli.sh/docs/guides/credential-stubs" + +func (c *AppsConnectCmd) Run(out *output.Writer) error { + input := api.ConnectAppInput{ + Provider: c.Provider, + ClientID: c.ClientID, + ClientSecret: c.ClientSecret, + } + + if c.DryRun { + preview := map[string]string{ + "provider": input.Provider, + "clientId": input.ClientID, + "clientSecret": "***", + } + return out.WriteDryRun("Would connect app", preview) + } + + client, err := newClient() + if err != nil { + return err + } + app, err := client.ConnectApp(newContext(), input) + if err != nil { + return err + } + + docsURL := docsBaseURL + "/" + input.Provider + ".md" + fallbackURL := docsBaseURL + "/general-app.md" + out.SetHint("Your MCP server needs local credential stub files to start. Create them in the format and location the MCP server expects, but use 'onecli-managed' as a placeholder for all secrets. See " + docsURL + " for examples (fallback: " + fallbackURL + " ). The OneCLI gateway handles real OAuth token exchange at request time.") + resp := connectResponse{ + ID: app.ID, + Provider: app.Provider, + Status: app.Status, + CreatedAt: app.CreatedAt, + } + return out.Write(resp) +} + +// AppsDisconnectCmd is `onecli apps disconnect`. +type AppsDisconnectCmd struct { + ID string `required:"" help:"ID of the app connection to disconnect."` + DryRun bool `optional:"" name:"dry-run" help:"Validate the request without executing it."` +} + +func (c *AppsDisconnectCmd) Run(out *output.Writer) error { + if err := validate.ResourceID(c.ID); err != nil { + return fmt.Errorf("invalid app ID: %w", err) + } + if c.DryRun { + return out.WriteDryRun("Would disconnect app", map[string]string{"id": c.ID}) + } + client, err := newClient() + if err != nil { + return err + } + if err := client.DisconnectApp(newContext(), c.ID); err != nil { + return err + } + return out.Write(map[string]string{"status": "disconnected", "id": c.ID}) +} diff --git a/cmd/onecli/help.go b/cmd/onecli/help.go index 9fef19d..c598e57 100644 --- a/cmd/onecli/help.go +++ b/cmd/onecli/help.go @@ -79,6 +79,15 @@ func (cmd *HelpCmd) Run(out *output.Writer) error { {Name: "secrets delete", Description: "Delete a secret.", Args: []ArgInfo{ {Name: "--id", Required: true, Description: "ID of the secret to delete."}, }}, + {Name: "apps list", Description: "List all app connections."}, + {Name: "apps connect", Description: "Connect an OAuth app.", Args: []ArgInfo{ + {Name: "--provider", Required: true, Description: "Provider name (e.g. 'google')."}, + {Name: "--client-id", Required: true, Description: "OAuth client ID."}, + {Name: "--client-secret", Required: true, Description: "OAuth client secret."}, + }}, + {Name: "apps disconnect", Description: "Disconnect an app.", Args: []ArgInfo{ + {Name: "--id", Required: true, Description: "ID of the app connection to disconnect."}, + }}, {Name: "rules list", Description: "List all policy rules."}, {Name: "rules create", Description: "Create a new policy rule.", Args: []ArgInfo{ {Name: "--name", Required: true, Description: "Display name for the rule."}, diff --git a/cmd/onecli/main.go b/cmd/onecli/main.go index f9f0f5a..cd1fa91 100644 --- a/cmd/onecli/main.go +++ b/cmd/onecli/main.go @@ -23,6 +23,7 @@ type CLI struct { Help HelpCmd `cmd:"" help:"Show available commands."` Agents AgentsCmd `cmd:"" help:"Manage agents."` Secrets SecretsCmd `cmd:"" help:"Manage secrets."` + Apps AppsCmd `cmd:"" help:"Manage app connections."` Rules RulesCmd `cmd:"" help:"Manage policy rules."` Auth AuthCmd `cmd:"" help:"Manage authentication."` Config ConfigCmd `cmd:"" help:"Manage configuration settings."` @@ -117,6 +118,8 @@ func hintForCommand(cmd, host string) string { return "Manage your secrets \u2192 " + host case "agents": return "Manage your agents \u2192 " + host + case "apps": + return "Manage your app connections \u2192 " + host case "rules": return "Manage your policy rules \u2192 " + host case "auth": diff --git a/internal/api/apps.go b/internal/api/apps.go new file mode 100644 index 0000000..c7f6da2 --- /dev/null +++ b/internal/api/apps.go @@ -0,0 +1,49 @@ +package api + +import ( + "context" + "fmt" + "net/http" +) + +// App represents an app connection returned by the API. +type App struct { + ID string `json:"id"` + Provider string `json:"provider"` + Status string `json:"status"` + Docs string `json:"docs,omitempty"` + CreatedAt string `json:"createdAt"` +} + +// ConnectAppInput is the request body for connecting an app. +type ConnectAppInput struct { + Provider string `json:"provider"` + ClientID string `json:"clientId"` + ClientSecret string `json:"clientSecret"` +} + +// ListApps returns all app connections for the authenticated user. +func (c *Client) ListApps(ctx context.Context) ([]App, error) { + var apps []App + if err := c.do(ctx, http.MethodGet, "/api/apps", nil, &apps); err != nil { + return nil, fmt.Errorf("listing apps: %w", err) + } + return apps, nil +} + +// ConnectApp creates a new app connection. +func (c *Client) ConnectApp(ctx context.Context, input ConnectAppInput) (*App, error) { + var app App + if err := c.do(ctx, http.MethodPost, "/api/apps", input, &app); err != nil { + return nil, fmt.Errorf("connecting app: %w", err) + } + return &app, nil +} + +// DisconnectApp removes an app connection by ID. +func (c *Client) DisconnectApp(ctx context.Context, id string) error { + if err := c.do(ctx, http.MethodDelete, "/api/apps/"+id, nil, nil); err != nil { + return fmt.Errorf("disconnecting app: %w", err) + } + return nil +} From 3bb22e1925542805400d5c6d61f01d451f165e84 Mon Sep 17 00:00:00 2001 From: Guy Ben Aharon Date: Mon, 6 Apr 2026 10:37:51 +0300 Subject: [PATCH 2/2] fix --- cmd/onecli/apps.go | 54 +++++++++++++++++++++++++++----------------- pkg/output/output.go | 1 + 2 files changed, 34 insertions(+), 21 deletions(-) diff --git a/cmd/onecli/apps.go b/cmd/onecli/apps.go index f8cded0..a5cd3ee 100644 --- a/cmd/onecli/apps.go +++ b/cmd/onecli/apps.go @@ -1,6 +1,7 @@ package main import ( + "encoding/json" "fmt" "github.com/onecli/onecli-cli/internal/api" @@ -19,6 +20,7 @@ type AppsCmd struct { type AppsListCmd 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 *AppsListCmd) Run(out *output.Writer) error { @@ -30,6 +32,9 @@ func (c *AppsListCmd) Run(out *output.Writer) error { if err != nil { return err } + if c.Max > 0 && len(apps) > c.Max { + apps = apps[:c.Max] + } if c.Quiet != "" { return out.WriteQuiet(apps, c.Quiet) } @@ -41,24 +46,35 @@ type AppsConnectCmd struct { Provider string `required:"" help:"Provider name (e.g. 'google')."` 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."` } -// connectResponse wraps the API response with agent-facing guidance. -type connectResponse struct { - ID string `json:"id"` - Provider string `json:"provider"` - Status string `json:"status"` - CreatedAt string `json:"createdAt"` -} - const docsBaseURL = "https://onecli.sh/docs/guides/credential-stubs" +// connectResult wraps the API response with onboarding guidance as structured fields. +type connectResult struct { + api.App + NextSteps string `json:"next_steps"` + DocsURL string `json:"docs_url"` +} + func (c *AppsConnectCmd) Run(out *output.Writer) error { - input := api.ConnectAppInput{ - Provider: c.Provider, - ClientID: c.ClientID, - ClientSecret: c.ClientSecret, + var input api.ConnectAppInput + if c.Json != "" { + if err := json.Unmarshal([]byte(c.Json), &input); err != nil { + return fmt.Errorf("invalid JSON payload: %w", err) + } + } else { + input = api.ConnectAppInput{ + Provider: c.Provider, + ClientID: c.ClientID, + ClientSecret: c.ClientSecret, + } + } + + if err := validate.ResourceID(input.Provider); err != nil { + return fmt.Errorf("invalid provider: %w", err) } if c.DryRun { @@ -79,16 +95,12 @@ func (c *AppsConnectCmd) Run(out *output.Writer) error { return err } - docsURL := docsBaseURL + "/" + input.Provider + ".md" - fallbackURL := docsBaseURL + "/general-app.md" - out.SetHint("Your MCP server needs local credential stub files to start. Create them in the format and location the MCP server expects, but use 'onecli-managed' as a placeholder for all secrets. See " + docsURL + " for examples (fallback: " + fallbackURL + " ). The OneCLI gateway handles real OAuth token exchange at request time.") - resp := connectResponse{ - ID: app.ID, - Provider: app.Provider, - Status: app.Status, - CreatedAt: app.CreatedAt, + result := connectResult{ + App: *app, + NextSteps: "Create local credential stub files using 'onecli-managed' as placeholder for all secrets. The OneCLI gateway handles real OAuth token exchange at request time.", + DocsURL: docsBaseURL + "/" + input.Provider + ".md", } - return out.Write(resp) + return out.Write(result) } // AppsDisconnectCmd is `onecli apps disconnect`. diff --git a/pkg/output/output.go b/pkg/output/output.go index f26ee71..8a0f5e6 100644 --- a/pkg/output/output.go +++ b/pkg/output/output.go @@ -38,6 +38,7 @@ func NewWithWriters(out, err io.Writer) *Writer { // property in every JSON response written to stdout. func (w *Writer) SetHint(msg string) { w.hint = msg + w.hintFn = nil } // SetHintFunc sets a function that lazily resolves the hint message at write