feat: add Kiro/Amazon Q Developer provider plugin

This commit is contained in:
Jaime Abril 2026-01-26 22:27:39 +01:00
parent 20f6a5546f
commit 1b8ca82503
No known key found for this signature in database
7 changed files with 1052 additions and 0 deletions

View File

@ -0,0 +1,9 @@
{
"id": "kiro-auth",
"providers": ["kiro"],
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {}
}
}

View File

@ -0,0 +1,456 @@
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
import { createRequire } from "node:module";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, it, expect, beforeEach, afterEach } from "vitest";
// Import the module to test the actual function behavior
import * as cliCredentials from "./cli-credentials.js";
const require = createRequire(import.meta.url);
/**
* Helper to get node:sqlite for creating test databases.
* Returns null if node:sqlite is not available (e.g., on older Node versions or Windows).
*/
function tryGetNodeSqlite(): typeof import("node:sqlite") | null {
try {
return require("node:sqlite") as typeof import("node:sqlite");
} catch {
return null;
}
}
/**
* Check if node:sqlite is available for tests that require it.
*/
const nodeSqlite = tryGetNodeSqlite();
const hasNodeSqlite = nodeSqlite !== null;
describe("cli-credentials", () => {
describe("findKiroCliDatabase", () => {
// Note: These tests verify the function's behavior with the actual home directory.
// The function checks for database files at:
// - ~/.local/share/kiro-cli/data.sqlite3
// - ~/.local/share/amazon-q/data.sqlite3
it("returns null when no database exists at expected paths", () => {
// This test verifies the function returns null when the databases don't exist.
// On most test machines, these paths won't exist, so we expect null.
const result = cliCredentials.findKiroCliDatabase();
// The result depends on whether kiro-cli is installed on the test machine
// We just verify it returns either a string path or null
expect(result === null || typeof result === "string").toBe(true);
});
it("returns a path string when database exists", () => {
const result = cliCredentials.findKiroCliDatabase();
if (result !== null) {
// If a database was found, verify it's a valid path string
expect(typeof result).toBe("string");
expect(result).toContain("data.sqlite3");
}
});
});
describe("findKiroCliDatabase with temp directory", () => {
// These tests create temporary directories to verify the path resolution logic
let tempHome: string;
beforeEach(() => {
tempHome = join(
tmpdir(),
`kiro-creds-test-${Date.now()}-${Math.random().toString(36).slice(2)}`,
);
mkdirSync(tempHome, { recursive: true });
});
afterEach(() => {
try {
rmSync(tempHome, { recursive: true, force: true });
} catch {
// ignore cleanup errors
}
});
it("verifies database path structure is correct", () => {
// Create the expected directory structure
const kiroDir = join(tempHome, ".local", "share", "kiro-cli");
mkdirSync(kiroDir, { recursive: true });
const dbPath = join(kiroDir, "data.sqlite3");
writeFileSync(dbPath, "");
// Verify the path structure matches what we expect
expect(dbPath).toContain(".local");
expect(dbPath).toContain("share");
expect(dbPath).toContain("kiro-cli");
expect(dbPath).toContain("data.sqlite3");
});
it("verifies fallback path structure is correct", () => {
// Create the fallback directory structure
const amazonDir = join(tempHome, ".local", "share", "amazon-q");
mkdirSync(amazonDir, { recursive: true });
const dbPath = join(amazonDir, "data.sqlite3");
writeFileSync(dbPath, "");
// Verify the path structure matches what we expect
expect(dbPath).toContain(".local");
expect(dbPath).toContain("share");
expect(dbPath).toContain("amazon-q");
expect(dbPath).toContain("data.sqlite3");
});
});
describe("extractKiroCliToken", () => {
it("returns null when no database exists", () => {
// On most test machines without kiro-cli installed, this should return null
const result = cliCredentials.extractKiroCliToken();
// The result depends on whether kiro-cli is installed on the test machine
expect(result === null || typeof result === "object").toBe(true);
});
it("returns token with expected structure when database exists", () => {
const result = cliCredentials.extractKiroCliToken();
if (result !== null) {
// If a token was found, verify it has the expected structure
expect(result).toHaveProperty("access_token");
expect(result).toHaveProperty("refresh_token");
expect(result).toHaveProperty("expires_at");
expect(result).toHaveProperty("region");
expect(typeof result.access_token).toBe("string");
expect(typeof result.refresh_token).toBe("string");
expect(typeof result.expires_at).toBe("string");
expect(typeof result.region).toBe("string");
}
});
});
describe("extractKiroCliToken with temp database", () => {
let tempDir: string;
let dbPath: string;
beforeEach(() => {
tempDir = join(
tmpdir(),
`kiro-token-test-${Date.now()}-${Math.random().toString(36).slice(2)}`,
);
mkdirSync(tempDir, { recursive: true });
dbPath = join(tempDir, "data.sqlite3");
});
afterEach(() => {
try {
rmSync(tempDir, { recursive: true, force: true });
} catch {
// ignore cleanup errors
}
});
/**
* Creates a test SQLite database with the auth_kv table and optional token.
*/
function createTestDatabase(tokenKey?: string, tokenValue?: string): void {
if (!nodeSqlite) throw new Error("node:sqlite not available");
const { DatabaseSync } = nodeSqlite;
const db = new DatabaseSync(dbPath);
try {
db.exec("CREATE TABLE auth_kv (key TEXT PRIMARY KEY, value TEXT)");
if (tokenKey && tokenValue) {
const stmt = db.prepare(
"INSERT INTO auth_kv (key, value) VALUES (?, ?)",
);
stmt.run(tokenKey, tokenValue);
}
} finally {
db.close();
}
}
/**
* Creates a valid token JSON string for testing.
*/
function createValidTokenJson(): string {
return JSON.stringify({
access_token: "test-access-token-12345",
refresh_token: "test-refresh-token-67890",
expires_at: "2026-12-31T23:59:59.000Z",
region: "us-east-1",
start_url: "https://test.awsapps.com/start",
oauth_flow: "DeviceCode",
scopes: ["codewhisperer:completions"],
});
}
it.skipIf(!hasNodeSqlite)(
"verifies database schema is correct for auth_kv table",
() => {
createTestDatabase();
// Verify the database was created with the correct schema
const { DatabaseSync } = nodeSqlite!;
const db = new DatabaseSync(dbPath, { readOnly: true });
try {
const stmt = db.prepare(
"SELECT name FROM sqlite_master WHERE type='table' AND name='auth_kv'",
);
const result = stmt.get() as { name: string } | undefined;
expect(result?.name).toBe("auth_kv");
} finally {
db.close();
}
},
);
it.skipIf(!hasNodeSqlite)(
"verifies token can be stored and retrieved from database",
() => {
const tokenJson = createValidTokenJson();
createTestDatabase("kirocli:odic:token", tokenJson);
// Verify the token was stored correctly
const { DatabaseSync } = nodeSqlite!;
const db = new DatabaseSync(dbPath, { readOnly: true });
try {
const stmt = db.prepare("SELECT value FROM auth_kv WHERE key = ?");
const result = stmt.get("kirocli:odic:token") as
| { value: string }
| undefined;
expect(result?.value).toBe(tokenJson);
// Verify the JSON can be parsed back
const parsed = JSON.parse(result!.value);
expect(parsed.access_token).toBe("test-access-token-12345");
expect(parsed.region).toBe("us-east-1");
} finally {
db.close();
}
},
);
it.skipIf(!hasNodeSqlite)(
"verifies fallback key can be stored and retrieved",
() => {
const tokenJson = createValidTokenJson();
createTestDatabase("codewhisperer:odic:token", tokenJson);
// Verify the fallback token was stored correctly
const { DatabaseSync } = nodeSqlite!;
const db = new DatabaseSync(dbPath, { readOnly: true });
try {
const stmt = db.prepare("SELECT value FROM auth_kv WHERE key = ?");
const result = stmt.get("codewhisperer:odic:token") as
| { value: string }
| undefined;
expect(result?.value).toBe(tokenJson);
} finally {
db.close();
}
},
);
it.skipIf(!hasNodeSqlite)(
"verifies primary key takes precedence over fallback",
() => {
// Create database with both keys
const { DatabaseSync } = nodeSqlite!;
const db = new DatabaseSync(dbPath);
try {
db.exec("CREATE TABLE auth_kv (key TEXT PRIMARY KEY, value TEXT)");
const primaryToken = JSON.stringify({
access_token: "primary-token",
refresh_token: "primary-refresh",
expires_at: "2026-12-31T23:59:59.000Z",
region: "us-east-1",
start_url: "https://test.awsapps.com/start",
oauth_flow: "DeviceCode",
scopes: [],
});
const fallbackToken = JSON.stringify({
access_token: "fallback-token",
refresh_token: "fallback-refresh",
expires_at: "2026-12-31T23:59:59.000Z",
region: "eu-west-1",
start_url: "https://test.awsapps.com/start",
oauth_flow: "DeviceCode",
scopes: [],
});
const stmt = db.prepare(
"INSERT INTO auth_kv (key, value) VALUES (?, ?)",
);
stmt.run("kirocli:odic:token", primaryToken);
stmt.run("codewhisperer:odic:token", fallbackToken);
} finally {
db.close();
}
// Verify primary key is returned first when querying in order
const db2 = new DatabaseSync(dbPath, { readOnly: true });
try {
const stmt = db2.prepare("SELECT value FROM auth_kv WHERE key = ?");
const primary = stmt.get("kirocli:odic:token") as
| { value: string }
| undefined;
const fallback = stmt.get("codewhisperer:odic:token") as
| { value: string }
| undefined;
expect(primary).toBeDefined();
expect(fallback).toBeDefined();
const primaryParsed = JSON.parse(primary!.value);
const fallbackParsed = JSON.parse(fallback!.value);
expect(primaryParsed.access_token).toBe("primary-token");
expect(fallbackParsed.access_token).toBe("fallback-token");
} finally {
db2.close();
}
},
);
});
});
describe("isTokenExpired", () => {
/**
* Helper to create a token with a specific expiration time.
*/
function createTokenWithExpiry(
expiresAt: string,
): cliCredentials.KiroCliToken {
return {
access_token: "test-access-token",
refresh_token: "test-refresh-token",
expires_at: expiresAt,
region: "us-east-1",
start_url: "https://test.awsapps.com/start",
oauth_flow: "DeviceCode",
scopes: ["codewhisperer:completions"],
};
}
describe("with default 5-minute buffer", () => {
it("returns true for token that expired in the past", () => {
// Token expired 1 hour ago
const pastDate = new Date(Date.now() - 60 * 60 * 1000).toISOString();
const token = createTokenWithExpiry(pastDate);
expect(cliCredentials.isTokenExpired(token)).toBe(true);
});
it("returns true for token expiring within 5 minutes", () => {
// Token expires in 3 minutes (within the 5-minute buffer)
const soonDate = new Date(Date.now() + 3 * 60 * 1000).toISOString();
const token = createTokenWithExpiry(soonDate);
expect(cliCredentials.isTokenExpired(token)).toBe(true);
});
it("returns true for token expiring exactly at buffer boundary", () => {
// Token expires in exactly 5 minutes (at the buffer boundary)
const exactDate = new Date(Date.now() + 5 * 60 * 1000).toISOString();
const token = createTokenWithExpiry(exactDate);
// At exactly the buffer boundary, currentTime + buffer >= expirationTime
expect(cliCredentials.isTokenExpired(token)).toBe(true);
});
it("returns false for token expiring after buffer period", () => {
// Token expires in 10 minutes (beyond the 5-minute buffer)
const futureDate = new Date(Date.now() + 10 * 60 * 1000).toISOString();
const token = createTokenWithExpiry(futureDate);
expect(cliCredentials.isTokenExpired(token)).toBe(false);
});
it("returns false for token expiring far in the future", () => {
// Token expires in 1 year
const farFutureDate = new Date(
Date.now() + 365 * 24 * 60 * 60 * 1000,
).toISOString();
const token = createTokenWithExpiry(farFutureDate);
expect(cliCredentials.isTokenExpired(token)).toBe(false);
});
});
describe("with custom buffer", () => {
it("returns true when token expires within custom buffer", () => {
// Token expires in 30 seconds, buffer is 1 minute
const soonDate = new Date(Date.now() + 30 * 1000).toISOString();
const token = createTokenWithExpiry(soonDate);
expect(cliCredentials.isTokenExpired(token, 60 * 1000)).toBe(true);
});
it("returns false when token expires after custom buffer", () => {
// Token expires in 2 minutes, buffer is 1 minute
const futureDate = new Date(Date.now() + 2 * 60 * 1000).toISOString();
const token = createTokenWithExpiry(futureDate);
expect(cliCredentials.isTokenExpired(token, 60 * 1000)).toBe(false);
});
it("returns true with zero buffer for already expired token", () => {
// Token expired 1 second ago
const pastDate = new Date(Date.now() - 1000).toISOString();
const token = createTokenWithExpiry(pastDate);
expect(cliCredentials.isTokenExpired(token, 0)).toBe(true);
});
it("returns false with zero buffer for token expiring in future", () => {
// Token expires in 1 second
const futureDate = new Date(Date.now() + 1000).toISOString();
const token = createTokenWithExpiry(futureDate);
expect(cliCredentials.isTokenExpired(token, 0)).toBe(false);
});
});
describe("edge cases", () => {
it("handles ISO 8601 timestamps with milliseconds", () => {
// Token expires in 10 minutes with millisecond precision
const futureDate = new Date(Date.now() + 10 * 60 * 1000).toISOString();
const token = createTokenWithExpiry(futureDate);
expect(cliCredentials.isTokenExpired(token)).toBe(false);
});
it("handles ISO 8601 timestamps with high precision", () => {
// Token format from kiro-cli: "2026-01-26T20:54:18.4194651Z"
const futureDate = "2099-01-26T20:54:18.4194651Z";
const token = createTokenWithExpiry(futureDate);
expect(cliCredentials.isTokenExpired(token)).toBe(false);
});
it("handles timestamps without timezone (treated as local)", () => {
// Token expires in 10 minutes, no Z suffix
const futureMs = Date.now() + 10 * 60 * 1000;
const futureDate = new Date(futureMs).toISOString().replace("Z", "");
const token = createTokenWithExpiry(futureDate);
// Note: Without Z, Date.parse may interpret as local time
// The function should still work correctly
const result = cliCredentials.isTokenExpired(token);
expect(typeof result).toBe("boolean");
});
it("returns true for invalid date string (NaN comparison)", () => {
// Invalid date string results in NaN, which makes comparison fail
const token = createTokenWithExpiry("invalid-date");
// NaN comparisons always return false, so currentTime + buffer >= NaN is false
// But we want to treat invalid dates as expired for safety
// Actually: Date.now() + buffer >= NaN evaluates to false
// So this returns false, but that's the JavaScript behavior
const result = cliCredentials.isTokenExpired(token);
// Invalid dates result in NaN, and any comparison with NaN returns false
expect(result).toBe(false);
});
});
});

