@agentskit/doc-bridge 0.1.0-alpha.3 → 1.0.1

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.
Files changed (52) hide show
  1. package/CHANGELOG.md +46 -0
  2. package/README.md +139 -57
  3. package/action.yml +78 -0
  4. package/dist/cli/program.js +1000 -70
  5. package/dist/cli/program.js.map +1 -1
  6. package/dist/index.d.ts +238 -26
  7. package/dist/index.js +717 -23
  8. package/dist/index.js.map +1 -1
  9. package/docs/DOGFOOD-ROUND3.md +74 -0
  10. package/docs/DOGFOOD-V1.md +84 -0
  11. package/docs/POSITIONING.md +2 -0
  12. package/docs/RELEASE.md +5 -3
  13. package/docs/chat-and-rag.md +2 -0
  14. package/docs/getting-started.md +14 -1
  15. package/docs/landing/index.html +299 -0
  16. package/docs/mcp.md +10 -1
  17. package/docs/ollama-demo.md +64 -0
  18. package/docs/playbook/doc-bridge-pattern.md +114 -0
  19. package/docs/recipes/index-pipeline.md +89 -0
  20. package/docs/skills/doc-bridge.md +64 -0
  21. package/docs/spec/cli.md +6 -0
  22. package/docs/spec/playbook-feedback.md +12 -4
  23. package/examples/demo-example/doc-bridge.config.json +23 -0
  24. package/examples/demo-example/docs/for-agents/INDEX.md +3 -0
  25. package/examples/demo-example/docs/for-agents/packages/example.md +14 -0
  26. package/examples/demo-example/package.json +7 -0
  27. package/examples/demo-example/src/.gitkeep +0 -0
  28. package/examples/demo-monorepo/doc-bridge.config.json +37 -0
  29. package/examples/demo-monorepo/docs/for-agents/INDEX.md +4 -0
  30. package/examples/demo-monorepo/docs/for-agents/packages/auth.md +22 -0
  31. package/examples/demo-monorepo/docs/for-agents/packages/billing.md +19 -0
  32. package/examples/demo-monorepo/docs/human/guides/auth.md +7 -0
  33. package/examples/demo-monorepo/package.json +7 -0
  34. package/examples/demo-monorepo/packages/auth/package.json +8 -0
  35. package/examples/demo-monorepo/packages/billing/package.json +7 -0
  36. package/examples/demo-monorepo/pnpm-workspace.yaml +2 -0
  37. package/examples/ollama-chat.config.ts +42 -0
  38. package/package.json +7 -4
  39. package/src/cli/demo.ts +143 -0
  40. package/src/cli/program.ts +194 -14
  41. package/src/doctor/badge.ts +53 -0
  42. package/src/doctor/run-doctor.ts +286 -0
  43. package/src/index-builder/build-handoffs.ts +19 -1
  44. package/src/index-builder/watch-index.ts +94 -0
  45. package/src/index.ts +24 -0
  46. package/src/intelligence/adapter.ts +14 -1
  47. package/src/mcp/install.ts +84 -0
  48. package/src/memory/github-pr.ts +190 -0
  49. package/src/playbook/doc-bridge-pattern.ts +121 -0
  50. package/src/query/query.ts +19 -1
  51. package/src/schemas/agent-handoff.ts +11 -0
  52. package/src/version.ts +1 -1
