open-knowledge-studio 0.1.0__py3-none-any.whl

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.
File without changes
@@ -0,0 +1,502 @@
1
+ """oks — Open Knowledge Studio CLI.
2
+
3
+ Typer-based CLI for knowledge base search, wiki CRUD, drafts, and maintenance.
4
+ """
5
+ from __future__ import annotations
6
+
7
+ import json
8
+ from pathlib import Path
9
+ from typing import Optional
10
+
11
+ import typer
12
+ from rich.console import Console
13
+ from rich.table import Table
14
+ from rich.panel import Panel
15
+ from rich.markdown import Markdown
16
+
17
+ from knowledge_studio import store
18
+ from knowledge_studio.recall import recall, recall_episodic, recall_knowledge
19
+
20
+ app = typer.Typer(
21
+ name="oks",
22
+ help="Open Knowledge Studio — file-based knowledge engineering CLI.",
23
+ no_args_is_help=True,
24
+ )
25
+ console = Console()
26
+
27
+ wiki_app = typer.Typer(help="Wiki page management.")
28
+ drafts_app = typer.Typer(help="Draft proposal management.")
29
+ config_app = typer.Typer(help="Global configuration (~/.oks/config.json).")
30
+ app.add_typer(wiki_app, name="wiki")
31
+ app.add_typer(drafts_app, name="drafts")
32
+ app.add_typer(config_app, name="config")
33
+
34
+
35
+ # ── Search / Recall ──────────────────────────────────────────────
36
+
37
+ @app.command()
38
+ def search(
39
+ query: str = typer.Argument(help="Search query"),
40
+ limit: int = typer.Option(5, "--limit", "-n", help="Max results"),
41
+ scope: Optional[str] = typer.Option(None, "--scope", "--domain", "-d", help="Soft scope: narrow to one area (opt-in, not a hard partition)"),
42
+ type_filter: Optional[str] = typer.Option(None, "--type", "-t", help="Filter by type"),
43
+ ):
44
+ """Search wiki pages using the 6-factor recall engine."""
45
+ results = recall_knowledge(query=query, limit=limit, scope=scope)
46
+
47
+ if type_filter:
48
+ results = [r for r in results if r.get("type") == type_filter]
49
+
50
+ if not results:
51
+ console.print("[dim]No results found.[/dim]")
52
+ return
53
+
54
+ table = Table(show_header=True, header_style="bold cyan")
55
+ table.add_column("Slug", style="dim", max_width=30)
56
+ table.add_column("Title", max_width=40)
57
+ table.add_column("Type", max_width=12)
58
+ table.add_column("Area", max_width=12)
59
+ table.add_column("Score", justify="right", max_width=8)
60
+ table.add_column("Relevance", justify="right", max_width=10)
61
+
62
+ for r in results:
63
+ table.add_row(
64
+ r["slug"],
65
+ r["title"],
66
+ r.get("type", ""),
67
+ r.get("area", ""),
68
+ f"{r.get('score', 0):.2f}",
69
+ f"{r.get('relevance', 0):.2f}",
70
+ )
71
+
72
+ console.print(table)
73
+ console.print(f"\n[dim]{len(results)} result(s) from wiki/[/dim]")
74
+
75
+
76
+ @app.command(name="recall")
77
+ def recall_cmd(
78
+ query: str = typer.Argument(help="Search query"),
79
+ topic_id: Optional[int] = typer.Option(None, "--topic-id", help="Filter by topic ID"),
80
+ limit: int = typer.Option(5, "--limit", "-n", help="Max results per path"),
81
+ scope: Optional[str] = typer.Option(None, "--scope", "-s", help="Soft scope: narrow knowledge path to one area (opt-in, not a hard partition)"),
82
+ ):
83
+ """Two-path recall: episodic (raw/) + knowledge (wiki/)."""
84
+ result = recall(query=query, topic_id=topic_id, limit=limit, scope=scope)
85
+
86
+ if result["episodic"]:
87
+ console.print("\n[bold blue]Episodic Memory (raw/ + profiles/)[/bold blue]")
88
+ for item in result["episodic"]:
89
+ console.print(Panel(
90
+ item.get("snippet", "")[:200],
91
+ title=f"[{item.get('type', '')}] {item.get('source_path', '')}",
92
+ border_style="blue",
93
+ expand=False,
94
+ ))
95
+
96
+ if result["knowledge"]:
97
+ console.print("\n[bold green]Semantic Memory (wiki/)[/bold green]")
98
+ for item in result["knowledge"]:
99
+ console.print(Panel(
100
+ item.get("body_preview", "")[:200],
101
+ title=f"[{item.get('type', '')}] {item.get('title', '')} ({item.get('slug', '')})",
102
+ subtitle=f"score={item.get('score', 0):.2f} relevance={item.get('relevance', 0):.2f}",
103
+ border_style="green",
104
+ expand=False,
105
+ ))
106
+
107
+ if not result["episodic"] and not result["knowledge"]:
108
+ console.print("[dim]No results from either path.[/dim]")
109
+
110
+
111
+ # ── Wiki ─────────────────────────────────────────────────────────
112
+
113
+ @wiki_app.command("list")
114
+ def wiki_list(
115
+ domain: Optional[str] = typer.Option(None, "--domain", "-d"),
116
+ type_filter: Optional[str] = typer.Option(None, "--type", "-t"),
117
+ status: Optional[str] = typer.Option(None, "--status", "-s"),
118
+ ):
119
+ """List all wiki pages."""
120
+ pages = store.list_wiki_pages()
121
+
122
+ if domain:
123
+ pages = [p for p in pages if p.get("area") == domain]
124
+ if type_filter:
125
+ pages = [p for p in pages if p.get("type") == type_filter]
126
+ if status:
127
+ pages = [p for p in pages if p.get("status") == status]
128
+
129
+ if not pages:
130
+ console.print("[dim]No wiki pages found.[/dim]")
131
+ return
132
+
133
+ table = Table(show_header=True, header_style="bold cyan")
134
+ table.add_column("Slug", style="dim", max_width=30)
135
+ table.add_column("Title", max_width=40)
136
+ table.add_column("Type", max_width=12)
137
+ table.add_column("Area", max_width=12)
138
+ table.add_column("Tier", max_width=8)
139
+ table.add_column("Score", justify="right", max_width=8)
140
+ table.add_column("Status", max_width=12)
141
+
142
+ for p in pages:
143
+ table.add_row(
144
+ p["slug"],
145
+ p.get("title", p["slug"]),
146
+ p.get("type", ""),
147
+ p.get("area", ""),
148
+ p.get("tier", ""),
149
+ f"{p.get('score', 0):.2f}",
150
+ p.get("status", "active"),
151
+ )
152
+
153
+ console.print(table)
154
+ console.print(f"\n[dim]{len(pages)} page(s)[/dim]")
155
+
156
+
157
+ @wiki_app.command("get")
158
+ def wiki_get(
159
+ slug: str = typer.Argument(help="Page slug"),
160
+ ):
161
+ """Get a wiki page by slug."""
162
+ page = store.get_wiki_page(slug)
163
+ if not page:
164
+ console.print(f"[red]Page not found: {slug}[/red]")
165
+ raise typer.Exit(1)
166
+
167
+ body = page.get("body", "")
168
+ console.print(Panel(
169
+ Markdown(body) if body else "[dim](empty)[/dim]",
170
+ title=f"{page.get('title', slug)}",
171
+ subtitle=f"slug={slug} | type={page.get('type', '')} | area={page.get('area', '')} | "
172
+ f"score={page.get('score', 0):.2f} | tier={page.get('tier', '')} | "
173
+ f"status={page.get('status', 'active')}",
174
+ border_style="cyan",
175
+ expand=True,
176
+ ))
177
+
178
+
179
+ @wiki_app.command("create")
180
+ def wiki_create(
181
+ title: str = typer.Option(..., "--title", help="Page title"),
182
+ page_type: str = typer.Option("concept", "--type", help="concept/strategy/anti-pattern"),
183
+ area: str = typer.Option("computing", "--area", help="Knowledge domain"),
184
+ importance: float = typer.Option(0.5, "--importance", help="0.0-1.0"),
185
+ content: str = typer.Option("", "--content", help="Page body (or pipe via stdin)"),
186
+ ):
187
+ """Create a new wiki page."""
188
+ import sys
189
+ if not content and not sys.stdin.isatty():
190
+ content = sys.stdin.read()
191
+
192
+ type_map = {"concept": "concepts", "strategy": "strategies", "anti-pattern": "anti-patterns"}
193
+ wiki_type = type_map.get(page_type, "concepts")
194
+
195
+ path = store.write_wiki_page(
196
+ title=title,
197
+ content=content,
198
+ wiki_type=wiki_type,
199
+ area=area,
200
+ importance=importance,
201
+ )
202
+ console.print(f"[green]Created:[/green] {path}")
203
+
204
+
205
+ @wiki_app.command("pin")
206
+ def wiki_pin(slug: str = typer.Argument(help="Page slug to pin")):
207
+ """Pin a wiki page (exempt from decay)."""
208
+ if store.pin_page(slug):
209
+ console.print(f"[green]Pinned:[/green] {slug}")
210
+ else:
211
+ console.print(f"[red]Not found:[/red] {slug}")
212
+ raise typer.Exit(1)
213
+
214
+
215
+ @wiki_app.command("archive")
216
+ def wiki_archive(slug: str = typer.Argument(help="Page slug to archive")):
217
+ """Archive a wiki page."""
218
+ if store.archive_page(slug):
219
+ console.print(f"[green]Archived:[/green] {slug}")
220
+ else:
221
+ console.print(f"[red]Not found:[/red] {slug}")
222
+ raise typer.Exit(1)
223
+
224
+
225
+ # ── Drafts ───────────────────────────────────────────────────────
226
+
227
+ @drafts_app.command("list")
228
+ def drafts_list():
229
+ """List all draft proposals."""
230
+ drafts = store.list_drafts()
231
+ if not drafts:
232
+ console.print("[dim]No drafts found.[/dim]")
233
+ return
234
+
235
+ table = Table(show_header=True, header_style="bold yellow")
236
+ table.add_column("Slug", style="dim", max_width=30)
237
+ table.add_column("Title", max_width=40)
238
+ table.add_column("Type", max_width=12)
239
+ table.add_column("Area", max_width=12)
240
+ table.add_column("Drafted", max_width=12)
241
+
242
+ for d in drafts:
243
+ table.add_row(
244
+ d["slug"],
245
+ d.get("title", d["slug"]),
246
+ d.get("draft_type", ""),
247
+ d.get("draft_area", ""),
248
+ d.get("drafted_at", ""),
249
+ )
250
+
251
+ console.print(table)
252
+ console.print(f"\n[dim]{len(drafts)} draft(s)[/dim]")
253
+
254
+
255
+ @drafts_app.command("promote")
256
+ def drafts_promote(slug: str = typer.Argument(help="Draft slug to promote")):
257
+ """Promote a draft to a wiki page."""
258
+ try:
259
+ new_slug = store.promote_draft(slug)
260
+ console.print(f"[green]Promoted:[/green] {slug} → {new_slug}")
261
+ except FileNotFoundError:
262
+ console.print(f"[red]Draft not found:[/red] {slug}")
263
+ raise typer.Exit(1)
264
+
265
+
266
+ @drafts_app.command("reject")
267
+ def drafts_reject(slug: str = typer.Argument(help="Draft slug to reject")):
268
+ """Delete a draft proposal."""
269
+ try:
270
+ store.reject_draft(slug)
271
+ console.print(f"[green]Rejected:[/green] {slug}")
272
+ except FileNotFoundError:
273
+ console.print(f"[red]Draft not found:[/red] {slug}")
274
+ raise typer.Exit(1)
275
+
276
+
277
+ # ── Maintenance ──────────────────────────────────────────────────
278
+
279
+ @app.command()
280
+ def status():
281
+ """Show knowledge base overview."""
282
+ digest = store.wiki_digest()
283
+ drafts = store.list_drafts()
284
+ root = store.repo_root()
285
+
286
+ raw_count = 0
287
+ raw_d = store.raw_dir()
288
+ if raw_d.exists():
289
+ raw_count = sum(1 for f in raw_d.rglob("*") if f.is_file() and f.name != ".gitkeep")
290
+
291
+ profiles_dir = root / "profiles"
292
+ profile_count = 0
293
+ if profiles_dir.exists():
294
+ profile_count = sum(1 for f in profiles_dir.rglob("*.md") if f.is_file())
295
+
296
+ wiki_d = store.wiki_dir()
297
+ domain_count = 0
298
+ if wiki_d.exists():
299
+ domain_count = sum(1 for d in wiki_d.iterdir() if d.is_dir() and not d.name.startswith("."))
300
+
301
+ console.print(Panel.fit(
302
+ f"[bold]Open Knowledge Studio — Status[/bold]\n"
303
+ f"[dim]Root: {root}[/dim]\n\n"
304
+ f"Wiki pages: [cyan]{digest['total']}[/cyan] "
305
+ f"Domains: [cyan]{domain_count}[/cyan] "
306
+ f"Drafts: [yellow]{len(drafts)}[/yellow]\n"
307
+ f"Raw files: [cyan]{raw_count}[/cyan] "
308
+ f"Profiles: [cyan]{profile_count}[/cyan]\n\n"
309
+ f"Tiers: hot={digest['tiers']['hot']} warm={digest['tiers']['warm']} "
310
+ f"cold={digest['tiers']['cold']} evictable={digest['tiers']['evictable']}\n"
311
+ f"Quality avg: {digest['quality_avg']}/100 "
312
+ f"Pinned: {digest['pinned']}\n"
313
+ f"Types: {', '.join(f'{k}={v}' for k, v in digest['types'].items())}",
314
+ border_style="cyan",
315
+ ))
316
+
317
+
318
+ @app.command()
319
+ def decay():
320
+ """Apply decay — drop wiki pages below threshold score."""
321
+ dropped = store.apply_decay()
322
+ if dropped:
323
+ console.print(f"[yellow]Dropped {len(dropped)} page(s):[/yellow]")
324
+ for slug in dropped:
325
+ console.print(f" [dim]- {slug}[/dim]")
326
+ else:
327
+ console.print("[green]No pages dropped.[/green]")
328
+
329
+
330
+ @app.command()
331
+ def lint():
332
+ """Run health check on the knowledge base."""
333
+ from knowledge_studio.health import run_health_check
334
+ result = run_health_check()
335
+
336
+ if result["errors"]:
337
+ console.print(f"[red]{len(result['errors'])} error(s):[/red]")
338
+ for e in result["errors"]:
339
+ console.print(f" [red]✗[/red] {e}")
340
+
341
+ if result["warnings"]:
342
+ console.print(f"[yellow]{len(result['warnings'])} warning(s):[/yellow]")
343
+ for w in result["warnings"]:
344
+ console.print(f" [yellow]![/yellow] {w}")
345
+
346
+ if not result["errors"] and not result["warnings"]:
347
+ console.print("[green]All checks passed.[/green]")
348
+
349
+ s = result["summary"]
350
+ console.print(f"\n[dim]Wiki: {s['total_wiki_pages']} pages "
351
+ f"(dropped: {s['dropped']}, orphan: {s['orphan']}) | "
352
+ f"Active coverage: {s['coverage_pct']:.0f}%[/dim]")
353
+
354
+
355
+ @app.command()
356
+ def metrics():
357
+ """Show 4-dimension knowledge metrics."""
358
+ from knowledge_studio.metrics import get_knowledge_report
359
+ report = get_knowledge_report()
360
+
361
+ console.print(Panel.fit(
362
+ f"[bold]Knowledge Report Card[/bold]\n\n"
363
+ f"[cyan]Scale[/cyan]\n"
364
+ f" Wiki pages: {report['scale']['total_wiki_pages']}\n"
365
+ f" By type: {report['scale']['wiki_by_type']}\n\n"
366
+ f"[cyan]Vitality[/cyan]\n"
367
+ f" Wiki last 7d: {report['vitality']['wiki_pages_last_7d']}\n"
368
+ f" Active ratio: {report['vitality']['active_wiki_ratio']}\n"
369
+ f" Dropped: {report['vitality']['dropped_count']}\n\n"
370
+ f"[cyan]Value[/cyan]\n"
371
+ f" With traces: {report['value']['wiki_with_traces']}\n"
372
+ f" With review: {report['value']['wiki_with_review']}\n"
373
+ f" Total access: {report['value']['total_access_count']}\n\n"
374
+ f"[cyan]Credibility[/cyan]\n"
375
+ f" Trace coverage: {report['credibility']['trace_coverage']:.0%}\n"
376
+ f" Review coverage: {report['credibility']['review_coverage']:.0%}\n"
377
+ f" Avg confidence: {report['credibility']['avg_confidence']:.2f}\n"
378
+ f" Avg score: {report['credibility']['avg_score']:.2f}",
379
+ border_style="cyan",
380
+ ))
381
+
382
+
383
+ @app.command()
384
+ def distill(
385
+ dry_run: bool = typer.Option(False, "--dry-run", help="Preview without writing"),
386
+ ):
387
+ """Run maintenance cycle: decay + evolve knowledge.
388
+
389
+ AI distillation (raw → drafts) is handled by Claude Code /ingest skill.
390
+ This command applies decay and generates draft proposals from page clusters.
391
+ """
392
+ from knowledge_studio.distiller import run_distill_cycle
393
+
394
+ if dry_run:
395
+ from knowledge_studio.store import list_wiki_pages
396
+ pages = list_wiki_pages()
397
+ console.print(f"[cyan]Dry run:[/cyan] {len(pages)} wiki pages would be evaluated.")
398
+ console.print("[dim]Use /ingest in Claude Code to triage raw/ files into drafts/.[/dim]")
399
+ return
400
+
401
+ result = run_distill_cycle()
402
+
403
+ if result["dropped"]:
404
+ console.print(f"[yellow]Dropped {len(result['dropped'])} page(s):[/yellow]")
405
+ for slug in result["dropped"]:
406
+ console.print(f" [dim]- {slug}[/dim]")
407
+ else:
408
+ console.print("[green]No pages dropped.[/green]")
409
+
410
+ if result["drafts"]:
411
+ console.print(f"[green]Generated {result['drafts']} draft proposal(s) in drafts/.[/green]")
412
+ else:
413
+ console.print("[dim]No new draft proposals generated.[/dim]")
414
+
415
+
416
+ @app.command()
417
+ def sync(
418
+ pull: bool = typer.Option(False, "--pull", help="Pull from remote"),
419
+ ):
420
+ """Git sync the knowledge repo."""
421
+ from knowledge_studio.sync import sync_repo
422
+ ok = sync_repo(pull=pull)
423
+ if ok:
424
+ console.print("[green]Sync complete.[/green]")
425
+ else:
426
+ console.print("[red]Sync failed.[/red]")
427
+ raise typer.Exit(1)
428
+
429
+
430
+ # ── Config ───────────────────────────────────────────────────────
431
+
432
+ @config_app.command("init")
433
+ def config_init(
434
+ kb_path: str = typer.Option(None, "--kb-path", help="Knowledge base path"),
435
+ ):
436
+ """Initialize global config at ~/.oks/config.json."""
437
+ from knowledge_studio.config import init_config
438
+
439
+ path = init_config(kb_path)
440
+ console.print(f"[green]Config created:[/green] {path}")
441
+
442
+ from knowledge_studio.config import load_config
443
+ config = load_config()
444
+ console.print(f" [dim]KB path: {config.get('knowledge_base_path', '')}[/dim]")
445
+
446
+
447
+ @config_app.command("show")
448
+ def config_show():
449
+ """Show current global configuration."""
450
+ from knowledge_studio.config import load_config, config_path
451
+
452
+ config = load_config()
453
+ console.print(f"[dim]Config file: {config_path()}[/dim]\n")
454
+ console.print(Panel.fit(
455
+ f"[bold]Knowledge Base[/bold]\n {config.get('knowledge_base_path', '(not set)')}\n\n"
456
+ f"[bold]API Keys[/bold]\n"
457
+ f" openai: {'✓ set' if config.get('api_keys', {}).get('openai') else '✗ empty'}\n"
458
+ f" anthropic: {'✓ set' if config.get('api_keys', {}).get('anthropic') else '✗ empty'}\n\n"
459
+ f"[bold]Handler Config[/bold]",
460
+ border_style="cyan",
461
+ ))
462
+
463
+ handlers = config.get("handlers", {})
464
+ if handlers:
465
+ table = Table(show_header=True, header_style="bold cyan")
466
+ table.add_column("Handler", max_width=15)
467
+ table.add_column("Settings", max_width=50)
468
+ for name, settings in handlers.items():
469
+ table.add_row(name, ", ".join(f"{k}={v}" for k, v in settings.items()))
470
+ console.print(table)
471
+
472
+
473
+ @config_app.command("set")
474
+ def config_set(
475
+ key: str = typer.Argument(help="Config key (e.g., api_keys.openai, handlers.video.frame_interval)"),
476
+ value: str = typer.Argument(help="Config value"),
477
+ ):
478
+ """Set a config value."""
479
+ from knowledge_studio.config import load_config, save_config
480
+
481
+ config = load_config()
482
+
483
+ keys = key.split(".")
484
+ target = config
485
+ for k in keys[:-1]:
486
+ if k not in target:
487
+ target[k] = {}
488
+ target = target[k]
489
+
490
+ if value.lower() in ("true", "false"):
491
+ target[keys[-1]] = value.lower() == "true"
492
+ elif value.isdigit():
493
+ target[keys[-1]] = int(value)
494
+ else:
495
+ target[keys[-1]] = value
496
+
497
+ save_config(config)
498
+ console.print(f"[green]Set:[/green] {key} = {value}")
499
+
500
+
501
+ if __name__ == "__main__":
502
+ app()
@@ -0,0 +1,144 @@
1
+ """Global configuration — ~/.oks/config.json
2
+
3
+ Enables cross-project access: any project can find the knowledge base
4
+ via the global config, without being inside the OKS repo.
5
+
6
+ Config structure:
7
+ {
8
+ "knowledge_base_path": "/path/to/open-knowledge-studio",
9
+ "api_keys": {
10
+ "openai": "",
11
+ "anthropic": ""
12
+ },
13
+ "handlers": {
14
+ "video": {"frame_interval": 30, "frames_per_batch": 9, "whisper_model": "base"},
15
+ "audio": {"whisper_model": "base"},
16
+ "image": {"vision_model": "gpt-4o", "max_tokens": 1000}
17
+ }
18
+ }
19
+ """
20
+ from __future__ import annotations
21
+
22
+ import json
23
+ import os
24
+ from pathlib import Path
25
+ from typing import Any
26
+
27
+
28
+ DEFAULT_CONFIG: dict[str, Any] = {
29
+ "knowledge_base_path": "",
30
+ "api_keys": {
31
+ "openai": "",
32
+ "anthropic": "",
33
+ },
34
+ "handlers": {
35
+ "video": {
36
+ "frame_interval": 30,
37
+ "frames_per_batch": 9,
38
+ "whisper_model": "base",
39
+ },
40
+ "audio": {
41
+ "whisper_model": "base",
42
+ },
43
+ "image": {
44
+ "vision_model": "gpt-4o",
45
+ "max_tokens": 1000,
46
+ },
47
+ },
48
+ }
49
+
50
+
51
+ def config_dir() -> Path:
52
+ return Path.home() / ".oks"
53
+
54
+
55
+ def config_path() -> Path:
56
+ return config_dir() / "config.json"
57
+
58
+
59
+ def load_config() -> dict[str, Any]:
60
+ """Load global config, creating default if missing."""
61
+ path = config_path()
62
+ if not path.exists():
63
+ return dict(DEFAULT_CONFIG)
64
+ with open(path) as f:
65
+ return json.load(f)
66
+
67
+
68
+ def save_config(config: dict[str, Any]) -> None:
69
+ """Save config with atomic write."""
70
+ path = config_path()
71
+ path.parent.mkdir(parents=True, exist_ok=True)
72
+
73
+ import tempfile
74
+ fd, tmp = tempfile.mkstemp(dir=str(path.parent), suffix=".tmp")
75
+ try:
76
+ with os.fdopen(fd, "w") as f:
77
+ json.dump(config, f, indent=2, ensure_ascii=False)
78
+ os.replace(tmp, path)
79
+ except Exception:
80
+ os.unlink(tmp)
81
+ raise
82
+
83
+
84
+ def init_config(kb_path: str | None = None) -> Path:
85
+ """Initialize global config. Returns the config path."""
86
+ config = load_config()
87
+
88
+ if kb_path:
89
+ config["knowledge_base_path"] = kb_path
90
+ elif not config.get("knowledge_base_path"):
91
+ try:
92
+ from knowledge_studio.store import repo_root
93
+ config["knowledge_base_path"] = str(repo_root())
94
+ except Exception:
95
+ config["knowledge_base_path"] = str(Path.cwd())
96
+
97
+ save_config(config)
98
+ return config_path()
99
+
100
+
101
+ def get_kb_root() -> Path:
102
+ """Get the knowledge base root path.
103
+
104
+ Priority:
105
+ 1. OKS_ROOT env var
106
+ 2. ~/.oks/config.json → knowledge_base_path
107
+ 3. Current working directory
108
+ """
109
+ env_root = os.environ.get("OKS_ROOT")
110
+ if env_root:
111
+ return Path(env_root)
112
+
113
+ config = load_config()
114
+ kb_path = config.get("knowledge_base_path")
115
+ if kb_path:
116
+ return Path(kb_path)
117
+
118
+ return Path.cwd()
119
+
120
+
121
+ def get_handler_config(handler_name: str) -> dict[str, Any]:
122
+ """Get handler-specific config from global config."""
123
+ config = load_config()
124
+ return config.get("handlers", {}).get(handler_name, {})
125
+
126
+
127
+ def get_api_key(provider: str = "openai") -> str:
128
+ """Get API key for a provider.
129
+
130
+ Priority:
131
+ 1. Environment variable (OPENAI_API_KEY, ANTHROPIC_API_KEY)
132
+ 2. ~/.oks/config.json → api_keys.<provider>
133
+ """
134
+ env_map = {
135
+ "openai": "OPENAI_API_KEY",
136
+ "anthropic": "ANTHROPIC_API_KEY",
137
+ }
138
+ env_var = env_map.get(provider, f"{provider.upper()}_API_KEY")
139
+ env_val = os.environ.get(env_var)
140
+ if env_val:
141
+ return env_val
142
+
143
+ config = load_config()
144
+ return config.get("api_keys", {}).get(provider, "")