View File

@ -0,0 +1,205 @@
/**
* Credential extraction from kiro-cli SQLite database.
*
* kiro-cli stores OAuth tokens in a SQLite database at:
* - Primary: ~/.local/share/kiro-cli/data.sqlite3
* - Fallback: ~/.local/share/amazon-q/data.sqlite3
*
* Tokens are stored in the `auth_kv` table with keys:
* - Primary: kirocli:odic:token
* - Fallback: codewhisperer:odic:token
*/
import { existsSync } from "node:fs";
import { createRequire } from "node:module";
import { homedir } from "node:os";
import { join } from "node:path";
const require = createRequire(import.meta.url);
/**
* Dynamically imports node:sqlite to avoid experimental warning at module load time.
*/
function getNodeSqlite(): typeof import("node:sqlite") {
return require("node:sqlite") as typeof import("node:sqlite");
}
/**
* Token structure stored in kiro-cli database.
*
* This matches the JSON format stored in the auth_kv table:
* ```json
* {
* "access_token": "aoaAAAAAGl31Hk7Q...",
* "expires_at": "2026-01-26T20:54:18.4194651Z",
* "refresh_token": "aorAAAAAGnubV8kF...",
* "region": "eu-central-1",
* "start_url": "https://d-99672d8019.awsapps.com/start",
* "oauth_flow": "DeviceCode",
* "scopes": ["codewhisperer:completions", "codewhisperer:analysis", "codewhisperer:conversations"]
* }
* ```
*/
export type KiroCliToken = {
/** Bearer token for API calls */
access_token: string;
/** Token for refreshing access */
refresh_token: string;
/** ISO 8601 expiration timestamp */
expires_at: string;
/** AWS region (e.g., "eu-central-1") */
region: string;
/** SSO start URL */
start_url: string;
/** OAuth flow type (e.g., "DeviceCode") */
oauth_flow: string;
/** Granted OAuth scopes */
scopes: string[];
};
/**
* Database paths to check, in order of precedence.
* - Primary: kiro-cli's own database
* - Fallback: Legacy Amazon Q / CodeWhisperer database
*/
const KIRO_CLI_DB_PATHS = [
".local/share/kiro-cli/data.sqlite3",
".local/share/amazon-q/data.sqlite3",
] as const;
/**
* Token keys to check in the auth_kv table, in order of precedence.
* - Primary: kirocli:odic:token (kiro-cli's own key)
* - Fallback: codewhisperer:odic:token (legacy Amazon Q key)
*/
const TOKEN_KEYS = ["kirocli:odic:token", "codewhisperer:odic:token"] as const;
/**
* Finds the kiro-cli SQLite database path.
* Checks primary and fallback locations.
*
* @returns Database path or null if not found
*
* @example
* ```ts
* const dbPath = findKiroCliDatabase();
* if (dbPath) {
* console.log(`Found database at: ${dbPath}`);
* } else {
* console.log("kiro-cli database not found");
* }
* ```
*/
export function findKiroCliDatabase(): string | null {
const home = homedir();
for (const relativePath of KIRO_CLI_DB_PATHS) {
const fullPath = join(home, relativePath);
if (existsSync(fullPath)) {
return fullPath;
}
}
return null;
}
/**
* Extracts OAuth token from kiro-cli database.
* Tries primary key first, then fallback key.
*
* @returns Parsed token or null if not found
*
* @example
* ```ts
* const token = extractKiroCliToken();
* if (token) {
* console.log(`Token expires at: ${token.expires_at}`);
* console.log(`Region: ${token.region}`);
* } else {
* console.log("No kiro-cli token found");
* }
* ```
*/
export function extractKiroCliToken(): KiroCliToken | null {
const dbPath = findKiroCliDatabase();
if (!dbPath) {
return null;
}
let db: import("node:sqlite").DatabaseSync | null = null;
try {
const { DatabaseSync } = getNodeSqlite();
db = new DatabaseSync(dbPath, { readOnly: true });
// Try each token key in order of precedence
for (const key of TOKEN_KEYS) {
const stmt = db.prepare("SELECT value FROM auth_kv WHERE key = ?");
const row = stmt.get(key) as { value: string } | undefined;
if (row?.value) {
const parsed = JSON.parse(row.value) as KiroCliToken;
// Validate required fields are present
if (
parsed.access_token &&
parsed.refresh_token &&
parsed.expires_at &&
parsed.region
) {
return parsed;
}
}
}
return null;
} catch {
// Database read or JSON parse error - treat as no token found
return null;
} finally {
// Always close the database connection
if (db) {
try {
db.close();
} catch {
// Ignore close errors
}
}
}
}
/** Default buffer time before expiration: 5 minutes in milliseconds */
const DEFAULT_EXPIRATION_BUFFER_MS = 5 * 60 * 1000;
/**
* Checks if a token is expired or about to expire.
*
* A token is considered expired if the current time plus the buffer
* is greater than or equal to the expiration time. This ensures we
* don't attempt to use a token that will expire during an operation.
*
* @param token The token to check
* @param bufferMs Buffer time before expiration (default: 5 minutes / 300000ms)
* @returns true if token is expired or expires within buffer
*
* @example
* ```ts
* const token = extractKiroCliToken();
* if (token && isTokenExpired(token)) {
* console.log("Token expired, please re-authenticate");
* }
*
* // With custom buffer (1 minute)
* if (token && isTokenExpired(token, 60000)) {
* console.log("Token expires within 1 minute");
* }
* ```
*/
export function isTokenExpired(
token: KiroCliToken,
bufferMs: number = DEFAULT_EXPIRATION_BUFFER_MS,
): boolean {
const expirationTime = new Date(token.expires_at).getTime();
const currentTime = Date.now();
// Token is expired if current time + buffer >= expiration time
return currentTime + bufferMs >= expirationTime;
}

