@rubixkube/rubix 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,41 @@
1
+ # Rubix CLI
2
+
3
+ Chat with your infrastructure from the terminal. **RubixKube** CLI for Site Reliability Intelligence—predict, prevent, and fix failures with AI.
4
+
5
+ ![Rubix CLI](https://raw.githubusercontent.com/rubixkube-io/rubixkube-ai/main/public/assets/rubix-cli.png)
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ npm install -g @rubixkube/rubix
11
+ ```
12
+
13
+ Or run without installing:
14
+
15
+ ```bash
16
+ npx @rubixkube/rubix
17
+ ```
18
+
19
+ ## Quick Start
20
+
21
+ 1. Run `rubix` to start the chat UI
22
+ 2. On first run, choose **Login** and complete the Auth0 device flow
23
+ 3. Start asking questions about your infrastructure
24
+
25
+ ## Features
26
+
27
+ - **Auth** — Auth0 device code flow; credentials stored in `~/.rubix/auth.json`
28
+ - **Chat** — Real-time streaming to RubixKube; session resume; cluster-aware
29
+ - **Slash commands** — `/login`, `/logout`, `/status`, `/sessions`, `/cluster`, `/new`, `/clear`, `/console`, `/help`, `/exit`
30
+ - **Shortcuts** — `?` help, `Tab` autocomplete, `Ctrl+X` then `H/C/Q` for help/clear/quit
31
+
32
+ ## Requirements
33
+
34
+ - Node.js 18+
35
+ - [RubixKube account](https://console.rubixkube.ai) (free signup)
36
+
37
+ ## Links
38
+
39
+ - [RubixKube](https://rubixkube.ai) — Site Reliability Intelligence platform
40
+ - [Documentation](https://docs.rubixkube.ai)
41
+ - [Console](https://console.rubixkube.ai)
package/dist/cli.js ADDED
@@ -0,0 +1,50 @@
1
+ #!/usr/bin/env node
2
+ import { Command } from "commander";
3
+ import { runChatCommand } from "./commands/chat.js";
4
+ import { VERSION } from "./version.js";
5
+ import { runLoginCommand } from "./commands/login.js";
6
+ import { runLogoutCommand } from "./commands/logout.js";
7
+ import { runSessionsCommand } from "./commands/sessions.js";
8
+ async function main() {
9
+ if (process.argv.length <= 2) {
10
+ await runChatCommand({});
11
+ return;
12
+ }
13
+ const program = new Command();
14
+ program
15
+ .name("rubix")
16
+ .description("Rubix terminal client")
17
+ .version(VERSION);
18
+ program
19
+ .command("chat")
20
+ .description("Start interactive chat UI")
21
+ .option("-s, --session-id <id>", "Use an existing session id")
22
+ .option("-p, --prompt <text>", "Send a prompt immediately after launch")
23
+ .action(async (options) => {
24
+ await runChatCommand(options);
25
+ });
26
+ program
27
+ .command("login")
28
+ .description("Authenticate with device code flow")
29
+ .action(async () => {
30
+ await runLoginCommand();
31
+ });
32
+ program
33
+ .command("logout")
34
+ .description("Clear local authentication")
35
+ .action(async () => {
36
+ await runLogoutCommand();
37
+ });
38
+ program
39
+ .command("sessions")
40
+ .description("List sessions")
41
+ .action(async () => {
42
+ await runSessionsCommand();
43
+ });
44
+ await program.parseAsync(process.argv);
45
+ }
46
+ main().catch((error) => {
47
+ const message = error instanceof Error ? error.message : String(error);
48
+ console.error(`Fatal: ${message}`);
49
+ process.exit(1);
50
+ });
@@ -0,0 +1,17 @@
1
+ import React from "react";
2
+ import { render } from "ink";
3
+ import { ThemeProvider } from "@inkjs/ui";
4
+ import { App } from "../ui/App.js";
5
+ import { inkTheme } from "../ui/ink-theme.js";
6
+ export async function runChatCommand(options) {
7
+ const instance = render(React.createElement(ThemeProvider, {
8
+ theme: inkTheme,
9
+ children: React.createElement(App, {
10
+ initialSessionId: options.sessionId,
11
+ seedPrompt: options.prompt,
12
+ }),
13
+ }), {
14
+ exitOnCtrlC: true,
15
+ });
16
+ await instance.waitUntilExit();
17
+ }
@@ -0,0 +1,17 @@
1
+ import { saveAuthConfig } from "../core/auth-store.js";
2
+ import { authenticateWithDeviceFlow } from "../core/device-auth.js";
3
+ export async function runLoginCommand() {
4
+ console.log("Starting Rubix device authentication...");
5
+ try {
6
+ const authConfig = await authenticateWithDeviceFlow((message) => {
7
+ console.log(message);
8
+ });
9
+ await saveAuthConfig(authConfig);
10
+ console.log(`Authentication successful for ${authConfig.userName ?? authConfig.userEmail ?? "user"}.`);
11
+ }
12
+ catch (error) {
13
+ const message = error instanceof Error ? error.message : String(error);
14
+ console.error(`Login not completed: ${message}`);
15
+ process.exitCode = 1;
16
+ }
17
+ }
@@ -0,0 +1,5 @@
1
+ import { clearAuthConfig } from "../core/auth-store.js";
2
+ export async function runLogoutCommand() {
3
+ await clearAuthConfig();
4
+ console.log("Logged out. Local auth config cleared.");
5
+ }
@@ -0,0 +1,26 @@
1
+ import { loadAuthConfig } from "../core/auth-store.js";
2
+ import { listSessions } from "../core/rubix-api.js";
3
+ export async function runSessionsCommand() {
4
+ try {
5
+ const auth = await loadAuthConfig();
6
+ if (!auth?.isAuthenticated) {
7
+ console.error("Not authenticated. Run `rubix login` first.");
8
+ process.exitCode = 1;
9
+ return;
10
+ }
11
+ const sessions = await listSessions(auth);
12
+ if (sessions.length === 0) {
13
+ console.log("No sessions found.");
14
+ return;
15
+ }
16
+ for (const session of sessions) {
17
+ const title = session.title ? ` ${session.title}` : "";
18
+ console.log(`${session.id} ${session.appName} ${session.updatedAt}${title}`);
19
+ }
20
+ }
21
+ catch (error) {
22
+ const message = error instanceof Error ? error.message : String(error);
23
+ console.error(`Failed to list sessions: ${message}`);
24
+ process.exitCode = 1;
25
+ }
26
+ }
@@ -0,0 +1,46 @@
1
+ import dotenv from "dotenv";
2
+ dotenv.config({ quiet: true });
3
+ /** Production defaults — baked in for installable binary. Override via env for dev/staging/self-hosted. */
4
+ const DEFAULTS = {
5
+ RUBIXKUBE_AUTH0_DOMAIN: "rubixkube.us.auth0.com",
6
+ RUBIXKUBE_AUTH0_CLIENT_ID: "l732txhrSBbcieX2WgZuaRC8nTZBteVh",
7
+ RUBIXKUBE_AUTH0_AUDIENCE: "",
8
+ RUBIXKUBE_AUTH_API_BASE: "https://console.rubixkube.ai/api/orchestrator",
9
+ RUBIXKUBE_OPEL_API_BASE: "https://console.rubixkube.ai/api/opel",
10
+ RUBIXKUBE_STREAM_TIMEOUT_MS: "600000",
11
+ };
12
+ export const REQUIRED_ENV = [
13
+ "RUBIXKUBE_AUTH0_DOMAIN",
14
+ "RUBIXKUBE_AUTH0_CLIENT_ID",
15
+ "RUBIXKUBE_AUTH_API_BASE",
16
+ "RUBIXKUBE_OPEL_API_BASE",
17
+ ];
18
+ export const AUTH_REQUIRED_ENV = [
19
+ "RUBIXKUBE_AUTH0_DOMAIN",
20
+ "RUBIXKUBE_AUTH0_CLIENT_ID",
21
+ "RUBIXKUBE_AUTH_API_BASE",
22
+ ];
23
+ export function getConfig() {
24
+ return {
25
+ auth0Domain: process.env.RUBIXKUBE_AUTH0_DOMAIN ?? DEFAULTS.RUBIXKUBE_AUTH0_DOMAIN,
26
+ auth0ClientId: process.env.RUBIXKUBE_AUTH0_CLIENT_ID ?? DEFAULTS.RUBIXKUBE_AUTH0_CLIENT_ID,
27
+ auth0Audience: (process.env.RUBIXKUBE_AUTH0_AUDIENCE ?? DEFAULTS.RUBIXKUBE_AUTH0_AUDIENCE) || undefined,
28
+ authApiBase: process.env.RUBIXKUBE_AUTH_API_BASE ?? DEFAULTS.RUBIXKUBE_AUTH_API_BASE,
29
+ opelApiBase: process.env.RUBIXKUBE_OPEL_API_BASE ?? DEFAULTS.RUBIXKUBE_OPEL_API_BASE,
30
+ streamTimeoutMs: Number(process.env.RUBIXKUBE_STREAM_TIMEOUT_MS ?? DEFAULTS.RUBIXKUBE_STREAM_TIMEOUT_MS) || 600000,
31
+ };
32
+ }
33
+ export function getMissingEnv(names) {
34
+ const config = getConfig();
35
+ const keyMap = {
36
+ RUBIXKUBE_AUTH0_DOMAIN: config.auth0Domain,
37
+ RUBIXKUBE_AUTH0_CLIENT_ID: config.auth0ClientId,
38
+ RUBIXKUBE_AUTH0_AUDIENCE: config.auth0Audience ?? "",
39
+ RUBIXKUBE_AUTH_API_BASE: config.authApiBase,
40
+ RUBIXKUBE_OPEL_API_BASE: config.opelApiBase,
41
+ };
42
+ return names.filter((name) => !keyMap[name]?.trim());
43
+ }
44
+ export function getMissingRequiredEnv() {
45
+ return getMissingEnv(REQUIRED_ENV);
46
+ }
@@ -0,0 +1,39 @@
1
+ import fs from "node:fs/promises";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ const CONFIG_DIR = ".rubix";
5
+ const AUTH_FILE = "auth.json";
6
+ export function getAuthPath() {
7
+ return path.join(os.homedir(), CONFIG_DIR, AUTH_FILE);
8
+ }
9
+ async function ensureConfigDir() {
10
+ await fs.mkdir(path.join(os.homedir(), CONFIG_DIR), { recursive: true, mode: 0o700 });
11
+ }
12
+ export async function loadAuthConfig() {
13
+ try {
14
+ const data = await fs.readFile(getAuthPath(), "utf8");
15
+ return JSON.parse(data);
16
+ }
17
+ catch (error) {
18
+ const asNodeError = error;
19
+ if (asNodeError.code === "ENOENT") {
20
+ return null;
21
+ }
22
+ throw error;
23
+ }
24
+ }
25
+ export async function saveAuthConfig(config) {
26
+ await ensureConfigDir();
27
+ await fs.writeFile(getAuthPath(), JSON.stringify(config, null, 2), { mode: 0o600 });
28
+ }
29
+ export async function clearAuthConfig() {
30
+ try {
31
+ await fs.unlink(getAuthPath());
32
+ }
33
+ catch (error) {
34
+ const asNodeError = error;
35
+ if (asNodeError.code !== "ENOENT") {
36
+ throw error;
37
+ }
38
+ }
39
+ }
@@ -0,0 +1,155 @@
1
+ import { spawn } from "child_process";
2
+ import { AUTH_REQUIRED_ENV, getConfig, getMissingEnv } from "../config/env.js";
3
+ function isLocalDomain(domain) {
4
+ return domain.startsWith("localhost") || domain.startsWith("127.0.0.1");
5
+ }
6
+ function auth0BaseUrl() {
7
+ const domain = getConfig().auth0Domain;
8
+ const protocol = isLocalDomain(domain) ? "http" : "https";
9
+ return `${protocol}://${domain}`;
10
+ }
11
+ function decodeJwtClaims(token) {
12
+ const parts = token.split(".");
13
+ if (parts.length < 2) {
14
+ throw new Error("Invalid JWT token format.");
15
+ }
16
+ const payload = parts[1];
17
+ const normalized = payload.replace(/-/g, "+").replace(/_/g, "/");
18
+ const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, "=");
19
+ const decoded = Buffer.from(padded, "base64").toString("utf8");
20
+ return JSON.parse(decoded);
21
+ }
22
+ export async function startDeviceAuth() {
23
+ const missing = getMissingEnv(AUTH_REQUIRED_ENV);
24
+ if (missing.length > 0) {
25
+ throw new Error(`Missing required environment variables: ${missing.join(", ")}`);
26
+ }
27
+ const { auth0ClientId, auth0Audience } = getConfig();
28
+ const body = new URLSearchParams();
29
+ body.set("client_id", auth0ClientId);
30
+ body.set("scope", "openid profile email");
31
+ if (auth0Audience) {
32
+ body.set("audience", auth0Audience);
33
+ }
34
+ const response = await fetch(`${auth0BaseUrl()}/oauth/device/code`, {
35
+ method: "POST",
36
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
37
+ body,
38
+ });
39
+ if (!response.ok) {
40
+ const text = await response.text();
41
+ throw new Error(`Device authorization request failed (${response.status}): ${text}`);
42
+ }
43
+ const payload = (await response.json());
44
+ return {
45
+ deviceCode: payload.device_code,
46
+ userCode: payload.user_code,
47
+ verificationUrl: payload.verification_uri_complete ?? payload.verification_uri ?? "",
48
+ expiresIn: payload.expires_in,
49
+ interval: payload.interval ?? 5,
50
+ };
51
+ }
52
+ async function pollForTokens(start, log) {
53
+ const body = new URLSearchParams();
54
+ body.set("grant_type", "urn:ietf:params:oauth:grant-type:device_code");
55
+ body.set("device_code", start.deviceCode);
56
+ body.set("client_id", getConfig().auth0ClientId);
57
+ const expiresAt = Date.now() + start.expiresIn * 1000;
58
+ const intervalMs = Math.max(1, start.interval) * 1000;
59
+ let waitingLogged = false;
60
+ while (Date.now() < expiresAt) {
61
+ const response = await fetch(`${auth0BaseUrl()}/oauth/token`, {
62
+ method: "POST",
63
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
64
+ body,
65
+ });
66
+ const raw = (await response.json());
67
+ if (response.ok && raw.id_token && raw.access_token) {
68
+ return raw;
69
+ }
70
+ if (raw.error === "authorization_pending" || raw.error === "slow_down") {
71
+ if (!waitingLogged) {
72
+ waitingLogged = true;
73
+ log?.("Waiting for authorization in browser...");
74
+ }
75
+ await new Promise((resolve) => setTimeout(resolve, intervalMs));
76
+ continue;
77
+ }
78
+ throw new Error(`Token polling failed: ${JSON.stringify(raw)}`);
79
+ }
80
+ throw new Error("Device authorization timed out.");
81
+ }
82
+ async function validateAuth0Token(idToken) {
83
+ const authBase = getConfig().authApiBase;
84
+ const claims = decodeJwtClaims(idToken);
85
+ const response = await fetch(`${authBase}/auth/auth0-login`, {
86
+ method: "POST",
87
+ headers: {
88
+ Authorization: `Bearer ${idToken}`,
89
+ "Content-Type": "application/json",
90
+ },
91
+ body: JSON.stringify({
92
+ auth0_user: {
93
+ sub: claims.sub,
94
+ email: claims.email,
95
+ name: claims.name,
96
+ picture: claims.picture,
97
+ email_verified: claims.email_verified,
98
+ },
99
+ }),
100
+ });
101
+ if (!response.ok) {
102
+ const text = await response.text();
103
+ throw new Error(`auth0-login failed (${response.status}): ${text}`);
104
+ }
105
+ return (await response.json());
106
+ }
107
+ async function getUserProfile(idToken) {
108
+ const authBase = getConfig().authApiBase;
109
+ const response = await fetch(`${authBase}/users/me`, {
110
+ headers: {
111
+ Authorization: `Bearer ${idToken}`,
112
+ "Content-Type": "application/json",
113
+ },
114
+ });
115
+ if (!response.ok) {
116
+ return null;
117
+ }
118
+ return (await response.json());
119
+ }
120
+ function openUrlInBrowser(url) {
121
+ const urlStr = String(url).trim();
122
+ if (!urlStr.startsWith("http://") && !urlStr.startsWith("https://"))
123
+ return;
124
+ if (process.platform === "darwin") {
125
+ spawn("open", [urlStr], { stdio: "ignore" });
126
+ }
127
+ else if (process.platform === "win32") {
128
+ spawn("cmd", ["/c", "start", "", urlStr], { stdio: "ignore" });
129
+ }
130
+ else {
131
+ spawn("xdg-open", [urlStr], { stdio: "ignore" });
132
+ }
133
+ }
134
+ export async function authenticateWithDeviceFlow(log) {
135
+ const start = await startDeviceAuth();
136
+ openUrlInBrowser(start.verificationUrl);
137
+ log?.(`Opening ${start.verificationUrl} in browser`);
138
+ log?.(`Enter code: ${start.userCode}`);
139
+ const tokens = await pollForTokens(start, log);
140
+ const validation = await validateAuth0Token(tokens.id_token);
141
+ const profile = await getUserProfile(tokens.id_token);
142
+ return {
143
+ authToken: tokens.access_token,
144
+ idToken: tokens.id_token,
145
+ refreshToken: tokens.refresh_token,
146
+ tenantId: profile?.tenant?.tenant_id ?? validation.tenant_id,
147
+ userEmail: profile?.email ?? validation.user?.email,
148
+ userId: profile?.user_id,
149
+ userName: profile?.full_name ?? validation.user?.name,
150
+ userRole: profile?.roles?.[0],
151
+ tenantPlan: profile?.tenant?.plan_tier,
152
+ isAuthenticated: true,
153
+ timestamp: Date.now(),
154
+ };
155
+ }
@@ -0,0 +1,71 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ const MAX_FILE_SIZE = 256 * 1024; // 256KB
4
+ const MAX_URL_SIZE = 512 * 1024; // 512KB
5
+ export async function readFileContext(filePath, cwd) {
6
+ const resolved = path.isAbsolute(filePath) ? filePath : path.resolve(cwd, filePath);
7
+ const label = path.basename(resolved);
8
+ const stat = await fs.stat(resolved);
9
+ if (!stat.isFile()) {
10
+ throw new Error(`Not a file: ${filePath}`);
11
+ }
12
+ if (stat.size > MAX_FILE_SIZE) {
13
+ throw new Error(`File too large (max ${MAX_FILE_SIZE / 1024}KB): ${filePath}`);
14
+ }
15
+ const content = await fs.readFile(resolved, "utf8");
16
+ let truncated = false;
17
+ let out = content;
18
+ if (content.length > MAX_FILE_SIZE) {
19
+ out = content.slice(0, MAX_FILE_SIZE);
20
+ truncated = true;
21
+ }
22
+ return { content: out, label, truncated };
23
+ }
24
+ export async function fetchUrlContext(url) {
25
+ const trimmed = url.trim();
26
+ if (!trimmed.startsWith("http://") && !trimmed.startsWith("https://")) {
27
+ throw new Error(`Invalid URL (must be http or https): ${url}`);
28
+ }
29
+ const controller = new AbortController();
30
+ const timeout = setTimeout(() => controller.abort(), 15000);
31
+ const response = await fetch(trimmed, {
32
+ signal: controller.signal,
33
+ headers: { "User-Agent": "Rubix-CLI/1.0" },
34
+ });
35
+ clearTimeout(timeout);
36
+ if (!response.ok) {
37
+ throw new Error(`HTTP ${response.status}: ${trimmed}`);
38
+ }
39
+ const contentType = response.headers.get("content-type") ?? "";
40
+ if (!contentType.includes("text/") && !contentType.includes("application/json")) {
41
+ throw new Error(`Unsupported content type: ${contentType}`);
42
+ }
43
+ const text = await response.text();
44
+ let label;
45
+ try {
46
+ const u = new URL(trimmed);
47
+ label = u.hostname + (u.pathname.replace(/\/$/, "") || "");
48
+ if (!label)
49
+ label = trimmed;
50
+ }
51
+ catch {
52
+ label = trimmed;
53
+ }
54
+ let truncated = false;
55
+ let out = text;
56
+ if (text.length > MAX_URL_SIZE) {
57
+ out = text.slice(0, MAX_URL_SIZE);
58
+ truncated = true;
59
+ }
60
+ return { content: out, label, truncated };
61
+ }
62
+ /**
63
+ * Format context block to match console's context message format.
64
+ * Console: [CONTEXT] **label** (type){: description}:\ncontent
65
+ * Parsed by: ^\[CONTEXT\]\s*\*\*([^*]+)\*\*
66
+ */
67
+ export function formatContextBlock(result, kind, description) {
68
+ const desc = description ?? (result.truncated ? "truncated" : undefined);
69
+ const header = `[CONTEXT] **${result.label}** (${kind})${desc ? ": " + desc : ""}`;
70
+ return `${header}:\n${result.content}`;
71
+ }