@@ -0,0 +1,286 @@
1
+ import type { DocBridgeConfigV1 } from '../config/schema.js'
2
+ import { buildDocBridgeIndex } from '../index-builder/build-index.js'
3
+ import { scanAgentCorpus } from '../index-builder/scan-corpus.js'
4
+ import { runGates, type GateRunResult } from '../gates/run-gates.js'
5
+ import { IndexNotFoundError, loadDocBridgeIndex } from '../query/load-index.js'
6
+ import type { DocBridgeIndexV1 } from '../schemas/doc-bridge-index.js'
7
+ import { doctorBadgeMetrics, type DoctorBadgeMetrics } from './badge.js'
8
+
9
+ export type DoctorIssue = {
10
+ readonly severity: 'error' | 'warn' | 'info'
11
+ readonly code: string
12
+ readonly message: string
13
+ readonly action?: string
14
+ }
15
+
16
+ export type DoctorCoverage = {
17
+ readonly packages: {
18
+ readonly total: number
19
+ readonly withAgentDoc: number
20
+ readonly withHumanDoc: number
21
+ readonly missingAgentDoc: readonly string[]
22
+ readonly missingHumanDoc: readonly string[]
23
+ }
24
+ readonly agentDocs: {
25
+ readonly total: number
26
+ readonly indexed: number
27
+ readonly unindexed: readonly string[]
28
+ }
29
+ readonly freshness: {
30
+ readonly ok: boolean
31
+ readonly message: string
32
+ readonly hasIndex: boolean
33
+ }
34
+ readonly gates: GateRunResult
35
+ }
36
+
37
+ export type DoctorReport = {
38
+ readonly ok: boolean
39
+ readonly score: number
40
+ readonly grade: 'A' | 'B' | 'C' | 'D' | 'F'
41
+ readonly coverage: DoctorCoverage
42
+ readonly badge: DoctorBadgeMetrics
43
+ readonly issues: readonly DoctorIssue[]
44
+ readonly nextActions: readonly string[]
45
+ }
46
+
47
+ const gradeForScore = (score: number): DoctorReport['grade'] => {
48
+ if (score >= 90) return 'A'
49
+ if (score >= 75) return 'B'
50
+ if (score >= 60) return 'C'
51
+ if (score >= 40) return 'D'
52
+ return 'F'
53
+ }
54
+
55
+ const agentDocPaths = (index: DocBridgeIndexV1): Set<string> => {
56
+ const paths = new Set<string>()
57
+ for (const owner of Object.values(index.lookup?.ownership ?? {})) {
58
+ if (owner.agentDoc) paths.add(owner.agentDoc)
59
+ }
60
+ for (const handoff of Object.values(index.handoffs ?? {})) {
61
+ if (handoff.startHere) paths.add(handoff.startHere)
62
+ }
63
+ return paths
64
+ }
65
+
66
+ const computeScore = (coverage: DoctorCoverage): number => {
67
+ let score = 0
68
+
69
+ if (coverage.freshness.hasIndex) score += 15
70
+ if (coverage.freshness.ok) score += 15
71
+
72
+ const { total, withAgentDoc, withHumanDoc } = coverage.packages
73
+ if (total > 0) {
74
+ score += Math.round((withAgentDoc / total) * 35)
75
+ score += Math.round((withHumanDoc / total) * 20)
76
+ } else if (coverage.agentDocs.indexed > 0) {
77
+ score += 35
78
+ }
79
+
80
+ if (coverage.gates.ok) score += 15
81
+ else {
82
+ const passed = coverage.gates.results.filter((gate) => gate.ok).length
83
+ const totalGates = coverage.gates.results.length || 1
84
+ score += Math.round((passed / totalGates) * 10)
85
+ }
86
+
87
+ return Math.min(100, Math.max(0, score))
88
+ }
89
+
90
+ const buildIssues = (coverage: DoctorCoverage): DoctorIssue[] => {
91
+ const issues: DoctorIssue[] = []
92
+
93
+ if (!coverage.freshness.hasIndex) {
94
+ issues.push({
95
+ severity: 'error',
96
+ code: 'index-missing',
97
+ message: 'No doc-bridge index found.',
98
+ action: 'ak-docs index',
99
+ })
100
+ } else if (!coverage.freshness.ok) {
101
+ issues.push({
102
+ severity: 'error',
103
+ code: 'index-stale',
104
+ message: coverage.freshness.message,
105
+ action: 'ak-docs index',
106
+ })
107
+ }
108
+
109
+ for (const id of coverage.packages.missingAgentDoc) {
110
+ issues.push({
111
+ severity: 'warn',
112
+ code: 'missing-agent-doc',
113
+ message: `Package "${id}" has no dedicated agent doc.`,
114
+ action: `ak-docs init --scaffold-workspaces # or edit docs/for-agents/packages/${id}.md`,
115
+ })
116
+ }
117
+
118
+ for (const id of coverage.packages.missingHumanDoc) {
119
+ issues.push({
120
+ severity: 'info',
121
+ code: 'missing-human-doc',
122
+ message: `Package "${id}" has no linked human guide.`,
123
+ action: 'ak-docs bootstrap agent-docs',
124
+ })
125
+ }
126
+
127
+ for (const gate of coverage.gates.results.filter((result) => !result.ok)) {
128
+ issues.push({
129
+ severity: gate.id === 'index-freshness' ? 'error' : 'warn',
130
+ code: `gate-${gate.id}`,
131
+ message: gate.message,
132
+ action: gate.id === 'index-freshness' ? 'ak-docs index' : 'ak-docs gate run',
133
+ })
134
+ }
135
+
136
+ return issues
137
+ }
138
+
139
+ const buildNextActions = (issues: readonly DoctorIssue[], coverage: DoctorCoverage): string[] => {
140
+ const actions = new Set<string>()
141
+ for (const issue of issues) {
142
+ if (issue.action) actions.add(issue.action)
143
+ }
144
+
145
+ if (!coverage.freshness.hasIndex || !coverage.freshness.ok) {
146
+ actions.add('ak-docs index')
147
+ }
148
+ if (coverage.packages.missingHumanDoc.length) {
149
+ actions.add('ak-docs bootstrap agent-docs')
150
+ }
151
+ if (coverage.packages.total > 0) {
152
+ const sample =
153
+ coverage.packages.missingAgentDoc[0] ??
154
+ coverage.packages.missingHumanDoc[0]
155
+ if (sample) actions.add(`ak-docs query package ${sample} --agent`)
156
+ }
157
+ if (!actions.size) {
158
+ actions.add('ak-docs mcp install --cursor')
159
+ actions.add('ak-docs gate run')
160
+ }
161
+
162
+ return [...actions].slice(0, 6)
163
+ }
164
+
165
+ export const runDoctor = (root: string, config: DocBridgeConfigV1): DoctorReport => {
166
+ let index: DocBridgeIndexV1 | undefined
167
+ let hasIndex = true
168
+ let freshnessOk = false
169
+ let freshnessMessage = 'Index is fresh'
170
+
171
+ try {
172
+ index = loadDocBridgeIndex(root, config)
173
+ const next = buildDocBridgeIndex({ root, config, write: false }).index.contentHash
174
+ freshnessOk = index.contentHash === next
175
+ freshnessMessage = freshnessOk ? 'Index is fresh' : 'Index is stale. Run: ak-docs index'
176
+ } catch (error) {
177
+ if (error instanceof IndexNotFoundError) {
178
+ hasIndex = false
179
+ freshnessOk = false
180
+ freshnessMessage = error.message
181
+ index = buildDocBridgeIndex({ root, config, write: false }).index
182
+ } else {
183
+ throw error
184
+ }
185
+ }
186
+
187
+ const ownership = Object.entries(index.lookup?.ownership ?? {})
188
+ const missingAgentDoc = ownership
189
+ .filter(([, owner]) => !owner.agentDoc || owner.agentDoc === config.corpus.agent.index)
190
+ .map(([id]) => id)
191
+ const missingHumanDoc = ownership.filter(([, owner]) => !owner.humanDoc).map(([id]) => id)
192
+
193
+ const indexedPaths = new Set(index.knowledge.map((entry) => entry.path))
194
+ const expectedAgentDocs = agentDocPaths(index)
195
+ const unindexed = [...expectedAgentDocs].filter((path) => !indexedPaths.has(path))
196
+
197
+ const corpusDocs = scanAgentCorpus(root, config).filter(
198
+ (doc) => doc.path !== config.corpus.agent.index,
199
+ )
200
+
201
+ const gates = runGates(root, config)
202
+
203
+ const coverage: DoctorCoverage = {
204
+ packages: {
205
+ total: ownership.length,
206
+ withAgentDoc: ownership.length - missingAgentDoc.length,
207
+ withHumanDoc: ownership.length - missingHumanDoc.length,
208
+ missingAgentDoc,
209
+ missingHumanDoc,
210
+ },
211
+ agentDocs: {
212
+ total: corpusDocs.length,
213
+ indexed: corpusDocs.filter((doc) => indexedPaths.has(doc.path)).length,
214
+ unindexed: corpusDocs.filter((doc) => !indexedPaths.has(doc.path)).map((doc) => doc.path),
215
+ },
216
+ freshness: {
217
+ ok: freshnessOk,
218
+ message: freshnessMessage,
219
+ hasIndex,
220
+ },
221
+ gates,
222
+ }
223
+
224
+ const issues = buildIssues(coverage)
225
+ const score = computeScore(coverage)
226
+ const nextActions = buildNextActions(issues, coverage)
227
+
228
+ const report: DoctorReport = {
229
+ ok: issues.every((issue) => issue.severity !== 'error') && gates.ok,
230
+ score,
231
+ grade: gradeForScore(score),
232
+ coverage,
233
+ badge: { handoffPct: 0, bridgePct: 0, score, grade: gradeForScore(score), packages: 0 },
234
+ issues,
235
+ nextActions,
236
+ }
237
+ return { ...report, badge: doctorBadgeMetrics(report) }
238
+ }
239
+
240
+ export const formatDoctorText = (report: DoctorReport): string[] => {
241
+ const { coverage } = report
242
+ const handoffPct =
243
+ coverage.packages.total > 0
244
+ ? Math.round((coverage.packages.withAgentDoc / coverage.packages.total) * 100)
245
+ : 0
246
+ const humanPct =
247
+ coverage.packages.total > 0
248
+ ? Math.round((coverage.packages.withHumanDoc / coverage.packages.total) * 100)
249
+ : 0
250
+
251
+ const lines = [
252
+ 'doc-bridge doctor',
253
+ '─'.repeat(40),
254
+ `Score: ${report.score}/100 (${report.grade})`,
255
+ '',
256
+ 'Coverage',
257
+ ` Packages: ${coverage.packages.total}`,
258
+ ` Agent docs: ${coverage.packages.withAgentDoc}/${coverage.packages.total} (${handoffPct}% handoff-ready)`,
259
+ ` Human guides: ${coverage.packages.withHumanDoc}/${coverage.packages.total} (${humanPct}% bridged)`,
260
+ ` Corpus indexed: ${coverage.agentDocs.indexed}/${coverage.agentDocs.total} agent docs`,
261
+ ` Index freshness: ${coverage.freshness.ok ? 'fresh' : 'stale or missing'}`,
262
+ ` Gates: ${coverage.gates.results.filter((g) => g.ok).length}/${coverage.gates.results.length} passing`,
263
+ ` Badge: handoff ${report.badge.handoffPct}% · bridge ${report.badge.bridgePct}%`,
264
+ ]
265
+
266
+ if (coverage.packages.missingHumanDoc.length) {
267
+ lines.push('', 'Missing humanDoc (bridge gap)', ...coverage.packages.missingHumanDoc.map((id) => ` • ${id}`))
268
+ }
269
+ if (coverage.packages.missingAgentDoc.length) {
270
+ lines.push('', 'Missing agent doc', ...coverage.packages.missingAgentDoc.map((id) => ` • ${id}`))
271
+ }
272
+
273
+ if (report.issues.length) {
274
+ lines.push('', 'Issues')
275
+ for (const issue of report.issues.slice(0, 8)) {
276
+ const icon = issue.severity === 'error' ? '✗' : issue.severity === 'warn' ? '!' : '·'
277
+ lines.push(` ${icon} [${issue.code}] ${issue.message}`)
278
+ }
279
+ if (report.issues.length > 8) {
280
+ lines.push(` … +${report.issues.length - 8} more`)
281
+ }
282
+ }
283
+
284
+ lines.push('', 'Next actions', ...report.nextActions.map((action) => ` → ${action}`))
285
+ return lines
286
+ }
@@ -183,6 +183,18 @@ export const buildLookup = (
183
183
  }
