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 bridgeKick from './commands/bridge-kick.js'
|
||||
import version from './commands/version.js'
|
||||
import wiki from './commands/wiki/index.js'
|
||||
import summary from './commands/summary/index.js'
|
||||
import {
|
||||
resetLimits,
|
||||
@@ -324,6 +325,7 @@ const COMMANDS = memoize((): Command[] => [
|
||||
usage,
|
||||
usageReport,
|
||||
vim,
|
||||
wiki,
|
||||
...(webCmd ? [webCmd] : []),
|
||||
...(forkCmd ? [forkCmd] : []),
|
||||
...(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