#!/usr/bin/env node
import { lstatSync, readFileSync } from 'node:fs'
import path from 'node:path'
import { pathToFileURL } from 'node:url'
import {
checkActivePlans,
clearLocalContext,
createDocument,
getGitState,
listDocuments,
loadDocsConfig,
planSummary,
readPlanBundle,
resolvePlanReference,
runObsidian,
updatePlanTask,
writeLocalContext,
} from './obsidian-docs-lib.mjs'
const REPO_ROOT = path.resolve(import.meta.dirname, '..')
export function parseArguments(argv) {
const positional = []
const options = {}
for (let index = 0; index < argv.length; index += 1) {
const argument = argv[index]
if (argument === '--') continue
if (!argument.startsWith('--')) {
positional.push(argument)
continue
}
const key = argument.slice(2)
const value = argv[index + 1]
if (!value || value.startsWith('--')) {
options[key] = true
} else {
options[key] = value
index += 1
}
}
return { options, positional }
}
function printHelp() {
console.log(`Motrix Obsidian documentation gateway
Optional maintainer tooling. Copy obsidian-docs.config.example.json to the
ignored obsidian-docs.config.json and customize it before using vault commands.
Usage:
pnpm run docs:doctor
pnpm run docs:create -- <directory> <path.md> [--from <file> | --stdin] [--overwrite]
pnpm run docs:list -- plans|specs [--all] [--include-indexes]
pnpm run docs:use -- <plan-id>
pnpm run docs:clear
pnpm run docs:context -- [plan-id] [--format json]
pnpm run docs:status -- [plan-id] [--format json]
pnpm run docs:task -- done <task-id> [plan-id] --commit <git-ref>
pnpm run docs:task -- todo <task-id> [plan-id]
pnpm run docs:check -- [--format json]
Create directories:
specs, plans, decisions, evidence, publication, archive-plans, legacy-import
`)
}
function requireValue(value, label) {
if (!value) throw new Error(`${label} is required`)
return value
}
function readCreateContent(options) {
const hasFile = options.from !== undefined
const hasStdin = options.stdin !== undefined
if (hasFile && hasStdin) {
throw new Error('Use only one of --from or --stdin')
}
if (hasFile) {
if (typeof options.from !== 'string') {
throw new Error('--from requires a file path')
}
const sourcePath = path.resolve(REPO_ROOT, options.from)
const stats = lstatSync(sourcePath)
if (stats.isSymbolicLink() || !stats.isFile()) {
throw new Error('--from must reference a regular file, not a symlink')
}
return readFileSync(sourcePath, 'utf8')
}
if (hasStdin) {
if (options.stdin !== true) {
throw new Error('--stdin does not accept a value')
}
return readFileSync(0, 'utf8')
}
return ''
}
function formatDocumentTable(documents) {
const rows = documents.map((document) => [
document.id,
document.language,
document.lifecycle ?? '—',
document.implementation_status ?? '—',
document.path,
])
return [
['ID', 'Lang', 'Lifecycle', 'Status', 'Path'].join('\t'),
...rows.map((row) => row.join('\t')),
].join('\n')
}
function formatContext(bundle) {
return bundle.documents
.map(
(document) => `<!-- Obsidian: ${document.path} -->\n\n${document.content}`
)
.join('\n\n---\n\n')
}
function formatStatus(summary) {
const lines = [
`Plan: ${summary.id}`,
`Path: ${summary.path}`,
`Pair: ${summary.pairPath}`,
`Git: ${summary.branch}@${summary.head.slice(0, 8)}`,
`Verified head: ${summary.verifiedHead ?? '—'}`,
`Needs revalidation: ${summary.requiresRevalidation ? 'yes' : 'no'}`,
`Last progress head: ${summary.lastProgressHead ?? '—'}`,
`Status: ${summary.implementationStatus ?? '—'}`,
`Progress: ${summary.progress.completed}/${summary.progress.total}`,
`Current task: ${summary.progress.currentTask ?? 'verification'}`,
'',
...summary.tasks.map(
(task) => `${task.done ? '[x]' : '[ ]'} ${task.id} ${task.description}`
),
]
return lines.join('\n')
}
export function main(argv = process.argv.slice(2)) {
const { options, positional } = parseArguments(argv)
const [command = 'help', ...args] = positional
if (command === 'help' || command === '--help') {
printHelp()
return
}
const config = loadDocsConfig(REPO_ROOT, options.config)
if (command === 'create') {
const kind = requireValue(args[0], 'document directory')
const filename = requireValue(args[1], 'document path')
if (args.length !== 2) {
throw new Error('create requires a document directory and path')
}
if (options.overwrite !== undefined && options.overwrite !== true) {
throw new Error('--overwrite does not accept a value')
}
const result = createDocument(config, {
content: readCreateContent(options),
filename,
kind,
overwrite: options.overwrite === true,
})
console.log(
`${result.overwritten ? 'Overwrote' : 'Created'} ${result.path}`
)
return
}
if (command === 'doctor') {
const version = runObsidian(config, ['version'])
const vault = runObsidian(config, ['vault', 'info=name'])
const projectFiles = runObsidian(config, [
'folder',
`path=${config.projectRoot}`,
'info=files',
])
const git = getGitState(config)
console.log(
[
`Obsidian: ${version}`,
`Vault: ${vault}`,
`Project root: ${config.projectRoot}`,
`Project files: ${projectFiles}`,
`Repository: ${config.repository}`,
`Git: ${git.branch}@${git.head.slice(0, 8)}`,
].join('\n')
)
return
}
if (command === 'list') {
const kind = requireValue(args[0], 'plans or specs')
let documents = listDocuments(config, kind)
if (kind === 'plans' && !options['include-indexes']) {
documents = documents.filter(
(document) => document.document_type === 'active-plan'
)
}
if (!options.all) {
documents = documents.filter((document) => document.language === 'en')
}
console.log(
options.format === 'json'
? JSON.stringify(documents, null, 2)
: formatDocumentTable(documents)
)
return
}
if (command === 'use') {
const ref = requireValue(args[0], 'plan-id')
const bundle = readPlanBundle(config, ref)
const plan = bundle.documents.find(
(document) => document.path === bundle.planPath
)
if (!plan) throw new Error(`Plan not found in bundle: ${ref}`)
const context = writeLocalContext(config, {
id: plan.metadata.doc_id ?? path.basename(plan.path, '.md'),
path: plan.path,
})
console.log(`Selected ${context.planId}\nContext: ${config.contextFile}`)
return
}
if (command === 'clear') {
console.log(
clearLocalContext(config)
? `Cleared ${config.contextFile}`
: `No local plan context was selected`
)
return
}
if (command === 'context') {
const ref = resolvePlanReference(config, args[0])
const bundle = readPlanBundle(config, ref, { includeRelated: true })
console.log(
options.format === 'json'
? JSON.stringify(bundle, null, 2)
: formatContext(bundle)
)
return
}
if (command === 'status') {
const ref = resolvePlanReference(config, args[0])
const summary = planSummary(
readPlanBundle(config, ref),
getGitState(config)
)
console.log(
options.format === 'json'
? JSON.stringify(summary, null, 2)
: formatStatus(summary)
)
return
}
if (command === 'task') {
const action = requireValue(args[0], 'task action')
const taskId = requireValue(args[1], 'task-id')
if (action !== 'done' && action !== 'todo') {
throw new Error('task action must be done or todo')
}
if (action === 'done' && typeof options.commit !== 'string') {
throw new Error('task done requires --commit <git-ref>')
}
const planRef = resolvePlanReference(config, args[2])
const result = updatePlanTask(config, {
commitRef: options.commit,
done: action === 'done',
planRef,
taskId,
})
console.log(JSON.stringify(result, null, 2))
return
}
if (command === 'check') {
const result = checkActivePlans(config)
if (options.format === 'json') {
console.log(JSON.stringify(result, null, 2))
} else {
console.log(
`Active plan pairs: ${result.pairCount}\nExecutable documents: ${result.executableDocumentCount}\nPlan indexes: ${result.indexDocumentCount}`
)
for (const warning of result.warnings) {
console.warn(`WARN: ${warning}`)
}
for (const error of result.errors) console.error(`ERROR: ${error}`)
}
if (result.errors.length > 0) process.exitCode = 1
return
}
throw new Error(`Unknown docs command: ${command}`)
}
if (
process.argv[1] &&
import.meta.url === pathToFileURL(process.argv[1]).href
) {
try {
main()
} catch (error) {
console.error(error instanceof Error ? error.message : String(error))
process.exitCode = 1
}
}