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
51 changes: 6 additions & 45 deletions cmd/onecli/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 -- <command> [args...]`.
type RunCmd struct {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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) {
Expand Down
104 changes: 0 additions & 104 deletions cmd/onecli/skill_gateway.md

This file was deleted.

85 changes: 85 additions & 0 deletions cmd/onecli/skill_gateway_fallback.md
Original file line number Diff line number Diff line change
@@ -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.
35 changes: 35 additions & 0 deletions internal/api/skill.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading