Skip to content
Open
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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
2 changes: 2 additions & 0 deletions src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand All @@ -36,6 +37,7 @@ export const routes = buildRouteMap({
content: contentRoutes,
"ingest-token": ingestTokenRoutes,
datastream: datastreamRoutes,
workspace: workspaceRoutes,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

workspace is a concept thats being removed -- are we sure we need to add it to the cli?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We need it for creating links to the customer's observe account, since the URL contains the workspaceID, e.g. https://146206672945.observe-eng.com/workspace/42587555/service-explorer. If we do https://146206672945.observe-eng.com/service-explorer the link does not work. I like having the agent provide this link, as the customer can directly see the data in observe that they set up via agent. I would remove this once we remove workspaces from the URLs. If we cannot have this API, I can also just not have the agent provide links at the end.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think having it in auth status is fine, perhaps marked (deprecated), but I wouldn't add observe workspace view.

cli: cliRoutes,
},
defaultCommand: "help",
Expand Down
20 changes: 13 additions & 7 deletions src/commands/auth/status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`;
Expand All @@ -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));
Expand All @@ -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);
Expand Down
12 changes: 12 additions & 0 deletions src/commands/workspace/index.ts
Original file line number Diff line number Diff line change
@@ -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.",
},
});
162 changes: 162 additions & 0 deletions src/commands/workspace/view.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
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<{
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"];

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");
});
});
59 changes: 59 additions & 0 deletions src/commands/workspace/view.ts
Original file line number Diff line number Diff line change
@@ -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<string, never>,
deps: ViewWorkspaceDeps = {},
): Promise<void> {
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",
},
});
11 changes: 11 additions & 0 deletions src/gql/workspace/view-workspace.graphql
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
query ViewWorkspace {
currentUser {
workspaces {
id
label
timezone
locale
createdDate
}
}
}
17 changes: 17 additions & 0 deletions src/gql/workspace/view-workspace.ts
Original file line number Diff line number Diff line change
@@ -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<GqlWorkspaceDetail | null> {
const response = await executeGraphQL(config, ViewWorkspaceDocument, {});
return response.data.currentUser?.workspaces[0] ?? null;
}