Skip to content

Commit f9aef70

Browse files
Release CLI v0.3.0
Add Midjourney commands and isolate local callback listeners by selected API key.
1 parent 6a92906 commit f9aef70

15 files changed

Lines changed: 1655 additions & 57 deletions

README.md

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,42 @@ Build pipelines that need to verify downloads themselves can consume the stable
119119
curl -fsSL https://runapi.ai/cli/latest.json | jq '.assets["linux-amd64"]'
120120
```
121121

122+
## Local callback listener
123+
124+
Local listeners require the credential issued by browser login. On first use, the CLI lists your enabled API keys and lets you choose one by name, stable ID, and masked value. The validated stable ID is written to `.runapi.toml` at the git root, or in the current directory outside a git repository; no credential or signing secret is stored there.
125+
126+
```bash
127+
runapi login
128+
runapi listen localhost:3000/webhooks/runapi
129+
```
130+
131+
Only tasks created with the selected API key are copied to that listener. Tasks created with another key stay isolated, including keys owned by the same Account member. A committed `.runapi.toml` is reusable by the same member on another machine; another member is prompted to select a key they own.
132+
133+
Selection precedence is `--callback-api-key-id` (one invocation only), then project `.runapi.toml`, then the TTY selector. The selector writes the config only after the server validates the session. The config has one allowed field:
134+
135+
```toml
136+
callback_api_key_id = "token_abc123"
137+
```
138+
139+
Renaming the API key does not invalidate this stable ID. Do not add credentials, names, masks, signing secrets, forwarding URLs, or `base_url`; unknown fields are rejected.
140+
141+
Agents and non-interactive shells can discover and select the key explicitly:
142+
143+
```bash
144+
runapi api-keys list --json
145+
runapi listen localhost:3000/webhooks/runapi --callback-api-key-id token_abc123
146+
```
147+
148+
Without `--callback-api-key-id` or `.runapi.toml`, a non-interactive invocation returns `callback_api_key_required` with the available key metadata and never chooses automatically. `cli_listen_required` means the active credential did not come from browser login: the imported API key keeps its existing API access, but cannot list or select listener keys. Remove any `--api-key` or `RUNAPI_API_KEY` override, run `runapi login`, then retry `runapi listen`. If the selected key becomes unusable, the listener exits without falling back to another key.
149+
150+
Print the selected key's stable Listen Signing Secret without starting a listener with:
151+
152+
```bash
153+
RUNAPI_WEBHOOK_SECRET="$(runapi listen --print-secret --callback-api-key-id token_abc123)"
154+
```
155+
156+
After upgrading from the previous Account-wide listener behavior, update the CLI, run `runapi login` again, and restart listeners. Previous listener sessions and Account-wide listener secrets are invalid after the migration.
157+
122158
## Agent runtimes
123159

124160
`runapi` ships a portable skill for the major AI agent runtimes. Install it once and the runtime can run RunAPI commands with full inline documentation:

cmd/runapi/README.md

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,42 @@ Build pipelines that need to verify downloads themselves can consume the stable
119119
curl -fsSL https://runapi.ai/cli/latest.json | jq '.assets["linux-amd64"]'
120120
```
121121