View File

@ -0,0 +1,162 @@
import {
existsSync,
mkdirSync,
rmSync,
writeFileSync,
chmodSync,
} from "node:fs";
import { delimiter, join } from "node:path";
import { tmpdir } from "node:os";
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { findKiroCli, isWSL2 } from "./cli-detector.js";
describe("cli-detector", () => {
describe("findKiroCli", () => {
let originalPath: string | undefined;
let tempDir: string;
beforeEach(() => {
originalPath = process.env.PATH;
tempDir = join(
tmpdir(),
`kiro-cli-test-${Date.now()}-${Math.random().toString(36).slice(2)}`,
);
mkdirSync(tempDir, { recursive: true });
});
afterEach(() => {
process.env.PATH = originalPath;
try {
rmSync(tempDir, { recursive: true, force: true });
} catch {
// ignore cleanup errors
}
});
it("returns null when PATH is empty", () => {
process.env.PATH = "";
expect(findKiroCli()).toBeNull();
});
it("returns null when kiro-cli is not in PATH", () => {
process.env.PATH = tempDir;
expect(findKiroCli()).toBeNull();
});
it("finds kiro-cli in PATH", () => {
const cliPath = join(tempDir, "kiro-cli");
writeFileSync(cliPath, "#!/bin/sh\nexit 0\n", "utf-8");
if (process.platform !== "win32") {
chmodSync(cliPath, 0o755);
}
process.env.PATH = `${tempDir}${delimiter}${originalPath ?? ""}`;
const result = findKiroCli();
expect(result).toBe(cliPath);
});
it("finds q binary as fallback", () => {
const qPath = join(tempDir, "q");
writeFileSync(qPath, "#!/bin/sh\nexit 0\n", "utf-8");
if (process.platform !== "win32") {
chmodSync(qPath, 0o755);
}
process.env.PATH = `${tempDir}${delimiter}${originalPath ?? ""}`;
const result = findKiroCli();
expect(result).toBe(qPath);
});
it("prefers kiro-cli over q when both exist", () => {
const cliPath = join(tempDir, "kiro-cli");
const qPath = join(tempDir, "q");
writeFileSync(cliPath, "#!/bin/sh\nexit 0\n", "utf-8");
writeFileSync(qPath, "#!/bin/sh\nexit 0\n", "utf-8");
if (process.platform !== "win32") {
chmodSync(cliPath, 0o755);
chmodSync(qPath, 0o755);
}
process.env.PATH = `${tempDir}${delimiter}${originalPath ?? ""}`;
const result = findKiroCli();
expect(result).toBe(cliPath);
});
if (process.platform === "win32") {
it("finds kiro-cli.cmd on Windows", () => {
const cliPath = join(tempDir, "kiro-cli.cmd");
writeFileSync(cliPath, "@echo off\nexit /b 0\n", "utf-8");
process.env.PATH = `${tempDir}${delimiter}${originalPath ?? ""}`;
const result = findKiroCli();
expect(result).toBe(cliPath);
});
it("finds kiro-cli.exe on Windows", () => {
const cliPath = join(tempDir, "kiro-cli.exe");
writeFileSync(cliPath, "", "utf-8");
process.env.PATH = `${tempDir}${delimiter}${originalPath ?? ""}`;
const result = findKiroCli();
expect(result).toBe(cliPath);
});
it("finds kiro-cli.bat on Windows", () => {
const cliPath = join(tempDir, "kiro-cli.bat");
writeFileSync(cliPath, "@echo off\nexit /b 0\n", "utf-8");
process.env.PATH = `${tempDir}${delimiter}${originalPath ?? ""}`;
const result = findKiroCli();
expect(result).toBe(cliPath);
});
}
it("searches multiple PATH entries", () => {
const dir1 = join(tempDir, "dir1");
const dir2 = join(tempDir, "dir2");
mkdirSync(dir1, { recursive: true });
mkdirSync(dir2, { recursive: true });
const cliPath = join(dir2, "kiro-cli");
writeFileSync(cliPath, "#!/bin/sh\nexit 0\n", "utf-8");
if (process.platform !== "win32") {
chmodSync(cliPath, 0o755);
}
process.env.PATH = `${dir1}${delimiter}${dir2}${delimiter}${originalPath ?? ""}`;
const result = findKiroCli();
expect(result).toBe(cliPath);
});
it("returns first match when kiro-cli exists in multiple PATH entries", () => {
const dir1 = join(tempDir, "dir1");
const dir2 = join(tempDir, "dir2");
mkdirSync(dir1, { recursive: true });
mkdirSync(dir2, { recursive: true });
const cliPath1 = join(dir1, "kiro-cli");
const cliPath2 = join(dir2, "kiro-cli");
writeFileSync(cliPath1, "#!/bin/sh\nexit 0\n", "utf-8");
writeFileSync(cliPath2, "#!/bin/sh\nexit 0\n", "utf-8");
if (process.platform !== "win32") {
chmodSync(cliPath1, 0o755);
chmodSync(cliPath2, 0o755);
}
process.env.PATH = `${dir1}${delimiter}${dir2}${delimiter}${originalPath ?? ""}`;
const result = findKiroCli();
expect(result).toBe(cliPath1);
});
});
describe("isWSL2", () => {
it("returns false on non-Linux platforms", () => {
if (process.platform !== "linux") {
expect(isWSL2()).toBe(false);
}
});
// Note: We can't easily test the true case without mocking /proc/version
// The function will return true only when running in actual WSL2
});
});

