@kibhq/cli 0.1.0 → 0.3.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.
package/README.md ADDED
@@ -0,0 +1,130 @@
1
+ # kib
2
+
3
+ The Headless Knowledge Compiler. A CLI-first, LLM-powered tool that turns raw source material into a structured, queryable markdown wiki — maintained entirely by AI.
4
+
5
+ `git` for knowledge — ingest, compile, query, lint, all from the terminal.
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ # Requires Bun (https://bun.sh)
11
+ npm i -g @kibhq/cli
12
+
13
+ # or run without installing
14
+ npx @kibhq/cli init
15
+ ```
16
+
17
+ Standalone binaries for macOS and Linux are available on the [releases page](https://github.com/keeganthomp/kib/releases).
18
+
19
+ ## Quick Start
20
+
21
+ ```bash
22
+ # Initialize a vault
23
+ kib init
24
+
25
+ # Ingest sources (URLs, files, PDFs, YouTube, GitHub repos)
26
+ kib ingest https://arxiv.org/abs/1706.03762
27
+ kib ingest ./papers/*.pdf
28
+ kib ingest https://www.youtube.com/watch?v=...
29
+
30
+ # Compile into wiki articles
31
+ kib compile
32
+
33
+ # Search your knowledge base
34
+ kib search "attention mechanisms"
35
+
36
+ # Ask questions (RAG over your wiki)
37
+ kib query "what are the tradeoffs between MoE and dense models?"
38
+
39
+ # Interactive chat
40
+ kib chat
41
+ ```
42
+
43
+ ## Commands
44
+
45
+ ```
46
+ CORE
47
+ init Create a new vault in the current directory
48
+ ingest <source> Ingest sources into raw/ (URLs, files, PDFs, etc.)
49
+ compile Compile raw sources into wiki articles via LLM
50
+ query <question> Ask a question against the knowledge base (RAG)
51
+ search <term> Fast BM25 text search across the vault
52
+ chat Interactive REPL with conversation history
53
+ lint Run health checks on the wiki
54
+ status Vault health dashboard
55
+
56
+ INTEGRATION
57
+ serve --mcp Start MCP server for AI tool integration
58
+ watch Watch inbox/ and auto-ingest new files
59
+
60
+ MANAGEMENT
61
+ config [key] [val] Get or set configuration
62
+ skill <sub> [name] Manage skills (list, run)
63
+ export Export wiki to markdown or HTML
64
+ ```
65
+
66
+ ## LLM Providers
67
+
68
+ On first use, kib walks you through provider setup interactively. Or set via environment:
69
+
70
+ | Provider | Env Variable | Default Model |
71
+ |----------|-------------|---------------|
72
+ | Anthropic | `ANTHROPIC_API_KEY` | claude-sonnet-4-20250514 |
73
+ | OpenAI | `OPENAI_API_KEY` | gpt-4o |
74
+ | Ollama | (auto-detect localhost:11434) | llama3 |
75
+
76
+ ## Vault Structure
77
+
78
+ ```
79
+ my-vault/
80
+ ├── .kb/ # Config, manifest, cache
81
+ ├── raw/ # Ingested source material (never modified by compile)
82
+ │ ├── articles/
83
+ │ ├── papers/
84
+ │ ├── transcripts/
85
+ │ └── repos/
86
+ ├── wiki/ # LLM-compiled knowledge base
87
+ │ ├── INDEX.md # Master index
88
+ │ ├── GRAPH.md # Article relationship graph
89
+ │ ├── concepts/
90
+ │ ├── topics/
91
+ │ ├── references/
92
+ │ └── outputs/
93
+ └── inbox/ # Drop zone for kib watch
94
+ ```
95
+
96
+ The vault is just files. View it in any editor. Version it with git. No lock-in.
97
+
98
+ ## MCP Server
99
+
100
+ Expose your vault as MCP tools for AI tool integration:
101
+
102
+ ```bash
103
+ kib serve --mcp
104
+ ```
105
+
106
+ 8 tools: `kib_status`, `kib_list`, `kib_read`, `kib_search`, `kib_query`, `kib_ingest`, `kib_compile`, `kib_lint`
107
+
108
+ Add to your MCP client config:
109
+
110
+ ```json
111
+ {
112
+ "mcpServers": {
113
+ "kib": {
114
+ "command": "kib",
115
+ "args": ["serve", "--mcp"],
116
+ "cwd": "/path/to/your/vault"
117
+ }
118
+ }
119
+ }
120
+ ```
121
+
122
+ ## Links
123
+
124
+ - [GitHub](https://github.com/keeganthomp/kib)
125
+ - [Roadmap](https://github.com/keeganthomp/kib/blob/main/ROADMAP.md)
126
+ - [@kibhq/core](https://www.npmjs.com/package/@kibhq/core) — core engine (for programmatic use)
127
+
128
+ ## License
129
+
130
+ MIT
package/package.json CHANGED
@@ -1,6 +1,7 @@
1
1
  {
2
2
  "name": "@kibhq/cli",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
+ "description": "The Headless Knowledge Compiler — turn raw sources into a structured, queryable wiki with AI",
4
5
  "type": "module",
5
6
  "bin": {
6
7
  "kib": "./bin/kib.ts"
@@ -8,6 +9,7 @@
8
9
  "files": [
9
10
  "bin",
10
11
  "src",
12
+ "README.md",
11
13
  "package.json"
12
14
  ],
13
15
  "repository": {
@@ -16,6 +18,9 @@
16
18
  "directory": "packages/cli"
17
19
  },
18
20
  "license": "MIT",
21
+ "publishConfig": {
22
+ "access": "public"
23
+ },
19
24
  "keywords": [
20
25
  "knowledge-base",
21
26
  "wiki",
@@ -30,9 +35,10 @@
30
35
  "test": "bun test"
31
36
  },
32
37
  "dependencies": {
33
- "@kibhq/core": "workspace:*",
34
- "commander": "^14.0.0",
38
+ "@kibhq/core": "^0.1.0",
39
+ "@modelcontextprotocol/sdk": "^1.29.0",
35
40
  "chalk": "^5.4.1",
41
+ "commander": "^14.0.0",
36
42
  "ora": "^8.2.0"
37
43
  },
38
44
  "devDependencies": {
@@ -1,5 +1,5 @@
1
1
  import * as readline from "node:readline";
2
- import type { Message } from "@kibhq/core";
2
+ import type { LLMProvider, Message } from "@kibhq/core";
3
3
  import {
4
4
  createProvider,
5
5
  loadConfig,
@@ -25,7 +25,7 @@ export async function chat() {
25
25
  const config = await loadConfig(root);
26
26
 
27
27
  // Create provider
28
- let provider;
28
+ let provider: LLMProvider;
29
29
  try {
30
30
  provider = await createProvider(config.provider.default, config.provider.model);
31
31
  } catch (err) {
@@ -1,3 +1,4 @@
1
+ import type { LLMProvider } from "@kibhq/core";
1
2
  import {
2
3
  createProvider,
3
4
  loadConfig,
@@ -33,7 +34,7 @@ export async function compile(opts: CompileOpts) {
33
34
  log.header("compiling wiki");
34
35
 
35
36
  // Create LLM provider
36
- let provider;
37
+ let provider: LLMProvider;
37
38
  const providerSpinner = createSpinner("Connecting to LLM provider...");
38
39
  providerSpinner.start();
39
40
  try {
@@ -45,28 +45,32 @@ export async function config(key?: string, value?: string, opts?: { list?: boole
45
45
  }
46
46
  }
47
47
 
48
- function getNestedValue(obj: any, path: string): unknown {
48
+ function getNestedValue(obj: Record<string, unknown>, path: string): unknown {
49
49
  const parts = path.split(".");
50
- let current = obj;
50
+ let current: unknown = obj;
51
51
  for (const part of parts) {
52
52
  if (current == null || typeof current !== "object") return undefined;
53
- current = current[part];
53
+ current = (current as Record<string, unknown>)[part];
54
54
  }
55
55
  return current;
56
56
  }
57
57
 
58
- function setNestedValue(obj: any, path: string, value: unknown): boolean {
58
+ function setNestedValue(obj: Record<string, unknown>, path: string, value: unknown): boolean {
59
59
  const parts = path.split(".");
60
- let current = obj;
60
+ let current: unknown = obj;
61
61
  for (let i = 0; i < parts.length - 1; i++) {
62
62
  if (current == null || typeof current !== "object") return false;
63
- current = current[parts[i]!];
63
+ current = (current as Record<string, unknown>)[parts[i]!];
64
64
  }
65
65
  const lastKey = parts[parts.length - 1]!;
66
- if (current == null || typeof current !== "object" || !(lastKey in current)) {
66
+ if (
67
+ current == null ||
68
+ typeof current !== "object" ||
69
+ !(lastKey in (current as Record<string, unknown>))
70
+ ) {
67
71
  return false;
68
72
  }
69
- current[lastKey] = value;
73
+ (current as Record<string, unknown>)[lastKey] = value;
70
74
  return true;
71
75
  }
72
76
 
@@ -78,7 +82,7 @@ function parseValue(val: string): unknown {
78
82
  return val;
79
83
  }
80
84
 
81
- function printConfig(obj: any, prefix: string) {
85
+ function printConfig(obj: Record<string, unknown>, prefix: string) {
82
86
  for (const [key, val] of Object.entries(obj)) {
83
87
  const fullKey = prefix ? `${prefix}.${key}` : key;
84
88
  if (val != null && typeof val === "object" && !Array.isArray(val)) {
@@ -19,7 +19,7 @@ export async function init(opts: InitOpts) {
19
19
  const provider = opts.provider ?? detected.name;
20
20
  const model = detected.model;
21
21
 
22
- const { root, manifest } = await initVault(cwd, {
22
+ await initVault(cwd, {
23
23
  name: opts.name,
24
24
  provider,
25
25
  model,
@@ -1,3 +1,4 @@
1
+ import type { LLMProvider } from "@kibhq/core";
1
2
  import {
2
3
  createProvider,
3
4
  loadConfig,
@@ -30,7 +31,7 @@ export async function query(question: string, opts: QueryOpts) {
30
31
  const config = await loadConfig(root);
31
32
 
32
33
  // Create provider
33
- let provider;
34
+ let provider: LLMProvider;
34
35
  try {
35
36
  provider = await createProvider(config.provider.default, config.provider.model);
36
37
  } catch (err) {
@@ -0,0 +1,31 @@
1
+ import { resolveVaultRoot, VaultNotFoundError } from "@kibhq/core";
2
+
3
+ interface ServeOpts {
4
+ mcp?: boolean;
5
+ }
6
+
7
+ export async function serve(opts: ServeOpts) {
8
+ if (!opts.mcp) {
9
+ console.error("Usage: kib serve --mcp");
10
+ console.error(" Starts an MCP server over stdio for AI tool integration.");
11
+ process.exit(1);
12
+ }
13
+
14
+ let root: string;
15
+ try {
16
+ root = resolveVaultRoot();
17
+ } catch (e) {
18
+ if (e instanceof VaultNotFoundError) {
19
+ console.error(e.message);
20
+ process.exit(1);
21
+ }
22
+ throw e;
23
+ }
24
+
25
+ // Load saved API keys before starting server
26
+ const { loadCredentials } = await import("../ui/setup-provider.js");
27
+ loadCredentials();
28
+
29
+ const { startMcpServer } = await import("../mcp/server.js");
30
+ await startMcpServer(root);
31
+ }
@@ -1,3 +1,4 @@
1
+ import type { LLMProvider } from "@kibhq/core";
1
2
  import {
2
3
  createProvider,
3
4
  loadConfig,
@@ -52,7 +53,7 @@ export async function skill(subcommand: string, name?: string, _opts?: unknown)
52
53
 
53
54
  log.header(`running skill: ${s.name}`);
54
55
 
55
- let provider;
56
+ let provider: LLMProvider | undefined;
56
57
  if (s.llm?.required) {
57
58
  const config = await loadConfig(root);
58
59
  const modelKey = s.llm.model === "fast" ? "fast_model" : "model";
@@ -1,7 +1,7 @@
1
1
  import { watch as fsWatch } from "node:fs";
2
2
  import { readdir, stat } from "node:fs/promises";
3
3
  import { join, resolve } from "node:path";
4
- import { INBOX_DIR, loadConfig, resolveVaultRoot, VaultNotFoundError } from "@kibhq/core";
4
+ import { loadConfig, resolveVaultRoot, VaultNotFoundError } from "@kibhq/core";
5
5
  import * as log from "../ui/logger.js";
6
6
 
7
7
  export async function watch() {
@@ -40,7 +40,7 @@ export async function watch() {
40
40
  const server = startHttpServer(root, ingestSource);
41
41
 
42
42
  // Watch for new files
43
- const watcher = fsWatch(inboxPath, { recursive: false }, async (event, filename) => {
43
+ const watcher = fsWatch(inboxPath, { recursive: false }, async (_event, filename) => {
44
44
  if (!filename || processed.has(filename)) return;
45
45
  if (filename.startsWith(".")) return; // skip dotfiles
46
46
 
package/src/index.ts CHANGED
@@ -114,6 +114,15 @@ program
114
114
  await watch();
115
115
  });
116
116
 
117
+ program
118
+ .command("serve")
119
+ .description("Start an MCP server for AI tool integration")
120
+ .option("--mcp", "expose vault as MCP tools over stdio")
121
+ .action(async (opts) => {
122
+ const { serve } = await import("./commands/serve.js");
123
+ await serve(opts);
124
+ });
125
+
117
126
  program
118
127
  .command("export")
119
128
  .description("Export wiki to other formats")
@@ -0,0 +1,332 @@
1
+ import {
2
+ compileVault,
3
+ createProvider,
4
+ ingestSource,
5
+ type LLMProvider,
6
+ lintVault,
7
+ listRaw,
8
+ listWiki,
9
+ loadConfig,
10
+ loadManifest,
11
+ type Manifest,
12
+ queryVault,
13
+ readGraph,
14
+ readIndex,
15
+ readRaw,
16
+ readWiki,
17
+ SearchIndex,
18
+ type VaultConfig,
19
+ } from "@kibhq/core";
20
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
21
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
22
+ import { z } from "zod";
23
+
24
+ // ─── Context ────────────────────────────────────────────────────
25
+
26
+ interface McpContext {
27
+ root: string;
28
+ getConfig(): Promise<VaultConfig>;
29
+ getManifest(): Promise<Manifest>;
30
+ getProvider(): Promise<LLMProvider>;
31
+ getSearchIndex(): Promise<SearchIndex>;
32
+ invalidateSearch(): void;
33
+ }
34
+
35
+ function createContext(root: string): McpContext {
36
+ let cachedConfig: VaultConfig | null = null;
37
+ let cachedProvider: LLMProvider | null = null;
38
+ let cachedIndex: SearchIndex | null = null;
39
+
40
+ return {
41
+ root,
42
+ async getConfig() {
43
+ if (!cachedConfig) cachedConfig = await loadConfig(root);
44
+ return cachedConfig;
45
+ },
46
+ async getManifest() {
47
+ // Always re-read — vault mutates
48
+ return loadManifest(root);
49
+ },
50
+ async getProvider() {
51
+ if (!cachedProvider) {
52
+ const config = await this.getConfig();
53
+ cachedProvider = await createProvider(config.provider.default, config.provider.model);
54
+ }
55
+ return cachedProvider;
56
+ },
57
+ async getSearchIndex() {
58
+ if (!cachedIndex) {
59
+ cachedIndex = new SearchIndex();
60
+ const loaded = await cachedIndex.load(root);
61
+ if (!loaded) {
62
+ await cachedIndex.build(root, "all");
63
+ await cachedIndex.save(root);
64
+ }
65
+ }
66
+ return cachedIndex;
67
+ },
68
+ invalidateSearch() {
69
+ cachedIndex = null;
70
+ },
71
+ };
72
+ }
73
+
74
+ // ─── Helpers ────────────────────────────────────────────────────
75
+
76
+ function ok(text: string) {
77
+ return { content: [{ type: "text" as const, text }] };
78
+ }
79
+
80
+ function err(text: string) {
81
+ return { content: [{ type: "text" as const, text }], isError: true as const };
82
+ }
83
+
84
+ function json(data: unknown) {
85
+ return ok(JSON.stringify(data, null, 2));
86
+ }
87
+
88
+ // ─── Server ─────────────────────────────────────────────────────
89
+
90
+ export async function startMcpServer(root: string) {
91
+ const ctx = createContext(root);
92
+
93
+ const server = new McpServer({
94
+ name: "kib",
95
+ version: "0.2.0",
96
+ });
97
+
98
+ // ── kib_status ────────────────────────────────────────────
99
+
100
+ server.tool(
101
+ "kib_status",
102
+ "Get vault status: source count, article count, provider, and config",
103
+ {},
104
+ async () => {
105
+ try {
106
+ const manifest = await ctx.getManifest();
107
+ const config = await ctx.getConfig();
108
+ return json({
109
+ name: manifest.vault.name,
110
+ provider: config.provider.default,
111
+ model: config.provider.model,
112
+ totalSources: manifest.stats.totalSources,
113
+ totalArticles: manifest.stats.totalArticles,
114
+ totalWords: manifest.stats.totalWords,
115
+ lastCompiled: manifest.vault.lastCompiled,
116
+ lastLint: manifest.stats.lastLintAt,
117
+ });
118
+ } catch (e) {
119
+ return err((e as Error).message);
120
+ }
121
+ },
122
+ );
123
+
124
+ // ── kib_list ──────────────────────────────────────────────
125
+
126
+ server.tool(
127
+ "kib_list",
128
+ "List all wiki articles or raw sources in the knowledge base",
129
+ {
130
+ scope: z.enum(["wiki", "raw"]).default("wiki").describe("List wiki articles or raw sources"),
131
+ },
132
+ async ({ scope }) => {
133
+ try {
134
+ const files = scope === "wiki" ? await listWiki(root) : await listRaw(root);
135
+ // Strip absolute path prefix to return vault-relative paths
136
+ const prefix = `${root}/`;
137
+ const relative = files.map((f) => f.replace(prefix, ""));
138
+ return json(relative);
139
+ } catch (e) {
140
+ return err((e as Error).message);
141
+ }
142
+ },
143
+ );
144
+
145
+ // ── kib_read ──────────────────────────────────────────────
146
+
147
+ server.tool(
148
+ "kib_read",
149
+ "Read a specific wiki article or raw source from the knowledge base",
150
+ {
151
+ path: z.string().describe("Relative path, e.g. 'concepts/attention.md'"),
152
+ scope: z.enum(["wiki", "raw"]).default("wiki").describe("Read from wiki/ or raw/"),
153
+ },
154
+ async ({ path, scope }) => {
155
+ try {
156
+ const content = scope === "wiki" ? await readWiki(root, path) : await readRaw(root, path);
157
+ return ok(content);
158
+ } catch (_e) {
159
+ return err(`File not found: ${path}`);
160
+ }
161
+ },
162
+ );
163
+
164
+ // ── kib_search ────────────────────────────────────────────
165
+
166
+ server.tool(
167
+ "kib_search",
168
+ "Search the knowledge base using full-text BM25 search",
169
+ {
170
+ query: z.string().describe("Search query"),
171
+ limit: z.number().int().positive().max(50).default(10).describe("Max results"),
172
+ },
173
+ async ({ query, limit }) => {
174
+ try {
175
+ const index = await ctx.getSearchIndex();
176
+ const results = index.search(query, { limit });
177
+ const prefix = `${root}/`;
178
+ return json(
179
+ results.map((r) => ({
180
+ title: r.title,
181
+ path: r.path.replace(prefix, ""),
182
+ score: r.score,
183
+ snippet: r.snippet,
184
+ })),
185
+ );
186
+ } catch (e) {
187
+ return err((e as Error).message);
188
+ }
189
+ },
190
+ );
191
+
192
+ // ── kib_query ─────────────────────────────────────────────
193
+
194
+ server.tool(
195
+ "kib_query",
196
+ "Ask a question against the knowledge base using RAG (retrieval-augmented generation)",
197
+ {
198
+ question: z.string().describe("Question to ask"),
199
+ max_articles: z
200
+ .number()
201
+ .int()
202
+ .positive()
203
+ .max(10)
204
+ .default(5)
205
+ .describe("Max articles to use as context"),
206
+ },
207
+ async ({ question, max_articles }) => {
208
+ try {
209
+ const provider = await ctx.getProvider();
210
+ const result = await queryVault(root, question, provider, {
211
+ maxArticles: max_articles,
212
+ });
213
+ return ok(`${result.answer}\n\n---\nSources: ${result.sourcePaths.join(", ") || "none"}`);
214
+ } catch (e) {
215
+ return err((e as Error).message);
216
+ }
217
+ },
218
+ );
219
+
220
+ // ── kib_ingest ────────────────────────────────────────────
221
+
222
+ server.tool(
223
+ "kib_ingest",
224
+ "Ingest a source (URL or file path) into the knowledge base",
225
+ {
226
+ source: z.string().describe("URL or file path to ingest"),
227
+ category: z
228
+ .string()
229
+ .optional()
230
+ .describe("Raw subdirectory override (e.g. 'papers', 'articles')"),
231
+ tags: z.string().optional().describe("Comma-separated tags"),
232
+ },
233
+ async ({ source, category, tags }) => {
234
+ try {
235
+ const result = await ingestSource(root, source, {
236
+ category,
237
+ tags: tags?.split(",").map((t) => t.trim()),
238
+ });
239
+ ctx.invalidateSearch();
240
+ return json({
241
+ path: result.path,
242
+ title: result.metadata?.title,
243
+ wordCount: result.metadata?.wordCount,
244
+ skipped: result.skipped,
245
+ skipReason: result.skipReason,
246
+ });
247
+ } catch (e) {
248
+ return err((e as Error).message);
249
+ }
250
+ },
251
+ );
252
+
253
+ // ── kib_compile ───────────────────────────────────────────
254
+
255
+ server.tool(
256
+ "kib_compile",
257
+ "Compile pending raw sources into wiki articles using the configured LLM",
258
+ {
259
+ force: z.boolean().default(false).describe("Recompile all sources"),
260
+ source: z.string().optional().describe("Compile only a specific source"),
261
+ dry_run: z.boolean().default(false).describe("Preview without writing"),
262
+ },
263
+ async ({ force, source, dry_run }) => {
264
+ try {
265
+ const provider = await ctx.getProvider();
266
+ const config = await ctx.getConfig();
267
+ const result = await compileVault(root, provider, config, {
268
+ force,
269
+ dryRun: dry_run,
270
+ sourceFilter: source,
271
+ });
272
+ ctx.invalidateSearch();
273
+ return json({
274
+ sourcesCompiled: result.sourcesCompiled,
275
+ articlesCreated: result.articlesCreated,
276
+ articlesUpdated: result.articlesUpdated,
277
+ articlesDeleted: result.articlesDeleted,
278
+ operations: result.operations,
279
+ });
280
+ } catch (e) {
281
+ return err((e as Error).message);
282
+ }
283
+ },
284
+ );
285
+
286
+ // ── kib_lint ──────────────────────────────────────────────
287
+
288
+ server.tool(
289
+ "kib_lint",
290
+ "Run health checks on the wiki and report issues",
291
+ {
292
+ rule: z
293
+ .string()
294
+ .optional()
295
+ .describe("Run only a specific rule: orphan, stale, missing, broken-link, frontmatter"),
296
+ },
297
+ async ({ rule }) => {
298
+ try {
299
+ const result = await lintVault(root, { ruleFilter: rule });
300
+ return json({
301
+ errors: result.errors,
302
+ warnings: result.warnings,
303
+ infos: result.infos,
304
+ diagnostics: result.diagnostics,
305
+ });
306
+ } catch (e) {
307
+ return err((e as Error).message);
308
+ }
309
+ },
310
+ );
311
+
312
+ // ── Resources ─────────────────────────────────────────────
313
+
314
+ server.resource("wiki-index", "wiki://index", { mimeType: "text/markdown" }, async () => {
315
+ const content = await readIndex(root);
316
+ return {
317
+ contents: [{ uri: "wiki://index", text: content || "(no index yet — run kib compile)" }],
318
+ };
319
+ });
320
+
321
+ server.resource("wiki-graph", "wiki://graph", { mimeType: "text/markdown" }, async () => {
322
+ const content = await readGraph(root);
323
+ return {
324
+ contents: [{ uri: "wiki://graph", text: content || "(no graph yet — run kib compile)" }],
325
+ };
326
+ });
327
+
328
+ // ── Start ─────────────────────────────────────────────────
329
+
330
+ const transport = new StdioServerTransport();
331
+ await server.connect(transport);
332
+ }
@@ -148,7 +148,7 @@ async function saveCredential(key: string, value: string): Promise<void> {
148
148
  const updated = lines.filter((l) => !l.startsWith(`${key}=`));
149
149
  updated.push(`${key}=${value}`);
150
150
 
151
- await writeFile(CREDENTIALS_FILE, updated.join("\n") + "\n", { mode: 0o600 });
151
+ await writeFile(CREDENTIALS_FILE, `${updated.join("\n")}\n`, { mode: 0o600 });
152
152
  }
153
153
 
154
154
  /**