feat: add wiki mvp commands (#532)

This commit is contained in:
Kevin Codex
2026-04-09 14:54:38 +08:00
committed by GitHub
parent 4ad6bc50c1
commit c328fdf9e2
13 changed files with 764 additions and 0 deletions

View File

@@ -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] : []),

View 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
View 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
}

View 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')
}

View 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/', './'))
})

View 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,
}
}

View 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
View 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,
}
}

View 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'),
}
}

View 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()
})

View 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,
]),
}
}

View 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
}

View 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
}