From 0326af001141b3d599ee92bffee1a63282c68bec Mon Sep 17 00:00:00 2001 From: "Hermes (agora)" Date: Mon, 6 Jul 2026 13:59:55 +0300 Subject: [PATCH] moved internal docs to new folder, docs/ is public facing documentation only --- .github/workflows/pages.yml | 3 +- CHANGELOG.md | 6 +-- CONTRIBUTING.md | 2 +- docs/_config.yml | 3 -- docs/automation.md | 2 +- docs/telemetry.md | 4 +- {docs => internal-docs/pages}/site.env | 0 ...26-06-07-project-webhook-implementation.md | 42 +++++++++++++++---- .../proposals/ci-matrix-expansion.md | 0 .../proposals/supply-chain-hardening.md | 0 .../proposals/telemetry-sentry-wireup.md | 0 .../2026-06-07-project-webhook-design.md | 0 internal/cli/open_targets_test.go | 8 ++-- internal/cli/telemetry.go | 6 +-- scripts/prepare-pages-site.py | 4 +- scripts/preview-pages-site.sh | 2 +- 16 files changed, 51 insertions(+), 31 deletions(-) rename {docs => internal-docs/pages}/site.env (100%) rename {docs/superpowers => internal-docs}/plans/2026-06-07-project-webhook-implementation.md (96%) rename {docs => internal-docs}/proposals/ci-matrix-expansion.md (100%) rename {docs => internal-docs}/proposals/supply-chain-hardening.md (100%) rename {docs => internal-docs}/proposals/telemetry-sentry-wireup.md (100%) rename {docs/superpowers => internal-docs}/specs/2026-06-07-project-webhook-design.md (100%) diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index 4b49127..efa0c4c 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -8,6 +8,7 @@ on: - "install.sh" - "install.ps1" - "docs/**" + - "internal-docs/pages/**" - "cmd/gendocs/**" - "internal/cli/**" - "scripts/prepare-pages-site.py" @@ -54,7 +55,7 @@ jobs: run: sudo chown -R "$(id -u):$(id -g)" _site - name: Prepare human and agent docs - run: python3 scripts/prepare-pages-site.py --source docs --site _site --env-file docs/site.env + run: python3 scripts/prepare-pages-site.py --source docs --site _site --env-file internal-docs/pages/site.env - name: Verify Pages installer freshness shell: bash diff --git a/CHANGELOG.md b/CHANGELOG.md index 8244145..881a0d5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -135,7 +135,7 @@ Set `AGORA_ALLOW_UPGRADE_IN_CI=1` only when a CI job intentionally needs to muta ### Added - Add GitHub Pages publishing for generated CLI docs and route `agora open --target docs` to the human CLI docs site, `agora open --target docs-md` to the agent-facing raw Markdown docs under `/md/`, and `product-docs` to Agora product docs. -- Add `docs/site.env` and Pages build-time URL injection so staging docs can publish with different `CLI_DOCS_BASE_URL` / `CLI_DOCS_MD_BASE_URL` values while keeping predictable human and Markdown paths. +- Add `internal-docs/pages/site.env` and Pages build-time URL injection so staging docs can publish with different `CLI_DOCS_BASE_URL` / `CLI_DOCS_MD_BASE_URL` values while keeping predictable human and Markdown paths. - Add a custom GitHub Pages theme for the human docs with responsive layout, system light/dark mode via `prefers-color-scheme`, and no manual theme toggle. - Add `make docs-preview` for a Ruby/Jekyll local docs preview that builds with localhost-friendly paths, injects localhost docs URLs, and serves both the human site and `/md/` Markdown tree. - Add global `--yes` / `-y` and `AGORA_NO_INPUT=1` support to accept defaults and suppress prompts. @@ -164,7 +164,7 @@ Set `AGORA_ALLOW_UPGRADE_IN_CI=1` only when a CI job intentionally needs to muta - `agora doctor` now suggests the exact shell-aware command for fixing a missing PATH entry. The `path_resolution` failure suggestion now reads `echo 'export PATH=":$PATH"' >> ~/.zshrc && source ~/.zshrc` for zsh, the equivalent for bash and `fish_add_path` for fish, the `setx PATH ...` form on Windows, and a generic `~/.profile` fallback for unknown shells. The doctor derives `` from the running binary's location, so the command is always copy-pasteable. - Add a curated `Requirements` and `Verifying release artifacts` section to the README, plus links to `SECURITY.md`, `SUPPORT.md`, the new troubleshooting doc, and `docs/schema/envelope.v1.json` from the Docs index. - Add a telemetry stub (`internal/cli/telemetry.go`) with the `telemetryClient` interface, default no-op sink, redaction helper, and `sentryClient` placeholder so the next release wires Sentry by adding the SDK + replacing one constant. The on/off contract (`agora telemetry`, `AGORA_SENTRY_ENABLED`, `DO_NOT_TRACK`) is fully wired; transport is a no-op until Sentry is connected. -- Add the proposal documents `docs/proposals/supply-chain-hardening.md`, `docs/proposals/ci-matrix-expansion.md`, and `docs/proposals/telemetry-sentry-wireup.md` for the next release. +- Add the proposal documents `internal-docs/proposals/supply-chain-hardening.md`, `internal-docs/proposals/ci-matrix-expansion.md`, and `internal-docs/proposals/telemetry-sentry-wireup.md` for the next release. ### Changed @@ -208,7 +208,7 @@ Set `AGORA_ALLOW_UPGRADE_IN_CI=1` only when a CI job intentionally needs to muta - README updates land in logical sections (Install requirements, Verifying releases, Docs index, Command Model additions, Troubleshooting redirect) without disturbing the marketing-first opening. - `CONTRIBUTING.md` documents the branching model (`main` is releasable, topic branches off `main`), the commit-message convention, the optional DCO sign-off path, and the per-command example requirement for new commands. - `docs/automation.md` adds a section documenting the `agora project env` raw-stdout exception and links the new envelope JSON Schema. -- `docs/proposals/` introduces a new directory for deferred-implementation proposals (supply-chain hardening, CI matrix expansion, Sentry wire-up). Each proposal carries a `status: proposed`, `target-release: next` front-matter so contributors can see what's planned without bisecting branches. +- `internal-docs/proposals/` introduces a new directory for deferred-implementation proposals (supply-chain hardening, CI matrix expansion, Sentry wire-up). Each proposal carries a `status: proposed`, `target-release: next` front-matter so contributors can see what's planned without bisecting branches. ## [0.1.9] - 2026-04-30 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index dd732e5..91d81ad 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -72,7 +72,7 @@ make docs-preview # optional: local Jekyll site + /md Documentation work: - Run `make docs-commands` after command-tree changes; CI uses `go run ./cmd/gendocs -check`. -- For GitHub Pages content, use `make docs-preview` (see `scripts/preview-pages-site.sh`). Published docs resolve `@@CLI_DOCS_*@@` and `@@CLI_INSTALL_*@@` tokens via `scripts/prepare-pages-site.py` and `docs/site.env` as documented in `docs/automation.md`. +- For GitHub Pages content, use `make docs-preview` (see `scripts/preview-pages-site.sh`). Published docs resolve `@@CLI_DOCS_*@@` and `@@CLI_INSTALL_*@@` tokens via `scripts/prepare-pages-site.py` and `internal-docs/pages/site.env` as documented in `docs/automation.md`. Install `golangci-lint` **v1.64.8** (matches CI). CI builds it with `go install` against your toolchain; locally prefer: diff --git a/docs/_config.yml b/docs/_config.yml index afc1eb8..dc5f2b5 100644 --- a/docs/_config.yml +++ b/docs/_config.yml @@ -11,6 +11,3 @@ defaults: path: "" values: layout: default - -exclude: - - site.env diff --git a/docs/automation.md b/docs/automation.md index d81deb8..0306d68 100644 --- a/docs/automation.md +++ b/docs/automation.md @@ -46,7 +46,7 @@ Use this guide for: - Set `AGORA_AGENT=` in automated environments to explicitly label agent traffic in the API `User-Agent`. When unset, the CLI may infer a coarse label such as `cursor`, `claude-code`, `cline`, `windsurf`, `codex`, or `aider` from known agent environment markers. Set `AGORA_AGENT_DISABLE_INFER=1` to disable inference. - Use `agora mcp serve` to expose local Agora CLI tools to MCP-capable agents. The full surface is exposed: `agora.version`, `agora.introspect`, `agora.auth.{status,logout}`, `agora.config.{path,get}`, `agora.telemetry.status`, `agora.upgrade.check`, `agora.project.{list,show,use,create,doctor,env,env_write}`, `agora.project.feature.{list,status,enable}`, `agora.project.webhook.{events,list,show,create,update,delete}`, `agora.quickstart.{list,create,env_write}`, and `agora.init`. Authentication is intentionally **not** exposed via MCP because OAuth requires an interactive browser; run `agora login` once on the host first. - Use `agora open --target docs` for the human GitHub Pages docs and `agora open --target docs-md` for the agent-facing raw Markdown index. In CI/non-TTY runs the command defaults to URL-only output unless `--browser` is set. The Markdown tree is published under predictable `/md/` URLs, for example `/md/commands.md`, `/md/automation.md`, and `/md/error-codes.md`. -- Docs publishing reads `docs/site.env` for `CLI_DOCS_*` and `CLI_INSTALL_*` URL defaults; staging Pages builds can override those environment variables at workflow time without changing docs content. The resolved values are published as `/docs.env` for transparency. +- Docs publishing reads `internal-docs/pages/site.env` for `CLI_DOCS_*` and `CLI_INSTALL_*` URL defaults; staging Pages builds can override those environment variables at workflow time without changing docs content. The resolved values are published as `/docs.env` for transparency. - The CLI maintains a short-lived on-disk completion cache for `agora project use ` under `/cache/projects.json`. The cache is only used for completions when a **local unexpired session exists** (`session.json` with a non-empty access token and a future `expiresAt`, when present), so Tab does not suggest stale project names after logout or local session expiry. The cache TTL is 5 minutes by default; override with `AGORA_PROJECT_CACHE_TTL_SECONDS=` (set to `0` to disable). Cache files older than 24 h are pruned at every CLI startup. Set `AGORA_DISABLE_CACHE=1` to drop the cache on the next startup. The cache is invalidated automatically by `agora logout` and `agora project create` (the latter clears the file; it does not embed the new project until the next successful list fetch). To **force-refresh** the cached completion page, run `agora project list --refresh-cache` while authenticated; that command fetches the unfiltered first page used by completion and rewrites `projects.json` when it succeeds. ### CI auto-detect diff --git a/docs/telemetry.md b/docs/telemetry.md index f3665f3..c1604c8 100644 --- a/docs/telemetry.md +++ b/docs/telemetry.md @@ -16,9 +16,7 @@ key pattern (case-insensitive) and replaces the value with the literal > **Status (current release):** the on/off contract below is fully > wired. The transport (Sentry SDK) is not yet linked into the binary, > so all telemetry calls are no-ops at runtime. The next release will -> wire Sentry per -> [`docs/proposals/telemetry-sentry-wireup.md`](proposals/telemetry-sentry-wireup.md); -> the surface and field schema will not change. +> wire Sentry; the surface and field schema will not change. ## Inspect or change the setting diff --git a/docs/site.env b/internal-docs/pages/site.env similarity index 100% rename from docs/site.env rename to internal-docs/pages/site.env diff --git a/docs/superpowers/plans/2026-06-07-project-webhook-implementation.md b/internal-docs/plans/2026-06-07-project-webhook-implementation.md similarity index 96% rename from docs/superpowers/plans/2026-06-07-project-webhook-implementation.md rename to internal-docs/plans/2026-06-07-project-webhook-implementation.md index eeaaded..8a381af 100644 --- a/docs/superpowers/plans/2026-06-07-project-webhook-implementation.md +++ b/internal-docs/plans/2026-06-07-project-webhook-implementation.md @@ -27,7 +27,7 @@ ## Task 0: Backend Confirmation Check **Files:** -- Modify: `docs/superpowers/specs/2026-06-07-project-webhook-design.md` +- Modify: `internal-docs/specs/2026-06-07-project-webhook-design.md` - [ ] **Step 1: Confirm event ID mapping before production behavior is wired** @@ -45,7 +45,7 @@ eventIds uses ncs-events.items[].eventId. - [ ] **Step 2: Record the confirmation in the design spec** -Edit `docs/superpowers/specs/2026-06-07-project-webhook-design.md` and replace the final sentence in the event API paragraph with: +Edit `internal-docs/specs/2026-06-07-project-webhook-design.md` and replace the final sentence in the event API paragraph with: ```markdown Backend owner confirmation: config `eventIds` are populated from event `eventId`, not `eventType`. @@ -56,7 +56,7 @@ Backend owner confirmation: config `eventIds` are populated from event `eventId` Run: ```bash -git add docs/superpowers/specs/2026-06-07-project-webhook-design.md +git add internal-docs/specs/2026-06-07-project-webhook-design.md git commit -m "docs: confirm webhook event id mapping" ``` @@ -360,7 +360,9 @@ Append these tests: ```go func TestNormalizeWebhookEventsIgnoresChineseDisplayName(t *testing.T) { - resp := ncsEventListResponse{Items: []ncsEvent{{EventID: 1001, DisplayName: "Channel Created", DisplayNameCn: "频道创建", EventType: 7, Payload: `{"x":1}`}}} + resp := ncsEventListResponse{Items: []ncsEvent{ + {EventID: 1001, DisplayName: "Channel Created", DisplayNameCn: "频道创建", EventType: 7, Payload: `{"x":1}`}, + }} got := normalizeWebhookEvents(resp) if len(got) != 1 || got[0].Key != "channel-created" || got[0].ID != 1001 || got[0].EventType != 7 || got[0].Payload != `{"x":1}` { t.Fatalf("unexpected normalized events: %#v", got) @@ -724,7 +726,9 @@ func TestProjectWebhookUpdateReadMergePut(t *testing.T) { defer api.server.Close() project := buildFakeProject("demo", "prj_0001", "app_0001", "global") api.projects[project.ProjectID] = &project - api.ncsConfigs["prj_0001/rtc"] = []fakeNCSConfig{{ConfigID: 42, URL: "https://old.example/webhook", URLRegion: "eu", Enabled: true, EventIDs: []int{1001}, UseIPWhitelist: false, Secret: "secret_123"}} + api.ncsConfigs["prj_0001/rtc"] = []fakeNCSConfig{ + {ConfigID: 42, URL: "https://old.example/webhook", URLRegion: "eu", Enabled: true, EventIDs: []int{1001}, UseIPWhitelist: false, Secret: "secret_123"}, + } configHome := t.TempDir() persistSessionForIntegration(t, configHome) result := runCLI(t, bin, map[string]string{"XDG_CONFIG_HOME": configHome, "AGORA_CLI_BASE_URL": api.baseURL}, "project", "webhook", "update", "42", "--project", "demo", "--feature", "rtc", "--url", "https://new.example/webhook", "--json") @@ -795,7 +799,9 @@ func TestProjectWebhookListRedactsAndShowWithSecretReveals(t *testing.T) { defer api.server.Close() project := buildFakeProject("demo", "prj_0001", "app_0001", "global") api.projects[project.ProjectID] = &project - api.ncsConfigs["prj_0001/rtc"] = []fakeNCSConfig{{ConfigID: 42, URL: "https://example.com/webhook", URLRegion: "na", Enabled: true, EventIDs: []int{1001}, UseIPWhitelist: false, Secret: "secret_123"}} + api.ncsConfigs["prj_0001/rtc"] = []fakeNCSConfig{ + {ConfigID: 42, URL: "https://example.com/webhook", URLRegion: "na", Enabled: true, EventIDs: []int{1001}, UseIPWhitelist: false, Secret: "secret_123"}, + } configHome := t.TempDir() persistSessionForIntegration(t, configHome) @@ -1062,7 +1068,10 @@ case "project webhook events": } case "project webhook list": m := data.(map[string]any) - printBlock(out, "Webhooks", [][2]string{{"Project", asString(m["projectName"])}, {"Feature", asString(m["feature"])}}) + printBlock(out, "Webhooks", [][2]string{ + {"Project", asString(m["projectName"])}, + {"Feature", asString(m["feature"])}, + }) if items, ok := m["items"].([]webhookConfig); ok { for _, item := range items { fmt.Fprintf(out, "- %d %s %s enabled=%v\n", item.ConfigID, item.URL, item.URLRegion, item.Enabled) @@ -1077,7 +1086,12 @@ case "project webhook show", "project webhook create", "project webhook update": } case "project webhook delete": m := data.(map[string]any) - printBlock(out, "Webhook", [][2]string{{"Project", asString(m["projectName"])}, {"Feature", asString(m["feature"])}, {"Config ID", asString(m["configId"])}, {"Deleted", "true"}}) + printBlock(out, "Webhook", [][2]string{ + {"Project", asString(m["projectName"])}, + {"Feature", asString(m["feature"])}, + {"Config ID", asString(m["configId"])}, + {"Deleted", "true"}, + }) ``` Add helper: @@ -1093,7 +1107,17 @@ func printWebhookBlock(out io.Writer, m map[string]any) { if len(keys) > 0 { events = strings.Join(keys, ", ") } - printBlock(out, "Webhook", [][2]string{{"Project", asString(m["projectName"])}, {"Feature", asString(m["feature"])}, {"Config ID", asString(cfg.ConfigID)}, {"URL", cfg.URL}, {"Events", events}, {"Delivery Region", renderWebhookDeliveryRegion(cfg.URLRegion)}, {"Enabled", asString(cfg.Enabled)}, {"Retry", asString(cfg.Retry)}, {"Secret", cfg.Secret}}) + printBlock(out, "Webhook", [][2]string{ + {"Project", asString(m["projectName"])}, + {"Feature", asString(m["feature"])}, + {"Config ID", asString(cfg.ConfigID)}, + {"URL", cfg.URL}, + {"Events", events}, + {"Delivery Region", renderWebhookDeliveryRegion(cfg.URLRegion)}, + {"Enabled", asString(cfg.Enabled)}, + {"Retry", asString(cfg.Retry)}, + {"Secret", cfg.Secret}, + }) } } ``` diff --git a/docs/proposals/ci-matrix-expansion.md b/internal-docs/proposals/ci-matrix-expansion.md similarity index 100% rename from docs/proposals/ci-matrix-expansion.md rename to internal-docs/proposals/ci-matrix-expansion.md diff --git a/docs/proposals/supply-chain-hardening.md b/internal-docs/proposals/supply-chain-hardening.md similarity index 100% rename from docs/proposals/supply-chain-hardening.md rename to internal-docs/proposals/supply-chain-hardening.md diff --git a/docs/proposals/telemetry-sentry-wireup.md b/internal-docs/proposals/telemetry-sentry-wireup.md similarity index 100% rename from docs/proposals/telemetry-sentry-wireup.md rename to internal-docs/proposals/telemetry-sentry-wireup.md diff --git a/docs/superpowers/specs/2026-06-07-project-webhook-design.md b/internal-docs/specs/2026-06-07-project-webhook-design.md similarity index 100% rename from docs/superpowers/specs/2026-06-07-project-webhook-design.md rename to internal-docs/specs/2026-06-07-project-webhook-design.md diff --git a/internal/cli/open_targets_test.go b/internal/cli/open_targets_test.go index c0c9c1d..b296471 100644 --- a/internal/cli/open_targets_test.go +++ b/internal/cli/open_targets_test.go @@ -129,24 +129,24 @@ func TestCLIDocsURLMatchesPagesWorkflow(t *testing.T) { } if !strings.Contains(string(body), "Prepare human and agent docs") || !strings.Contains(string(body), "scripts/prepare-pages-site.py") || - !strings.Contains(string(body), "docs/site.env") { + !strings.Contains(string(body), "internal-docs/pages/site.env") { t.Fatal("pages.yml does not prepare docs with the env-file driven Pages script") } } func TestPagesSiteEnvDefaultsMatchOpenTargets(t *testing.T) { repoRoot := findRepoRootForTest(t) - siteEnv := filepath.Join(repoRoot, "docs", "site.env") + siteEnv := filepath.Join(repoRoot, "internal-docs", "pages", "site.env") body, err := os.ReadFile(siteEnv) if err != nil { t.Fatalf("could not read %s: %v", siteEnv, err) } content := string(body) if !strings.Contains(content, "CLI_DOCS_BASE_URL="+strings.TrimSuffix(cliDocsURL, "/")) { - t.Fatalf("docs/site.env does not match cliDocsURL %q:\n%s", cliDocsURL, content) + t.Fatalf("internal-docs/pages/site.env does not match cliDocsURL %q:\n%s", cliDocsURL, content) } if !strings.Contains(content, "CLI_DOCS_MD_BASE_URL="+strings.TrimSuffix(cliDocsURL, "/")+"/md") { - t.Fatalf("docs/site.env does not match cliDocsMarkdownURL base %q:\n%s", cliDocsMarkdownURL, content) + t.Fatalf("internal-docs/pages/site.env does not match cliDocsMarkdownURL base %q:\n%s", cliDocsMarkdownURL, content) } } diff --git a/internal/cli/telemetry.go b/internal/cli/telemetry.go index f4bfbcd..6d54051 100644 --- a/internal/cli/telemetry.go +++ b/internal/cli/telemetry.go @@ -25,7 +25,7 @@ import ( // Today this file ships a no-op default. The Sentry-backed sink is // scaffolded as `sentryClient` below so the next release can flip the // constructor over without changing the public API. See -// docs/proposals/telemetry-sentry-wireup.md for the wire-up plan. +// internal-docs/proposals/telemetry-sentry-wireup.md for the wire-up plan. type telemetryClient interface { // Enabled reports whether the underlying sink will actually emit. Enabled() bool @@ -46,7 +46,7 @@ type telemetryClient interface { // agoraSentryDSN is the Sentry project DSN that the CLI ships with. // Empty string disables Sentry transport entirely (the default until the // Sentry SDK is wired in for the next release). When set, events go to the -// Agora CLI Sentry project (see docs/proposals/telemetry-sentry-wireup.md). +// Agora CLI Sentry project (see internal-docs/proposals/telemetry-sentry-wireup.md). const agoraSentryDSN = "" // initTelemetry returns the telemetry client appropriate for the current @@ -73,7 +73,7 @@ func initTelemetry(configEnabled bool, env map[string]string, _ versionInformati if agoraSentryDSN == "" { // Sentry SDK not compiled in for this build. The wire-up is // scheduled for the next release; until then this is the - // expected path. See docs/proposals/telemetry-sentry-wireup.md. + // expected path. See internal-docs/proposals/telemetry-sentry-wireup.md. return noopTelemetry{} } return newSentryClient(agoraSentryDSN, env) diff --git a/scripts/prepare-pages-site.py b/scripts/prepare-pages-site.py index f9776f0..c2a1a62 100644 --- a/scripts/prepare-pages-site.py +++ b/scripts/prepare-pages-site.py @@ -3,7 +3,7 @@ The Pages workflow renders the human site with Jekyll, then this script: -1. Reads docs/site.env for default published URLs. +1. Reads internal-docs/pages/site.env for default published URLs. 2. Lets workflow/job env vars override those defaults for staging builds. 3. Replaces URL placeholders in rendered site files. 4. Copies raw Markdown and MDC files to _site/md/ with the same replacements. @@ -128,7 +128,7 @@ def main() -> int: parser = argparse.ArgumentParser() parser.add_argument("--source", default="docs", type=Path) parser.add_argument("--site", default="_site", type=Path) - parser.add_argument("--env-file", default=Path("docs/site.env"), type=Path) + parser.add_argument("--env-file", default=Path("internal-docs/pages/site.env"), type=Path) parser.add_argument("--repo-root", default=Path("."), type=Path) args = parser.parse_args() diff --git a/scripts/preview-pages-site.sh b/scripts/preview-pages-site.sh index 492801e..34c35e0 100755 --- a/scripts/preview-pages-site.sh +++ b/scripts/preview-pages-site.sh @@ -43,7 +43,7 @@ CLI_DOCS_BASE_URL="http://localhost:${PORT}" \ CLI_DOCS_MD_BASE_URL="http://localhost:${PORT}/md" \ CLI_INSTALL_SH_URL="http://localhost:${PORT}/install.sh" \ CLI_INSTALL_PS1_URL="http://localhost:${PORT}/install.ps1" \ - python3 scripts/prepare-pages-site.py --source docs --site "$SITE_DIR" --env-file docs/site.env + python3 scripts/prepare-pages-site.py --source docs --site "$SITE_DIR" --env-file internal-docs/pages/site.env cat <