diff --git a/AGENTS.md b/AGENTS.md index 0198696..c02c171 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -36,13 +36,13 @@ src/ │ ├── alert/ # Alert commands (list, view) │ ├── auth/ # Auth commands (configure, login, logout, status) │ ├── cli/ # CLI management (install, uninstall, upgrade) -│ ├── content/ # Content pack management +│ ├── content/ # Content pack management (experimental: gated + hidden) │ │ ├── host/ # Host Explorer (install, view) │ │ ├── kubernetes/ # Kubernetes Explorer (install, view) │ │ └── tracing/ # Trace Explorer (install, view) │ ├── dataset/ # Dataset commands (list, view) │ ├── datastream/ # Datastream commands (create, list, view, update) -│ ├── ingest-token/ # Ingest token commands (create, list, view, update) +│ ├── ingest-token/ # Ingest token commands (experimental: gated + hidden) │ ├── metric/ # Metric commands (list, view) │ ├── skill/ # AI agent skill commands (list, view) │ ├── tag-key/ # Tag key commands (list) @@ -78,6 +78,7 @@ src/ ├── cel.ts # CEL expression support ├── kg-search.ts # Knowledge graph search ├── writer.ts # Output writer + ├── experimental.ts # Experimental command gating (env flag, badge, hide) └── format-error.ts # Error formatting ``` @@ -101,6 +102,46 @@ src/ - Update the **Project Structure** section in this `AGENTS.md` to include the new command/resource. - Update the **Commands** table in `README.md`. The README command order must always match the route order in `src/app.ts`. +### Adding an Experimental Command + +Experimental commands live behind `OBSERVE_CLI_EXPERIMENTAL=1`: hidden from help +and non-runnable unless the flag is set, tagged `[experimental]` when visible. +All helpers come from [`src/lib/experimental.ts`](src/lib/experimental.ts). + +A developer marks a command experimental by touching three things: + +1. **Gate the handler** — wrap the loader so it refuses to run without the flag + (see [`data-connection/generate-stack-url.ts`](src/commands/data-connection/generate-stack-url.ts)): + + ```typescript + import { gateExperimental } from "../../lib/experimental"; + loader: async () => gateExperimental(myHandler), + ``` + +2. **Badge the brief** — so it reads `[experimental] …` in help: + + ```typescript + import { withExperimentalBadge } from "../../lib/experimental"; + docs: { brief: withExperimentalBadge("Do the experimental thing") }, + ``` + +3. **Hide it in the parent route map** — a command cannot hide itself, so list + it in the parent's `docs.hideRoute`. For a subcommand, hide it in its group's + route map; for a top-level command, hide it in `src/app.ts`: + + ```typescript + import { hideExperimentalRoutes } from "../../lib/experimental"; + docs: { brief: "…", hideRoute: hideExperimentalRoutes(["myCommand"]) }, + ``` + +**Marking a whole group experimental** (see the `content` group): badge the +group's brief in its `index.ts`, hide the group name in the parent map's +`hideRoute`, and — because hiding does not block execution — wrap the loader of +_every leaf command_ in the group with `gateExperimental`. + +**Promote to GA** by deleting those markers — drop `gateExperimental`, drop +`withExperimentalBadge`, and remove the name from `hideExperimentalRoutes`. + ### Command Pattern Commands follow this structure: diff --git a/README.md b/README.md index a7f6baa..b7b93dc 100644 --- a/README.md +++ b/README.md @@ -10,9 +10,6 @@ Command line interface for [Observe Inc](https://www.observeinc.com). - **OPAL Query Execution** - Run OPAL queries directly from your terminal with schema-aware table output. - **AI Agent Skills** - List and view reusable AI-agent instruction documents stored in Observe. - **Alert Monitoring** - List and view alerts with severity filtering and active-only views. -- **Content Packs** - Install and view Host Explorer, Kubernetes Explorer, and Trace Explorer content. -- **Ingest Token Management** - Full CRUD for ingest tokens with datastream association. -- **Data Integrations** - Create data connections and datasources (AWS, Kubernetes, host) and generate CloudFormation quick-create URLs for AWS filedrop deployments. - **Datastream Management** - Create, list, view, and update datastreams. - **Multiple Output Formats** - All commands support `--format json` and `--format csv` for scripting and pipelines. - **Responsive Tables** - Terminal-aware column widths with automatic text wrapping. @@ -53,22 +50,6 @@ To update installed skills after edits in this repo, run `npx skills update`. | `observe skill view` | View skill details and content | | `observe alert list` | List alerts with severity and status filtering | | `observe alert view` | View full alert details | -| `observe content host install` | Install Host Explorer content | -| `observe content host view` | View Host Explorer content | -| `observe content kubernetes install` | Install Kubernetes Explorer content | -| `observe content kubernetes view` | View Kubernetes Explorer content | -| `observe content tracing install` | Install Trace Explorer content | -| `observe content tracing view` | View Trace Explorer content | -| `observe ingest-token create` | Create a new ingest token | -| `observe ingest-token list` | List and search ingest tokens | -| `observe ingest-token view` | View an ingest token by ID | -| `observe ingest-token update` | Update an ingest token | -| `observe datasource create` | Create a datasource attached to a data connection | -| `observe datasource update` | Update an existing datasource's config | -| `observe datasource generate-stack-url` | Build a CloudFormation quick-create URL for a filedrop | -| `observe data-connection create` | Create a data connection (AWS, kubernetes, host, etc.) | -| `observe data-connection list` | List data connections | -| `observe data-connection view` | View a data connection by ID (with its datasources) | | `observe datastream create` | Create a new datastream | | `observe datastream list` | List datastreams | | `observe datastream view` | View a datastream by ID | @@ -78,6 +59,18 @@ To update installed skills after edits in this repo, run `npx skills update`. | `observe cli uninstall` | Remove shell integration | | `observe cli upgrade` | Upgrade to the latest version | +### Experimental commands + +Experimental commands are hidden by default and gated behind an environment +variable. They are **not** covered by SemVer — their names, flags, and output +may change or be removed without notice. + +```bash +# Enable experimental commands for the session +export OBSERVE_CLI_EXPERIMENTAL=1 +observe help # experimental commands now appear, tagged [experimental] +``` + ## Configuration Credentials are stored in `~/.observe/config.json` with mode `600` (owner-only access). Permissions are automatically enforced on every write. diff --git a/src/app.ts b/src/app.ts index a51b600..5f16587 100644 --- a/src/app.ts +++ b/src/app.ts @@ -17,6 +17,7 @@ import { skillRoutes } from "./commands/skill/index.js"; import { tagKeyRoutes } from "./commands/tag-key/index.js"; import { tagValueRoutes } from "./commands/tag-value/index.js"; import { CURRENT_CLI_VERSION } from "./lib/constants.js"; +import { hideExperimentalRoutes } from "./lib/experimental.js"; /** Top-level route map containing all CLI commands */ export const routes = buildRouteMap({ @@ -44,6 +45,13 @@ export const routes = buildRouteMap({ fullDescription: "observe is a command-line interface for interacting with Observe Inc. " + "It provides commands for configuration, querying datasets, and more.", + // Hide experimental command groups unless OBSERVE_CLI_EXPERIMENTAL=1. + hideRoute: hideExperimentalRoutes([ + "content", + "data-connection", + "ingest-token", + "datasource", + ]), }, }); diff --git a/src/commands/content/host/index.ts b/src/commands/content/host/index.ts index c30ef4c..3806185 100644 --- a/src/commands/content/host/index.ts +++ b/src/commands/content/host/index.ts @@ -1,4 +1,5 @@ import { buildRouteMap } from "@stricli/core"; +import { withExperimentalBadge } from "../../../lib/experimental"; import { installCommand } from "./install"; import { viewCommand } from "./view"; @@ -8,6 +9,6 @@ export const hostContentRoutes = buildRouteMap({ view: viewCommand, }, docs: { - brief: "Manage Host Explorer content", + brief: withExperimentalBadge("Manage Host Explorer content"), }, }); diff --git a/src/commands/content/host/install.ts b/src/commands/content/host/install.ts index 363aadb..12be0d1 100644 --- a/src/commands/content/host/install.ts +++ b/src/commands/content/host/install.ts @@ -1,4 +1,8 @@ import { buildCommand } from "@stricli/core"; +import { + gateExperimental, + withExperimentalBadge, +} from "../../../lib/experimental"; import type { LocalContext } from "../../../context"; import { updateHostContent } from "../../../gql/content/update-host-content"; import { GqlApiError } from "../../../gql/gql-request"; @@ -42,8 +46,9 @@ export async function install( } } +// EXPERIMENTAL export const installCommand = buildCommand({ - loader: async () => install, + loader: async () => gateExperimental(install), parameters: { positional: { kind: "tuple", @@ -65,7 +70,7 @@ export const installCommand = buildCommand({ }, }, docs: { - brief: "Install or update Host Explorer content", + brief: withExperimentalBadge("Install or update Host Explorer content"), fullDescription: [ "Install or update Host Explorer content. This creates the derived host logs", "content used by the Host Explorer UI.", diff --git a/src/commands/content/host/view.ts b/src/commands/content/host/view.ts index 8bdca5c..7880a57 100644 --- a/src/commands/content/host/view.ts +++ b/src/commands/content/host/view.ts @@ -1,4 +1,8 @@ import { buildCommand } from "@stricli/core"; +import { + gateExperimental, + withExperimentalBadge, +} from "../../../lib/experimental"; import type { LocalContext } from "../../../context"; import { getHostContent } from "../../../gql/content/view-host-content"; import { GqlApiError } from "../../../gql/gql-request"; @@ -36,8 +40,9 @@ export async function view( } } +// EXPERIMENTAL export const viewCommand = buildCommand({ - loader: async () => view, + loader: async () => gateExperimental(view), parameters: { positional: { kind: "tuple", @@ -46,6 +51,6 @@ export const viewCommand = buildCommand({ flags: {}, }, docs: { - brief: "View current Host Explorer content status", + brief: withExperimentalBadge("View current Host Explorer content status"), }, }); diff --git a/src/commands/content/index.ts b/src/commands/content/index.ts index 564ceef..327b64c 100644 --- a/src/commands/content/index.ts +++ b/src/commands/content/index.ts @@ -1,4 +1,5 @@ import { buildRouteMap } from "@stricli/core"; +import { withExperimentalBadge } from "../../lib/experimental"; import { hostContentRoutes } from "./host/index"; import { kubernetesContentRoutes } from "./kubernetes/index"; import { tracingContentRoutes } from "./tracing/index"; @@ -10,14 +11,8 @@ export const contentRoutes = buildRouteMap({ tracing: tracingContentRoutes, }, docs: { - brief: "Manage installed content", - fullDescription: [ - "Install and view content packs in Observe.", - "", - "Commands:", - " host Manage Host Explorer content", - " kubernetes Manage Kubernetes Explorer content", - " tracing Manage Trace Explorer content", - ].join("\n"), + // EXPERIMENTAL + brief: withExperimentalBadge("Manage installed content"), + fullDescription: "Install and view content packs in Observe.", }, }); diff --git a/src/commands/content/kubernetes/index.ts b/src/commands/content/kubernetes/index.ts index 9cfaf1f..728568d 100644 --- a/src/commands/content/kubernetes/index.ts +++ b/src/commands/content/kubernetes/index.ts @@ -1,4 +1,5 @@ import { buildRouteMap } from "@stricli/core"; +import { withExperimentalBadge } from "../../../lib/experimental"; import { installCommand } from "./install"; import { viewCommand } from "./view"; @@ -8,6 +9,6 @@ export const kubernetesContentRoutes = buildRouteMap({ view: viewCommand, }, docs: { - brief: "Manage Kubernetes Explorer content", + brief: withExperimentalBadge("Manage Kubernetes Explorer content"), }, }); diff --git a/src/commands/content/kubernetes/install.ts b/src/commands/content/kubernetes/install.ts index 2515357..1fddb68 100644 --- a/src/commands/content/kubernetes/install.ts +++ b/src/commands/content/kubernetes/install.ts @@ -1,4 +1,8 @@ import { buildCommand } from "@stricli/core"; +import { + gateExperimental, + withExperimentalBadge, +} from "../../../lib/experimental"; import type { LocalContext } from "../../../context"; import { updateKubernetesContent } from "../../../gql/content/update-kubernetes-content"; import { GqlApiError } from "../../../gql/gql-request"; @@ -51,8 +55,9 @@ export async function install( } } +// EXPERIMENTAL export const installCommand = buildCommand({ - loader: async () => install, + loader: async () => gateExperimental(install), parameters: { positional: { kind: "tuple", @@ -87,7 +92,7 @@ export const installCommand = buildCommand({ }, }, docs: { - brief: "Install or update Kubernetes content", + brief: withExperimentalBadge("Install or update Kubernetes content"), fullDescription: "Install or update Kubernetes Explorer content. This creates correlation tags,\n" + "RBAC rules, and derived datasets for Kubernetes observability.\n\n" + diff --git a/src/commands/content/kubernetes/view.ts b/src/commands/content/kubernetes/view.ts index 2896467..2a176fd 100644 --- a/src/commands/content/kubernetes/view.ts +++ b/src/commands/content/kubernetes/view.ts @@ -1,4 +1,8 @@ import { buildCommand } from "@stricli/core"; +import { + gateExperimental, + withExperimentalBadge, +} from "../../../lib/experimental"; import type { LocalContext } from "../../../context"; import { getKubernetesContent } from "../../../gql/content/view-kubernetes-content"; import { GqlApiError } from "../../../gql/gql-request"; @@ -36,8 +40,9 @@ export async function view( } } +// EXPERIMENTAL export const viewCommand = buildCommand({ - loader: async () => view, + loader: async () => gateExperimental(view), parameters: { positional: { kind: "tuple", @@ -46,6 +51,6 @@ export const viewCommand = buildCommand({ flags: {}, }, docs: { - brief: "View current Kubernetes content status", + brief: withExperimentalBadge("View current Kubernetes content status"), }, }); diff --git a/src/commands/content/tracing/index.ts b/src/commands/content/tracing/index.ts index 8259e19..1f7e289 100644 --- a/src/commands/content/tracing/index.ts +++ b/src/commands/content/tracing/index.ts @@ -1,4 +1,5 @@ import { buildRouteMap } from "@stricli/core"; +import { withExperimentalBadge } from "../../../lib/experimental"; import { installCommand } from "./install"; import { viewCommand } from "./view"; @@ -8,6 +9,6 @@ export const tracingContentRoutes = buildRouteMap({ view: viewCommand, }, docs: { - brief: "Manage Trace Explorer content", + brief: withExperimentalBadge("Manage Trace Explorer content"), }, }); diff --git a/src/commands/content/tracing/install.ts b/src/commands/content/tracing/install.ts index 8038345..8ea1f67 100644 --- a/src/commands/content/tracing/install.ts +++ b/src/commands/content/tracing/install.ts @@ -1,4 +1,8 @@ import { buildCommand } from "@stricli/core"; +import { + gateExperimental, + withExperimentalBadge, +} from "../../../lib/experimental"; import type { LocalContext } from "../../../context"; import { installTracingContent } from "../../../gql/content/install-tracing-content"; import { GqlApiError } from "../../../gql/gql-request"; @@ -55,8 +59,9 @@ export async function install( } } +// EXPERIMENTAL export const installCommand = buildCommand({ - loader: async () => install, + loader: async () => gateExperimental(install), parameters: { positional: { kind: "tuple", @@ -90,7 +95,7 @@ export const installCommand = buildCommand({ }, }, docs: { - brief: "Install Trace Explorer content", + brief: withExperimentalBadge("Install Trace Explorer content"), fullDescription: "Install Trace Explorer content. If dataset IDs are provided, uses those\n" + "specific datasets; otherwise auto-discovers from existing datastreams.\n\n" + diff --git a/src/commands/content/tracing/view.ts b/src/commands/content/tracing/view.ts index 01ca12e..ceb4c53 100644 --- a/src/commands/content/tracing/view.ts +++ b/src/commands/content/tracing/view.ts @@ -1,4 +1,8 @@ import { buildCommand } from "@stricli/core"; +import { + gateExperimental, + withExperimentalBadge, +} from "../../../lib/experimental"; import type { LocalContext } from "../../../context"; import { getTracingContent } from "../../../gql/content/view-tracing-content"; import { GqlApiError } from "../../../gql/gql-request"; @@ -36,8 +40,9 @@ export async function view( } } +// EXPERIMENTAL export const viewCommand = buildCommand({ - loader: async () => view, + loader: async () => gateExperimental(view), parameters: { positional: { kind: "tuple", @@ -46,6 +51,6 @@ export const viewCommand = buildCommand({ flags: {}, }, docs: { - brief: "View current Trace Explorer content status", + brief: withExperimentalBadge("View current Trace Explorer content status"), }, }); diff --git a/src/commands/data-connection/create/aws.ts b/src/commands/data-connection/create/aws.ts index c54b5c4..d5b3b0d 100644 --- a/src/commands/data-connection/create/aws.ts +++ b/src/commands/data-connection/create/aws.ts @@ -1,4 +1,8 @@ import { buildCommand } from "@stricli/core"; +import { + gateExperimental, + withExperimentalBadge, +} from "../../../lib/experimental"; import type { LocalContext } from "../../../context.js"; import { createConnection } from "../../../gql/connection/create-connection.js"; import { @@ -75,8 +79,9 @@ export async function createAwsConnectionCmd( } } +// EXPERIMENTAL export const createAwsConnectionCommand = buildCommand({ - loader: async () => createAwsConnectionCmd, + loader: async () => gateExperimental(createAwsConnectionCmd), parameters: { positional: { kind: "tuple", parameters: [] }, flags: { @@ -121,7 +126,7 @@ export const createAwsConnectionCommand = buildCommand({ }, }, docs: { - brief: "Create an AWS data connection", + brief: withExperimentalBadge("Create an AWS data connection"), fullDescription: `Creates a new AWS data connection (module: ${AWS_MODULE_ID}).\n\n` + "--version defaults to the latest stable version published to the\n" + diff --git a/src/commands/data-connection/create/index.ts b/src/commands/data-connection/create/index.ts index 0b1295c..a635e5f 100644 --- a/src/commands/data-connection/create/index.ts +++ b/src/commands/data-connection/create/index.ts @@ -1,4 +1,5 @@ import { buildRouteMap } from "@stricli/core"; +import { withExperimentalBadge } from "../../../lib/experimental.js"; import { createAwsConnectionCommand } from "./aws.js"; export const createConnectionRoutes = buildRouteMap({ @@ -6,12 +7,7 @@ export const createConnectionRoutes = buildRouteMap({ aws: createAwsConnectionCommand, }, docs: { - brief: "Create a data connection", - fullDescription: [ - "Create a data connection of a specific module type.", - "", - "Modules:", - " aws AWS data connection (module: observeinc/connection/aws)", - ].join("\n"), + brief: withExperimentalBadge("Create a data connection"), + fullDescription: "Create a data connection of a specific module type.", }, }); diff --git a/src/commands/data-connection/generate-stack-url.ts b/src/commands/data-connection/generate-stack-url.ts index 421ee38..2e8b9e0 100644 --- a/src/commands/data-connection/generate-stack-url.ts +++ b/src/commands/data-connection/generate-stack-url.ts @@ -5,6 +5,10 @@ import { getConnection } from "../../gql/connection/get-connection.js"; import { DatasourceType } from "../../gql/generated/graphql.js"; import { GqlApiError } from "../../gql/gql-request.js"; import { loadConfig } from "../../lib/config.js"; +import { + gateExperimental, + withExperimentalBadge, +} from "../../lib/experimental.js"; import { buildCloudFormationUrl } from "./stack-url-utils.js"; interface GenerateStackUrlFlags { @@ -172,8 +176,9 @@ export async function generateStackUrlCmd( } } +// EXPERIMENTAL export const generateStackUrlCommand = buildCommand({ - loader: async () => generateStackUrlCmd, + loader: async () => gateExperimental(generateStackUrlCmd), parameters: { positional: { kind: "tuple", @@ -202,8 +207,9 @@ export const generateStackUrlCommand = buildCommand({ }, }, docs: { - brief: + brief: withExperimentalBadge( "Generate a CloudFormation quick-create URL for a data connection's AWS stack", + ), fullDescription: "Builds the CloudFormation quick-create URL that deploys the AWS collection\n" + "stack for a data connection. The CLI reads the connection's variables and\n" + diff --git a/src/commands/data-connection/index.ts b/src/commands/data-connection/index.ts index 0b0c1f5..a1e8a24 100644 --- a/src/commands/data-connection/index.ts +++ b/src/commands/data-connection/index.ts @@ -1,4 +1,5 @@ import { buildRouteMap } from "@stricli/core"; +import { withExperimentalBadge } from "../../lib/experimental.js"; import { createConnectionRoutes } from "./create/index.js"; import { generateStackUrlCommand } from "./generate-stack-url.js"; import { listCommand } from "./list.js"; @@ -12,7 +13,8 @@ export const dataConnectionRoutes = buildRouteMap({ view: viewCommand, }, docs: { - brief: "Manage data connections", + // EXPERIMENTAL + brief: withExperimentalBadge("Manage data connections"), fullDescription: [ "Create, list, and view data connections in Observe.", "", diff --git a/src/commands/data-connection/list.ts b/src/commands/data-connection/list.ts index bcbe362..6d34615 100644 --- a/src/commands/data-connection/list.ts +++ b/src/commands/data-connection/list.ts @@ -1,4 +1,8 @@ import { buildCommand } from "@stricli/core"; +import { + gateExperimental, + withExperimentalBadge, +} from "../../lib/experimental"; import type { LocalContext } from "../../context.js"; import { searchConnections } from "../../gql/connection/search-connections.js"; import { GqlApiError } from "../../gql/gql-request.js"; @@ -39,8 +43,9 @@ export async function list( } } +// EXPERIMENTAL export const listCommand = buildCommand({ - loader: async () => list, + loader: async () => gateExperimental(list), parameters: { positional: { kind: "tuple", parameters: [] }, flags: { @@ -59,6 +64,6 @@ export const listCommand = buildCommand({ }, }, docs: { - brief: "List data connections", + brief: withExperimentalBadge("List data connections"), }, }); diff --git a/src/commands/data-connection/view.ts b/src/commands/data-connection/view.ts index e95ce47..87780cd 100644 --- a/src/commands/data-connection/view.ts +++ b/src/commands/data-connection/view.ts @@ -1,4 +1,8 @@ import { buildCommand } from "@stricli/core"; +import { + gateExperimental, + withExperimentalBadge, +} from "../../lib/experimental"; import type { LocalContext } from "../../context.js"; import { getConnection } from "../../gql/connection/get-connection.js"; import { GqlApiError } from "../../gql/gql-request.js"; @@ -32,8 +36,9 @@ export async function view( } } +// EXPERIMENTAL export const viewCommand = buildCommand({ - loader: async () => view, + loader: async () => gateExperimental(view), parameters: { positional: { kind: "tuple", @@ -47,6 +52,6 @@ export const viewCommand = buildCommand({ flags: {}, }, docs: { - brief: "View a data connection by ID", + brief: withExperimentalBadge("View a data connection by ID"), }, }); diff --git a/src/commands/datasource/create.ts b/src/commands/datasource/create.ts index 6d0d114..d225e69 100644 --- a/src/commands/datasource/create.ts +++ b/src/commands/datasource/create.ts @@ -1,4 +1,8 @@ import { buildCommand } from "@stricli/core"; +import { + gateExperimental, + withExperimentalBadge, +} from "../../lib/experimental"; import type { LocalContext } from "../../context.js"; import { createDatasource } from "../../gql/connection/create-datasource.js"; import { getConnection } from "../../gql/connection/get-connection.js"; @@ -127,8 +131,9 @@ export async function createDatasourceCmd( } } +// EXPERIMENTAL export const createDatasourceCommand = buildCommand({ - loader: async () => createDatasourceCmd, + loader: async () => gateExperimental(createDatasourceCmd), parameters: { positional: { kind: "tuple", parameters: [] }, flags: { @@ -203,7 +208,7 @@ export const createDatasourceCommand = buildCommand({ }, }, docs: { - brief: "Create a datasource", + brief: withExperimentalBadge("Create a datasource"), fullDescription: "Creates a new datasource within an existing data connection.\n\n" + "Named flags (--collect-logs, --collect-metrics, --collect-resources) override\n" + diff --git a/src/commands/datasource/index.ts b/src/commands/datasource/index.ts index f2b81af..c731133 100644 --- a/src/commands/datasource/index.ts +++ b/src/commands/datasource/index.ts @@ -1,4 +1,5 @@ import { buildRouteMap } from "@stricli/core"; +import { withExperimentalBadge } from "../../lib/experimental.js"; import { createDatasourceCommand } from "./create.js"; import { updateDatasourceCommand } from "./update.js"; @@ -8,7 +9,8 @@ export const datasourceRoutes = buildRouteMap({ update: updateDatasourceCommand, }, docs: { - brief: "Manage datasources", + // EXPERIMENTAL + brief: withExperimentalBadge("Manage datasources"), fullDescription: [ "Create and update datasources.", "", diff --git a/src/commands/datasource/update.ts b/src/commands/datasource/update.ts index 74c4e40..3ae0559 100644 --- a/src/commands/datasource/update.ts +++ b/src/commands/datasource/update.ts @@ -1,4 +1,8 @@ import { buildCommand } from "@stricli/core"; +import { + gateExperimental, + withExperimentalBadge, +} from "../../lib/experimental"; import type { LocalContext } from "../../context.js"; import { getConnection } from "../../gql/connection/get-connection.js"; import { @@ -230,8 +234,9 @@ function mapExistingConfigToInput( }; } +// EXPERIMENTAL export const updateDatasourceCommand = buildCommand({ - loader: async () => updateDatasourceCmd, + loader: async () => gateExperimental(updateDatasourceCmd), parameters: { positional: { kind: "tuple", @@ -308,7 +313,7 @@ export const updateDatasourceCommand = buildCommand({ }, }, docs: { - brief: "Update an existing datasource", + brief: withExperimentalBadge("Update an existing datasource"), fullDescription: "Updates the configuration of an existing datasource.\n\n" + "Use 'observe data-connection view ' to find datasource IDs.\n\n" + diff --git a/src/commands/ingest-token/create.ts b/src/commands/ingest-token/create.ts index ad7e7d2..1ebc167 100644 --- a/src/commands/ingest-token/create.ts +++ b/src/commands/ingest-token/create.ts @@ -1,4 +1,8 @@ import { buildCommand } from "@stricli/core"; +import { + gateExperimental, + withExperimentalBadge, +} from "../../lib/experimental"; import type { LocalContext } from "../../context"; import { createIngestToken } from "../../gql/ingest-token/create-ingest-token"; import { updateIngestTokenAssociation } from "../../gql/ingest-token/update-ingest-token-association"; @@ -62,8 +66,9 @@ export async function create( } } +// EXPERIMENTAL export const createCommand = buildCommand({ - loader: async () => create, + loader: async () => gateExperimental(create), parameters: { positional: { kind: "tuple", @@ -92,7 +97,7 @@ export const createCommand = buildCommand({ }, }, docs: { - brief: "Create an ingest token", + brief: withExperimentalBadge("Create an ingest token"), fullDescription: "Create a new ingest token, optionally associating it with datastreams.\n\n" + "When --datastream-ids is omitted, the token is created with no associations\n" + diff --git a/src/commands/ingest-token/index.ts b/src/commands/ingest-token/index.ts index e1b86c4..83a1e25 100644 --- a/src/commands/ingest-token/index.ts +++ b/src/commands/ingest-token/index.ts @@ -1,4 +1,5 @@ import { buildRouteMap } from "@stricli/core"; +import { withExperimentalBadge } from "../../lib/experimental"; import { createCommand } from "./create"; import { viewCommand } from "./view"; import { listCommand } from "./list"; @@ -12,15 +13,8 @@ export const ingestTokenRoutes = buildRouteMap({ update: updateCommand, }, docs: { - brief: "Manage ingest tokens", - fullDescription: [ - "Create, read, update, and list ingest tokens in Observe.", - "", - "Commands:", - " create Create a new ingest token and associate with datastreams", - " view View an ingest token by ID", - " list List/search ingest tokens", - " update Update an ingest token", - ].join("\n"), + // EXPERIMENTAL + brief: withExperimentalBadge("Manage ingest tokens"), + fullDescription: "Create, read, update, and list ingest tokens in Observe.", }, }); diff --git a/src/commands/ingest-token/list.ts b/src/commands/ingest-token/list.ts index cb27048..2b5bd03 100644 --- a/src/commands/ingest-token/list.ts +++ b/src/commands/ingest-token/list.ts @@ -1,4 +1,8 @@ import { buildCommand } from "@stricli/core"; +import { + gateExperimental, + withExperimentalBadge, +} from "../../lib/experimental"; import type { LocalContext } from "../../context"; import { searchIngestToken } from "../../gql/ingest-token/search-ingest-token"; import { GqlApiError } from "../../gql/gql-request"; @@ -36,8 +40,9 @@ export async function list( } } +// EXPERIMENTAL export const listCommand = buildCommand({ - loader: async () => list, + loader: async () => gateExperimental(list), parameters: { positional: { kind: "tuple", @@ -53,6 +58,6 @@ export const listCommand = buildCommand({ }, }, docs: { - brief: "List ingest tokens", + brief: withExperimentalBadge("List ingest tokens"), }, }); diff --git a/src/commands/ingest-token/update.ts b/src/commands/ingest-token/update.ts index a73ff83..fd8f237 100644 --- a/src/commands/ingest-token/update.ts +++ b/src/commands/ingest-token/update.ts @@ -1,4 +1,8 @@ import { buildCommand } from "@stricli/core"; +import { + gateExperimental, + withExperimentalBadge, +} from "../../lib/experimental"; import type { LocalContext } from "../../context"; import { updateIngestToken } from "../../gql/ingest-token/update-ingest-token"; import { viewIngestToken } from "../../gql/ingest-token/view-ingest-token"; @@ -73,8 +77,9 @@ export async function update( } } +// EXPERIMENTAL export const updateCommand = buildCommand({ - loader: async () => update, + loader: async () => gateExperimental(update), parameters: { positional: { kind: "tuple", @@ -108,7 +113,7 @@ export const updateCommand = buildCommand({ }, }, docs: { - brief: "Update an ingest token", + brief: withExperimentalBadge("Update an ingest token"), fullDescription: "Update an ingest token's name, description, and/or disabled state.\n" + "Only the fields you pass are changed; omitted fields keep their current\n" + diff --git a/src/commands/ingest-token/view.ts b/src/commands/ingest-token/view.ts index 6a9883a..82ea288 100644 --- a/src/commands/ingest-token/view.ts +++ b/src/commands/ingest-token/view.ts @@ -1,4 +1,8 @@ import { buildCommand } from "@stricli/core"; +import { + gateExperimental, + withExperimentalBadge, +} from "../../lib/experimental"; import type { LocalContext } from "../../context"; import { viewIngestToken } from "../../gql/ingest-token/view-ingest-token"; import { GqlApiError } from "../../gql/gql-request"; @@ -32,8 +36,9 @@ export async function view( } } +// EXPERIMENTAL export const viewCommand = buildCommand({ - loader: async () => view, + loader: async () => gateExperimental(view), parameters: { positional: { kind: "tuple", @@ -47,6 +52,6 @@ export const viewCommand = buildCommand({ flags: {}, }, docs: { - brief: "View an ingest token by ID", + brief: withExperimentalBadge("View an ingest token by ID"), }, }); diff --git a/src/lib/experimental.test.ts b/src/lib/experimental.test.ts new file mode 100644 index 0000000..a9d6d84 --- /dev/null +++ b/src/lib/experimental.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, test } from "bun:test"; +import type { LocalContext } from "../context"; +import { + EXPERIMENTAL_ENV_VAR, + isExperimentalEnabled, + withExperimentalBadge, + hideExperimentalRoutes, + gateExperimental, +} from "./experimental"; + +describe("isExperimentalEnabled", () => { + test.each([ + ["1", true], + ["true", true], + ["TRUE", true], + [" true ", true], + ["0", false], + ["false", false], + ["yes", false], + ["", false], + [undefined, false], + ])("%p -> %p", (value, expected) => { + const env = value === undefined ? {} : { [EXPERIMENTAL_ENV_VAR]: value }; + expect(isExperimentalEnabled(env)).toBe(expected); + }); +}); + +describe("withExperimentalBadge", () => { + test("prefixes the badge and keeps the original brief", () => { + const result = withExperimentalBadge("List widgets"); + expect(result).toContain("[experimental]"); + expect(result).toContain("List widgets"); + }); +}); + +describe("hideExperimentalRoutes", () => { + test("hides routes when the flag is off", () => { + expect(hideExperimentalRoutes(["a", "b"], {})).toEqual({ + a: true, + b: true, + }); + }); + + test("reveals routes when the flag is on", () => { + expect( + hideExperimentalRoutes(["a", "b"], { [EXPERIMENTAL_ENV_VAR]: "1" }), + ).toEqual({ a: false, b: false }); + }); +}); + +describe("gateExperimental", () => { + function fakeContext() { + const errors: string[] = []; + const exits: number[] = []; + const noop = () => { + return; + }; + const ctx = { + writer: { + write: noop, + info: noop, + success: noop, + warn: noop, + error: (msg: string) => errors.push(msg), + }, + process: { + exit: (code?: number) => { + exits.push(code ?? 0); + }, + }, + } as unknown as LocalContext; + return { ctx, errors, exits }; + } + + test("blocks execution and exits when the flag is off", async () => { + const { ctx, errors, exits } = fakeContext(); + let ran = false; + const guarded = gateExperimental(async () => { + ran = true; + }, {}); + + await guarded.call(ctx); + + expect(ran).toBe(false); + expect(exits).toEqual([1]); + expect(errors[0]).toContain(EXPERIMENTAL_ENV_VAR); + }); + + test("runs the handler and forwards args when the flag is on", async () => { + const { ctx, exits } = fakeContext(); + const seen: unknown[] = []; + const guarded = gateExperimental( + async function (this: LocalContext, a: string, b: number) { + seen.push(a, b); + expect(this).toBe(ctx); + }, + { [EXPERIMENTAL_ENV_VAR]: "1" }, + ); + + await guarded.call(ctx, "x", 7); + + expect(seen).toEqual(["x", 7]); + expect(exits).toEqual([]); + }); +}); diff --git a/src/lib/experimental.ts b/src/lib/experimental.ts new file mode 100644 index 0000000..17904d6 --- /dev/null +++ b/src/lib/experimental.ts @@ -0,0 +1,76 @@ +/** + * Experimental command gating. + * + * Experimental commands are hidden behind the `OBSERVE_CLI_EXPERIMENTAL=1` + * environment variable and carry an `[experimental]` badge when visible. + * + * To mark a command experimental: + * 1. `loader: async () => gateExperimental(handler)` — blocks execution. + * 2. `docs.brief: withExperimentalBadge("...")` — adds the badge. + * 3. parent route map `docs.hideRoute: hideExperimentalRoutes(["name"])` — + * hides it from help. + * Promoting to GA = removing these three markers. + */ + +import type { LocalContext } from "../context.js"; +import { yellow } from "./formatters/colors.js"; + +/** Environment variable that opts a session into experimental CLI commands. */ +export const EXPERIMENTAL_ENV_VAR = "OBSERVE_CLI_EXPERIMENTAL"; + +/** Badge prefixed onto the brief of an experimental command/route. */ +export const EXPERIMENTAL_BADGE = yellow("[experimental]"); + +/** + * Whether the current process has opted into experimental commands. Accepts + * `1` or `true` (case-insensitive, surrounding whitespace ignored). + */ +export function isExperimentalEnabled( + env: NodeJS.ProcessEnv = process.env, +): boolean { + const raw = env[EXPERIMENTAL_ENV_VAR]?.trim().toLowerCase(); + return raw === "1" || raw === "true"; +} + +/** Prefix a command/route `brief` with the `[experimental]` badge. */ +export function withExperimentalBadge(brief: string): string { + return `${EXPERIMENTAL_BADGE} ${brief}`; +} + +/** + * Build a `docs.hideRoute` map that hides the named routes from help unless + * the experimental flag is set. + */ +export function hideExperimentalRoutes( + names: string[], + env: NodeJS.ProcessEnv = process.env, +): Record { + const hidden = !isExperimentalEnabled(env); + return Object.fromEntries(names.map((name) => [name, hidden])); +} + +/** Friendly message shown when a gated command is run without the flag. */ +export function experimentalDisabledMessage(): string { + return ( + "This command is experimental and may change or be removed. " + + `Set ${EXPERIMENTAL_ENV_VAR}=1 to enable it.` + ); +} + +/** + * Wrap a stricli command handler so it refuses to run unless the experimental + * flag is set. + */ +export function gateExperimental( + handler: (this: LocalContext, ...args: A) => Promise | void, + env: NodeJS.ProcessEnv = process.env, +) { + return async function (this: LocalContext, ...args: A): Promise { + if (!isExperimentalEnabled(env)) { + this.writer.error(experimentalDisabledMessage()); + this.process.exit(1); + return; + } + await handler.apply(this, args); + }; +}