122+
## Local callback listener
123+
124+
Local listeners require the credential issued by browser login. On first use, the CLI lists your enabled API keys and lets you choose one by name, stable ID, and masked value. The validated stable ID is written to `.runapi.toml` at the git root, or in the current directory outside a git repository; no credential or signing secret is stored there.
125+
126+
```bash
127+
runapi login
128+
runapi listen localhost:3000/webhooks/runapi
129+
```
130+
131+
Only tasks created with the selected API key are copied to that listener. Tasks created with another key stay isolated, including keys owned by the same Account member. A committed `.runapi.toml` is reusable by the same member on another machine; another member is prompted to select a key they own.
132+
133+
Selection precedence is `--callback-api-key-id` (one invocation only), then project `.runapi.toml`, then the TTY selector. The selector writes the config only after the server validates the session. The config has one allowed field:
134+
135+
```toml
136+
callback_api_key_id = "token_abc123"
137+
```
138+
139+
Renaming the API key does not invalidate this stable ID. Do not add credentials, names, masks, signing secrets, forwarding URLs, or `base_url`; unknown fields are rejected.
140+
141+
Agents and non-interactive shells can discover and select the key explicitly:
142+
143+
```bash
144+
runapi api-keys list --json
145+
runapi listen localhost:3000/webhooks/runapi --callback-api-key-id token_abc123
146+
```
147+
148+
Without `--callback-api-key-id` or `.runapi.toml`, a non-interactive invocation returns `callback_api_key_required` with the available key metadata and never chooses automatically. `cli_listen_required` means the active credential did not come from browser login: the imported API key keeps its existing API access, but cannot list or select listener keys. Remove any `--api-key` or `RUNAPI_API_KEY` override, run `runapi login`, then retry `runapi listen`. If the selected key becomes unusable, the listener exits without falling back to another key.
149+
150+
Print the selected key's stable Listen Signing Secret without starting a listener with:
151+
152+
```bash
153+
RUNAPI_WEBHOOK_SECRET="$(runapi listen --print-secret --callback-api-key-id token_abc123)"
154+
```
155+
156+
After upgrading from the previous Account-wide listener behavior, update the CLI, run `runapi login` again, and restart listeners. Previous listener sessions and Account-wide listener secrets are invalid after the migration.
157+
122158
## Agent runtimes
123159

124160
`runapi` ships a portable skill for the major AI agent runtimes. Install it once and the runtime can run RunAPI commands with full inline documentation:

cmd/runapi/api_keys.go

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
package main
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"fmt"
7+
"io"
8+
"net/http"
9+
"os"
10+
"strings"
11+
"text/tabwriter"
12+
"time"
13+
14+
runapi "github.com/runapi-ai/cli/internal/runapi"
15+
"github.com/runapi-ai/core-sdk/go/core"
16+
"github.com/spf13/cobra"
17+
)
18+
19+
type callbackAPIKey struct {
20+
ID string `json:"id"`
21+
Name string `json:"name"`
22+
MaskedToken string `json:"masked_token"`
23+
Enabled bool `json:"enabled"`
24+
}
25+
26+
type apiKeysResponse struct {
27+
APIKeys []callbackAPIKey `json:"api_keys"`
28+
}
29+
30+
func (c *cli) apiKeysCommand() *cobra.Command {
31+
apiKeys := &cobra.Command{
32+
Use: "api-keys",
33+
Short: "Inspect API keys available to CLI listener operations",
34+
Args: cobra.NoArgs,
35+
}
36+
var outputJSON bool
37+
list := &cobra.Command{
38+
Use: "list",
39+
Short: "List callback API key candidates",
40+
Args: cobra.NoArgs,
41+
RunE: func(cmd *cobra.Command, _ []string) error {
42+
apiKey, baseURL, err := c.listenerCredentials()
43+
if err != nil {
44+
return err
45+
}
46+
response, err := fetchAPIKeys(cmd.Context(), c.listenHTTPClient(), baseURL, apiKey)
47+
if err != nil {
48+
return err
49+
}
50+
if outputJSON {
51+
return c.writeJSON(response)
52+
}
53+
54+
writer := tabwriter.NewWriter(c.stdout, 0, 4, 2, ' ', 0)
55+
_, _ = fmt.Fprintln(writer, "NAME\tID\tMASKED KEY\tENABLED")
56+
for _, key := range response.APIKeys {
57+
_, _ = fmt.Fprintf(writer, "%s\t%s\t%s\t%t\n", key.Name, key.ID, key.MaskedToken, key.Enabled)
58+
}
59+
return writer.Flush()
60+
},
61+
}
62+
list.Flags().BoolVar(&outputJSON, "json", false, "Write the API key list as JSON")
63+
apiKeys.AddCommand(list)
64+
return apiKeys
65+
}
66+
67+
func fetchAPIKeys(ctx context.Context, client *http.Client, baseURL, apiKey string) (apiKeysResponse, error) {
68+
req, err := http.NewRequestWithContext(ctx, http.MethodGet, strings.TrimRight(baseURL, "/")+"/api/v1/cli/keys", nil)
69+
if err != nil {
70+
return apiKeysResponse{}, err
71+
}
72+
req.Header.Set("X-API-Key", apiKey)
73+
req.Header.Set("User-Agent", core.CLIUserAgent(runapi.Version))
74+
75+
resp, err := client.Do(req)
76+
if err != nil {
77+
return apiKeysResponse{}, err
78+
}
79+
defer resp.Body.Close()
80+
body, err := io.ReadAll(resp.Body)
81+
if err != nil {
82+
return apiKeysResponse{}, err
83+
}
84+
if resp.StatusCode != http.StatusOK {
85+
return apiKeysResponse{}, cliAPIError(resp, body)
86+
}
87+
88+
var response apiKeysResponse
89+
if err := json.Unmarshal(body, &response); err != nil {
90+
return apiKeysResponse{}, err
91+
}
92+
return response, nil
93+
}
94+
95+
func (c *cli) listenerCredentials() (string, string, error) {
96+
cfg, err := loadConfig()
97+
if err != nil {
98+
return "", "", err
99+
}
100+
apiKey := firstNonEmpty(
101+
strings.TrimSpace(c.apiKeyFlag),
102+
strings.TrimSpace(os.Getenv("RUNAPI_API_KEY")),
103+
strings.TrimSpace(cfg.APIKey),
104+
)
105+
if apiKey == "" {
106+
return "", "", core.NewError(
107+
core.ErrAuthentication,
108+
"API key required (--api-key, RUNAPI_API_KEY, or runapi login)",
109+
http.StatusUnauthorized,
110+
"",
111+
nil,
112+
nil,
113+
)
114+
}
115+
baseURL := strings.TrimRight(firstNonEmpty(
116+
strings.TrimSpace(c.baseURLFlag),
117+
strings.TrimSpace(os.Getenv("RUNAPI_BASE_URL")),
118+
strings.TrimSpace(cfg.BaseURL),
119+
core.DefaultBaseURL,
120+
), "/")
121+
return apiKey, baseURL, nil
122+
}
123+
124+
func (c *cli) listenHTTPClient() *http.Client {
125+
if c.httpClient != nil {
126+
return c.httpClient
127+
}
128+
return &http.Client{Timeout: 30 * time.Second}
129+
}
130+
131+
func cliAPIError(response *http.Response, body []byte) error {
132+
err := core.ErrorFromResponse(response, body)
133+
apiErr, ok := err.(*core.Error)
134+
if !ok {
135+
return err
136+
}
137+
var payload struct {
138+
Code string `json:"code"`
139+
}
140+
if json.Unmarshal(body, &payload) == nil && payload.Code != "" {
141+
apiErr.Code = core.ErrorCode(payload.Code)
142+
}
143+
return apiErr
144+
}