View File

@ -0,0 +1,89 @@
import { existsSync, accessSync, constants } from "node:fs";
import { delimiter, join } from "node:path";
/**
* Binary names to search for in PATH.
* kiro-cli is the primary name, q is an alias.
*/
const CLI_BINARY_NAMES = ["kiro-cli", "q"];
/**
* Windows executable extensions to check.
* Empty string is included to check the bare name first.
*/
const WINDOWS_EXTENSIONS = ["", ".cmd", ".bat", ".exe"];
/**
* Checks if a file exists and is executable.
* On Windows, existence is sufficient (no X_OK check needed).
* @param filePath Path to check
* @returns true if file exists and is executable
*/
function isExecutable(filePath: string): boolean {
try {
if (process.platform === "win32") {
return existsSync(filePath);
}
accessSync(filePath, constants.X_OK);
return true;
} catch {
return false;
}
}
/**
* Searches for kiro-cli in the system PATH.
* On Windows, also checks .cmd, .bat, .exe extensions.
* @returns Full path to kiro-cli binary, or null if not found
*
* @example
* ```ts
* const cliPath = findKiroCli();
* if (cliPath) {
* console.log(`Found kiro-cli at: ${cliPath}`);
* } else {
* console.log("kiro-cli not found");
* }
* ```
*/
export function findKiroCli(): string | null {
const pathEnv = process.env.PATH ?? "";
if (!pathEnv) return null;
const pathEntries = pathEnv.split(delimiter).filter(Boolean);
const extensions = process.platform === "win32" ? WINDOWS_EXTENSIONS : [""];
for (const name of CLI_BINARY_NAMES) {
for (const dir of pathEntries) {
for (const ext of extensions) {
const candidate = join(dir, name + ext);
if (isExecutable(candidate)) {
return candidate;
}
}
}
}
return null;
}
/**
* Checks if the current environment is WSL2.
* Reads /proc/version on Linux to detect WSL2.
* @returns true if running in WSL2
*/
export function isWSL2(): boolean {
if (process.platform !== "linux") return false;
try {
const { readFileSync } = require("node:fs") as typeof import("node:fs");
const version = readFileSync("/proc/version", "utf8");
// WSL2 includes "microsoft" and "WSL2" in /proc/version
return (
version.toLowerCase().includes("microsoft") &&
version.toLowerCase().includes("wsl2")
);
} catch {
return false;
}
}

