@kibhq/cli 0.1.0

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.
@@ -0,0 +1,37 @@
1
+ import chalk from "chalk";
2
+
3
+ const PREFIX = chalk.bold.cyan("kib");
4
+
5
+ export function info(msg: string) {
6
+ console.log(` ${chalk.green("+")} ${msg}`);
7
+ }
8
+
9
+ export function success(msg: string) {
10
+ console.log(` ${chalk.green("✓")} ${msg}`);
11
+ }
12
+
13
+ export function warn(msg: string) {
14
+ console.log(` ${chalk.yellow("⚠")} ${msg}`);
15
+ }
16
+
17
+ export function error(msg: string) {
18
+ console.error(` ${chalk.red("✗")} ${msg}`);
19
+ }
20
+
21
+ export function header(msg: string) {
22
+ console.log();
23
+ console.log(` ${chalk.bold("◆")} ${PREFIX} ${chalk.dim("—")} ${msg}`);
24
+ console.log();
25
+ }
26
+
27
+ export function dim(msg: string) {
28
+ console.log(` ${chalk.dim(msg)}`);
29
+ }
30
+
31
+ export function blank() {
32
+ console.log();
33
+ }
34
+
35
+ export function keyValue(key: string, value: string) {
36
+ console.log(` ${chalk.dim(key.padEnd(14))} ${value}`);
37
+ }
@@ -0,0 +1,163 @@
1
+ import * as readline from "node:readline";
2
+ import chalk from "chalk";
3
+
4
+ const isTTY = process.stdin.isTTY;
5
+
6
+ /**
7
+ * Prompt user to select from a list of options.
8
+ * Returns the index of the selected option.
9
+ */
10
+ export async function select(
11
+ message: string,
12
+ options: { label: string; hint?: string }[],
13
+ ): Promise<number> {
14
+ // Fallback for non-interactive (piped stdin)
15
+ if (!isTTY) {
16
+ return selectFallback(message, options);
17
+ }
18
+
19
+ let selected = 0;
20
+
21
+ // Hide cursor
22
+ process.stdout.write("\x1B[?25l");
23
+
24
+ const render = () => {
25
+ // Move cursor up to overwrite previous render
26
+ const lines = options.length + 1;
27
+ process.stdout.write(`\x1B[${lines}A`);
28
+ printOptions(message, options, selected);
29
+ };
30
+
31
+ // Initial render
32
+ printOptions(message, options, selected);
33
+
34
+ return new Promise<number>((resolve) => {
35
+ process.stdin.setRawMode(true);
36
+ process.stdin.resume();
37
+
38
+ const onKeypress = (data: Buffer) => {
39
+ const key = data.toString();
40
+
41
+ if (key === "\x1B[A" || key === "k") {
42
+ selected = (selected - 1 + options.length) % options.length;
43
+ render();
44
+ } else if (key === "\x1B[B" || key === "j") {
45
+ selected = (selected + 1) % options.length;
46
+ render();
47
+ } else if (key === "\r" || key === "\n") {
48
+ process.stdin.setRawMode(false);
49
+ process.stdin.removeListener("data", onKeypress);
50
+ process.stdin.pause();
51
+ process.stdout.write("\x1B[?25h");
52
+ resolve(selected);
53
+ } else if (key === "\x03") {
54
+ process.stdout.write("\x1B[?25h");
55
+ process.exit(0);
56
+ }
57
+ };
58
+
59
+ process.stdin.on("data", onKeypress);
60
+ });
61
+ }
62
+
63
+ function printOptions(
64
+ message: string,
65
+ options: { label: string; hint?: string }[],
66
+ selected: number,
67
+ ) {
68
+ console.log(` ${chalk.bold("◆")} ${message}`);
69
+ for (let i = 0; i < options.length; i++) {
70
+ const opt = options[i];
71
+ const cursor = i === selected ? chalk.cyan("●") : chalk.dim("○");
72
+ const label = i === selected ? chalk.cyan(opt.label) : opt.label;
73
+ const hint = opt.hint ? chalk.dim(` — ${opt.hint}`) : "";
74
+ console.log(` ${cursor} ${label}${hint}`);
75
+ }
76
+ }
77
+
78
+ /** Simple numbered fallback for non-TTY */
79
+ function selectFallback(
80
+ message: string,
81
+ options: { label: string; hint?: string }[],
82
+ ): Promise<number> {
83
+ const rl = readline.createInterface({
84
+ input: process.stdin,
85
+ output: process.stdout,
86
+ });
87
+
88
+ console.log(` ${chalk.bold("◆")} ${message}`);
89
+ for (let i = 0; i < options.length; i++) {
90
+ const opt = options[i];
91
+ const hint = opt.hint ? chalk.dim(` — ${opt.hint}`) : "";
92
+ console.log(` ${chalk.cyan(`${i + 1})`)} ${opt.label}${hint}`);
93
+ }
94
+
95
+ return new Promise<number>((resolve) => {
96
+ rl.question(` ${chalk.bold("◆")} Enter number [1]: `, (answer) => {
97
+ rl.close();
98
+ const num = Number.parseInt(answer.trim() || "1", 10);
99
+ resolve(Math.max(0, Math.min(num - 1, options.length - 1)));
100
+ });
101
+ });
102
+ }
103
+
104
+ /**
105
+ * Prompt user for text input. Input is masked if `mask` is true.
106
+ */
107
+ export async function input(
108
+ message: string,
109
+ opts?: { mask?: boolean; placeholder?: string },
110
+ ): Promise<string> {
111
+ const prefix = ` ${chalk.bold("◆")} ${message} `;
112
+
113
+ if (opts?.mask && isTTY) {
114
+ return maskedInput(prefix);
115
+ }
116
+
117
+ // Standard readline input (works with or without TTY)
118
+ const rl = readline.createInterface({
119
+ input: process.stdin,
120
+ output: process.stdout,
121
+ });
122
+
123
+ return new Promise<string>((resolve) => {
124
+ rl.question(prefix, (answer) => {
125
+ rl.close();
126
+ resolve(answer.trim());
127
+ });
128
+ });
129
+ }
130
+
131
+ function maskedInput(prefix: string): Promise<string> {
132
+ process.stdout.write(prefix);
133
+ process.stdin.setRawMode(true);
134
+ process.stdin.resume();
135
+
136
+ let value = "";
137
+
138
+ return new Promise<string>((resolve) => {
139
+ const onKeypress = (data: Buffer) => {
140
+ const key = data.toString();
141
+
142
+ if (key === "\r" || key === "\n") {
143
+ process.stdin.setRawMode(false);
144
+ process.stdin.removeListener("data", onKeypress);
145
+ process.stdin.pause();
146
+ console.log();
147
+ resolve(value);
148
+ } else if (key === "\x7F" || key === "\b") {
149
+ if (value.length > 0) {
150
+ value = value.slice(0, -1);
151
+ process.stdout.write("\b \b");
152
+ }
153
+ } else if (key === "\x03") {
154
+ process.exit(0);
155
+ } else if (key.charCodeAt(0) >= 32) {
156
+ value += key;
157
+ process.stdout.write("•");
158
+ }
159
+ };
160
+
161
+ process.stdin.on("data", onKeypress);
162
+ });
163
+ }
@@ -0,0 +1,182 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
3
+ import { homedir } from "node:os";
4
+ import { join } from "node:path";
5
+ import type { LLMProvider } from "@kibhq/core";
6
+ import { createProvider, loadConfig, saveConfig } from "@kibhq/core";
7
+ import chalk from "chalk";
8
+ import * as log from "./logger.js";
9
+ import { input, select } from "./prompt.js";
10
+
11
+ const CREDENTIALS_DIR = join(homedir(), ".config", "kib");
12
+ const CREDENTIALS_FILE = join(CREDENTIALS_DIR, "credentials");
13
+
14
+ const PROVIDERS = [
15
+ {
16
+ name: "anthropic",
17
+ label: "Anthropic",
18
+ hint: "Claude — recommended",
19
+ envKey: "ANTHROPIC_API_KEY",
20
+ keyPrefix: "sk-ant-",
21
+ keyUrl: "https://console.anthropic.com/settings/keys",
22
+ },
23
+ {
24
+ name: "openai",
25
+ label: "OpenAI",
26
+ hint: "GPT-4o",
27
+ envKey: "OPENAI_API_KEY",
28
+ keyPrefix: "sk-",
29
+ keyUrl: "https://platform.openai.com/api-keys",
30
+ },
31
+ {
32
+ name: "ollama",
33
+ label: "Ollama",
34
+ hint: "local models, no API key needed",
35
+ envKey: null,
36
+ keyPrefix: null,
37
+ keyUrl: null,
38
+ },
39
+ ] as const;
40
+
41
+ /**
42
+ * Interactive setup flow when no LLM provider is configured.
43
+ * Guides user through provider selection + API key entry.
44
+ * Returns a connected provider ready to use.
45
+ */
46
+ export async function setupProvider(root: string): Promise<LLMProvider> {
47
+ console.log();
48
+ log.dim("kib needs an LLM to compile your sources into wiki articles.");
49
+ log.dim("Let's set that up.\n");
50
+
51
+ // 1. Select provider
52
+ const providerIndex = await select(
53
+ "Which provider?",
54
+ PROVIDERS.map((p) => ({
55
+ label: p.label,
56
+ hint: p.hint,
57
+ })),
58
+ );
59
+ const provider = PROVIDERS[providerIndex];
60
+
61
+ console.log();
62
+
63
+ // 2. Handle Ollama (no API key, just check if running)
64
+ if (provider.name === "ollama") {
65
+ return await setupOllama(root);
66
+ }
67
+
68
+ // 3. For API key providers, prompt for the key
69
+ log.dim(`Get your API key at: ${chalk.underline(provider.keyUrl)}`);
70
+ console.log();
71
+
72
+ const key = await input(`${provider.label} API key:`, { mask: true });
73
+
74
+ if (!key) {
75
+ log.error("No API key entered.");
76
+ process.exit(1);
77
+ }
78
+
79
+ // 4. Set in current process
80
+ process.env[provider.envKey] = key;
81
+
82
+ // 5. Save to credentials file
83
+ await saveCredential(provider.envKey, key);
84
+
85
+ // 6. Update vault config
86
+ const config = await loadConfig(root);
87
+ config.provider.default = provider.name;
88
+ await saveConfig(root, config);
89
+
90
+ // 7. Test connection
91
+ console.log();
92
+ try {
93
+ const llm = await createProvider(config.provider.default, config.provider.model);
94
+ log.success(`Connected to ${llm.name}`);
95
+ log.success(`Saved to ${chalk.dim("~/.config/kib/credentials")}`);
96
+ console.log();
97
+ return llm;
98
+ } catch {
99
+ log.error("Could not connect — check your API key and try again.");
100
+ process.exit(1);
101
+ }
102
+ }
103
+
104
+ async function setupOllama(root: string): Promise<LLMProvider> {
105
+ log.dim("Checking if Ollama is running...");
106
+ console.log();
107
+
108
+ try {
109
+ const res = await fetch("http://localhost:11434/api/tags");
110
+ if (!res.ok) throw new Error();
111
+ } catch {
112
+ log.error("Ollama is not running.");
113
+ log.dim("Start it with: ollama serve");
114
+ log.dim("Then run this command again.");
115
+ process.exit(1);
116
+ }
117
+
118
+ const config = await loadConfig(root);
119
+ config.provider.default = "ollama";
120
+ config.provider.model = "llama3";
121
+ config.provider.fast_model = "llama3";
122
+ await saveConfig(root, config);
123
+
124
+ const llm = await createProvider("ollama", "llama3");
125
+ log.success(`Connected to ${llm.name}`);
126
+ console.log();
127
+ return llm;
128
+ }
129
+
130
+ // ─── Credentials persistence ────────────────────────────────────
131
+
132
+ /**
133
+ * Save a credential to ~/.config/kib/credentials.
134
+ * Simple KEY=value format, one per line.
135
+ */
136
+ async function saveCredential(key: string, value: string): Promise<void> {
137
+ await mkdir(CREDENTIALS_DIR, { recursive: true });
138
+
139
+ let existing = "";
140
+ try {
141
+ existing = await readFile(CREDENTIALS_FILE, "utf-8");
142
+ } catch {
143
+ // File doesn't exist yet
144
+ }
145
+
146
+ // Parse existing lines, replace or append
147
+ const lines = existing.split("\n").filter((l) => l.trim() && !l.startsWith("#"));
148
+ const updated = lines.filter((l) => !l.startsWith(`${key}=`));
149
+ updated.push(`${key}=${value}`);
150
+
151
+ await writeFile(CREDENTIALS_FILE, updated.join("\n") + "\n", { mode: 0o600 });
152
+ }
153
+
154
+ /**
155
+ * Load saved credentials from ~/.config/kib/credentials into process.env.
156
+ * Only sets variables that aren't already set (env vars take precedence).
157
+ */
158
+ export function loadCredentials(): void {
159
+ const path = join(homedir(), ".config", "kib", "credentials");
160
+ if (!existsSync(path)) return;
161
+
162
+ try {
163
+ const content = readFileSync(path, "utf-8");
164
+ for (const line of content.split("\n")) {
165
+ const trimmed = line.trim();
166
+ if (!trimmed || trimmed.startsWith("#")) continue;
167
+
168
+ const eqIndex = trimmed.indexOf("=");
169
+ if (eqIndex === -1) continue;
170
+
171
+ const key = trimmed.slice(0, eqIndex);
172
+ const value = trimmed.slice(eqIndex + 1);
173
+
174
+ // Don't override existing env vars
175
+ if (!process.env[key]) {
176
+ process.env[key] = value;
177
+ }
178
+ }
179
+ } catch {
180
+ // Silently ignore read errors
181
+ }
182
+ }
@@ -0,0 +1,9 @@
1
+ import ora from "ora";
2
+
3
+ export function createSpinner(text: string) {
4
+ return ora({
5
+ text: ` ${text}`,
6
+ indent: 0,
7
+ spinner: "dots",
8
+ });
9
+ }