cmd/runapi/api_keys_test.go

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
package main
2+
3+
import (
4+
"bytes"
5+
"encoding/json"
6+
"net/http"
7+
"net/http/httptest"
8+
"strings"
9+
"testing"
10+
)
11+
12+
func TestAPIKeysListJSON(t *testing.T) {
13+
isolateConfig(t)
14+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
15+
if r.Method != http.MethodGet || r.URL.Path != "/api/v1/cli/keys" {
16+
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
17+
w.WriteHeader(http.StatusNotFound)
18+
return
19+
}
20+
if got := r.Header.Get("X-API-Key"); got != "cli-credential" {
21+
t.Errorf("expected CLI credential header, got %q", got)
22+
}
23+
w.Header().Set("Content-Type", "application/json")
24+
_, _ = w.Write([]byte(`{"api_keys":[{"id":"token_project","name":"Project key","masked_token":"runapi_abcd••••••••1234","enabled":true}]}`))
25+
}))
26+
defer server.Close()
27+
if err := saveConfig(configFile{APIKey: "cli-credential", BaseURL: server.URL}); err != nil {
28+
t.Fatal(err)
29+
}
30+
31+
var stdout bytes.Buffer
32+
c := newCLI()
33+
c.stdout = &stdout
34+
c.stderr = &bytes.Buffer{}
35+
c.httpClient = server.Client()
36+
cmd := c.command()
37+
cmd.SetArgs([]string{"api-keys", "list", "--json"})
38+
39+
if err := cmd.Execute(); err != nil {
40+
t.Fatal(err)
41+
}
42+
43+
var result apiKeysResponse
44+
if err := json.Unmarshal(stdout.Bytes(), &result); err != nil {
45+
t.Fatal(err)
46+
}
47+
if len(result.APIKeys) != 1 {
48+
t.Fatalf("expected one API key, got %d", len(result.APIKeys))
49+
}
50+
key := result.APIKeys[0]
51+
if key.ID != "token_project" || key.Name != "Project key" || !key.Enabled {
52+
t.Fatalf("unexpected API key: %#v", key)
53+
}
54+
}
55+
56+
func TestListenExplainsHowToReplaceAnImportedAPIKey(t *testing.T) {
57+
isolateConfig(t)
58+
const message = "`runapi listen` requires the CLI credential created by `runapi login` so it can list your eligible API keys and let you select one callback source. Your current API key can keep using its existing API access, but it cannot access listener operations. Remove any `--api-key` or `RUNAPI_API_KEY` override, run `runapi login`, then retry `runapi listen`."
59+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
60+
if r.Method != http.MethodGet || r.URL.Path != "/api/v1/cli/keys" {
61+
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
62+
w.WriteHeader(http.StatusNotFound)
63+
return
64+
}
65+
w.Header().Set("Content-Type", "application/json")
66+
w.WriteHeader(http.StatusForbidden)
67+
_ = json.NewEncoder(w).Encode(map[string]string{
68+
"error": message,
69+
"code": "cli_listen_required",
70+
})
71+
}))
72+
defer server.Close()
73+
if err := saveConfig(configFile{APIKey: "imported-api-key", BaseURL: server.URL}); err != nil {
74+
t.Fatal(err)
75+
}
76+
77+
var stdout, stderr bytes.Buffer
78+
c := newCLI()
79+
c.stdout = &stdout
80+
c.stderr = &stderr
81+
c.httpClient = server.Client()
82+
c.projectDir = projectRootFixture(t, "")
83+
84+
if code := c.run([]string{"listen", "localhost:3000/webhooks/runapi"}); code == 0 {
85+
t.Fatal("expected listen to reject an imported API key")
86+
}
87+
88+
var result struct {
89+
Error struct {
90+
Message string `json:"message"`
91+
Code string `json:"code"`
92+
} `json:"error"`
93+
}
94+
if err := json.Unmarshal(stdout.Bytes(), &result); err != nil {
95+
t.Fatalf("decode JSON error: %v\nstdout=%s", err, stdout.String())
96+
}
97+
if result.Error.Code != "cli_listen_required" || result.Error.Message != message {
98+
t.Fatalf("unexpected JSON error: %#v", result.Error)
99+
}
100+
for _, expected := range []string{"runapi login", "list your eligible API keys", "RUNAPI_API_KEY"} {
101+
if !strings.Contains(stderr.String(), expected) {
102+
t.Fatalf("expected stderr to contain %q, got %q", expected, stderr.String())
103+
}
104+
}
105+
}

0 commit comments

Comments
 (0)