kairos-cli 0.0.3

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,48 @@
1
+ # kairos-cli
2
+
3
+ Local CLI bridge for KairOS agent tools.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ npm install -g kairos-cli
9
+ ```
10
+
11
+ ## Login
12
+
13
+ ```bash
14
+ kairos login --api-url https://your-kairos-url.com
15
+ kairos whoami
16
+ ```
17
+
18
+ ## Example commands
19
+
20
+ ```bash
21
+ kairos tools
22
+ kairos call query_today '{}'
23
+ kairos call query_tasks '{"activeOnly":true}'
24
+ ```
25
+
26
+ ## Local development
27
+
28
+ From the monorepo root:
29
+
30
+ ```bash
31
+ pnpm cli:build
32
+ pnpm kairos --help
33
+ ```
34
+
35
+ ## Publish and release
36
+
37
+ `kairos-cli` is published by GitHub Actions (`.github/workflows/release-cli.yml`).
38
+
39
+ - Workflow permissions include `id-token: write` for npm trusted publishing support.
40
+ - Current publish step also supports token-based auth via `NPM_TOKEN`.
41
+ - A `403 Forbidden` during publish means the npm credentials lack publish rights for the package/account.
42
+
43
+ Before release troubleshooting, verify:
44
+
45
+ ```bash
46
+ npm whoami
47
+ pnpm --filter kairos-cli build
48
+ ```
@@ -0,0 +1,11 @@
1
+ export type DeviceTokenResponse = {
2
+ access_token: string;
3
+ token_type: "Bearer";
4
+ expires_in: number;
5
+ };
6
+ /** POST /api/cli/device/token — shared by loopback auth. */
7
+ export declare function exchangeDeviceTokenAtApi(apiBaseUrl: string, body: {
8
+ code: string;
9
+ code_verifier: string;
10
+ redirect_uri: string;
11
+ }): Promise<DeviceTokenResponse>;
@@ -0,0 +1,35 @@
1
+ function parseErrorBody(body) {
2
+ if (!body || typeof body !== "object")
3
+ return null;
4
+ const record = body;
5
+ if (typeof record.error !== "string" || typeof record.code !== "string") {
6
+ return null;
7
+ }
8
+ return { error: record.error, code: record.code };
9
+ }
10
+ function deviceTokenErrorMessageFromResponse(status, bodyText) {
11
+ try {
12
+ const parsed = parseErrorBody(JSON.parse(bodyText));
13
+ if (parsed)
14
+ return parsed.error;
15
+ }
16
+ catch {
17
+ if (bodyText)
18
+ return `Token exchange failed: ${bodyText}`;
19
+ }
20
+ return `Token exchange failed (HTTP ${status})`;
21
+ }
22
+ /** POST /api/cli/device/token — shared by loopback auth. */
23
+ export async function exchangeDeviceTokenAtApi(apiBaseUrl, body) {
24
+ const baseUrl = apiBaseUrl.replace(/\/$/, "");
25
+ const res = await fetch(`${baseUrl}/api/cli/device/token`, {
26
+ method: "POST",
27
+ headers: { "Content-Type": "application/json" },
28
+ body: JSON.stringify(body),
29
+ });
30
+ const text = await res.text();
31
+ if (!res.ok) {
32
+ throw new Error(deviceTokenErrorMessageFromResponse(res.status, text));
33
+ }
34
+ return JSON.parse(text);
35
+ }
@@ -0,0 +1,19 @@
1
+ /** Default wait for browser consent (matches server auth code TTL). */
2
+ export declare const DEFAULT_LOGIN_TIMEOUT_MS: number;
3
+ /** stderr heartbeat while waiting for loopback callback (documented in docs/cli-auth.md §7). */
4
+ export declare const LOGIN_HEARTBEAT_MS = 15000;
5
+ /** Minimum `kairos login --timeout` value (seconds). */
6
+ export declare const MIN_LOGIN_TIMEOUT_SEC = 30;
7
+ export type LoopbackAuthOptions = {
8
+ /** Skip `open` / `xdg-open` (for agents and headless terminals). */
9
+ noOpen?: boolean;
10
+ /** Max time to wait for loopback callback (default 5 minutes). */
11
+ timeoutMs?: number;
12
+ };
13
+ /**
14
+ * Runs PKCE loopback auth against the KairOS web app.
15
+ */
16
+ export declare function authenticateViaLoopback(apiUrl: string, options?: LoopbackAuthOptions): Promise<{
17
+ access_token: string;
18
+ expires_in: number;
19
+ }>;
@@ -0,0 +1,153 @@
1
+ import { spawn } from "node:child_process";
2
+ import http from "node:http";
3
+ import { createPkcePair } from "./pkce.js";
4
+ import { exchangeDeviceTokenAtApi } from "./device-token-client.js";
5
+ /** Default wait for browser consent (matches server auth code TTL). */
6
+ export const DEFAULT_LOGIN_TIMEOUT_MS = 5 * 60 * 1000;
7
+ /** stderr heartbeat while waiting for loopback callback (documented in docs/cli-auth.md §7). */
8
+ export const LOGIN_HEARTBEAT_MS = 15_000;
9
+ /** Minimum `kairos login --timeout` value (seconds). */
10
+ export const MIN_LOGIN_TIMEOUT_SEC = 30;
11
+ function openBrowser(url) {
12
+ const opts = { stdio: "ignore", detached: true };
13
+ let child;
14
+ if (process.platform === "darwin") {
15
+ child = spawn("open", [url], opts);
16
+ }
17
+ else if (process.platform === "win32") {
18
+ child = spawn("cmd", ["/c", "start", "", url], { ...opts, shell: false });
19
+ }
20
+ else {
21
+ child = spawn("xdg-open", [url], opts);
22
+ }
23
+ child.on("error", (err) => {
24
+ console.error("Failed to launch browser opener:", err);
25
+ });
26
+ child.unref();
27
+ }
28
+ const SUCCESS_HTML = `<!DOCTYPE html>
29
+ <html>
30
+ <body style="font-family:system-ui,sans-serif;display:flex;align-items:center;justify-content:center;height:100vh;margin:0;background:#0b0f19;color:#f3f4f6">
31
+ <div style="padding:2rem;border-radius:12px;background:#111827;border:1px solid #1f2937;text-align:center;max-width:400px">
32
+ <h1 style="color:#10b981;margin:0 0 1rem">Authenticated</h1>
33
+ <p style="margin:0;color:#9ca3af">You can close this tab and return to the terminal.</p>
34
+ </div>
35
+ </body>
36
+ </html>`;
37
+ function loopbackRedirectUri(port) {
38
+ return `http://127.0.0.1:${port}/callback`;
39
+ }
40
+ function listenOnLoopback(server) {
41
+ return new Promise((resolve, reject) => {
42
+ server.once("error", reject);
43
+ server.listen(0, "127.0.0.1", () => {
44
+ const addr = server.address();
45
+ if (!addr || typeof addr === "string") {
46
+ reject(new Error("Could not bind loopback server"));
47
+ return;
48
+ }
49
+ resolve(addr.port);
50
+ });
51
+ });
52
+ }
53
+ function closeLoopbackServer(server) {
54
+ return new Promise((resolve, reject) => {
55
+ server.close((err) => (err ? reject(err) : resolve()));
56
+ });
57
+ }
58
+ function waitForAuthorizationCode(server, timeoutMs) {
59
+ return new Promise((resolve, reject) => {
60
+ let settled = false;
61
+ const finish = (fn) => {
62
+ if (settled)
63
+ return;
64
+ settled = true;
65
+ clearInterval(heartbeat);
66
+ clearTimeout(timeout);
67
+ cleanup();
68
+ fn();
69
+ };
70
+ const onError = (err) => {
71
+ finish(() => reject(err));
72
+ };
73
+ const onRequest = (req, res) => {
74
+ let urlObj;
75
+ try {
76
+ urlObj = new URL(req.url ?? "/", "http://127.0.0.1");
77
+ }
78
+ catch {
79
+ res.writeHead(400, { Connection: "close" });
80
+ res.end();
81
+ return;
82
+ }
83
+ if (urlObj.pathname !== "/callback") {
84
+ res.writeHead(404, { Connection: "close" });
85
+ res.end();
86
+ return;
87
+ }
88
+ const code = urlObj.searchParams.get("code");
89
+ if (!code) {
90
+ res.writeHead(400, {
91
+ "Content-Type": "text/html",
92
+ Connection: "close",
93
+ });
94
+ res.end("<h1>Auth failed: missing code</h1>");
95
+ finish(() => reject(new Error("No authorization code received")));
96
+ return;
97
+ }
98
+ res.writeHead(200, {
99
+ "Content-Type": "text/html",
100
+ Connection: "close",
101
+ });
102
+ res.end(SUCCESS_HTML);
103
+ finish(() => resolve(code));
104
+ };
105
+ const cleanup = () => {
106
+ server.off("error", onError);
107
+ server.off("request", onRequest);
108
+ };
109
+ server.on("error", onError);
110
+ server.on("request", onRequest);
111
+ console.error("Waiting for browser authorization… Sign in if prompted, then click Authorize.");
112
+ console.error("Press Ctrl+C to cancel.");
113
+ const heartbeat = setInterval(() => {
114
+ console.error("Still waiting… Open the URL above and click Authorize when ready.");
115
+ }, LOGIN_HEARTBEAT_MS);
116
+ heartbeat.unref();
117
+ const timeout = setTimeout(() => {
118
+ finish(() => reject(new Error(`Login timed out after ${Math.round(timeoutMs / 1000)}s. Run kairos login again.`)));
119
+ }, timeoutMs);
120
+ timeout.unref();
121
+ });
122
+ }
123
+ /**
124
+ * Runs PKCE loopback auth against the KairOS web app.
125
+ */
126
+ export async function authenticateViaLoopback(apiUrl, options = {}) {
127
+ const { verifier, challenge } = createPkcePair();
128
+ const baseUrl = apiUrl.replace(/\/$/, "");
129
+ const timeoutMs = options.timeoutMs ?? DEFAULT_LOGIN_TIMEOUT_MS;
130
+ const server = http.createServer();
131
+ try {
132
+ const port = await listenOnLoopback(server);
133
+ const redirectUri = loopbackRedirectUri(port);
134
+ const loginUrl = `${baseUrl}/cli/device?code_challenge=${challenge}&redirect_uri=${encodeURIComponent(redirectUri)}`;
135
+ console.log(`\nOpen this URL to authenticate:\n${loginUrl}\n`);
136
+ if (options.noOpen) {
137
+ console.error("Browser not opened (--no-open). Copy the URL above into your browser.");
138
+ }
139
+ else {
140
+ openBrowser(loginUrl);
141
+ }
142
+ const code = await waitForAuthorizationCode(server, timeoutMs);
143
+ console.error("Authorization received, exchanging token…");
144
+ return await exchangeDeviceTokenAtApi(baseUrl, {
145
+ code,
146
+ code_verifier: verifier,
147
+ redirect_uri: redirectUri,
148
+ });
149
+ }
150
+ finally {
151
+ await closeLoopbackServer(server).catch(() => undefined);
152
+ }
153
+ }
@@ -0,0 +1,5 @@
1
+ /** PKCE verifier + S256 challenge for the CLI loopback flow. */
2
+ export declare function createPkcePair(): {
3
+ verifier: string;
4
+ challenge: string;
5
+ };
@@ -0,0 +1,9 @@
1
+ import crypto from "node:crypto";
2
+ function pkceS256Challenge(verifier) {
3
+ return crypto.createHash("sha256").update(verifier).digest("base64url");
4
+ }
5
+ /** PKCE verifier + S256 challenge for the CLI loopback flow. */
6
+ export function createPkcePair() {
7
+ const verifier = crypto.randomBytes(32).toString("base64url");
8
+ return { verifier, challenge: pkceS256Challenge(verifier) };
9
+ }
@@ -0,0 +1,11 @@
1
+ export type Credentials = {
2
+ api_url: string;
3
+ access_token: string;
4
+ expires_at: string;
5
+ };
6
+ export declare function getDefaultApiUrl(): string;
7
+ export declare function loadCredentials(): Credentials | null;
8
+ export declare function saveCredentials(creds: Credentials): void;
9
+ export declare function clearCredentials(): void;
10
+ export declare function getValidAccessToken(): string | null;
11
+ export declare function credentialsPath(): string;
@@ -0,0 +1,43 @@
1
+ import { mkdirSync, readFileSync, writeFileSync, unlinkSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { getCliDefaultApiUrl } from "../env.js";
5
+ const CONFIG_DIR = join(homedir(), ".config", "kairos");
6
+ const CREDENTIALS_PATH = join(CONFIG_DIR, "credentials.json");
7
+ export function getDefaultApiUrl() {
8
+ return getCliDefaultApiUrl();
9
+ }
10
+ export function loadCredentials() {
11
+ try {
12
+ const raw = readFileSync(CREDENTIALS_PATH, "utf8");
13
+ return JSON.parse(raw);
14
+ }
15
+ catch {
16
+ return null;
17
+ }
18
+ }
19
+ export function saveCredentials(creds) {
20
+ mkdirSync(CONFIG_DIR, { recursive: true });
21
+ writeFileSync(CREDENTIALS_PATH, JSON.stringify(creds, null, 2), {
22
+ mode: 0o600,
23
+ });
24
+ }
25
+ export function clearCredentials() {
26
+ try {
27
+ unlinkSync(CREDENTIALS_PATH);
28
+ }
29
+ catch {
30
+ // ignore
31
+ }
32
+ }
33
+ export function getValidAccessToken() {
34
+ const creds = loadCredentials();
35
+ if (!creds?.access_token)
36
+ return null;
37
+ if (new Date(creds.expires_at) <= new Date())
38
+ return null;
39
+ return creds.access_token;
40
+ }
41
+ export function credentialsPath() {
42
+ return CREDENTIALS_PATH;
43
+ }
package/dist/bin.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/bin.js ADDED
@@ -0,0 +1,156 @@
1
+ #!/usr/bin/env node
2
+ import { authenticateViaLoopback, DEFAULT_LOGIN_TIMEOUT_MS, MIN_LOGIN_TIMEOUT_SEC, } from "./auth/loopback.js";
3
+ import { clearCredentials, credentialsPath, getDefaultApiUrl, getValidAccessToken, loadCredentials, saveCredentials, } from "./auth/token-store.js";
4
+ import { KairosClient } from "./client.js";
5
+ function usage() {
6
+ console.log(`KairOS Agent CLI
7
+
8
+ Usage:
9
+ kairos login [--api-url <url>] [--no-open] [--timeout <seconds>]
10
+ kairos logout
11
+ kairos whoami
12
+ kairos tools
13
+ kairos call <toolName> '<json>'
14
+
15
+ Examples:
16
+ kairos login --no-open
17
+ kairos call query_calendar '{"from":"2026-05-18","to":"2026-05-25"}'
18
+ kairos call create_task '{"title":"Follow up"}'
19
+ kairos call query_tasks '{"activeOnly":true}'
20
+ kairos call create_lead '{"company":"Acme","role":"Staff Engineer"}'
21
+ kairos call query_leads '{"activeOnly":true}'
22
+
23
+ Environment:
24
+ KAIROS_API_URL API base URL (default: https://kairos.querobines.com)
25
+ `);
26
+ }
27
+ function takeFlagValue(args, index, flag) {
28
+ const value = args[index + 1];
29
+ if (!value || value.startsWith("-")) {
30
+ console.error(`Missing value for ${flag}`);
31
+ usage();
32
+ process.exit(1);
33
+ }
34
+ return value;
35
+ }
36
+ function parseLoginArgs(args) {
37
+ let apiUrl = getDefaultApiUrl();
38
+ let noOpen = false;
39
+ let timeoutSec = Math.round(DEFAULT_LOGIN_TIMEOUT_MS / 1000);
40
+ for (let i = 0; i < args.length; i++) {
41
+ const flag = args[i];
42
+ if (flag === "--no-open") {
43
+ noOpen = true;
44
+ continue;
45
+ }
46
+ if (flag === "--api-url") {
47
+ apiUrl = takeFlagValue(args, i, flag);
48
+ i++;
49
+ continue;
50
+ }
51
+ if (flag === "--timeout") {
52
+ const value = takeFlagValue(args, i, flag);
53
+ const parsed = Number.parseInt(value, 10);
54
+ if (!/^\d+$/.test(value) || parsed < MIN_LOGIN_TIMEOUT_SEC) {
55
+ console.error(`--timeout must be an integer of at least ${MIN_LOGIN_TIMEOUT_SEC} seconds`);
56
+ process.exit(1);
57
+ }
58
+ timeoutSec = parsed;
59
+ i++;
60
+ continue;
61
+ }
62
+ if (flag.startsWith("-")) {
63
+ console.error(`Unknown flag: ${flag}`);
64
+ usage();
65
+ process.exit(1);
66
+ }
67
+ console.error(`Unexpected argument: ${flag}`);
68
+ usage();
69
+ process.exit(1);
70
+ }
71
+ return { apiUrl, noOpen, timeoutMs: timeoutSec * 1000 };
72
+ }
73
+ async function cmdLogin(args) {
74
+ const { apiUrl, noOpen, timeoutMs } = parseLoginArgs(args);
75
+ const tokens = await authenticateViaLoopback(apiUrl, { noOpen, timeoutMs });
76
+ const expiresAt = new Date(Date.now() + tokens.expires_in * 1000).toISOString();
77
+ saveCredentials({
78
+ api_url: apiUrl,
79
+ access_token: tokens.access_token,
80
+ expires_at: expiresAt,
81
+ });
82
+ console.log(`Saved credentials to ${credentialsPath()}`);
83
+ }
84
+ async function cmdWhoami() {
85
+ const creds = loadCredentials();
86
+ if (!creds) {
87
+ console.log("Not logged in. Run: kairos login");
88
+ process.exit(1);
89
+ }
90
+ const valid = getValidAccessToken() !== null;
91
+ console.log(`API: ${creds.api_url}`);
92
+ console.log(`Expires: ${creds.expires_at ?? "unknown"}`);
93
+ console.log(`Token valid: ${valid ? "yes" : "no (run kairos login)"}`);
94
+ }
95
+ async function cmdCall(args) {
96
+ const toolName = args[0];
97
+ const jsonArg = args[1];
98
+ if (!toolName || !jsonArg) {
99
+ console.error("Usage: kairos call <toolName> '<json>'");
100
+ process.exit(1);
101
+ }
102
+ let parsed;
103
+ try {
104
+ parsed = JSON.parse(jsonArg);
105
+ }
106
+ catch {
107
+ console.error("Invalid JSON input");
108
+ process.exit(1);
109
+ }
110
+ const client = KairosClient.fromEnv();
111
+ const data = await client.callTool(toolName, parsed);
112
+ console.log(JSON.stringify(data, null, 2));
113
+ }
114
+ async function cmdTools() {
115
+ const client = KairosClient.fromEnv();
116
+ const tools = await client.listTools();
117
+ for (const t of tools) {
118
+ const flag = t.implemented ? "" : " (pending)";
119
+ console.log(`${t.name}${flag} — ${t.description}`);
120
+ }
121
+ }
122
+ async function main() {
123
+ const [, , command, ...rest] = process.argv;
124
+ switch (command) {
125
+ case "login":
126
+ await cmdLogin(rest);
127
+ break;
128
+ case "logout":
129
+ clearCredentials();
130
+ console.log("Logged out.");
131
+ break;
132
+ case "whoami":
133
+ await cmdWhoami();
134
+ break;
135
+ case "tools":
136
+ await cmdTools();
137
+ break;
138
+ case "call":
139
+ await cmdCall(rest);
140
+ break;
141
+ case undefined:
142
+ case "help":
143
+ case "--help":
144
+ case "-h":
145
+ usage();
146
+ break;
147
+ default:
148
+ console.error(`Unknown command: ${command}`);
149
+ usage();
150
+ process.exit(1);
151
+ }
152
+ }
153
+ main().catch((err) => {
154
+ console.error(err instanceof Error ? err.message : err);
155
+ process.exit(1);
156
+ });
@@ -0,0 +1,14 @@
1
+ export declare class KairosClient {
2
+ private readonly apiUrl;
3
+ constructor(apiUrl: string);
4
+ static fromEnv(): KairosClient;
5
+ private baseUrl;
6
+ private authHeaders;
7
+ private request;
8
+ callTool<T = unknown>(toolName: string, input: Record<string, unknown>): Promise<T>;
9
+ listTools(): Promise<Array<{
10
+ name: string;
11
+ description: string;
12
+ implemented: boolean;
13
+ }>>;
14
+ }
package/dist/client.js ADDED
@@ -0,0 +1,51 @@
1
+ import { getDefaultApiUrl, getValidAccessToken, loadCredentials, } from "./auth/token-store.js";
2
+ export class KairosClient {
3
+ apiUrl;
4
+ constructor(apiUrl) {
5
+ this.apiUrl = apiUrl;
6
+ }
7
+ static fromEnv() {
8
+ const creds = loadCredentials();
9
+ return new KairosClient(creds?.api_url ?? getDefaultApiUrl());
10
+ }
11
+ baseUrl() {
12
+ return this.apiUrl.replace(/\/$/, "");
13
+ }
14
+ async authHeaders() {
15
+ const token = getValidAccessToken();
16
+ if (!token) {
17
+ throw new Error("Not authenticated. Run: kairos login");
18
+ }
19
+ return {
20
+ Authorization: `Bearer ${token}`,
21
+ "Content-Type": "application/json",
22
+ };
23
+ }
24
+ async request(path, init) {
25
+ const res = await fetch(`${this.baseUrl()}${path}`, {
26
+ ...init,
27
+ headers: { ...(await this.authHeaders()), ...init?.headers },
28
+ });
29
+ const text = await res.text();
30
+ let body;
31
+ try {
32
+ body = JSON.parse(text);
33
+ }
34
+ catch {
35
+ throw new Error(`HTTP ${res.status}: ${text || res.statusText || "Empty response"}`);
36
+ }
37
+ if (!res.ok || !body.ok) {
38
+ throw new Error(body.error ?? `HTTP ${res.status}`);
39
+ }
40
+ return body.data;
41
+ }
42
+ async callTool(toolName, input) {
43
+ return this.request(`/api/ai/tools/${toolName}`, {
44
+ method: "POST",
45
+ body: JSON.stringify(input),
46
+ });
47
+ }
48
+ async listTools() {
49
+ return this.request("/api/ai/tools");
50
+ }
51
+ }
package/dist/env.d.ts ADDED
@@ -0,0 +1,4 @@
1
+ /** Returns a trimmed env value, or null when unset/blank. */
2
+ export declare function getOptionalEnv(name: string): string | null;
3
+ /** Default API base for CLI login and calls when not set explicitly. */
4
+ export declare function getCliDefaultApiUrl(): string;
package/dist/env.js ADDED
@@ -0,0 +1,9 @@
1
+ /** Returns a trimmed env value, or null when unset/blank. */
2
+ export function getOptionalEnv(name) {
3
+ const value = process.env[name]?.trim();
4
+ return value ? value : null;
5
+ }
6
+ /** Default API base for CLI login and calls when not set explicitly. */
7
+ export function getCliDefaultApiUrl() {
8
+ return getOptionalEnv("KAIROS_API_URL") ?? "https://kairos.querobines.com";
9
+ }
package/package.json ADDED
@@ -0,0 +1,24 @@
1
+ {
2
+ "name": "kairos-cli",
3
+ "version": "0.0.3",
4
+ "description": "KairOS Agent CLI — login and tool calls",
5
+ "type": "module",
6
+ "bin": {
7
+ "kairos": "./dist/bin.js"
8
+ },
9
+ "files": [
10
+ "dist"
11
+ ],
12
+ "engines": {
13
+ "node": ">=20"
14
+ },
15
+ "devDependencies": {
16
+ "@types/node": "^25.5.0",
17
+ "typescript": "^6.0.3"
18
+ },
19
+ "scripts": {
20
+ "build": "tsc",
21
+ "dev": "tsc --watch",
22
+ "start": "node dist/bin.js"
23
+ }
24
+ }