184
184
  ownership[pkg.id] = record
185
185
 
186
+ const bridge = record.humanDoc
187
+ ? /^https?:\/\//.test(record.humanDoc)
188
+ ? { humanDoc: 'external' as const }
189
+ : { humanDoc: 'linked' as const }
190
+ : config.corpus.human
191
+ ? {
192
+ humanDoc: 'missing' as const,
193
+ action: 'ak-docs bootstrap agent-docs',
194
+ bootstrap: `docs/for-agents/human/${pkg.id}.md`,
195
+ }
196
+ : undefined
197
+
186
198
  handoffs[pkg.id] = {
187
199
  type: 'agent-handoff',
188
200
  schemaVersion: 1,
@@ -201,7 +213,13 @@ export const buildLookup = (
201
213
  editRoots: [record.path],
202
214
  checks: [...record.checks],
203
215
  ...(record.humanDoc ? { humanDoc: record.humanDoc } : {}),
204
- notes: record.purpose ? [record.purpose] : [],
216
+ ...(bridge ? { bridge } : {}),
217
+ notes: [
218
+ ...(record.purpose ? [record.purpose] : []),
219
+ ...(!record.humanDoc && config.corpus.human
220
+ ? [`Human guide missing for ${pkg.id}. Run: ak-docs bootstrap agent-docs`]
221
+ : []),
222
+ ],
205
223
  }
206
224
  }