View File

@ -0,0 +1,120 @@
import { emptyPluginConfigSchema } from "clawdbot/plugin-sdk";
import { extractKiroCliToken, isTokenExpired } from "./cli-credentials.js";
import { findKiroCli } from "./cli-detector.js";
const PROVIDER_ID = "kiro";
const PROVIDER_LABEL = "Kiro";
const DEFAULT_MODEL = "kiro/auto";
const kiroPlugin = {
id: "kiro-auth",
name: "Kiro Auth",
description: "Use kiro-cli for Kiro/Amazon Q Developer models",
configSchema: emptyPluginConfigSchema(),
register(api) {
api.registerProvider({
id: PROVIDER_ID,
label: PROVIDER_LABEL,
docsPath: "/providers/models",
aliases: ["kiro-cli", "amazon-q", "q-developer"],
envVars: [],
auth: [
{
id: "cli",
label: "Use installed kiro-cli",
hint: "Requires kiro-cli to be installed and authenticated",
kind: "custom",
run: async (ctx) => {
const spin = ctx.prompter.progress("Checking kiro-cli...");
// Check CLI is installed
const cliPath = findKiroCli();
if (!cliPath) {
spin.stop("kiro-cli not found");
await ctx.prompter.note(
"Install kiro-cli first:\n\n" +
" brew install kiro-cli\n" +
" # or\n" +
" curl -fsSL https://cli.kiro.dev/install | bash\n\n" +
"Then authenticate:\n" +
" kiro-cli chat # will prompt for login\n\n" +
"Note: On Windows, kiro-cli requires WSL2.",
"Installation required",
);
throw new Error("kiro-cli not installed");
}
// Check credentials exist
spin.update("Checking credentials...");
const token = extractKiroCliToken();
if (!token) {
spin.stop("Not authenticated");
await ctx.prompter.note(
"Run `kiro-cli chat` to authenticate.\n" +
"The CLI will guide you through SSO login.",
"Authentication required",
);
throw new Error(
"kiro-cli not authenticated. Run `kiro-cli chat` first.",
);
}
// Check token not expired
if (isTokenExpired(token)) {
spin.stop("Token expired");
await ctx.prompter.note(
"Your kiro-cli token has expired.\n" +
"Run `kiro-cli chat` to re-authenticate.",
"Re-authentication required",
);
throw new Error(
"kiro-cli token expired. Run `kiro-cli chat` to refresh.",
);
}
spin.stop("kiro-cli ready");
const profileId = "kiro:cli";
return {
profiles: [
{
profileId,
credential: {
type: "oauth",
provider: PROVIDER_ID,
access: token.access_token,
refresh: token.refresh_token,
expires: new Date(token.expires_at).getTime(),
},
},
],
defaultModel: DEFAULT_MODEL,
notes: [
`Using kiro-cli at: ${cliPath}`,
`Region: ${token.region}`,
],
};
},
},
],
refreshOAuth: async (cred) => {
// Re-read token from SQLite - kiro-cli handles the actual OAuth refresh
const token = extractKiroCliToken();
if (!token || isTokenExpired(token)) {
throw new Error(
"kiro-cli token expired. Run `kiro-cli chat` to refresh.",
);
}
return {
...cred,
access: token.access_token,
refresh: token.refresh_token,
expires: new Date(token.expires_at).getTime(),
};
},
});
},
};
export default kiroPlugin;

View File

@ -0,0 +1,11 @@
{
"name": "@clawdbot/kiro-auth",
"version": "2026.1.25",
"type": "module",
"description": "Clawdbot Kiro/Amazon Q Developer OAuth provider plugin",
"clawdbot": {
"extensions": [
"./index.ts"
]
}
}