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
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,5 @@ STRATEGY.md

# Local hygiene denylist — the list itself is the leak
.skill-denylist
__pycache__/
*.pyc
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ MCP gives your agent access. Skills give it judgment.
| [`go-agent-install`](skills/go-agent-install/SKILL.md) | Instrument a Go service with Last9 go-agent: detect the stack, wire chi + `database/sql` tracing, promote opt-in body capture, and verify spans land — without double-instrumenting |
| [`last9-logs`](skills/last9-logs/SKILL.md) | Log investigation: scope to a service first, attribute filters over body search, aggregate before drilling into raw lines |
| [`last9-traces`](skills/last9-traces/SKILL.md) | Trace investigation: a five-question interview that lands on the right tool call, plus a `tracejson` syntax reference card |
| [`last9-api`](skills/last9-api/SKILL.md) | Call the Last9 REST API from scripts and CI without MCP: a stdlib Python helper handles refresh-token exchange and the `X-LAST9-API-TOKEN` header; references cover logs, traces, change events, and Alertmanager migration |
| [`last9-cloudwatch`](skills/last9-cloudwatch/SKILL.md) | CloudWatch investigation across Billing, RDS/Aurora, ElastiCache, MSK, DynamoDB, EC2, SQS, DMS, KMS, and S3; focused family references share discovery, statistic, and evidence rules |

## Installation
Expand Down
2 changes: 1 addition & 1 deletion plugins/opencode-last9/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ export LAST9_ORG_SLUG="<org-slug>"
## What it registers