207
225
 
@@ -0,0 +1,94 @@
1
+ import { existsSync, watch } from 'node:fs'
2
+ import { dirname, join, resolve } from 'node:path'
3
+
4
+ import type { DocBridgeConfigV1 } from '../config/schema.js'
5
+ import { buildDocBridgeIndex } from './build-index.js'
6
+
7
+ export type WatchIndexOptions = {
8
+ readonly root: string
9
+ readonly config: DocBridgeConfigV1
10
+ readonly configPath?: string
11
+ readonly debounceMs?: number
12
+ readonly onRebuild?: (summary: { knowledgeCount: number; handoffCount: number; hash: string }) => void
13
+ }
14
+
15
+ const WATCH_PATTERN = /\.(md|mdx|json|ya?ml|mdc)$/i
16
+
17
+ const collectWatchRoots = (root: string, config: DocBridgeConfigV1, configPath?: string): string[] => {
18
+ const roots = new Set<string>()
19
+ roots.add(resolve(root, config.corpus.agent.root))
20
+ const humanSources = config.corpus.human
21
+ ? Array.isArray(config.corpus.human)
22
+ ? config.corpus.human
23
+ : [config.corpus.human]
24
+ : []
25
+ for (const source of humanSources) {
26
+ const humanOpts = source.options ?? {}
27
+ for (const key of ['contentDir', 'docsDir', 'root']) {
28
+ const value = humanOpts[key]
29
+ if (typeof value === 'string' && value.length) roots.add(resolve(root, value))
30
+ }
31
+ }
32
+ if (configPath) roots.add(dirname(resolve(configPath)))
33
+ return [...roots].filter((dir) => existsSync(dir))
34
+ }
35
+
36
+ export const watchDocBridgeIndex = (opts: WatchIndexOptions): Promise<number> => {
37
+ const debounceMs = opts.debounceMs ?? 350
38
+ let timer: ReturnType<typeof setTimeout> | undefined
39
+ let running = false
40
+
41
+ const rebuild = (): void => {
42
+ if (timer) clearTimeout(timer)
43
+ timer = setTimeout(() => {
44
+ if (running) return
45
+ running = true
46
+ try {
47
+ const result = buildDocBridgeIndex({ root: opts.root, config: opts.config })
48
+ const handoffCount = Object.keys(result.index.handoffs ?? {}).length
49
+ const summary = {
50
+ knowledgeCount: result.index.knowledge.length,
51
+ handoffCount,
52
+ hash: result.index.contentHash.slice(0, 8),
53
+ }
54
+ opts.onRebuild?.(summary)
55
+ process.stdout.write(
56
+ `[ak-docs] indexed ${summary.knowledgeCount} docs, ${summary.handoffCount} handoffs (${summary.hash}…)\n`,
57
+ )
58
+ } catch (error) {
59
+ process.stderr.write(
60
+ `[ak-docs] index failed: ${error instanceof Error ? error.message : String(error)}\n`,
61
+ )
62
+ } finally {
63
+ running = false
64
+ }
65
+ }, debounceMs)
66
+ }
67
+
68
+ rebuild()
69
+
70
+ for (const dir of collectWatchRoots(opts.root, opts.config, opts.configPath)) {
71
+ watch(dir, { recursive: true }, (_event, filename) => {
72
+ if (!filename || !WATCH_PATTERN.test(filename)) return
73
+ rebuild()
74
+ })
75
+ }
76
+
77
+ const configDir = resolve(opts.root)
78
+ if (existsSync(configDir)) {
79
+ watch(configDir, (_event, filename) => {
80
+ if (!filename || !/doc-bridge\.config/.test(filename)) return
81
+ rebuild()
82
+ })
83
+ }
84
+
85
+ process.stdout.write(
86
+ `[ak-docs] watching ${collectWatchRoots(opts.root, opts.config, opts.configPath).join(', ') || opts.root} (Ctrl+C to stop)\n`,
87
+ )
88
+
89
+ return new Promise((resolvePromise) => {
90
+ const onSignal = () => resolvePromise(0)
91
+ process.once('SIGINT', onSignal)
92
+ process.once('SIGTERM', onSignal)
93
+ })
94
+ }
package/src/index.ts CHANGED
@@ -20,7 +20,9 @@ export {
20
20
  HandoffTargetTypeSchema,
21
21
  HANDOFF_SCHEMA_VERSION,
22
22
  normalizeAgentHandoff,
23
+ HandoffBridgeSchema,
23
24
  type AgentHandoffV1,
25
+ type HandoffBridge,
24
26
  type AgentSearchV1,
25
27
  type HandoffTarget,
26
28
  type HandoffTargetType,
@@ -74,6 +76,22 @@ export {
74
76
  type GateRunResult,
75
77
  } from './gates/run-gates.js'
76
78
  export { MCP_TOOLS, handleMcpRequest, startMcpStdioServer } from './mcp/server.js'
79
+ export { installMcpConfig, mcpSnippet, type McpInstallResult, type McpInstallTarget } from './mcp/install.js'
80
+ export { runDoctor, formatDoctorText, type DoctorReport, type DoctorIssue, type DoctorCoverage } from './doctor/run-doctor.js'
81
+ export {
82
+ doctorBadgeMetrics,
83
+ formatDoctorBadgeJson,
84
+ formatDoctorBadgeMarkdown,
85
+ type DoctorBadgeMetrics,
86
+ } from './doctor/badge.js'
87
+ export { watchDocBridgeIndex, type WatchIndexOptions } from './index-builder/watch-index.js'
88
+ export {
89
+ promoteMemoryToGithubPr,
90
+ writePromotionDraft,
91
+ defaultPromotionDraftPath,
92
+ type GithubPrOptions,
93
+ type GithubPrResult,
94
+ } from './memory/github-pr.js'
77
95
  export { sha256NormalizedV1 } from './index-builder/content-hash.js'
78
96
  export { IndexNotFoundError, indexFilePath, loadDocBridgeIndex, resolveRoot } from './query/load-index.js'
79
97
  export { runQuery, type QueryKind, type QueryRequest, type QueryResult } from './query/query.js'
@@ -118,3 +136,9 @@ export { projectRootFromConfigPath } from './config/load-config.js'
118
136
  export { createDocBridgeRag } from './intelligence/rag.js'
119
137
  export { runChatOnce, startInkChat } from './intelligence/chat.js'
120
138
  export { PeerMissingError, layer1InstallHint } from './intelligence/peers.js'
139
+ export {
140
+ DOC_BRIDGE_PATTERN_ID,
141
+ DOC_BRIDGE_PATTERN_META,
142
+ docBridgePatternMarkdown,
143
+ docBridgePatternPayload,
144
+ } from './playbook/doc-bridge-pattern.js'
@@ -9,6 +9,19 @@ const readEnv = (name: string | undefined): string | undefined => {
9
9
  return value && value.length > 0 ? value : undefined
10
10
  }
11
11
 
12
+ const providerDefaultApiKeyEnv = (provider: string): string | undefined => {
13
+ switch (provider) {
14
+ case 'openai':
15
+ return 'OPENAI_API_KEY'
16
+ case 'anthropic':
17
+ return 'ANTHROPIC_API_KEY'
18
+ case 'openrouter':
19
+ return 'OPENROUTER_API_KEY'
20
+ default:
21
+ return undefined
22
+ }
23
+ }
24
+
12
25
  export type ResolvedIntelligence = {
13
26
  readonly adapter: unknown
14
27
  readonly embed: unknown
@@ -30,7 +43,7 @@ export const resolveIntelligenceRuntime = async (
30
43
  const adapters = await importPeer<typeof import('@agentskit/adapters')>('@agentskit/adapters')
31
44
  const provider = adapterCfg.provider
32
45
  const model = adapterCfg.model
33
- const apiKey = readEnv(adapterCfg.apiKeyEnv) ?? readEnv('OPENAI_API_KEY') ?? readEnv('ANTHROPIC_API_KEY')
46
+ const apiKey = readEnv(adapterCfg.apiKeyEnv) ?? readEnv(providerDefaultApiKeyEnv(provider))
34
47
  const baseUrl = adapterCfg.baseUrl
35
48
 
36
49
  let adapter: unknown
@@ -0,0 +1,84 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
2
+ import { homedir } from 'node:os'
3
+ import { dirname, join, resolve } from 'node:path'
4
+
5
+ export type McpInstallTarget = 'cursor' | 'claude'
6
+
7
+ export type McpInstallResult = {
8
+ readonly ok: boolean
9
+ readonly target: McpInstallTarget
10
+ readonly configPath: string
11
+ readonly created: boolean
12
+ readonly serverName: string
13
+ readonly nextSteps: readonly string[]
14
+ }
15
+
16
+ const SERVER_NAME = 'ak-docs'
17
+
18
+ const mcpServerEntry = (root: string) => ({
19
+ command: 'npx',
20
+ args: ['ak-docs', 'mcp'],
21
+ cwd: root,
22
+ })
23
+
24
+ const readJson = (path: string): Record<string, unknown> => {
25
+ if (!existsSync(path)) return {}
26
+ try {
27
+ const parsed = JSON.parse(readFileSync(path, 'utf8')) as unknown
28
+ return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
29
+ ? (parsed as Record<string, unknown>)
30
+ : {}
31
+ } catch {
32
+ return {}
33
+ }
34
+ }
35
+
36
+ const writeJson = (path: string, value: Record<string, unknown>): void => {
37
+ mkdirSync(dirname(path), { recursive: true })
38
+ writeFileSync(path, `${JSON.stringify(value, null, 2)}\n`, 'utf8')
39
+ }
40
+
41
+ const resolveTargetPath = (target: McpInstallTarget, root: string): string => {
42
+ if (target === 'cursor') return resolve(root, '.cursor', 'mcp.json')
43
+ return join(homedir(), 'Library', 'Application Support', 'Claude', 'claude_desktop_config.json')
44
+ }
45
+
46
+ export const installMcpConfig = (
47
+ root: string,
48
+ target: McpInstallTarget,
49
+ ): McpInstallResult => {
50
+ const configPath = resolveTargetPath(target, root)
51
+ const created = !existsSync(configPath)
52
+ const existing = readJson(configPath)
53
+ const servers =
54
+ existing.mcpServers && typeof existing.mcpServers === 'object' && !Array.isArray(existing.mcpServers)
55
+ ? { ...(existing.mcpServers as Record<string, unknown>) }
56
+ : {}
57
+
58
+ servers[SERVER_NAME] = mcpServerEntry(root)
59
+ writeJson(configPath, { ...existing, mcpServers: servers })
60
+
61
+ const nextSteps =
62
+ target === 'cursor'
63
+ ? [
64
+ 'Restart Cursor or reload MCP servers',
65
+ 'Paste the doc-bridge skill into Cursor rules (see docs/skills/doc-bridge.md)',
66
+ 'Before editing packages/* call handoff.resolve',
67
+ ]
68
+ : [
69
+ 'Restart Claude Desktop',
70
+ 'Run ak-docs index after doc changes',
71
+ ]
72
+
73
+ return {
74
+ ok: true,
75
+ target,
76
+ configPath,
77
+ created,
78
+ serverName: SERVER_NAME,
79
+ nextSteps,
80
+ }
81
+ }
82
+
83
+ export const mcpSnippet = (root: string): string =>
84
+ JSON.stringify({ mcpServers: { [SERVER_NAME]: mcpServerEntry(root) } }, null, 2)