- Please return to the terminal. You may now close this window.
-
+
-
Error: $ERROR
- $DETAILS
-
+
+

+
+
Login Failed
+
$ERROR
$DETAILS
@@ -187,7 +234,9 @@ func (h authHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if err := r.URL.Query().Get("error"); err != "" {
details := r.URL.Query().Get("error_description")
- rendered := strings.Replace(strings.Replace(htmlError, "$ERROR", err, 1), "$DETAILS", details, 1)
+ // Escape the query params before interpolating into HTML to avoid
+ // reflected XSS via the localhost redirect URL.
+ rendered := strings.Replace(strings.Replace(htmlError, "$ERROR", html.EscapeString(err), 1), "$DETAILS", html.EscapeString(details), 1)
w.Write([]byte(rendered))
h.c <- ""
return
@@ -285,10 +334,33 @@ func (ac *AuthorizationCodeTokenSource) Token() (*oauth2.Token, error) {
}
}()
- // Open auth URL in browser, print for manual use in case open fails.
- fmt.Fprintln(os.Stderr, "Open your browser to log in using the URL:")
- fmt.Fprintln(os.Stderr, authorizeURL.String())
- open(authorizeURL.String())
+ // Print welcome banner, show login URL, and ask before opening browser.
+ fmt.Fprint(os.Stderr, `
+ _ ___ ___ _ _
+ /_\ | _ \_ _|_ __ ___| |_ _ _(_)__ ___
+ / _ \| _/| || ' \/ -_| _| '_| / _(_-<
+ /_/ \_\_| |___|_|_|_\___|\__|_| |_\__/__/
+`)
+ fmt.Fprintln(os.Stderr, "Welcome to APImetrics CLI!")
+ fmt.Fprintln(os.Stderr, "")
+ fmt.Fprintln(os.Stderr, "To log in, open the following URL in your browser:")
+ fmt.Fprintln(os.Stderr, "")
+ fmt.Fprintln(os.Stderr, " "+authorizeURL.String())
+ fmt.Fprintln(os.Stderr, "")
+ // Only prompt interactively if stdin is a live terminal. If a file or
+ // command has been piped in it is likely the request body to use after
+ // auth, so we must not consume it here (see the manual-code path below).
+ if isatty.IsTerminal(os.Stdin.Fd()) || isatty.IsCygwinTerminal(os.Stdin.Fd()) {
+ fmt.Fprint(os.Stderr, "Open your browser now? [Y/n]: ")
+ reader := bufio.NewReader(os.Stdin)
+ answer, _ := reader.ReadString('\n')
+ answer = strings.TrimSpace(strings.ToLower(answer))
+ if answer == "" || answer == "y" || answer == "yes" {
+ open(authorizeURL.String())
+ }
+ } else {
+ open(authorizeURL.String())
+ }
// Provide a way to manually enter the code, e.g. for remote SSH sessions.
// Only read from stdin if it is a live terminal, if a file or command has
diff --git a/skills/embed/setup-api-monitor.md b/skills/embed/setup-api-monitor.md
new file mode 100644
index 0000000..4fb4baf
--- /dev/null
+++ b/skills/embed/setup-api-monitor.md
@@ -0,0 +1,131 @@
+---
+name: setup-api-monitor
+description: >
+ Set up an API monitor (HTTP call) in APImetrics: create the monitor,
+ attach or create a schedule, run on-demand, and verify the result.
+ Use when asked to create, configure, or test an API monitor or API call.
+---
+
+## Input format
+
+All `apimetrics` create commands read JSON from stdin. Use heredoc syntax:
+
+```bash
+apimetrics create-call <<'EOF'
+{ ... }
+EOF
+```
+
+There is no `--body`, `--data`, or `-d` flag on any `apimetrics` command.
+
+## Steps
+
+### 1. Create the API monitor
+
+```bash
+apimetrics create-call <<'EOF'
+{
+ "meta": {
+ "name": "
"
+ },
+ "request": {
+ "method": "GET",
+ "url": ""
+ }
+}
+EOF
+```
+
+Save the returned `id` — used in all subsequent steps.
+
+To add request headers or auth:
+```bash
+apimetrics create-call <<'EOF'
+{
+ "meta": { "name": "" },
+ "request": {
+ "method": "GET",
+ "url": "",
+ "headers": [{"key": "Accept", "value": "application/json"}],
+ "auth_id": ""
+ }
+}
+EOF
+```
+
+**Validation gate:** Response must contain an `id` field. If creation fails with 400, check that `meta.name`, `request.url`, and `request.method` are all present.
+
+### 2. Attach a schedule
+
+Always offer scheduling after creating the monitor, unless the user has explicitly said they do not want one.
+
+First, list existing schedules so the user can choose:
+```bash
+apimetrics list-schedules
+```
+
+Present the options to the user:
+- Attach to one of the existing schedules
+- Create a new schedule and attach this monitor to it
+- Skip scheduling for now
+
+Wait for the user's choice before proceeding.
+
+**To attach to an existing schedule** (both IDs are positional arguments):
+```bash
+apimetrics add-call-to-schedule
+```
+
+**To create a new schedule:**
+```bash
+apimetrics create-schedule <<'EOF'
+{
+ "name": "",
+ "frequency": 300,
+ "targets": [""]
+}
+EOF
+```
+
+`frequency` is in seconds. Common values: `60` (1 min), `300` (5 min), `3600` (1 hour).
+
+**Validation gate:** Confirm the schedule lists the monitor ID in its targets before proceeding.
+
+### 3. Run the monitor on-demand
+
+`run-call` takes the call ID as a positional argument:
+```bash
+apimetrics run-call
+```
+
+The response contains a `result_id`. Save it for the next step.
+
+**Validation gate:** Response must contain `result_id`. A 422 response means the project is out of quota.
+
+### 4. Poll results to verify
+
+```bash
+apimetrics list-call-results
+```
+
+A successful result has `result.success: true` and an HTTP status code in the 2xx range. Poll until the result from step 3 appears (match by `result_id`). Use `-f` to narrow output:
+
+```bash
+apimetrics list-call-results -f body.results[0]
+```
+
+**Validation gate:** Confirm `result.success` is `true`. If `false`, inspect `result.failure_reason` and the response body for details.
+
+## Hard rules
+
+- Always verify the call ID before attaching to a schedule — attaching the wrong ID silently succeeds.
+- Do not poll results in a tight loop. Wait 5–10 seconds between checks; on-demand runs typically complete within 30 seconds.
+- `frequency` on schedules is in seconds, not minutes.
+- `add-call-to-schedule` takes two positional args: schedule ID first, then target ID.
+
+## Error recovery
+
+- **400 on create:** Missing required fields. Check `meta.name`, `request.url`, and `request.method` are all present and non-empty.
+- **401/403:** Confirm `--api-key` or project is configured. Run `apimetrics project show` to check the active project.
+- **422 on run:** Project is out of quota. Check billing or reduce monitor frequency.
+- **No result after 60s:** The run may be queued behind other runs. Increase wait time or check monitor status.
diff --git a/skills/embed/setup-browser-monitor.md b/skills/embed/setup-browser-monitor.md
new file mode 100644
index 0000000..93e36d1
--- /dev/null
+++ b/skills/embed/setup-browser-monitor.md
@@ -0,0 +1,123 @@
+---
+name: setup-browser-monitor
+description: >
+ Set up a Browser monitor in APImetrics: create the monitor, attach or
+ create a schedule, run on-demand, and verify the result.
+ Use when asked to create, configure, or test a browser monitor.
+---
+
+## Input format
+
+All `apimetrics` create commands read JSON from stdin. Use heredoc syntax:
+
+```bash
+apimetrics create-browser-monitor <<'EOF'
+{ ... }
+EOF
+```
+
+There is no `--body`, `--data`, or `-d` flag on any `apimetrics` command.
+
+## Steps
+
+### 1. Create the browser monitor
+
+```bash
+apimetrics create-browser-monitor <<'EOF'
+{
+ "name": "",
+ "url": ""
+}
+EOF
+```
+
+Save the returned `id` — used in all subsequent steps.
+
+Optional fields:
+- `"description"` — human-readable description
+- `"browser_types"` — list of browsers the monitor is permitted to run on
+- `"tags"` — list of tags for grouping
+- `"user_agent"` — override the User-Agent header
+- `"workspace"` — workspace ID if using workspaces
+
+**Validation gate:** Response must contain an `id` field. If creation fails with 400, confirm `name` and `url` are both present and that `url` includes the scheme (`https://`).
+
+### 2. Attach a schedule
+
+Always offer scheduling after creating the monitor, unless the user has explicitly said they do not want one.
+
+First, list existing schedules so the user can choose:
+```bash
+apimetrics list-schedules
+```
+
+Present the options to the user:
+- Attach to one of the existing schedules
+- Create a new schedule and attach this monitor to it
+- Skip scheduling for now
+
+Wait for the user's choice before proceeding.
+
+**To attach to an existing schedule** (both IDs are positional arguments):
+```bash
+apimetrics add-call-to-schedule
+```
+
+**To create a new schedule:**
+```bash
+apimetrics create-schedule <<'EOF'
+{
+ "name": "",
+ "frequency": 300,
+ "targets": [""]
+}
+EOF
+```
+
+`frequency` is in seconds. Common values: `60` (1 min), `300` (5 min), `3600` (1 hour).
+
+**Validation gate:** Confirm the schedule lists the monitor ID in its targets before proceeding.
+
+### 3. Run the monitor on-demand
+
+`run-monitor` takes the monitor ID as a positional argument and requires a JSON body:
+```bash
+apimetrics run-monitor <<'EOF'
+{}
+EOF
+```
+
+Save the `result_id` from the response — used in the next step.
+
+**Validation gate:** Response must include a `result_id`. A missing result ID means the run was not queued.
+
+### 4. Poll result to verify
+
+```bash
+apimetrics get-result
+```
+
+Poll until `result` is no longer `QUEUED`. Wait 10–15 seconds between checks (browser monitors typically complete within 60 seconds).
+
+**Validation gate:** `result` must be `PASS`. Values of `FAIL`, `WARN`, `ERROR`, or `TIMEOUT` indicate a problem — inspect the response for details.
+
+To get a screenshot of the page at the time of the run:
+```bash
+apimetrics get-result-screenshot
+```
+
+## Hard rules
+
+- Always verify the monitor ID before attaching to a schedule — attaching the wrong ID silently succeeds.
+- `run-monitor` and `get-result` take positional arguments — do not use `--monitor-id` or `--result-id` flags.
+- `add-call-to-schedule` takes two positional args: schedule ID first, then target ID.
+- Browser monitors take longer to complete than API monitors — wait at least 60 seconds before polling results.
+- Do not poll results in a tight loop. Wait 10–15 seconds between checks.
+- `frequency` on schedules is in seconds, not minutes.
+
+## Error recovery
+
+- **400 on create:** Confirm `name` and `url` are both provided and that the URL includes the scheme (`https://`).
+- **401/403:** Confirm `--api-key` or project is configured. Run `apimetrics project show` to check the active project.
+- **No result after 120s:** Browser monitors may take longer in high-load periods. Check the monitor with `apimetrics read-browser-monitor ` and retry.
+- **Screenshot unavailable:** Not all result types include screenshots. Fall back to `get-result-content`.
diff --git a/skills/embed/setup-mcp-monitor.md b/skills/embed/setup-mcp-monitor.md
new file mode 100644
index 0000000..fd6df47
--- /dev/null
+++ b/skills/embed/setup-mcp-monitor.md
@@ -0,0 +1,133 @@
+---
+name: setup-mcp-monitor
+description: >
+ Set up an MCP monitor in APImetrics: create the monitor with session steps,
+ attach or create a schedule, run on-demand, and verify the result.
+ Use when asked to create, configure, or test an MCP monitor.
+---
+
+## Input format
+
+All `apimetrics` create commands read JSON from stdin. Use heredoc syntax:
+
+```bash
+apimetrics create-mcp-monitor <<'EOF'
+{ ... }
+EOF
+```
+
+There is no `--body`, `--data`, or `-d` flag on any `apimetrics` command.
+
+## Steps
+
+### 1. Create the MCP monitor
+
+```bash
+apimetrics create-mcp-monitor <<'EOF'
+{
+ "name": "",
+ "url": ""
+}
+EOF
+```
+
+Save the returned `id` — used in all subsequent steps.
+
+Optional fields:
+- `"steps"` — steps to execute during the MCP session
+- `"auth_id"` — Auth Settings ID if the MCP server requires authentication
+- `"token_id"` — Auth Token ID for token-based auth
+- `"overall_timeout_ms"` — session timeout in milliseconds (default: 30000)
+- `"description"` — human-readable description
+- `"tags"` — list of tags for grouping
+- `"workspace"` — workspace ID if using workspaces
+
+Example with steps:
+```bash
+apimetrics create-mcp-monitor <<'EOF'
+{
+ "name": "Check tools endpoint",
+ "url": "https://mcp.example.com/sse",
+ "steps": [{"step_type": "list_tools", "timeout_ms": 10000}]
+}
+EOF
+```
+
+**Validation gate:** Response must contain an `id` field. If creation fails with 400, confirm `name` and `url` are both present and that the URL points to a reachable MCP SSE endpoint.
+
+### 2. Attach a schedule
+
+Always offer scheduling after creating the monitor, unless the user has explicitly said they do not want one.
+
+First, list existing schedules so the user can choose:
+```bash
+apimetrics list-schedules
+```
+
+Present the options to the user:
+- Attach to one of the existing schedules
+- Create a new schedule and attach this monitor to it
+- Skip scheduling for now
+
+Wait for the user's choice before proceeding.
+
+**To attach to an existing schedule** (both IDs are positional arguments):
+```bash
+apimetrics add-call-to-schedule
+```
+
+**To create a new schedule:**
+```bash
+apimetrics create-schedule <<'EOF'
+{
+ "name": "",
+ "frequency": 300,
+ "targets": [""]
+}
+EOF
+```
+
+`frequency` is in seconds. Common values: `60` (1 min), `300` (5 min), `3600` (1 hour).
+
+**Validation gate:** Confirm the schedule lists the monitor ID in its targets before proceeding.
+
+### 3. Run the monitor on-demand
+
+`run-monitor` takes the monitor ID as a positional argument and requires a JSON body:
+```bash
+apimetrics run-monitor <<'EOF'
+{}
+EOF
+```
+
+Save the `result_id` from the response — used in the next step.
+
+**Validation gate:** Response must include a `result_id`. A 422 means the project is out of quota.
+
+### 4. Poll result to verify
+
+```bash
+apimetrics get-result
+```
+
+Poll until `result` is no longer `QUEUED`. Allow up to the `overall_timeout_ms` value plus processing time. Wait 10–15 seconds between checks.
+
+**Validation gate:** `result` must be `PASS`. Values of `FAIL`, `WARN`, `ERROR`, or `TIMEOUT` indicate a problem. Common failure causes: unreachable server, auth failure, or a step that did not return the expected tool response.
+
+## Hard rules
+
+- Always verify the monitor ID before attaching to a schedule — attaching the wrong ID silently succeeds.
+- `run-monitor` and `get-result` take positional arguments — do not use `--monitor-id` or `--result-id` flags.
+- `add-call-to-schedule` takes two positional args: schedule ID first, then target ID.
+- MCP monitor runs are bounded by `overall_timeout_ms`. Sessions exceeding this limit are terminated and reported as failures.
+- Do not poll results in a tight loop. Wait 10–15 seconds between checks.
+- `frequency` on schedules is in seconds, not minutes.
+- The `url` must be an SSE endpoint, not a plain HTTP endpoint.
+
+## Error recovery
+
+- **400 on create:** Confirm `name` and `url` are both provided and that the URL is a valid SSE endpoint.
+- **401/403:** Confirm `--api-key` or project is configured. Run `apimetrics project show` to check the active project.
+- **Session timeout failures:** Increase `overall_timeout_ms` and re-run.
+- **422 on run:** Project is out of quota. Check billing or reduce monitor frequency.
+- **No result after 120s:** Verify the MCP server URL is reachable before retrying.
diff --git a/skills/skills.go b/skills/skills.go
new file mode 100644
index 0000000..d7163ff
--- /dev/null
+++ b/skills/skills.go
@@ -0,0 +1,246 @@
+package skills
+
+import (
+ "bytes"
+ "embed"
+ "fmt"
+ "io/fs"
+ "os"
+ "path/filepath"
+ "strings"
+
+ "apicontext.com/apimetrics/cli"
+ "github.com/spf13/cobra"
+)
+
+//go:embed embed/*.md
+var skillFiles embed.FS
+
+type agent int
+
+const (
+ agentCustom agent = iota
+ agentClaudeCode
+ agentCodex
+)
+
+// Init registers the skills command group and the top-level onboard command.
+func Init(cmd *cobra.Command) {
+ skills := cobra.Command{
+ Use: "skills",
+ Short: "Manage agent skills for APImetrics",
+ }
+
+ install := cobra.Command{
+ Use: "install",
+ Short: "Install APImetrics skills into an agent skills directory",
+ Example: " " + os.Args[0] + " skills install --claude-code\n" +
+ " " + os.Args[0] + " skills install --codex\n" +
+ " " + os.Args[0] + " skills install --dir ./custom",
+ RunE: func(cmd *cobra.Command, args []string) error {
+ claudeCode, _ := cmd.Flags().GetBool("claude-code")
+ codex, _ := cmd.Flags().GetBool("codex")
+ dir, _ := cmd.Flags().GetString("dir")
+
+ target, kind, err := resolveTarget(claudeCode, codex, dir)
+ if err != nil {
+ return err
+ }
+
+ return installSkills(target, kind)
+ },
+ }
+
+ install.Flags().Bool("claude-code", false, "Install into .claude/commands/ as slash commands and write .claude/agents.md")
+ install.Flags().Bool("codex", false, "Install into .codex/skills/")
+ install.Flags().StringP("dir", "d", "", "Install into a custom directory path")
+
+ onboard := cobra.Command{
+ Use: "onboard",
+ Short: "Print all APImetrics skills to stdout for agent onboarding",
+ Long: "Prints all embedded skill workflows to stdout.\n" +
+ "Run this command and include the output in your agent's context\n" +
+ "to onboard it on the correct APImetrics CLI workflows.",
+ RunE: func(cmd *cobra.Command, args []string) error {
+ return printSkills()
+ },
+ }
+
+ skills.AddCommand(&install)
+ cmd.AddCommand(&skills)
+ cmd.AddCommand(&onboard)
+}
+
+func resolveTarget(claudeCode, codex bool, dir string) (string, agent, error) {
+ count := 0
+ if claudeCode {
+ count++
+ }
+ if codex {
+ count++
+ }
+ if dir != "" {
+ count++
+ }
+
+ if count == 0 {
+ return "", agentCustom, fmt.Errorf("specify a target: --claude-code, --codex, or --dir ")
+ }
+ if count > 1 {
+ return "", agentCustom, fmt.Errorf("specify only one of --claude-code, --codex, or --dir")
+ }
+
+ switch {
+ case claudeCode:
+ return ".claude/commands", agentClaudeCode, nil
+ case codex:
+ return ".codex/skills", agentCodex, nil
+ default:
+ return dir, agentCustom, nil
+ }
+}
+
+func installSkills(target string, kind agent) error {
+ if err := os.MkdirAll(target, 0755); err != nil {
+ return fmt.Errorf("creating directory %s: %w", target, err)
+ }
+
+ entries, err := fs.ReadDir(skillFiles, "embed")
+ if err != nil {
+ return fmt.Errorf("reading embedded skills: %w", err)
+ }
+
+ var names []string
+ for _, entry := range entries {
+ if entry.IsDir() {
+ continue
+ }
+
+ data, err := skillFiles.ReadFile("embed/" + entry.Name())
+ if err != nil {
+ return fmt.Errorf("reading skill %s: %w", entry.Name(), err)
+ }
+
+ dest := filepath.Join(target, entry.Name())
+ if err := os.WriteFile(dest, data, 0644); err != nil {
+ return fmt.Errorf("writing skill %s: %w", dest, err)
+ }
+
+ fmt.Fprintf(cli.Stdout, "Installed %s\n", dest)
+ names = append(names, strings.TrimSuffix(entry.Name(), ".md"))
+ }
+
+ fmt.Fprintf(cli.Stdout, "\nSkills installed to %s\n", target)
+
+ switch kind {
+ case agentClaudeCode:
+ fmt.Fprintf(cli.Stdout, "\nAvailable as slash commands in Claude Code:\n")
+ for _, name := range names {
+ fmt.Fprintf(cli.Stdout, " /%s\n", name)
+ }
+ if err := writeAgentsMD(names, os.Args[0]); err != nil {
+ return err
+ }
+ if err := updateClaudeMD(); err != nil {
+ return err
+ }
+ case agentCodex:
+ fmt.Fprintf(cli.Stdout, "\nReference these files from your agent's context configuration.\n")
+ }
+
+ fmt.Fprintf(cli.Stdout, "\nTo onboard any agent directly, run: %s onboard\n", os.Args[0])
+ return nil
+}
+
+func writeAgentsMD(skillNames []string, bin string) error {
+ const path = ".claude/agents.md"
+
+ content := buildAgentsMD(skillNames, bin)
+ if err := os.WriteFile(path, []byte(content), 0644); err != nil {
+ return fmt.Errorf("writing %s: %w", path, err)
+ }
+
+ fmt.Fprintf(cli.Stdout, "\nWritten %s\n", path)
+ return nil
+}
+
+// updateClaudeMD appends an @.claude/agents.md reference to CLAUDE.md if not already present.
+func updateClaudeMD() error {
+ const path = "CLAUDE.md"
+ const ref = "@.claude/agents.md"
+
+ existing, _ := os.ReadFile(path)
+
+ if bytes.Contains(existing, []byte(ref)) {
+ return nil
+ }
+
+ updated := existing
+ if len(updated) > 0 && updated[len(updated)-1] != '\n' {
+ updated = append(updated, '\n')
+ }
+ if len(updated) > 0 {
+ updated = append(updated, '\n')
+ }
+ updated = append(updated, []byte(ref+"\n")...)
+
+ if err := os.WriteFile(path, updated, 0644); err != nil {
+ return fmt.Errorf("updating CLAUDE.md: %w", err)
+ }
+
+ fmt.Fprintf(cli.Stdout, "Added @.claude/agents.md reference to CLAUDE.md\n")
+ return nil
+}
+
+func buildAgentsMD(skillNames []string, bin string) string {
+ var b strings.Builder
+
+ b.WriteString("# APImetrics CLI Agent Guide\n\n")
+ b.WriteString(fmt.Sprintf("Use `%s` for all apimetrics commands in this project.\n\n", bin))
+
+ b.WriteString("## Prerequisites\n\n")
+ b.WriteString("Before running any command:\n\n")
+ b.WriteString(fmt.Sprintf("1. Run `%s project show` to confirm a project is active — all commands fail without one. If none is set, run `%s project select`.\n", bin, bin))
+ b.WriteString("2. Invoke the relevant skill for the task.\n\n")
+
+ b.WriteString("## Critical facts\n\n")
+ b.WriteString("- Commands are flat, not grouped: use `create-call`, not `calls create`\n")
+ b.WriteString("- All create commands read JSON from stdin using heredoc syntax — there is no `--body`, `--data`, or `-d` flag\n")
+ b.WriteString(fmt.Sprintf("- Correct pattern: `%s create-call <<'EOF' ... EOF`\n\n", bin))
+
+ b.WriteString("## Skills\n\n")
+ for _, name := range skillNames {
+ b.WriteString(fmt.Sprintf("- `/%s`\n", name))
+ }
+ b.WriteString(fmt.Sprintf("\nIf slash commands are unavailable or you need to refresh context, run:\n\n```bash\n%s onboard\n```\n\nThis works for any agent and always reflects the current skill set.\n", bin))
+
+ return b.String()
+}
+
+func printSkills() error {
+ entries, err := fs.ReadDir(skillFiles, "embed")
+ if err != nil {
+ return fmt.Errorf("reading embedded skills: %w", err)
+ }
+
+ first := true
+ for _, entry := range entries {
+ if entry.IsDir() {
+ continue
+ }
+
+ data, err := skillFiles.ReadFile("embed/" + entry.Name())
+ if err != nil {
+ return fmt.Errorf("reading skill %s: %w", entry.Name(), err)
+ }
+
+ if !first {
+ fmt.Fprintln(cli.Stdout)
+ }
+ first = false
+
+ fmt.Fprint(cli.Stdout, string(data))
+ }
+
+ return nil
+}