- **`last9` MCP server** — a remote MCP endpoint at `https://app.last9.io/api/v4/organizations/<org-slug>/mcp`. OpenCode handles OAuth automatically (dynamic client registration + browser flow).
- **Last9 skills** — the canonical skills from [`last9/ai-toolkit`](https://github.com/last9/ai-toolkit) (`last9-logs`, `last9-traces`, `last9-cloudwatch`, `go-agent-install`), loaded via `skills.paths`.
- **Last9 skills** — the canonical skills from [`last9/ai-toolkit`](https://github.com/last9/ai-toolkit) (`last9-logs`, `last9-traces`, `last9-api`, `last9-cloudwatch`, `go-agent-install`), loaded via `skills.paths`.

## Configuration

Expand Down
57 changes: 57 additions & 0 deletions scripts/check-skill-pack-selftest.sh
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,63 @@ printf 'orphan\n' > "$FIX/skills/orphan/references/family.md"
commit_fault
expect_fail "reference without entrypoint"

setup_fixture script-happy
mkdir -p "$FIX/skills/last9-logs/scripts"
printf 'print(1)\n' > "$FIX/skills/last9-logs/scripts/helper.py"
printf 'print(2)\n' > "$FIX/skills/last9-logs/scripts/test_helper.py"
commit_fault
if ! run_full sh "$FIX/scripts/check-skill-pack.sh" >/dev/null 2>&1; then
echo "selftest FAILED: tracked flat Python script expected exit 0" >&2
exit 1
fi

setup_fixture script-wrong-ext
mkdir -p "$FIX/skills/last9-logs/scripts"
printf 'x\n' > "$FIX/skills/last9-logs/scripts/helper.js"
commit_fault
expect_fail "script with wrong extension"

setup_fixture script-nested
mkdir -p "$FIX/skills/last9-logs/scripts/sub"
printf 'x\n' > "$FIX/skills/last9-logs/scripts/sub/helper.py"
commit_fault
expect_fail "nested script"

setup_fixture script-uppercase-or-dash
mkdir -p "$FIX/skills/last9-logs/scripts"
printf 'x\n' > "$FIX/skills/last9-logs/scripts/Helper-Run.py"
commit_fault
expect_fail "script with uppercase or dash"

setup_fixture script-symlink
mkdir -p "$FIX/skills/last9-logs/scripts"
printf 'outside skill\n' > "$FIX/outside.py"
ln -s ../../../outside.py "$FIX/skills/last9-logs/scripts/helper.py"
commit_fault
expect_fail "tracked script symlink"

setup_fixture script-parent-symlink
mkdir -p "$FIX/skills/last9-logs/scripts"
printf 'print(1)\n' > "$FIX/skills/last9-logs/scripts/helper.py"
commit_fault
mv "$FIX/skills/last9-logs/scripts" "$FIX/outside-scripts"
ln -s ../../outside-scripts "$FIX/skills/last9-logs/scripts"
expect_fail "working-tree script parent symlink"

setup_fixture script-missing-from-tarball
mkdir -p "$FIX/skills/last9-logs/scripts"
printf 'print(1)\n' > "$FIX/skills/last9-logs/scripts/helper.py"
commit_fault
jq '.scripts.prepack += " && rm skills/last9-logs/scripts/helper.py"' "$FIX/plugins/opencode-last9/package.json" > "$FIX/package.tmp"
mv "$FIX/package.tmp" "$FIX/plugins/opencode-last9/package.json"
expect_fail "missing packaged script"

setup_fixture script-without-entrypoint
mkdir -p "$FIX/skills/orphan/scripts"
printf 'print(1)\n' > "$FIX/skills/orphan/scripts/helper.py"
commit_fault
expect_fail "script without entrypoint"

# The gate must never delete or inspect another invocation's archive.
setup_fixture archive-isolation
ARCHIVE_TMP="$SANDBOX/archive-temp"
Expand Down
12 changes: 7 additions & 5 deletions scripts/check-skill-pack.sh
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,12 @@ if [ -n "$(git ls-files 'plugins/*/skills/*')" ]; then
exit 1
fi

# Canonical payload is deliberately narrow: one entrypoint plus optional direct
# Markdown references. Reject links before prepack can follow them outside skills/.
# Canonical payload is deliberately narrow: one entrypoint, direct Markdown
# references, and optional flat scripts/*.py helpers (no nesting, no other
# extensions). Reject links before prepack can follow them outside skills/.
PAYLOAD_RE='^skills/[a-z0-9-]+/(SKILL\.md|references/[a-z0-9-]+\.md|scripts/[a-z0-9_]+\.py)$'
for payload in $(git ls-files 'skills/**'); do
if ! printf '%s\n' "$payload" | grep -Eq '^skills/[a-z0-9-]+/(SKILL\.md|references/[a-z0-9-]+\.md)$'; then
if ! printf '%s\n' "$payload" | grep -Eq "$PAYLOAD_RE"; then
echo "::error::unsupported canonical skill payload: $payload" >&2
exit 1
fi
Expand All @@ -32,7 +34,7 @@ for payload in $(git ls-files 'skills/**'); do
echo "::error::skill reference has no tracked entrypoint: $payload" >&2
exit 1
fi
if [ -L skills ] || [ -L "$skill_dir" ] || [ -L "$skill_dir/references" ] || [ -L "$payload" ] || [ ! -f "$payload" ]; then
if [ -L skills ] || [ -L "$skill_dir" ] || [ -L "$skill_dir/references" ] || [ -L "$skill_dir/scripts" ] || [ -L "$payload" ] || [ ! -f "$payload" ]; then
echo "::error::skill payload must be a regular file without symlink parents: $payload" >&2
exit 1
fi
Expand Down Expand Up @@ -135,7 +137,7 @@ for payload in $(git ls-files 'skills/**'); do
done
for packed in $(grep -E '^package/skills/' "$tgz.list" || true); do
payload="${packed#package/}"
if ! printf '%s\n' "$payload" | grep -Eq '^skills/[a-z0-9-]+/(SKILL\.md|references/[a-z0-9-]+\.md)$' || ! git ls-files --error-unmatch -- "$payload" >/dev/null 2>&1; then
if ! printf '%s\n' "$payload" | grep -Eq "$PAYLOAD_RE" || ! git ls-files --error-unmatch -- "$payload" >/dev/null 2>&1; then
echo "::error::opencode tarball ships unexpected or untracked skills payload: $packed" >&2
missing=1
fi
Expand Down
78 changes: 78 additions & 0 deletions skills/last9-api/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
---
name: last9-api
description: Call the Last9 REST API from the command line with a stdlib Python helper that handles refresh-token exchange, access-token caching, and the X-LAST9-API-TOKEN header. Use when calling the Last9 REST API from scripts, CI, or an agent without the MCP server — exchanging refresh tokens for access tokens, sending change events, querying logs or traces over HTTP, migrating Alertmanager rules ("last9 api", "access token", "refresh token", "change events", "X-LAST9-API-TOKEN").
compatibility: Requires python3; network access to app.last9.io
metadata:
author: last9
---

# Last9 REST API

`<skill-dir>/scripts/last9.py` is a single-file, stdlib-only Python 3.8+ CLI. It stores the refresh token, exchanges it for short-lived access tokens (docs say 24h; 72h observed, see references/auth.md), caches them, sets the auth header, and retries once if a token has expired.

## REST vs MCP

- If Last9 MCP tools are connected, prefer them for interactive investigation. See the `last9-logs` and `last9-traces` skills.
- Use this skill for automation, CI, change events, Alertmanager migration, or any session without the MCP server.

## Setup

The user runs login themselves. Never ask the user to paste a token into chat.

1. Check first: `python3 <skill-dir>/scripts/last9.py status`. Exit 0 means already logged in.
2. If not logged in, tell the user to run this in their own session (the `!` prefix runs it there):

```text
! python3 <skill-dir>/scripts/last9.py login --region <region>
```

It opens the API Access page, prompts for the refresh token with hidden input, validates it by exchanging it, and saves it to `~/.last9/credentials` (mode 0600). Only Admins can create refresh tokens; Editors must ask an Admin. `--region` is saved with the profile and added automatically to logs/traces calls. Add `--no-browser` to skip opening the browser.
3. In CI, set `LAST9_REFRESH_TOKEN` from a secret store instead. It wins over saved profiles and is never written to disk.
4. Run `status` again to confirm org, host, scopes, and expiry. It prints no secrets.

**Finding your region:** use the region the org's data lives in (for example `ap-south-1`). If unsure, check the Last9 UI or org settings, or ask the user. Never guess in a loop. `LAST9_REGION` overrides the saved region.

Config lives in `~/.last9` (override with `LAST9_CONFIG_DIR`). `logout` removes a profile.

## Hard rules

- Always call the API through `last9.py api`. It sets `X-LAST9-API-TOKEN: Bearer <token>` and refreshes expired tokens. Do not hand-build the header.
- Never print, echo, or log a token or the refresh token into the transcript. Use `last9.py token` only when piping into another tool (`$(...)`).
- Pick the profile by scope and org: `--profile <name>` (or `LAST9_PROFILE`). Keep a read-only profile for queries and a separate write profile for change events or migrations. One profile per org.
- `api` sends the token only over `https://` and only to the token's own host. It refuses plaintext `http://` URLs and any other host, and does not follow redirects.
- When comparing query results, pin absolute `start`/`end` values. Two "last N minutes" queries issued seconds apart already diverge.

## Using `api`

```bash
python3 <skill-dir>/scripts/last9.py api METHOD PATH [-d DATA] [-q k=v ...] [-H 'K: V' ...] [-i]
```

- `PATH` without a leading `/api/` is relative to `https://<host>/api/v4/organizations/<org>/`. A path starting `/api/` is used on the token's host (for example `/api/v4/oauth/...`).
- `-q k=v` is repeatable and URL-encoded. `-d` takes a literal string, `@file`, or `-` for stdin and sets `Content-Type: application/json`.
- 2xx: body to stdout, exit 0. Otherwise: body to stdout, `HTTP <code>` to stderr, exit 1. `-i` prints the status on success too.

## Task to reference

| Task | Reference |
| ---- | --------- |
| Roles, scopes, token expiry, revocation, header rules, raw curl | [references/auth.md](references/auth.md) |
| Query, filter, and discover labels in logs | [references/logs.md](references/logs.md) |
| Query traces, search by duration, fetch a trace, tags | [references/traces.md](references/traces.md) |
| Send deploy/config change events from CI or scripts | [references/change-events.md](references/change-events.md) |
| Convert Prometheus Alertmanager rules to Last9 config | [references/alertmanager-migration.md](references/alertmanager-migration.md) |

## Errors

| Symptom | Meaning | Fix |
| ------- | ------- | --- |
| `400 {"error":"invalid access token"}` | Header value missing the `Bearer ` prefix | Use `last9.py api`; never send the raw token |
| `401` on an API call | Wrong header name (`Authorization`), or invalid or expired token | `api` retries once with a fresh token. If it still fails, run `status` and re-login |
| `{"error":"Authorization token is expired"}` | Access token past its `expires_at` | Handled automatically by `api` (forced refresh and one retry) |
| "refresh token invalid, expired, or revoked" | Exchange returned 400, 401, or 403 | The user generates a new refresh token at https://app.last9.io/settings/api-access and re-runs login |
| "not logged in" | No `LAST9_REFRESH_TOKEN` and no saved profile | Have the user run login (see Setup) |
| `400 region query parameter is required` | Logs/traces call without a region | Pass `-q region=<r>`, set `LAST9_REGION`, or `login --region <r>` |
| `500 ERR_S3_CONFIG_MISSING` or `502` "Maintenance Mode" HTML on logs/traces | Wrong region for this org | Use the org's real region; do not retry other regions blindly |
| `400 invalid refresh token: ...` from an API call | Misleading wording; the access token is bad | Delete `~/.last9/cache/<profile>.json` or re-login |
| `403` | Token scope too low for the operation | Use a profile whose refresh token has the needed scope (read, write, delete) |
| "refusing to send token to foreign host" or "over plaintext HTTP" | Full URL on another host, or using `http://` | Use a relative path, or an `https://` URL on the token's host |
47 changes: 47 additions & 0 deletions skills/last9-api/references/alertmanager-migration.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
# Alertmanager migration

Source: Last9 "Prometheus alertmanager Migrations" documentation.

Converts a Prometheus Alertmanager-compatible alert rules YAML into Last9 alert configuration.

```text
POST /entities/migrate/alertmanager (org base)
```

- Request body: the Alertmanager YAML, sent as-is.
- Response: YAML compatible with Last9.

## Recipe

```bash
python3 <skill-dir>/scripts/last9.py api POST entities/migrate/alertmanager \
-d @sample.yaml -H 'Content-Type: application/yaml' > last9-alerts.yaml
```

`-d` sets `Content-Type: application/json` by default; the doc's curl uses `--data-binary` with no content type, so override it with `-H` if the server rejects the default. This is unverified.

## Response shape (trimmed)

```yaml
entities:
- name: payment service
type: alert-manager
external_ref: payment service-alert-manager-alert-manager
entity_class: alert-manager
indicators:
- name: "EXPR: HighRequestLatency - breach"
query: job:request_latency_seconds:mean5m{service="payment"} > 0.5
alert_rules:
- name: High request latency
indicator: "EXPR: HighRequestLatency - breach"
total_minutes: 10
bad_minutes: 10
greater_than: 0
```

Each Alertmanager rule yields an indicator plus an alert rule keyed to it.

## Notes

- The doc writes the path as `/v4/organizations/{org_slug}/entities/migrate/alertmanager` on `app.last9.io` (no `/api` prefix, unlike every other endpoint). The recipe uses the org-relative form like the other references; this is unverified.
- Review the converted YAML before applying it; the doc does not describe how unsupported rule features are handled.
99 changes: 99 additions & 0 deletions skills/last9-api/references/auth.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
# Authentication

Source: Last9 "Getting started with API" documentation.

## Roles

- Admins can generate and revoke refresh tokens, and exchange them for access tokens.
- Editors can exchange existing refresh tokens for access tokens but cannot generate refresh tokens. They must ask an Admin.
- Viewers cannot access the API Access page.

Tokens are managed at https://app.last9.io/settings/api-access. The Refresh Token tab is Admin-only. Refresh tokens are shown only once at creation.

## Token model

- A refresh token is created by an Admin with a name and a scope: read, write, or delete.
- Exchange it for a short-lived access token. Access tokens expire after 24 hours.
- Scopes: read tokens only read state; write tokens create or modify data; delete tokens can remove data irrevocably, so use them sparingly.
- Revoking a refresh token invalidates it immediately, and access tokens generated from it are rejected.
- Token creation and revocation appear in Settings > Audit Trail.

## Observed behavior (verified live)

- Access token lifetime was observed at 72h although the docs say 24h. The CLI uses `expires_at`, so never hardcode a lifetime.
- Refresh tokens are not rotated on exchange: the response returns the same refresh token.
- A malformed or garbage access token returns HTTP 400 with `invalid refresh token: ...`. The wording is misleading; it means the access token is bad.

## Base URL

```text
https://{domain}/api/{version}/organizations/{org}/{endpoint}
```

`last9.py` derives the host and `{org}` from the refresh token's claims, so you rarely type either.

## Exchange endpoint

```text
POST https://app.last9.io/api/v4/oauth/access_token
```

The OAuth endpoint does not include the organization in the URL. Body:

```json
{ "refresh_token": "<refresh-token>" }
```

Response (trimmed):

```json
{
"access_token": "<access-token>",
"expires_at": 1587412870,
"issued_at": 1587240070,
"refresh_token": "<refresh-token>",
"type": "Bearer",
"scopes": ["read", "write", "delete"]
}
```

`last9.py` does this for you and caches the result. It keeps your original refresh token and does not store the one returned.

## Header rules

The token goes in `X-LAST9-API-TOKEN`, prefixed with `Bearer ` (with a trailing space).

| Example | Result |
| ------- | ------ |
| `X-LAST9-API-TOKEN: Bearer <access-token>` | correct |
| `X-LAST9-API-TOKEN: <access-token>` | 400 `{"error":"invalid access token"}` (missing Bearer prefix) |
| `Authorization: Bearer <access-token>` | 401 (wrong header) |

An expired access token returns `{"error": "Authorization token is expired"}`.

## Recipes

Check identity (no secrets printed):

```bash
python3 <skill-dir>/scripts/last9.py status
```

Second org or a write-scoped token under its own profile:

```bash
! python3 <skill-dir>/scripts/last9.py --profile writer login
python3 <skill-dir>/scripts/last9.py --profile writer status
```

CI: export `LAST9_REFRESH_TOKEN` from a secret, then call `api` as usual.

Raw curl, for environments without Python only (placeholders, not real values):

```bash
ACCESS=$(curl -s -X POST https://app.last9.io/api/v4/oauth/access_token \
-H 'Content-Type: application/json' \
-d '{"refresh_token":"<refresh-token>"}' | python3 -c 'import sys,json;print(json.load(sys.stdin)["access_token"])')
curl -H "X-LAST9-API-TOKEN: Bearer $ACCESS" \
'https://app.last9.io/api/v4/organizations/<org>/logs/api/v1/labels?start=<ns>&end=<ns>'
```
Loading
Loading