feat: add wiki mvp commands (#532)
This commit is contained in:
@@ -143,6 +143,7 @@ import heapDump from './commands/heapdump/index.js'
|
|||||||
import mockLimits from './commands/mock-limits/index.js'
|
import mockLimits from './commands/mock-limits/index.js'
|
||||||
import bridgeKick from './commands/bridge-kick.js'
|
import bridgeKick from './commands/bridge-kick.js'
|
||||||
import version from './commands/version.js'
|
import version from './commands/version.js'
|
||||||
|
import wiki from './commands/wiki/index.js'
|
||||||
import summary from './commands/summary/index.js'
|
import summary from './commands/summary/index.js'
|
||||||
import {
|
import {
|
||||||
resetLimits,
|
resetLimits,
|
||||||
@@ -324,6 +325,7 @@ const COMMANDS = memoize((): Command[] => [
|
|||||||
usage,
|
usage,
|
||||||
usageReport,
|
usageReport,
|
||||||
vim,
|
vim,
|
||||||
|
wiki,
|
||||||
...(webCmd ? [webCmd] : []),
|
...(webCmd ? [webCmd] : []),
|
||||||
...(forkCmd ? [forkCmd] : []),
|
...(forkCmd ? [forkCmd] : []),
|
||||||
...(buddy ? [buddy] : []),
|
...(buddy ? [buddy] : []),
|
||||||
|
|||||||
12
src/commands/wiki/index.ts
Normal file
12
src/commands/wiki/index.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import type { Command } from '../../commands.js'
|
||||||
|
|
||||||
|
const wiki = {
|
||||||
|
type: 'local-jsx',
|
||||||
|
name: 'wiki',
|
||||||
|
description: 'Initialize and inspect the OpenClaude project wiki',
|
||||||
|
argumentHint: '[init|status]',
|
||||||
|
immediate: true,
|
||||||
|
load: () => import('./wiki.js'),
|
||||||
|
} satisfies Command
|
||||||
|
|
||||||
|
export default wiki
|
||||||
123
src/commands/wiki/wiki.tsx
Normal file
123
src/commands/wiki/wiki.tsx
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { COMMON_HELP_ARGS, COMMON_INFO_ARGS } from '../../constants/xml.js'
|
||||||
|
import { ingestLocalWikiSource } from '../../services/wiki/ingest.js'
|
||||||
|
import { initializeWiki } from '../../services/wiki/init.js'
|
||||||
|
import { getWikiStatus } from '../../services/wiki/status.js'
|
||||||
|
import type {
|
||||||
|
LocalJSXCommandCall,
|
||||||
|
LocalJSXCommandOnDone,
|
||||||
|
} from '../../types/command.js'
|
||||||
|
import { getCwd } from '../../utils/cwd.js'
|
||||||
|
|
||||||
|
function renderHelp(): string {
|
||||||
|
return `Usage: /wiki [init|status|ingest <path>]
|
||||||
|
|
||||||
|
Manage the OpenClaude project wiki stored in .openclaude/wiki.
|
||||||
|
|
||||||
|
Commands:
|
||||||
|
/wiki init Initialize the wiki structure in the current project
|
||||||
|
/wiki status Show wiki status and page/source counts
|
||||||
|
/wiki ingest Ingest a local file into wiki sources
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
/wiki init
|
||||||
|
/wiki status
|
||||||
|
/wiki ingest README.md`
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatInitResult(result: Awaited<ReturnType<typeof initializeWiki>>): string {
|
||||||
|
const lines = [`Initialized OpenClaude wiki at ${result.root}`]
|
||||||
|
|
||||||
|
if (result.alreadyExisted) {
|
||||||
|
lines.push('', 'Wiki already existed. No new files were created.')
|
||||||
|
return lines.join('\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.createdFiles.length > 0) {
|
||||||
|
lines.push('', 'Created files:')
|
||||||
|
for (const file of result.createdFiles) {
|
||||||
|
lines.push(`- ${file}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines.join('\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatStatus(status: Awaited<ReturnType<typeof getWikiStatus>>): string {
|
||||||
|
if (!status.initialized) {
|
||||||
|
return `OpenClaude wiki is not initialized in this project.\n\nRun /wiki init to create ${status.root}.`
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'OpenClaude wiki status',
|
||||||
|
'',
|
||||||
|
`Root: ${status.root}`,
|
||||||
|
`Pages: ${status.pageCount}`,
|
||||||
|
`Sources: ${status.sourceCount}`,
|
||||||
|
`Schema: ${status.hasSchema ? 'present' : 'missing'}`,
|
||||||
|
`Index: ${status.hasIndex ? 'present' : 'missing'}`,
|
||||||
|
`Log: ${status.hasLog ? 'present' : 'missing'}`,
|
||||||
|
`Last updated: ${status.lastUpdatedAt ?? 'unknown'}`,
|
||||||
|
].join('\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatIngestResult(
|
||||||
|
result: Awaited<ReturnType<typeof ingestLocalWikiSource>>,
|
||||||
|
): string {
|
||||||
|
return [
|
||||||
|
`Ingested ${result.sourceFile} into the OpenClaude wiki.`,
|
||||||
|
'',
|
||||||
|
`Title: ${result.title}`,
|
||||||
|
`Source note: ${result.sourceNote}`,
|
||||||
|
`Summary: ${result.summary}`,
|
||||||
|
].join('\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runWikiCommand(
|
||||||
|
onDone: LocalJSXCommandOnDone,
|
||||||
|
args: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const cwd = getCwd()
|
||||||
|
const normalized = args.trim().toLowerCase()
|
||||||
|
|
||||||
|
if (COMMON_HELP_ARGS.includes(normalized) || COMMON_INFO_ARGS.includes(normalized)) {
|
||||||
|
onDone(renderHelp(), { display: 'system' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!normalized || normalized === 'status') {
|
||||||
|
onDone(formatStatus(await getWikiStatus(cwd)), { display: 'system' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalized === 'init') {
|
||||||
|
onDone(formatInitResult(await initializeWiki(cwd)), { display: 'system' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalized.startsWith('ingest')) {
|
||||||
|
const pathArg = args.trim().slice('ingest'.length).trim()
|
||||||
|
if (!pathArg) {
|
||||||
|
onDone('Usage: /wiki ingest <local-file-path>', { display: 'system' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
onDone(formatIngestResult(await ingestLocalWikiSource(cwd, pathArg)), {
|
||||||
|
display: 'system',
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
onDone(`Unknown wiki subcommand: ${args.trim()}\n\n${renderHelp()}`, {
|
||||||
|
display: 'system',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export const call: LocalJSXCommandCall = async (
|
||||||
|
onDone,
|
||||||
|
_context,
|
||||||
|
args,
|
||||||
|
): Promise<React.ReactNode> => {
|
||||||
|
await runWikiCommand(onDone, args ?? '')
|
||||||
|
return null
|
||||||
|
}
|
||||||
68
src/services/wiki/indexBuilder.ts
Normal file
68
src/services/wiki/indexBuilder.ts
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import { readdir, readFile, writeFile } from 'fs/promises'
|
||||||
|
import { basename, relative } from 'path'
|
||||||
|
import { getWikiPaths } from './paths.js'
|
||||||
|
|
||||||
|
async function listMarkdownFiles(dir: string): Promise<string[]> {
|
||||||
|
const entries = await readdir(dir, { withFileTypes: true })
|
||||||
|
const files: string[] = []
|
||||||
|
|
||||||
|
for (const entry of entries) {
|
||||||
|
const fullPath = `${dir}/${entry.name}`
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
files.push(...(await listMarkdownFiles(fullPath)))
|
||||||
|
} else if (entry.isFile() && entry.name.endsWith('.md')) {
|
||||||
|
files.push(fullPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return files.sort()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getPageTitle(path: string): Promise<string> {
|
||||||
|
const content = await readFile(path, 'utf8')
|
||||||
|
const titleLine = content
|
||||||
|
.split('\n')
|
||||||
|
.map(line => line.trim())
|
||||||
|
.find(line => line.startsWith('# '))
|
||||||
|
|
||||||
|
return titleLine ? titleLine.replace(/^#\s+/, '') : basename(path, '.md')
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function rebuildWikiIndex(cwd: string): Promise<void> {
|
||||||
|
const paths = getWikiPaths(cwd)
|
||||||
|
const pageFiles = await listMarkdownFiles(paths.pagesDir)
|
||||||
|
const sourceFiles = await listMarkdownFiles(paths.sourcesDir)
|
||||||
|
|
||||||
|
const pageLinks = await Promise.all(
|
||||||
|
pageFiles.map(async file => {
|
||||||
|
const rel = relative(paths.root, file)
|
||||||
|
const title = await getPageTitle(file)
|
||||||
|
return `- [${title}](./${rel.replace(/\\/g, '/')})`
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const sourceLinks = sourceFiles.map(file => {
|
||||||
|
const rel = relative(paths.root, file).replace(/\\/g, '/')
|
||||||
|
const title = basename(file, '.md')
|
||||||
|
return `- [${title}](./${rel})`
|
||||||
|
})
|
||||||
|
|
||||||
|
const content = `# ${basename(cwd)} Wiki
|
||||||
|
|
||||||
|
This wiki is maintained by OpenClaude as a durable project knowledge layer.
|
||||||
|
|
||||||
|
## Core Pages
|
||||||
|
|
||||||
|
${pageLinks.length > 0 ? pageLinks.join('\n') : '- No pages yet'}
|
||||||
|
|
||||||
|
## Sources
|
||||||
|
|
||||||
|
${sourceLinks.length > 0 ? sourceLinks.join('\n') : '- No sources yet'}
|
||||||
|
|
||||||
|
## Recent Updates
|
||||||
|
|
||||||
|
- See [log.md](./log.md)
|
||||||
|
`
|
||||||
|
|
||||||
|
await writeFile(paths.indexFile, content, 'utf8')
|
||||||
|
}
|
||||||
48
src/services/wiki/ingest.test.ts
Normal file
48
src/services/wiki/ingest.test.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import { afterEach, expect, test } from 'bun:test'
|
||||||
|
import { mkdtemp, readFile, rm, writeFile } from 'fs/promises'
|
||||||
|
import { tmpdir } from 'os'
|
||||||
|
import { join } from 'path'
|
||||||
|
import { ingestLocalWikiSource } from './ingest.js'
|
||||||
|
import { getWikiPaths } from './paths.js'
|
||||||
|
|
||||||
|
const tempDirs: string[] = []
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await Promise.all(
|
||||||
|
tempDirs.splice(0).map(dir => rm(dir, { recursive: true, force: true })),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
async function makeProjectDir(): Promise<string> {
|
||||||
|
const dir = await mkdtemp(join(tmpdir(), 'openclaude-wiki-ingest-'))
|
||||||
|
tempDirs.push(dir)
|
||||||
|
return dir
|
||||||
|
}
|
||||||
|
|
||||||
|
test('ingestLocalWikiSource creates a source note and updates log/index', async () => {
|
||||||
|
const cwd = await makeProjectDir()
|
||||||
|
const sourcePath = join(cwd, 'notes.md')
|
||||||
|
await writeFile(
|
||||||
|
sourcePath,
|
||||||
|
'# Design Notes\n\nThis subsystem coordinates provider routing and session state.\nIt should be documented for future contributors.\n',
|
||||||
|
'utf8',
|
||||||
|
)
|
||||||
|
|
||||||
|
const result = await ingestLocalWikiSource(cwd, 'notes.md')
|
||||||
|
const paths = getWikiPaths(cwd)
|
||||||
|
|
||||||
|
expect(result.sourceFile).toBe('notes.md')
|
||||||
|
expect(result.title).toBe('Design Notes')
|
||||||
|
expect(result.sourceNote.startsWith('.openclaude/wiki/sources/')).toBe(true)
|
||||||
|
|
||||||
|
const sourceNote = await readFile(join(cwd, result.sourceNote), 'utf8')
|
||||||
|
expect(sourceNote).toContain('# Design Notes')
|
||||||
|
expect(sourceNote).toContain('Path: `notes.md`')
|
||||||
|
|
||||||
|
const log = await readFile(paths.logFile, 'utf8')
|
||||||
|
expect(log).toContain('Ingested `notes.md`')
|
||||||
|
|
||||||
|
const index = await readFile(paths.indexFile, 'utf8')
|
||||||
|
expect(index).toContain('./sources/')
|
||||||
|
expect(index).toContain(result.sourceNote.replace('.openclaude/wiki/', './'))
|
||||||
|
})
|
||||||
93
src/services/wiki/ingest.ts
Normal file
93
src/services/wiki/ingest.ts
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
import { appendFile, readFile, stat, writeFile } from 'fs/promises'
|
||||||
|
import { basename, extname, isAbsolute, relative, resolve } from 'path'
|
||||||
|
import { initializeWiki } from './init.js'
|
||||||
|
import { rebuildWikiIndex } from './indexBuilder.js'
|
||||||
|
import { getWikiPaths } from './paths.js'
|
||||||
|
import type { WikiIngestResult } from './types.js'
|
||||||
|
import {
|
||||||
|
extractTitleFromText,
|
||||||
|
sanitizeWikiSlug,
|
||||||
|
summarizeText,
|
||||||
|
} from './utils.js'
|
||||||
|
|
||||||
|
function buildSourceNote(params: {
|
||||||
|
title: string
|
||||||
|
sourcePath: string
|
||||||
|
ingestedAt: string
|
||||||
|
summary: string
|
||||||
|
excerpt: string
|
||||||
|
}): string {
|
||||||
|
const { title, sourcePath, ingestedAt, summary, excerpt } = params
|
||||||
|
|
||||||
|
return `# ${title}
|
||||||
|
|
||||||
|
## Source
|
||||||
|
|
||||||
|
- Path: \`${sourcePath}\`
|
||||||
|
- Ingested at: ${ingestedAt}
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
${summary}
|
||||||
|
|
||||||
|
## Excerpt
|
||||||
|
|
||||||
|
\`\`\`
|
||||||
|
${excerpt}
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
## Linked Pages
|
||||||
|
|
||||||
|
- [Architecture](../pages/architecture.md)
|
||||||
|
`
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildLogEntry(sourcePath: string, title: string, ingestedAt: string): string {
|
||||||
|
return `- ${ingestedAt}: Ingested \`${sourcePath}\` into source note "${title}"`
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function ingestLocalWikiSource(
|
||||||
|
cwd: string,
|
||||||
|
rawPath: string,
|
||||||
|
): Promise<WikiIngestResult> {
|
||||||
|
await initializeWiki(cwd)
|
||||||
|
|
||||||
|
const resolvedPath = isAbsolute(rawPath) ? rawPath : resolve(cwd, rawPath)
|
||||||
|
const fileInfo = await stat(resolvedPath)
|
||||||
|
if (!fileInfo.isFile()) {
|
||||||
|
throw new Error(`Not a file: ${resolvedPath}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = await readFile(resolvedPath, 'utf8')
|
||||||
|
const relSourcePath = relative(cwd, resolvedPath).replace(/\\/g, '/')
|
||||||
|
const ingestedAt = new Date().toISOString()
|
||||||
|
const baseName = basename(resolvedPath, extname(resolvedPath))
|
||||||
|
const title = extractTitleFromText(baseName, content)
|
||||||
|
const summary = summarizeText(content)
|
||||||
|
const excerpt = content.split('\n').slice(0, 20).join('\n').trim()
|
||||||
|
const slug = sanitizeWikiSlug(`${baseName}-${Date.now()}`) || `source-${Date.now()}`
|
||||||
|
|
||||||
|
const paths = getWikiPaths(cwd)
|
||||||
|
const sourceNotePath = `${paths.sourcesDir}/${slug}.md`
|
||||||
|
|
||||||
|
await writeFile(
|
||||||
|
sourceNotePath,
|
||||||
|
buildSourceNote({
|
||||||
|
title,
|
||||||
|
sourcePath: relSourcePath,
|
||||||
|
ingestedAt,
|
||||||
|
summary,
|
||||||
|
excerpt,
|
||||||
|
}),
|
||||||
|
'utf8',
|
||||||
|
)
|
||||||
|
await appendFile(paths.logFile, `${buildLogEntry(relSourcePath, title, ingestedAt)}\n`, 'utf8')
|
||||||
|
await rebuildWikiIndex(cwd)
|
||||||
|
|
||||||
|
return {
|
||||||
|
sourceFile: relSourcePath,
|
||||||
|
sourceNote: relative(cwd, sourceNotePath).replace(/\\/g, '/'),
|
||||||
|
summary,
|
||||||
|
title,
|
||||||
|
}
|
||||||
|
}
|
||||||
54
src/services/wiki/init.test.ts
Normal file
54
src/services/wiki/init.test.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import { afterEach, expect, test } from 'bun:test'
|
||||||
|
import { mkdtemp, readFile, rm } from 'fs/promises'
|
||||||
|
import { tmpdir } from 'os'
|
||||||
|
import { join } from 'path'
|
||||||
|
import { initializeWiki } from './init.js'
|
||||||
|
import { getWikiPaths } from './paths.js'
|
||||||
|
|
||||||
|
const tempDirs: string[] = []
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await Promise.all(
|
||||||
|
tempDirs.splice(0).map(dir => rm(dir, { recursive: true, force: true })),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
async function makeProjectDir(): Promise<string> {
|
||||||
|
const dir = await mkdtemp(join(tmpdir(), 'openclaude-wiki-init-'))
|
||||||
|
tempDirs.push(dir)
|
||||||
|
return dir
|
||||||
|
}
|
||||||
|
|
||||||
|
test('initializeWiki creates the expected wiki scaffold', async () => {
|
||||||
|
const cwd = await makeProjectDir()
|
||||||
|
const result = await initializeWiki(cwd)
|
||||||
|
const paths = getWikiPaths(cwd)
|
||||||
|
|
||||||
|
expect(result.alreadyExisted).toBe(false)
|
||||||
|
expect(result.createdFiles).toEqual([
|
||||||
|
'.openclaude/wiki/schema.md',
|
||||||
|
'.openclaude/wiki/index.md',
|
||||||
|
'.openclaude/wiki/log.md',
|
||||||
|
'.openclaude/wiki/pages/architecture.md',
|
||||||
|
])
|
||||||
|
expect(await readFile(paths.schemaFile, 'utf8')).toContain(
|
||||||
|
'# OpenClaude Wiki Schema',
|
||||||
|
)
|
||||||
|
expect(await readFile(paths.indexFile, 'utf8')).toContain('Wiki')
|
||||||
|
expect(await readFile(paths.logFile, 'utf8')).toContain(
|
||||||
|
'Wiki initialized by OpenClaude',
|
||||||
|
)
|
||||||
|
expect(await readFile(join(paths.pagesDir, 'architecture.md'), 'utf8')).toContain(
|
||||||
|
'# Architecture',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('initializeWiki is idempotent and preserves existing files', async () => {
|
||||||
|
const cwd = await makeProjectDir()
|
||||||
|
|
||||||
|
await initializeWiki(cwd)
|
||||||
|
const second = await initializeWiki(cwd)
|
||||||
|
|
||||||
|
expect(second.alreadyExisted).toBe(true)
|
||||||
|
expect(second.createdFiles).toEqual([])
|
||||||
|
})
|
||||||
140
src/services/wiki/init.ts
Normal file
140
src/services/wiki/init.ts
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
import { mkdir, writeFile } from 'fs/promises'
|
||||||
|
import { basename, relative } from 'path'
|
||||||
|
import { getWikiPaths } from './paths.js'
|
||||||
|
import type { WikiInitResult } from './types.js'
|
||||||
|
|
||||||
|
function buildSchemaTemplate(projectName: string): string {
|
||||||
|
return `# OpenClaude Wiki Schema
|
||||||
|
|
||||||
|
This wiki stores durable, human-readable project knowledge for ${projectName}.
|
||||||
|
|
||||||
|
## Goals
|
||||||
|
|
||||||
|
- Keep useful project knowledge in markdown, not only in chat history
|
||||||
|
- Prefer synthesized facts over raw copy-paste
|
||||||
|
- Keep source attribution explicit
|
||||||
|
- Make pages easy for both humans and agents to update
|
||||||
|
|
||||||
|
## Structure
|
||||||
|
|
||||||
|
- \`index.md\`: top-level navigation and major topics
|
||||||
|
- \`log.md\`: append-only update log
|
||||||
|
- \`pages/\`: durable topic and architecture pages
|
||||||
|
- \`sources/\`: source ingestion notes and summaries
|
||||||
|
|
||||||
|
## Page Rules
|
||||||
|
|
||||||
|
- Keep pages focused on one topic
|
||||||
|
- Use stable headings such as:
|
||||||
|
- \`## Summary\`
|
||||||
|
- \`## Key Facts\`
|
||||||
|
- \`## Relationships\`
|
||||||
|
- \`## Open Questions\`
|
||||||
|
- \`## Sources\`
|
||||||
|
- Add or update facts only when they are grounded in project files or explicit source notes
|
||||||
|
- Prefer editing an existing page over creating duplicates
|
||||||
|
`
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildIndexTemplate(projectName: string): string {
|
||||||
|
return `# ${projectName} Wiki
|
||||||
|
|
||||||
|
This wiki is maintained by OpenClaude as a durable project knowledge layer.
|
||||||
|
|
||||||
|
## Core Pages
|
||||||
|
|
||||||
|
- [Architecture](./pages/architecture.md)
|
||||||
|
|
||||||
|
## Sources
|
||||||
|
|
||||||
|
- Source notes live in [sources/](./sources/)
|
||||||
|
|
||||||
|
## Recent Updates
|
||||||
|
|
||||||
|
- See [log.md](./log.md)
|
||||||
|
`
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildLogTemplate(timestamp: string): string {
|
||||||
|
return `# Wiki Update Log
|
||||||
|
|
||||||
|
- ${timestamp}: Wiki initialized by OpenClaude
|
||||||
|
`
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildArchitectureTemplate(projectName: string): string {
|
||||||
|
return `# Architecture
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
High-level architecture notes for ${projectName}.
|
||||||
|
|
||||||
|
## Key Facts
|
||||||
|
|
||||||
|
- This page is the starting point for durable architecture knowledge.
|
||||||
|
|
||||||
|
## Relationships
|
||||||
|
|
||||||
|
- Link this page to major subsystems as the wiki grows.
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
- What are the most important runtime subsystems?
|
||||||
|
- Which files best represent the system architecture?
|
||||||
|
|
||||||
|
## Sources
|
||||||
|
|
||||||
|
- Wiki bootstrap
|
||||||
|
`
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureFile(
|
||||||
|
filePath: string,
|
||||||
|
content: string,
|
||||||
|
createdFiles: string[],
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
await writeFile(filePath, content, { encoding: 'utf8', flag: 'wx' })
|
||||||
|
createdFiles.push(filePath)
|
||||||
|
} catch (error: unknown) {
|
||||||
|
if (
|
||||||
|
typeof error === 'object' &&
|
||||||
|
error !== null &&
|
||||||
|
'code' in error &&
|
||||||
|
error.code === 'EEXIST'
|
||||||
|
) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function initializeWiki(cwd: string): Promise<WikiInitResult> {
|
||||||
|
const paths = getWikiPaths(cwd)
|
||||||
|
const createdDirectories: string[] = []
|
||||||
|
const createdFiles: string[] = []
|
||||||
|
|
||||||
|
for (const dir of [paths.root, paths.pagesDir, paths.sourcesDir]) {
|
||||||
|
await mkdir(dir, { recursive: true })
|
||||||
|
createdDirectories.push(dir)
|
||||||
|
}
|
||||||
|
|
||||||
|
const projectName = basename(cwd)
|
||||||
|
const timestamp = new Date().toISOString()
|
||||||
|
|
||||||
|
await ensureFile(paths.schemaFile, buildSchemaTemplate(projectName), createdFiles)
|
||||||
|
await ensureFile(paths.indexFile, buildIndexTemplate(projectName), createdFiles)
|
||||||
|
await ensureFile(paths.logFile, buildLogTemplate(timestamp), createdFiles)
|
||||||
|
await ensureFile(
|
||||||
|
`${paths.pagesDir}/architecture.md`,
|
||||||
|
buildArchitectureTemplate(projectName),
|
||||||
|
createdFiles,
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
root: paths.root,
|
||||||
|
createdFiles: createdFiles.map(file => relative(cwd, file)),
|
||||||
|
createdDirectories: createdDirectories.map(dir => relative(cwd, dir)),
|
||||||
|
alreadyExisted: createdFiles.length === 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
18
src/services/wiki/paths.ts
Normal file
18
src/services/wiki/paths.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { join } from 'path'
|
||||||
|
import type { WikiPaths } from './types.js'
|
||||||
|
|
||||||
|
export const OPENCLAUDE_DIRNAME = '.openclaude'
|
||||||
|
export const WIKI_DIRNAME = 'wiki'
|
||||||
|
|
||||||
|
export function getWikiPaths(cwd: string): WikiPaths {
|
||||||
|
const root = join(cwd, OPENCLAUDE_DIRNAME, WIKI_DIRNAME)
|
||||||
|
|
||||||
|
return {
|
||||||
|
root,
|
||||||
|
pagesDir: join(root, 'pages'),
|
||||||
|
sourcesDir: join(root, 'sources'),
|
||||||
|
schemaFile: join(root, 'schema.md'),
|
||||||
|
indexFile: join(root, 'index.md'),
|
||||||
|
logFile: join(root, 'log.md'),
|
||||||
|
}
|
||||||
|
}
|
||||||
55
src/services/wiki/status.test.ts
Normal file
55
src/services/wiki/status.test.ts
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import { afterEach, expect, test } from 'bun:test'
|
||||||
|
import { mkdtemp, mkdir, rm, writeFile } from 'fs/promises'
|
||||||
|
import { tmpdir } from 'os'
|
||||||
|
import { join } from 'path'
|
||||||
|
import { initializeWiki } from './init.js'
|
||||||
|
import { getWikiPaths } from './paths.js'
|
||||||
|
import { getWikiStatus } from './status.js'
|
||||||
|
|
||||||
|
const tempDirs: string[] = []
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await Promise.all(
|
||||||
|
tempDirs.splice(0).map(dir => rm(dir, { recursive: true, force: true })),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
async function makeProjectDir(): Promise<string> {
|
||||||
|
const dir = await mkdtemp(join(tmpdir(), 'openclaude-wiki-status-'))
|
||||||
|
tempDirs.push(dir)
|
||||||
|
return dir
|
||||||
|
}
|
||||||
|
|
||||||
|
test('getWikiStatus reports uninitialized wiki state', async () => {
|
||||||
|
const cwd = await makeProjectDir()
|
||||||
|
const status = await getWikiStatus(cwd)
|
||||||
|
|
||||||
|
expect(status.initialized).toBe(false)
|
||||||
|
expect(status.pageCount).toBe(0)
|
||||||
|
expect(status.sourceCount).toBe(0)
|
||||||
|
expect(status.lastUpdatedAt).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('getWikiStatus counts pages and sources for initialized wiki', async () => {
|
||||||
|
const cwd = await makeProjectDir()
|
||||||
|
await initializeWiki(cwd)
|
||||||
|
const paths = getWikiPaths(cwd)
|
||||||
|
|
||||||
|
await writeFile(join(paths.pagesDir, 'commands.md'), '# Commands\n', 'utf8')
|
||||||
|
await mkdir(join(paths.sourcesDir, 'external'), { recursive: true })
|
||||||
|
await writeFile(
|
||||||
|
join(paths.sourcesDir, 'external', 'spec.md'),
|
||||||
|
'# Spec\n',
|
||||||
|
'utf8',
|
||||||
|
)
|
||||||
|
|
||||||
|
const status = await getWikiStatus(cwd)
|
||||||
|
|
||||||
|
expect(status.initialized).toBe(true)
|
||||||
|
expect(status.pageCount).toBe(2)
|
||||||
|
expect(status.sourceCount).toBe(1)
|
||||||
|
expect(status.hasSchema).toBe(true)
|
||||||
|
expect(status.hasIndex).toBe(true)
|
||||||
|
expect(status.hasLog).toBe(true)
|
||||||
|
expect(status.lastUpdatedAt).not.toBeNull()
|
||||||
|
})
|
||||||
82
src/services/wiki/status.ts
Normal file
82
src/services/wiki/status.ts
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
import { readdir, stat } from 'fs/promises'
|
||||||
|
import { getWikiPaths } from './paths.js'
|
||||||
|
import type { WikiStatus } from './types.js'
|
||||||
|
|
||||||
|
async function pathExists(path: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await stat(path)
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listMarkdownFiles(dir: string): Promise<string[]> {
|
||||||
|
if (!(await pathExists(dir))) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
const entries = await readdir(dir, { withFileTypes: true })
|
||||||
|
const files: string[] = []
|
||||||
|
|
||||||
|
for (const entry of entries) {
|
||||||
|
const fullPath = `${dir}/${entry.name}`
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
files.push(...(await listMarkdownFiles(fullPath)))
|
||||||
|
} else if (entry.isFile() && entry.name.endsWith('.md')) {
|
||||||
|
files.push(fullPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return files
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getLastUpdatedAt(pathsToCheck: string[]): Promise<string | null> {
|
||||||
|
const mtimes: number[] = []
|
||||||
|
|
||||||
|
for (const path of pathsToCheck) {
|
||||||
|
try {
|
||||||
|
const info = await stat(path)
|
||||||
|
mtimes.push(info.mtimeMs)
|
||||||
|
} catch {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mtimes.length === 0) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Date(Math.max(...mtimes)).toISOString()
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getWikiStatus(cwd: string): Promise<WikiStatus> {
|
||||||
|
const paths = getWikiPaths(cwd)
|
||||||
|
|
||||||
|
const [hasRoot, hasSchema, hasIndex, hasLog, pages, sources] =
|
||||||
|
await Promise.all([
|
||||||
|
pathExists(paths.root),
|
||||||
|
pathExists(paths.schemaFile),
|
||||||
|
pathExists(paths.indexFile),
|
||||||
|
pathExists(paths.logFile),
|
||||||
|
listMarkdownFiles(paths.pagesDir),
|
||||||
|
listMarkdownFiles(paths.sourcesDir),
|
||||||
|
])
|
||||||
|
|
||||||
|
return {
|
||||||
|
initialized: hasRoot && hasSchema && hasIndex && hasLog,
|
||||||
|
root: paths.root,
|
||||||
|
pageCount: pages.length,
|
||||||
|
sourceCount: sources.length,
|
||||||
|
hasSchema,
|
||||||
|
hasIndex,
|
||||||
|
hasLog,
|
||||||
|
lastUpdatedAt: await getLastUpdatedAt([
|
||||||
|
paths.schemaFile,
|
||||||
|
paths.indexFile,
|
||||||
|
paths.logFile,
|
||||||
|
...pages,
|
||||||
|
...sources,
|
||||||
|
]),
|
||||||
|
}
|
||||||
|
}
|
||||||
33
src/services/wiki/types.ts
Normal file
33
src/services/wiki/types.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
export type WikiPaths = {
|
||||||
|
root: string
|
||||||
|
pagesDir: string
|
||||||
|
sourcesDir: string
|
||||||
|
schemaFile: string
|
||||||
|
indexFile: string
|
||||||
|
logFile: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type WikiInitResult = {
|
||||||
|
root: string
|
||||||
|
createdFiles: string[]
|
||||||
|
createdDirectories: string[]
|
||||||
|
alreadyExisted: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export type WikiStatus = {
|
||||||
|
initialized: boolean
|
||||||
|
root: string
|
||||||
|
pageCount: number
|
||||||
|
sourceCount: number
|
||||||
|
hasSchema: boolean
|
||||||
|
hasIndex: boolean
|
||||||
|
hasLog: boolean
|
||||||
|
lastUpdatedAt: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type WikiIngestResult = {
|
||||||
|
sourceFile: string
|
||||||
|
sourceNote: string
|
||||||
|
summary: string
|
||||||
|
title: string
|
||||||
|
}
|
||||||
36
src/services/wiki/utils.ts
Normal file
36
src/services/wiki/utils.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
export function sanitizeWikiSlug(value: string): string {
|
||||||
|
return value
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, '-')
|
||||||
|
.replace(/^-+|-+$/g, '')
|
||||||
|
.replace(/-{2,}/g, '-')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function summarizeText(input: string, maxLength = 280): string {
|
||||||
|
const normalized = input.replace(/\s+/g, ' ').trim()
|
||||||
|
if (!normalized) {
|
||||||
|
return 'No summary available.'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalized.length <= maxLength) {
|
||||||
|
return normalized
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${normalized.slice(0, maxLength - 1).trimEnd()}…`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractTitleFromText(
|
||||||
|
fallbackName: string,
|
||||||
|
content: string,
|
||||||
|
): string {
|
||||||
|
const firstNonEmptyLine = content
|
||||||
|
.split('\n')
|
||||||
|
.map(line => line.trim())
|
||||||
|
.find(Boolean)
|
||||||
|
|
||||||
|
if (!firstNonEmptyLine) {
|
||||||
|
return fallbackName
|
||||||
|
}
|
||||||
|
|
||||||
|
return firstNonEmptyLine.replace(/^#+\s*/, '') || fallbackName
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user