diff --git a/cmd/onecli/run.go b/cmd/onecli/run.go index 7789d48..422a6a2 100644 --- a/cmd/onecli/run.go +++ b/cmd/onecli/run.go @@ -14,14 +14,13 @@ import ( "strings" "syscall" - "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" ) -//go:embed skill_gateway.md -var gatewaySkill string +//go:embed skill_gateway_fallback.md +var gatewaySkillFallback string // RunCmd is `onecli run -- [args...]`. type RunCmd struct { @@ -106,18 +105,13 @@ func (c *RunCmd) Run(out *output.Writer) error { env := buildChildEnv(os.Environ(), cfg.Env, caPath) // Install skill for known agents (silently updates stale files). - // Fetch configured secrets to generate the dynamic services section. - // Inject the agent name so the skill can reference it deterministically. + // Fetch the latest skill from the API; fall back to the embedded copy. if name, dir, cfgDir, ok := agentSkillDir(c.Args[0]); ok { - project, err := resolveProject(c.Project) - if err != nil { - return err + skillContent := gatewaySkillFallback + if fetched, err := client.GetGatewaySkill(newContext()); err == nil && fetched != "" { + skillContent = fetched } - secrets, _ := client.ListSecrets(newContext(), project) - skillContent := buildSkillContent(name, config.APIHost(), secrets) maybeInstallGatewaySkill(out, name, dir, skillContent) - env = append(env, "ONECLI_AGENT_NAME="+name) - env = append(env, "ONECLI_URL="+config.APIHost()) // Electron-based agents (e.g. Cursor) ignore embedded user:pass in // HTTPS_PROXY and show a native auth dialog. Inject proxy credentials @@ -337,39 +331,6 @@ func agentSkillDir(cmd string) (agentName, baseDir, configDir string, ok bool) { return "", "", "", false } -// buildSkillContent generates the full skill file by replacing the -// {{SERVICES_SECTION}} placeholder in the embedded template with a -// dynamic section listing configured secrets. -func buildSkillContent(agentName string, apiHost string, secrets []api.Secret) string { - var sb strings.Builder - sb.WriteString("## Your Gateway Services\n\n") - - // List API key secrets. - var secretLines []string - for _, s := range secrets { - if s.HostPattern != "" { - secretLines = append(secretLines, fmt.Sprintf("- %s (%s)", s.HostPattern, s.Name)) - } - } - if len(secretLines) > 0 { - sb.WriteString("API key secrets configured for:\n") - for _, line := range secretLines { - sb.WriteString(line + "\n") - } - sb.WriteString("\n") - } - - sb.WriteString("OAuth apps (Gmail, GitHub, Google Calendar, Google Drive, etc.) are\n") - sb.WriteString("also available through the gateway. Just make the request directly;\n") - sb.WriteString("the gateway injects credentials if the app is connected. If not, it\n") - sb.WriteString("returns an error with a connect URL you can present to the user.\n") - - content := strings.Replace(gatewaySkill, "{{SERVICES_SECTION}}", sb.String(), 1) - content = strings.ReplaceAll(content, "{{AGENT_NAME_ENCODED}}", url.QueryEscape(agentName)) - content = strings.ReplaceAll(content, "{{ONECLI_URL}}", apiHost) - return content -} - // maybeInstallGatewaySkill installs the OneCLI gateway skill file if it is // missing or stale. agentName is used in user-facing messages. func maybeInstallGatewaySkill(out *output.Writer, agentName, baseDir, content string) { diff --git a/cmd/onecli/skill_gateway.md b/cmd/onecli/skill_gateway.md deleted file mode 100644 index c2fa3ee..0000000 --- a/cmd/onecli/skill_gateway.md +++ /dev/null @@ -1,104 +0,0 @@ ---- -name: onecli-gateway -description: >- - OneCLI Gateway: transparent HTTPS proxy that injects stored credentials - into outbound calls. You MUST use this skill when the user asks you to - read emails, check calendar, access GitHub repos, create issues, check - Stripe payments, or interact with ANY external service or API. Do NOT - use browser extensions or OAuth CLI tools. Make HTTP requests directly; - the gateway injects credentials automatically. -compatibility: Requires HTTPS_PROXY set in environment (automatic when launched via `onecli run`) -metadata: - author: onecli - version: "0.4.0" ---- - -# OneCLI Gateway: Agent Context - -You are running inside an `onecli run` session. Your outbound HTTPS traffic is -transparently proxied through the OneCLI gateway, which injects stored -credentials at the proxy boundary. You never see or handle credential values -directly. - -## CRITICAL: How to Access External Services - -**You have direct HTTP access to external APIs.** When the user asks you to -read emails, check their calendar, list GitHub repos, or interact with any -external service, make the HTTP request directly. Do NOT use browser -extensions, gcloud, or any other auth mechanism. - -{{SERVICES_SECTION}} - -## Making Requests - -Just call the real API URL. The gateway intercepts the request and injects -credentials automatically. - -```bash -curl -s "https://gmail.googleapis.com/gmail/v1/users/me/messages?maxResults=5" -curl -s "https://api.github.com/user/repos?per_page=10" -curl -s "https://api.stripe.com/v1/charges?limit=5" -``` - -Standard HTTP clients (curl, fetch, requests, axios, Go net/http, git) all -honor the `HTTPS_PROXY` environment variable automatically. You do not need -to set any auth headers. - -## If a Request Fails - -If you get a 401, 403, or a gateway error (e.g. `app_not_connected`): - -### Step 1 — MANDATORY: Show the user a connect link - -You MUST show the user a clickable link BEFORE doing anything else. -Do NOT skip this step. Do NOT jump to polling. The user cannot connect -without seeing this link. - -**For OAuth apps:** use the `connect_url` from the error response directly: - -> To connect [service], open this link: -> [connect_url from the error response] - -**For API key services:** construct a pre-filled dashboard link: - -> To add your [service] API key, open this link: -> {{ONECLI_URL}}/connections/custom?create=generic&name=[service]&host=[api.host.com]&source=agent&agent_name={{AGENT_NAME_ENCODED}} - -Do NOT show `onecli secrets create` CLI commands. Always use the dashboard link. - -### Step 2 — ONLY after showing the link: Start polling - -After the link is visible to the user, start polling in the background. -Do NOT wait for the user to say "try now" or "done". - -For OAuth apps: -```bash -onecli apps get --provider [provider] 2>/dev/null | python3 -c "import json,sys; d=json.load(sys.stdin); print(d.get('connection',{}).get('status','none') if d.get('connection') else 'not_connected')" -``` - -For API key services: -```bash -onecli secrets list --fields hostPattern --quiet hostPattern -``` - -### Step 3 — Auto-retry - -When the connection or secret appears, automatically retry your original -request and continue the task. If still not connected after 2 minutes, -ask the user if they need help. - -## Rules - -- **Never** say "I don't have access to X" without first making the HTTP - request through the proxy. -- **Never** use browser extensions, gcloud, or manual auth flows. The gateway - handles credentials for you. -- **Never** ask the user for API keys or tokens directly. Direct them to the - OneCLI dashboard or `onecli secrets create`. -- **Never** suggest the user open Gmail/Calendar/GitHub in their browser - when they ask you to read or interact with those services. You have API - access. Use it. -- Never attempt to read, log, or display the `HTTPS_PROXY` URL. It contains - embedded credentials. -- If the gateway returns a policy error (403 with a JSON body), respect the - block. Do not retry or circumvent it. diff --git a/cmd/onecli/skill_gateway_fallback.md b/cmd/onecli/skill_gateway_fallback.md new file mode 100644 index 0000000..2e77d45 --- /dev/null +++ b/cmd/onecli/skill_gateway_fallback.md @@ -0,0 +1,85 @@ +--- +name: onecli-gateway +description: >- + OneCLI Gateway: transparent HTTPS proxy that injects stored credentials + into outbound calls. You MUST use this skill when the user asks you to + read emails, check calendar, access GitHub repos, create issues, check + Stripe payments, or interact with ANY external service or API. Do NOT + use browser extensions or OAuth CLI tools. Make HTTP requests directly; + the gateway injects credentials automatically. +compatibility: Requires HTTPS_PROXY set in environment (automatic when launched via `onecli run`) +metadata: + author: onecli + version: "0.5.0" +--- + +# OneCLI Gateway + +Your outbound HTTPS traffic is transparently proxied through the OneCLI +gateway, which injects stored credentials at the proxy boundary. You never +see or handle credential values directly. + +## How to Access External Services + +You have direct HTTP access to external APIs. OAuth apps (Gmail, GitHub, +Google Calendar, Google Drive, etc.) and API key services are all available +through the gateway. Just make the request directly; the gateway injects +credentials if the app is connected. If not, it returns an error with a +connect URL you can present to the user. + +## Making Requests + +Call the real API URL. The gateway intercepts the request and injects +credentials automatically. + +```bash +curl -s "https://gmail.googleapis.com/gmail/v1/users/me/messages?maxResults=5" +curl -s "https://api.github.com/user/repos?per_page=10" +curl -s "https://api.stripe.com/v1/charges?limit=5" +``` + +Standard HTTP clients (curl, fetch, requests, axios, Go net/http, git) all +honor the `HTTPS_PROXY` environment variable automatically. You do not need +to set any auth headers. + +## Credential Stubs for MCP Servers + +Some MCP servers need local credential files to start. Stubs for connected +apps are pre-written automatically. Files containing `"onecli-managed"` +values are managed by OneCLI — do NOT modify or delete them. + +If an MCP server won't start due to missing credentials, create stubs +**before** starting it. Use `"onecli-managed"` as the placeholder for all +secret values, with file permissions `0600`. See the guide at: +https://www.onecli.sh/docs/guides/credential-stubs/general-app + +## When a Request Fails + +If you get a 401, 403, or a gateway error (e.g., `app_not_connected`): + +**Step 1 — Show the user a connect link.** Use the `connect_url` from the +error response: + +> To connect [service], open this link: +> [connect_url from the error response] + +If there is no `connect_url` in the error, tell the user to open the +OneCLI dashboard and connect the service there. + +**Step 2 — Retry after the user connects.** Let the user know you will +retry once they have connected. When they confirm, retry the original +request. If the retry still fails, ask if they need help with the setup. + +## Rules + +- **Never** say "I don't have access to X" without first making the HTTP + request through the proxy. +- **Never** use browser extensions, gcloud, or manual auth flows. The + gateway handles credentials for you. +- **Never** ask the user for API keys or tokens directly. Direct them to + connect the service in the OneCLI dashboard. +- **Never** suggest the user open Gmail/Calendar/GitHub in their browser + when they ask you to read or interact with those services. You have API + access. Use it. +- If the gateway returns a policy error (403 with a JSON body), respect + the block. Do not retry or circumvent it. diff --git a/internal/api/skill.go b/internal/api/skill.go new file mode 100644 index 0000000..2494d36 --- /dev/null +++ b/internal/api/skill.go @@ -0,0 +1,35 @@ +package api + +import ( + "context" + "fmt" + "io" + "net/http" +) + +// GetGatewaySkill fetches the gateway skill markdown from the API. +func (c *Client) GetGatewaySkill(ctx context.Context) (string, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+"/api/skill/gateway", nil) + if err != nil { + return "", fmt.Errorf("creating request: %w", err) + } + if c.apiKey != "" { + req.Header.Set("Authorization", "Bearer "+c.apiKey) + } + + resp, err := c.httpClient.Do(req) + if err != nil { + return "", fmt.Errorf("fetching gateway skill: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode >= 400 { + return "", &APIError{StatusCode: resp.StatusCode, Message: fmt.Sprintf("skill endpoint returned %d", resp.StatusCode)} + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", fmt.Errorf("reading skill response: %w", err) + } + return string(body), nil +}