Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/commands/wiki/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ const wiki = {
type: 'local-jsx',
name: 'wiki',
description: 'Initialize and inspect the OpenClaude project wiki',
argumentHint: '[init|status]',
argumentHint: '[init|status|scan]',
immediate: true,
load: () => import('./wiki.js'),
} satisfies Command
Expand Down
31 changes: 28 additions & 3 deletions src/commands/wiki/wiki.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,25 +3,28 @@ 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 { forceScanConventions } from '../../services/wiki/conventions.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>]
return `Usage: /wiki [init|status|scan|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 scan Re-scan project conventions (build system, test framework, linting, etc.)
/wiki ingest Ingest a local file into wiki sources

Examples:
/wiki init
/wiki status
/wiki scan
/wiki ingest README.md`
}

Expand All @@ -48,7 +51,7 @@ function formatStatus(status: Awaited<ReturnType<typeof getWikiStatus>>): string
return `OpenClaude wiki is not initialized in this project.\n\nRun /wiki init to create ${status.root}.`
}

return [
const lines = [
'OpenClaude wiki status',
'',
`Root: ${status.root}`,
Expand All @@ -57,8 +60,15 @@ function formatStatus(status: Awaited<ReturnType<typeof getWikiStatus>>): string
`Schema: ${status.hasSchema ? 'present' : 'missing'}`,
`Index: ${status.hasIndex ? 'present' : 'missing'}`,
`Log: ${status.hasLog ? 'present' : 'missing'}`,
`Conventions: ${status.hasConventions ? 'present' : 'not yet scanned'}`,
`Last updated: ${status.lastUpdatedAt ?? 'unknown'}`,
].join('\n')
]

if (status.conventionsScannedAt) {
lines.push(`Conventions last scanned: ${status.conventionsScannedAt}`)
}

return lines.join('\n')
}

function formatIngestResult(
Expand Down Expand Up @@ -108,6 +118,21 @@ async function runWikiCommand(
return
}

if (normalized === 'scan') {
const result = await forceScanConventions(cwd)
onDone(
[
'Project conventions scanned and saved.',
'',
result.markdown,
'',
'_Run `/wiki status` to verify._',
].join('\n'),
{ display: 'system' },
)
return
}

onDone(`Unknown wiki subcommand: ${args.trim()}\n\n${renderHelp()}`, {
display: 'system',
})
Expand Down
6 changes: 6 additions & 0 deletions src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -416,6 +416,12 @@ export function startDeferredPrefetches(): void {
}
void countFilesRoundedRg(getCwd(), AbortSignal.timeout(3000), []);

// Project convention scanner — reads config files and persists to wiki.
// No-op if wiki isn't initialized (~1 stat call).
void import('./services/wiki/conventions.js').then(({ scanAndSaveConventions }) =>
scanAndSaveConventions(getCwd()),
);

// Analytics and feature flag initialization
void initializeAnalyticsGates();
void prefetchOfficialMcpUrls();
Expand Down
149 changes: 149 additions & 0 deletions src/services/wiki/conventions.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
import { afterEach, expect, test } from 'bun:test'
import { mkdtemp, mkdir, rm, writeFile } from 'fs/promises'
import { tmpdir } from 'os'
import { join } from 'path'
import { scanProjectConventions } from './conventions.js'
import { initializeWiki } from './init.js'
import { getWikiPaths } from './paths.js'
import { scanAndSaveConventions, forceScanConventions } from './conventions.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-conventions-'))
tempDirs.push(dir)
return dir
}

test('scanProjectConventions detects package.json conventions', async () => {
const cwd = await makeProjectDir()
await writeFile(
join(cwd, 'package.json'),
JSON.stringify({
name: 'test-project',
scripts: { test: 'bun test', build: 'bun run build.ts' },
devDependencies: { vitest: '^1.0.0', typescript: '^5.0.0' },
}),
'utf8',
)
await writeFile(
join(cwd, 'tsconfig.json'),
JSON.stringify({
compilerOptions: {
target: 'ES2023',
module: 'ESNext',
strict: true,
jsx: 'react-jsx',
},
}),
'utf8',
)

const result = await scanProjectConventions(cwd)

expect(result.markdown).toContain('test-project')
expect(result.markdown).toContain('Package Manager')
expect(result.markdown).toContain('TypeScript Config')
expect(result.markdown).toContain('ES2023')
expect(result.markdown).toContain('vitest')
expect(result.markdown).toContain('ESNext')
expect(result.identity.name).toBe('test-project')
expect(result.fingerprint).toHaveLength(16)
})

test('scanProjectConventions handles project with no config files', async () => {
const cwd = await makeProjectDir()
const result = await scanProjectConventions(cwd)

// Should still return a markdown with the directory name
expect(result.markdown).toBeTruthy()
expect(result.sections).toHaveLength(0)
expect(result.identity.name).toBeTruthy()
})

test('scanProjectConventions detects ESLint config', async () => {
const cwd = await makeProjectDir()
await writeFile(
join(cwd, 'package.json'),
JSON.stringify({
name: 'lint-project',
}),
'utf8',
)
await writeFile(
join(cwd, '.eslintrc.json'),
JSON.stringify({
extends: ['eslint:recommended', 'plugin:@typescript-eslint/recommended'],
plugins: ['@typescript-eslint'],
}),
'utf8',
)

const result = await scanProjectConventions(cwd)
expect(result.markdown).toContain('ESLint Config')
})

test('scanAndSaveConventions creates conventions page in wiki', async () => {
const cwd = await makeProjectDir()
await initializeWiki(cwd)
await writeFile(
join(cwd, 'package.json'),
JSON.stringify({
name: 'wiki-project',
scripts: { test: 'bun test' },
}),
'utf8',
)

const result = await scanAndSaveConventions(cwd)
expect(result).not.toBeNull()
expect(result!.markdown).toContain('wiki-project')
expect(result!.fingerprint).toHaveLength(16)

// Verify it was written to the wiki
const paths = getWikiPaths(cwd)
const wikiContent = await Bun.file(paths.conventionsFile).text()
expect(wikiContent).toContain('wiki-project')
})

test('scanAndSaveConventions returns null on no change', async () => {
const cwd = await makeProjectDir()
await initializeWiki(cwd)
await writeFile(
join(cwd, 'package.json'),
JSON.stringify({
name: 'no-change',
scripts: { test: 'bun test' },
}),
'utf8',
)

const first = await scanAndSaveConventions(cwd)
expect(first).not.toBeNull()

const second = await scanAndSaveConventions(cwd)
expect(second).toBeNull()
})

test('forceScanConventions always returns result', async () => {
const cwd = await makeProjectDir()
await initializeWiki(cwd)
await writeFile(
join(cwd, 'package.json'),
JSON.stringify({ name: 'force-scan' }),
'utf8',
)

const first = await forceScanConventions(cwd)
expect(first).not.toBeNull()

const second = await forceScanConventions(cwd)
expect(second).not.toBeNull()
expect(second.fingerprint).toBe(first.fingerprint)
})
Loading
Loading