From 3d9021e4251bb103508511ef3dec65956334d71b Mon Sep 17 00:00:00 2001 From: Viraj Ramakrishnan Date: Tue, 16 Jun 2026 14:29:32 -0700 Subject: [PATCH 1/3] feat(workspace): add workspace view command and surface ID in auth status Adds `observe workspace view` which returns the current workspace's id, label, timezone, locale, and createdDate as JSON. The workspace ID (the numeric suffix in the UI URL) was previously invisible in the CLI. Also adds `Workspace ID` to `auth status` output so it's visible without needing a separate command. Co-Authored-By: Claude Sonnet 4.6 --- src/app.ts | 2 + src/commands/auth/status.ts | 20 ++- src/commands/workspace/index.ts | 12 ++ src/commands/workspace/view.test.ts | 156 +++++++++++++++++++++++ src/commands/workspace/view.ts | 59 +++++++++ src/gql/workspace/view-workspace.graphql | 11 ++ src/gql/workspace/view-workspace.ts | 17 +++ 7 files changed, 270 insertions(+), 7 deletions(-) create mode 100644 src/commands/workspace/index.ts create mode 100644 src/commands/workspace/view.test.ts create mode 100644 src/commands/workspace/view.ts create mode 100644 src/gql/workspace/view-workspace.graphql create mode 100644 src/gql/workspace/view-workspace.ts diff --git a/src/app.ts b/src/app.ts index df05fd6..5e960cc 100644 --- a/src/app.ts +++ b/src/app.ts @@ -16,6 +16,7 @@ import { queryCommand } from "./commands/query.js"; import { skillRoutes } from "./commands/skill/index.js"; import { tagKeyRoutes } from "./commands/tag-key/index.js"; import { tagValueRoutes } from "./commands/tag-value/index.js"; +import { workspaceRoutes } from "./commands/workspace/index.js"; import { CURRENT_CLI_VERSION } from "./lib/constants.js"; /** Top-level route map containing all CLI commands */ @@ -36,6 +37,7 @@ export const routes = buildRouteMap({ content: contentRoutes, "ingest-token": ingestTokenRoutes, datastream: datastreamRoutes, + workspace: workspaceRoutes, cli: cliRoutes, }, defaultCommand: "help", diff --git a/src/commands/auth/status.ts b/src/commands/auth/status.ts index e01619b..1fb3c4d 100644 --- a/src/commands/auth/status.ts +++ b/src/commands/auth/status.ts @@ -37,12 +37,14 @@ async function status( let valid = false; let workspaceName: string | null = null; + let workspaceId: string | null = null; let errorMessage: string | null = null; try { const { workspace } = await getDefaultWorkspace(config); valid = true; workspaceName = workspace?.label ?? null; + workspaceId = workspace?.id ?? null; } catch (error) { if (error instanceof GqlApiError) { errorMessage = `${error.statusCode}: ${error.message}`; @@ -61,6 +63,7 @@ async function status( configPath: getConfigPath(), ...(config.tokenId && { tokenId: config.tokenId }), ...(workspaceName && { workspace: workspaceName }), + ...(workspaceId && { workspaceId }), ...(errorMessage && { error: errorMessage }), }; writer.write(JSON.stringify(result, null, 2)); @@ -73,17 +76,20 @@ async function status( writer.error("Authentication invalid\n"); } - writer.write(chalk.dim(" Customer ID ") + config.customerId); - writer.write(chalk.dim(" Domain ") + config.domain); - writer.write(chalk.dim(" API URL ") + baseUrl); - writer.write(chalk.dim(" Token ") + maskedToken); + writer.write(chalk.dim(" Customer ID ") + config.customerId); + writer.write(chalk.dim(" Domain ") + config.domain); + writer.write(chalk.dim(" API URL ") + baseUrl); + writer.write(chalk.dim(" Token ") + maskedToken); if (config.tokenId) { - writer.write(chalk.dim(" Token ID ") + config.tokenId); + writer.write(chalk.dim(" Token ID ") + config.tokenId); } if (workspaceName) { - writer.write(chalk.dim(" Workspace ") + workspaceName); + writer.write(chalk.dim(" Workspace ") + workspaceName); } - writer.write(chalk.dim(" Config ") + getConfigPath()); + if (workspaceId) { + writer.write(chalk.dim(" Workspace ID ") + workspaceId); + } + writer.write(chalk.dim(" Config ") + getConfigPath()); if (errorMessage) { writer.write("\n" + chalk.red(" Error: ") + errorMessage); diff --git a/src/commands/workspace/index.ts b/src/commands/workspace/index.ts new file mode 100644 index 0000000..db4726e --- /dev/null +++ b/src/commands/workspace/index.ts @@ -0,0 +1,12 @@ +import { buildRouteMap } from "@stricli/core"; +import { viewCommand } from "./view"; + +export const workspaceRoutes = buildRouteMap({ + routes: { + view: viewCommand, + }, + docs: { + brief: "View workspace information", + fullDescription: "View workspace information including the workspace ID.", + }, +}); diff --git a/src/commands/workspace/view.test.ts b/src/commands/workspace/view.test.ts new file mode 100644 index 0000000..fc01bae --- /dev/null +++ b/src/commands/workspace/view.test.ts @@ -0,0 +1,156 @@ +import { + afterAll, + beforeAll, + beforeEach, + describe, + expect, + mock, + test, +} from "bun:test"; +import { resolve } from "node:path"; +import type { LocalContext } from "../../context"; +import { createWriter } from "../../lib/writer"; + +const repoRoot = resolve(import.meta.dir, "../../.."); +const gqlModulePath = resolve( + repoRoot, + "src/gql/workspace/view-workspace.ts", +); + +const loadConfigFn = mock(() => ({ + customerId: "test-customer", + token: "test-token", + domain: "observeinc.com", +})); + +const viewWorkspaceFn = mock((_config: unknown) => + Promise.resolve({ + id: "42587555", + label: "Default", + timezone: "America/Los_Angeles", + locale: "en_US", + createdDate: "2024-01-01T00:00:00Z", + }), +); + +let view: (typeof import("./view"))["view"]; + +let previousNoColor: string | undefined; +let previousForceColor: string | undefined; + +const deps = { + loadConfig: loadConfigFn, + viewWorkspace: viewWorkspaceFn, +} as Parameters<(typeof import("./view"))["view"]>[1]; + +beforeAll(async () => { + previousNoColor = process.env.NO_COLOR; + previousForceColor = process.env.FORCE_COLOR; + process.env.NO_COLOR = "1"; + process.env.FORCE_COLOR = "0"; + + void mock.module(gqlModulePath, () => ({ + viewWorkspace: viewWorkspaceFn, + })); + + const mod = await import("./view.ts"); + view = mod.view; +}); + +afterAll(() => { + mock.restore(); + if (previousNoColor === undefined) { + delete process.env.NO_COLOR; + } else { + process.env.NO_COLOR = previousNoColor; + } + if (previousForceColor === undefined) { + delete process.env.FORCE_COLOR; + } else { + process.env.FORCE_COLOR = previousForceColor; + } +}); + +function createMockContext() { + const stdout: string[] = []; + const stderr: string[] = []; + let exitCode: number | undefined; + + const processMock = { + stdout: { + write: (msg: string) => { + stdout.push(msg); + return true; + }, + }, + stderr: { + write: (msg: string) => { + stderr.push(msg); + return true; + }, + }, + exit: (code?: number) => { + exitCode = code ?? 0; + throw new Error("process.exit"); + }, + }; + + const context = { + process: processMock, + writer: createWriter({ process: processMock }), + } as unknown as LocalContext; + + return { context, stdout, stderr, getExitCode: () => exitCode }; +} + +describe("workspace view", () => { + beforeEach(() => { + loadConfigFn.mockClear(); + viewWorkspaceFn.mockClear(); + }); + + test("outputs workspace id, label, and metadata", async () => { + const { context, stdout } = createMockContext(); + await view.call(context, {}, deps); + + expect(viewWorkspaceFn).toHaveBeenCalledTimes(1); + const output = JSON.parse(stdout.join("")); + expect(output.id).toBe("42587555"); + expect(output.label).toBe("Default"); + expect(output.timezone).toBe("America/Los_Angeles"); + expect(output.locale).toBe("en_US"); + }); + + test("exits with code 1 when no workspace is found", async () => { + viewWorkspaceFn.mockImplementationOnce(() => Promise.resolve(null)); + + const { context, stderr, getExitCode } = createMockContext(); + try { + await view.call(context, {}, deps); + throw new Error("expected process.exit"); + } catch (error) { + expect((error as Error).message).toBe("process.exit"); + } + expect(getExitCode()).toBe(1); + expect(stderr.join("")).toContain("No workspace found"); + }); + + test("exits with code 1 on API error", async () => { + viewWorkspaceFn.mockImplementationOnce(() => { + const err = new Error("Unauthorized"); + err.name = "GqlApiError"; + (err as unknown as { statusCode: number }).statusCode = 401; + throw err; + }); + + const { context, stderr, getExitCode } = createMockContext(); + try { + await view.call(context, {}, deps); + throw new Error("expected process.exit"); + } catch (error) { + expect((error as Error).message).toBe("process.exit"); + } + expect(getExitCode()).toBe(1); + expect(stderr.join("")).toContain("Error"); + }); +}); diff --git a/src/commands/workspace/view.ts b/src/commands/workspace/view.ts new file mode 100644 index 0000000..81e95f1 --- /dev/null +++ b/src/commands/workspace/view.ts @@ -0,0 +1,59 @@ +import { buildCommand } from "@stricli/core"; +import type { LocalContext } from "../../context"; +import { viewWorkspace } from "../../gql/workspace/view-workspace"; +import { GqlApiError } from "../../gql/gql-request"; +import { loadConfig } from "../../lib/config"; + +export interface ViewWorkspaceDeps { + loadConfig?: typeof loadConfig; + viewWorkspace?: typeof viewWorkspace; +} + +export async function view( + this: LocalContext, + _flags: Record, + deps: ViewWorkspaceDeps = {}, +): Promise { + const { + loadConfig: loadConfigImpl = loadConfig, + viewWorkspace: viewWorkspaceImpl = viewWorkspace, + } = deps; + const { process, writer } = this; + + try { + const config = loadConfigImpl(); + const workspace = await viewWorkspaceImpl(config); + if (!workspace) { + writer.error("No workspace found"); + process.exit(1); + return; + } + writer.write(JSON.stringify(workspace, null, 2)); + } catch (error) { + if (error instanceof GqlApiError) { + writer.error(`API Error (${error.statusCode}): ${error.message}`); + } else { + const message = error instanceof Error ? error.message : String(error); + writer.error(`Error: ${message}`); + } + process.exit(1); + } +} + +export const viewCommand = buildCommand({ + loader: async () => view, + parameters: { + positional: { + kind: "tuple", + parameters: [], + }, + flags: {}, + }, + docs: { + brief: "View the current workspace", + fullDescription: + "Displays the current workspace ID, name, timezone, locale, and creation date.\n\n" + + "Examples:\n" + + " observe workspace view", + }, +}); diff --git a/src/gql/workspace/view-workspace.graphql b/src/gql/workspace/view-workspace.graphql new file mode 100644 index 0000000..f0e7812 --- /dev/null +++ b/src/gql/workspace/view-workspace.graphql @@ -0,0 +1,11 @@ +query ViewWorkspace { + currentUser { + workspaces { + id + label + timezone + locale + createdDate + } + } +} diff --git a/src/gql/workspace/view-workspace.ts b/src/gql/workspace/view-workspace.ts new file mode 100644 index 0000000..8ddea89 --- /dev/null +++ b/src/gql/workspace/view-workspace.ts @@ -0,0 +1,17 @@ +import type { Config } from "../../lib/config"; +import { + ViewWorkspaceDocument, + type ViewWorkspaceQuery, +} from "../generated/graphql"; +import { executeGraphQL } from "../gql-request"; + +export type GqlWorkspaceDetail = NonNullable< + ViewWorkspaceQuery["currentUser"] +>["workspaces"][number]; + +export async function viewWorkspace( + config: Config, +): Promise { + const response = await executeGraphQL(config, ViewWorkspaceDocument, {}); + return response.data.currentUser?.workspaces[0] ?? null; +} From 8637f3c9dc2e6cfe194d52d4e4f0e88f4bca8e47 Mon Sep 17 00:00:00 2001 From: Viraj Ramakrishnan Date: Tue, 16 Jun 2026 15:07:03 -0700 Subject: [PATCH 2/3] fix(workspace): fix mock return type and rebase onto main after #3 merged Co-Authored-By: Claude Sonnet 4.6 --- src/commands/workspace/view.test.ts | 30 +++++++++++++++++------------ 1 file changed, 18 insertions(+), 12 deletions(-) diff --git a/src/commands/workspace/view.test.ts b/src/commands/workspace/view.test.ts index fc01bae..e215129 100644 --- a/src/commands/workspace/view.test.ts +++ b/src/commands/workspace/view.test.ts @@ -12,10 +12,7 @@ import type { LocalContext } from "../../context"; import { createWriter } from "../../lib/writer"; const repoRoot = resolve(import.meta.dir, "../../.."); -const gqlModulePath = resolve( - repoRoot, - "src/gql/workspace/view-workspace.ts", -); +const gqlModulePath = resolve(repoRoot, "src/gql/workspace/view-workspace.ts"); const loadConfigFn = mock(() => ({ customerId: "test-customer", @@ -23,14 +20,23 @@ const loadConfigFn = mock(() => ({ domain: "observeinc.com", })); -const viewWorkspaceFn = mock((_config: unknown) => - Promise.resolve({ - id: "42587555", - label: "Default", - timezone: "America/Los_Angeles", - locale: "en_US", - createdDate: "2024-01-01T00:00:00Z", - }), +const viewWorkspaceFn = mock( + ( + _config: unknown, + ): Promise<{ + id: string; + label: string; + timezone: string; + locale: string; + createdDate: string; + } | null> => + Promise.resolve({ + id: "42587555", + label: "Default", + timezone: "America/Los_Angeles", + locale: "en_US", + createdDate: "2024-01-01T00:00:00Z", + }), ); let view: (typeof import("./view"))["view"]; From a2e9191a5916a0b15ded59e1e6583c13a9acc822 Mon Sep 17 00:00:00 2001 From: Viraj Ramakrishnan Date: Tue, 16 Jun 2026 15:19:02 -0700 Subject: [PATCH 3/3] docs: add workspace view to README commands table Co-Authored-By: Claude Sonnet 4.6 --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 525ef21..d36142b 100644 --- a/README.md +++ b/README.md @@ -72,6 +72,7 @@ To update installed skills after edits in this repo, run `npx skills update`. | `observe tag-value list` | Search tag values in the knowledge graph | | `observe skill list` | List AI agent skills | | `observe skill view` | View skill details and content | +| `observe workspace view` | View current workspace ID, name, and metadata | | `observe query` | Execute OPAL queries on datasets | | `observe cli install` | Configure shell integration (PATH, completions) | | `observe cli uninstall` | Remove shell integration |