From 523428c7d08d0603be3c79fba1f54456667f3c7c Mon Sep 17 00:00:00 2001
From: miles-kt-inkeep <135626743+miles-kt-inkeep@users.noreply.github.com>
Date: Wed, 1 Jul 2026 15:03:16 -0400
Subject: [PATCH] fix(open-knowledge): visible ok init git-root promotion +
--content-dir/--json (#2335)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* fix(open-knowledge): surface ok init git-root promotion + add --content-dir and --json
ok init in a git sub-folder scaffolds .ok/ at the git root and scopes the
whole repo as content. That promotion previously surfaced only as a stdout
log line that a piped tail could drop, so a large repo could get
whole-repo-scoped unnoticed.
- Disclose the promotion as a stderr warning at decision time and repeat it
in the init summary next to the file-count preview.
- Add ok init --content-dir
to scope content to a sub-folder (written
to .ok/config.yml as content.dir). Out-of-project, missing, or file paths
are rejected with a usage error (exit 64). An explicit scope suppresses the
whole-repo promotion warning.
- Add ok init --json to emit a structured summary (projectRoot,
gitRootPromoted, contentDir, contentFileCount, and more) to stdout;
diagnostics stay on stderr.
The content-scope disclosure distinguishes a scaffolding failure from a
pre-existing config so it never falsely claims config.yml already exists.
* fix(open-knowledge): symlink-safe --content-dir + independent --json scope read
Address two review edge cases on the ok init scope surfaces:
- resolveRequestedContentDir canonicalizes both the project root and the
requested path before the containment check, so --content-dir . no longer
wrongly throws under a symlinked working tree (macOS /var to /private/var).
- The --json contentDir field reads config.content.dir in its own try,
independent of the preview walk, so a preview failure on a narrowed re-init
can't leave the scope field at a stale '.' seed that contradicts disk.
* fix(open-knowledge): sharpen --content-dir errors, --help, and --json preview signal
Address improvement-grade review recommendations:
- resolveRequestedContentDir distinguishes ENOENT from other stat errors
(EACCES/ELOOP/ENOTDIR) so a readable-but-inaccessible path no longer
reports 'does not exist'.
- --content-dir help text notes it is ignored on re-init when config.yml
already exists.
- --json adds a previewError field so a null contentFileCount is
unambiguous (preview failure vs genuinely zero files).
* docs(open-knowledge): clarify --content-dir help — input is cwd-relative, stored git-root-relative
The prior help text conflated the input anchor (current directory) with the
storage anchor (git root), making it unclear which a relative path resolves
against. Spell out that is interpreted relative to the current directory
and then saved to config.yml as a git-root-relative content.dir.
* docs(open-knowledge): simplify --content-dir help text
GitOrigin-RevId: f3ceccba8dbf668aceee9c0c998969f4d003de28
---
.changeset/init-git-root-promotion-warning.md | 29 ++
packages/cli/src/commands/init.test.ts | 260 +++++++++++++++
packages/cli/src/commands/init.ts | 312 +++++++++++++++---
3 files changed, 557 insertions(+), 44 deletions(-)
create mode 100644 .changeset/init-git-root-promotion-warning.md
diff --git a/.changeset/init-git-root-promotion-warning.md b/.changeset/init-git-root-promotion-warning.md
new file mode 100644
index 000000000..1e8882405
--- /dev/null
+++ b/.changeset/init-git-root-promotion-warning.md
@@ -0,0 +1,29 @@
+---
+"@inkeep/open-knowledge": patch
+---
+
+fix(open-knowledge): make `ok init` git-root promotion visible + add `--content-dir`
+
+When `ok init` runs in a sub-folder of a git repo, it scaffolds `.ok/` at the
+git root and scopes the whole repo as content (one `.ok/` per git repo). That
+promotion previously surfaced only as a single `console.log` line on stdout,
+which `ok init 2>&1 | tail` / `| head` silently dropped — so a large repo could
+get whole-repo-scoped without the user noticing.
+
+- The promotion is now disclosed as a styled warning on stderr at decision
+ time, and repeated in the init summary directly next to the "Found N markdown
+ files" preview, naming the sub-folder and how to narrow scope.
+- New `ok init --content-dir ` flag scopes content to `` (resolved
+ relative to the current directory, written to `.ok/config.yml` as
+ `content.dir`). `--content-dir .` from a promoted sub-folder scopes the
+ project to that folder. Paths outside the project root, missing paths, or
+ files are rejected with a clear usage error (exit 64). Supplying the flag
+ suppresses the whole-repo promotion warning (the scope choice was explicit).
+- New `ok init --json` flag prints a machine-readable summary to stdout
+ (`projectRoot`, `gitRootPromoted`, `promotedFromDir`, `contentDir`,
+ `contentDirRequested`, `contentDirApplied`, `contentFileCount`, `didGitInit`,
+ `mcpAction`, `editors[]`) so scripts and agents read the promotion + scope
+ signals as fields instead of scraping log text. Diagnostics stay on stderr;
+ stdout carries only the JSON document.
+
+Behavior of the promotion itself is unchanged.
diff --git a/packages/cli/src/commands/init.test.ts b/packages/cli/src/commands/init.test.ts
index 3cd7d96df..593d50ba7 100644
--- a/packages/cli/src/commands/init.test.ts
+++ b/packages/cli/src/commands/init.test.ts
@@ -35,6 +35,8 @@ import {
} from '../native/toml-config-engine.ts';
import {
applySharingMode,
+ buildInitJsonSummary,
+ ContentDirError,
classifyExistingMcpEntry,
detectInstalledEditors,
type EditorMcpResult,
@@ -45,6 +47,7 @@ import {
LAUNCH_UI_CHAIN_V1,
readExistingMcpEntry,
resolveMcpScope,
+ resolveRequestedContentDir,
resolveSharingMode,
runInit,
writeEditorMcpConfig,
@@ -1518,6 +1521,11 @@ describe('runInit — projectRoot threading', () => {
expect(result.projectRoot).toBe(repo);
expect(existsSync(join(repo, OK_DIR))).toBe(true);
expect(existsSync(join(sub, OK_DIR))).toBe(false);
+ expect(result.gitRootPromoted).toBe(true);
+ expect(result.promotedFromDir).toBe('sub');
+ const output = formatInitResult(result, result.projectRoot);
+ expect(output).toContain('Content scope promoted to the git repo root');
+ expect(output).toContain('content.dir: sub');
});
it('returns projectRoot equal to cwd when cwd is the git root', async () => {
@@ -1534,6 +1542,10 @@ describe('runInit — projectRoot threading', () => {
expect(result.projectRoot).toBe(repo);
expect(existsSync(join(repo, OK_DIR))).toBe(true);
+ expect(result.gitRootPromoted).toBe(false);
+ expect(result.promotedFromDir).toBeUndefined();
+ const output = formatInitResult(result, result.projectRoot);
+ expect(output).not.toContain('Content scope promoted to the git repo root');
});
it('loadConfig succeeds when called against the resolved projectRoot', async () => {
@@ -1555,6 +1567,254 @@ describe('runInit — projectRoot threading', () => {
expect(rootConfig).toBeDefined();
expect(rootConfig.content.dir).toBe('.');
});
+
+ it('--content-dir . from a sub-folder narrows scope to that folder', async () => {
+ const repo = join(fakeHome, 'repo-cd-dot');
+ const sub = join(repo, 'notes');
+ mkdirSync(sub, { recursive: true });
+ Bun.spawnSync({ cmd: ['git', 'init', '-q', repo], stdout: 'ignore', stderr: 'ignore' });
+
+ const result = await runInit({
+ cwd: sub,
+ home: fakeHome,
+ installUserSkill: defaultInstallUserSkill,
+ scope: 'user',
+ contentDir: '.',
+ });
+
+ expect(result.projectRoot).toBe(repo);
+ expect(result.gitRootPromoted).toBe(true);
+ expect(result.contentDir).toBe('notes');
+ const { config } = loadConfig(result.projectRoot);
+ expect(config.content.dir).toBe('notes');
+
+ const output = formatInitResult(result, result.projectRoot);
+ expect(output).not.toContain('Content scope promoted to the git repo root');
+ expect(output).toContain('Content scope set to notes/');
+ });
+
+ it('--content-dir narrows scope relative to cwd', async () => {
+ const repo = join(fakeHome, 'repo-cd-sub');
+ const nested = join(repo, 'docs', 'guides');
+ mkdirSync(nested, { recursive: true });
+ Bun.spawnSync({ cmd: ['git', 'init', '-q', repo], stdout: 'ignore', stderr: 'ignore' });
+
+ const result = await runInit({
+ cwd: repo,
+ home: fakeHome,
+ installUserSkill: defaultInstallUserSkill,
+ scope: 'user',
+ contentDir: 'docs/guides',
+ });
+
+ expect(result.projectRoot).toBe(repo);
+ expect(result.contentDir).toBe('docs/guides');
+ const { config } = loadConfig(result.projectRoot);
+ expect(config.content.dir).toBe('docs/guides');
+ });
+
+ it('--content-dir outside the project root throws ContentDirError', async () => {
+ const repo = join(fakeHome, 'repo-cd-escape');
+ mkdirSync(repo, { recursive: true });
+ Bun.spawnSync({ cmd: ['git', 'init', '-q', repo], stdout: 'ignore', stderr: 'ignore' });
+
+ await expect(
+ runInit({
+ cwd: repo,
+ home: fakeHome,
+ installUserSkill: defaultInstallUserSkill,
+ scope: 'user',
+ contentDir: '..',
+ }),
+ ).rejects.toBeInstanceOf(ContentDirError);
+ expect(existsSync(join(repo, OK_DIR))).toBe(false);
+ });
+
+ it('--content-dir on re-init is ignored (config.yml already exists) and warns', async () => {
+ const repo = join(fakeHome, 'repo-cd-reinit');
+ const sub = join(repo, 'notes');
+ mkdirSync(sub, { recursive: true });
+ Bun.spawnSync({ cmd: ['git', 'init', '-q', repo], stdout: 'ignore', stderr: 'ignore' });
+
+ await runInit({
+ cwd: repo,
+ home: fakeHome,
+ installUserSkill: defaultInstallUserSkill,
+ scope: 'user',
+ });
+ expect(loadConfig(repo).config.content.dir).toBe('.');
+
+ const result = await runInit({
+ cwd: sub,
+ home: fakeHome,
+ installUserSkill: defaultInstallUserSkill,
+ scope: 'user',
+ contentDir: '.',
+ });
+ expect(result.contentDir).toBeUndefined();
+ expect(result.contentDirRequested).toBe('.');
+ expect(result.contentScaffoldFailed).toBe(false);
+ expect(loadConfig(repo).config.content.dir).toBe('.');
+ const output = formatInitResult(result, result.projectRoot);
+ expect(output).toContain('ignored');
+ expect(
+ buildInitJsonSummary(result, { contentDir: '.', contentFileCount: null }).contentDirApplied,
+ ).toBe(false);
+ });
+
+ it('does not claim "config.yml already exists" when content scaffolding failed', async () => {
+ const repo = join(fakeHome, 'repo-scaffold-fail');
+ mkdirSync(repo, { recursive: true });
+ Bun.spawnSync({ cmd: ['git', 'init', '-q', repo], stdout: 'ignore', stderr: 'ignore' });
+ const base = await runInit({
+ cwd: repo,
+ home: fakeHome,
+ installUserSkill: defaultInstallUserSkill,
+ scope: 'user',
+ });
+
+ const scaffoldFailed = {
+ ...base,
+ contentDirRequested: 'notes',
+ contentDir: undefined,
+ contentScaffoldFailed: true,
+ };
+ expect(formatInitResult(scaffoldFailed, base.projectRoot)).not.toContain('ignored');
+
+ const configExisted = {
+ ...base,
+ contentDirRequested: 'notes',
+ contentDir: undefined,
+ contentScaffoldFailed: false,
+ };
+ expect(formatInitResult(configExisted, base.projectRoot)).toContain('ignored');
+ });
+
+ it('buildInitJsonSummary projects a promoted, narrowed result into stable JSON fields', async () => {
+ const repo = join(fakeHome, 'repo-json');
+ const sub = join(repo, 'notes');
+ mkdirSync(sub, { recursive: true });
+ Bun.spawnSync({ cmd: ['git', 'init', '-q', repo], stdout: 'ignore', stderr: 'ignore' });
+
+ const result = await runInit({
+ cwd: sub,
+ home: fakeHome,
+ installUserSkill: defaultInstallUserSkill,
+ scope: 'user',
+ contentDir: '.',
+ });
+
+ const summary = buildInitJsonSummary(result, { contentDir: 'notes', contentFileCount: 3 });
+ expect(summary.projectRoot).toBe(repo);
+ expect(summary.gitRootPromoted).toBe(true);
+ expect(summary.promotedFromDir).toBe('notes');
+ expect(summary.contentDir).toBe('notes');
+ expect(summary.contentDirRequested).toBe('.');
+ expect(summary.contentDirApplied).toBe(true);
+ expect(summary.contentFileCount).toBe(3);
+ expect(summary.previewError).toBeNull();
+ expect(JSON.parse(JSON.stringify(summary))).toEqual(summary);
+ });
+
+ it('buildInitJsonSummary surfaces previewError so a null count is unambiguous', async () => {
+ const repo = join(fakeHome, 'repo-json-previewerr');
+ mkdirSync(repo, { recursive: true });
+ Bun.spawnSync({ cmd: ['git', 'init', '-q', repo], stdout: 'ignore', stderr: 'ignore' });
+ const base = await runInit({
+ cwd: repo,
+ home: fakeHome,
+ installUserSkill: defaultInstallUserSkill,
+ scope: 'user',
+ });
+ const withPreviewError = { ...base, previewWarning: 'cannot access content directory' };
+ const summary = buildInitJsonSummary(withPreviewError, {
+ contentDir: '.',
+ contentFileCount: null,
+ });
+ expect(summary.contentFileCount).toBeNull();
+ expect(summary.previewError).toBe('cannot access content directory');
+ });
+
+ it('buildInitJsonSummary uses null for absent promotion / request / count', async () => {
+ const repo = join(fakeHome, 'repo-json-flat');
+ mkdirSync(repo, { recursive: true });
+ Bun.spawnSync({ cmd: ['git', 'init', '-q', repo], stdout: 'ignore', stderr: 'ignore' });
+
+ const result = await runInit({
+ cwd: repo,
+ home: fakeHome,
+ installUserSkill: defaultInstallUserSkill,
+ scope: 'user',
+ });
+
+ const summary = buildInitJsonSummary(result, { contentDir: '.', contentFileCount: null });
+ expect(summary.gitRootPromoted).toBe(false);
+ expect(summary.promotedFromDir).toBeNull();
+ expect(summary.contentDirRequested).toBeNull();
+ expect(summary.contentDirApplied).toBe(true);
+ expect(summary.contentFileCount).toBeNull();
+ });
+});
+
+describe('resolveRequestedContentDir', () => {
+ let root: string;
+ beforeEach(() => {
+ const raw = join(tmpdir(), `rrcd-${Date.now()}-${Math.random().toString(36).slice(2)}`);
+ mkdirSync(raw, { recursive: true });
+ root = realpathSync(raw);
+ mkdirSync(join(root, 'sub'), { recursive: true });
+ });
+ afterEach(() => {
+ rmSync(root, { recursive: true, force: true });
+ });
+
+ it('returns "." when the request resolves to the project root itself', () => {
+ expect(resolveRequestedContentDir('.', root, root)).toBe('.');
+ });
+
+ it('returns the git-root-relative path for a descendant (cwd-relative input)', () => {
+ expect(resolveRequestedContentDir('sub', root, root)).toBe('sub');
+ expect(resolveRequestedContentDir('.', root, join(root, 'sub'))).toBe('sub');
+ });
+
+ it('throws when the resolved path escapes the project root', () => {
+ expect(() => resolveRequestedContentDir('..', root, root)).toThrow(ContentDirError);
+ });
+
+ it('throws when the path does not exist', () => {
+ expect(() => resolveRequestedContentDir('nope', root, root)).toThrow(ContentDirError);
+ });
+
+ it('throws when the path is a file, not a directory', () => {
+ writeFileSync(join(root, 'file.md'), '# x');
+ expect(() => resolveRequestedContentDir('file.md', root, root)).toThrow(ContentDirError);
+ });
+
+ it('reports a non-ENOENT stat error as "not accessible", not "does not exist"', () => {
+ writeFileSync(join(root, 'file.md'), '# x');
+ let msg = '';
+ try {
+ resolveRequestedContentDir('file.md/nested', root, root);
+ } catch (e) {
+ msg = e instanceof Error ? e.message : String(e);
+ }
+ expect(msg).toContain('not accessible');
+ expect(msg).not.toContain('does not exist');
+ });
+
+ it('resolves . when cwd reaches the project via a symlinked prefix', () => {
+ const linkParent = join(
+ tmpdir(),
+ `rrcd-link-${Date.now()}-${Math.random().toString(36).slice(2)}`,
+ );
+ symlinkSync(root, linkParent);
+ try {
+ expect(resolveRequestedContentDir('.', root, linkParent)).toBe('.');
+ expect(resolveRequestedContentDir('sub', root, linkParent)).toBe('sub');
+ } finally {
+ rmSync(linkParent, { force: true });
+ }
+ });
});
describe('resolveMcpScope', () => {
diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts
index b2941c5e6..1123a886b 100644
--- a/packages/cli/src/commands/init.ts
+++ b/packages/cli/src/commands/init.ts
@@ -1,5 +1,13 @@
-import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
-import { dirname, join, relative, resolve } from 'node:path';
+import {
+ existsSync,
+ mkdirSync,
+ readFileSync,
+ realpathSync,
+ statSync,
+ writeFileSync,
+} from 'node:fs';
+import { dirname, isAbsolute, join, relative, resolve } from 'node:path';
+import { inspect } from 'node:util';
import { atomicWriteFileSync, withFileLockSync } from '@inkeep/open-knowledge-core/server';
import type {
InstallUserSkillOptions,
@@ -27,7 +35,7 @@ import {
parseTree as parseJsoncTree,
} from 'jsonc-parser';
import { stringify as stringifyToml } from 'smol-toml';
-import { OK_DIR } from '../constants.ts';
+import { CONFIG_FILENAME, OK_DIR } from '../constants.ts';
import { formatPreviewBlock, type PreviewResult } from '../content/preview.ts';
import { resolveProjectRoot } from '../integrations/resolve-project-root.ts';
import {
@@ -370,6 +378,54 @@ interface InitCommandOptions {
promptFn?: () => Promise;
sharing?: 'shared' | 'local-only';
sharingPromptFn?: (defaultMode: 'shared' | 'local-only') => Promise<'shared' | 'local-only'>;
+ contentDir?: string;
+}
+
+export class ContentDirError extends Error {
+ constructor(message: string) {
+ super(message);
+ this.name = 'ContentDirError';
+ }
+}
+
+export function resolveRequestedContentDir(
+ input: string,
+ projectRoot: string,
+ cwd: string,
+): string {
+ const abs = resolve(cwd, input);
+ let stat: ReturnType;
+ try {
+ stat = statSync(abs);
+ } catch (e) {
+ const code = (e as NodeJS.ErrnoException).code;
+ if (code === 'ENOENT') {
+ throw new ContentDirError(`--content-dir path does not exist: ${abs}`);
+ }
+ throw new ContentDirError(
+ `--content-dir path is not accessible (${code ?? 'unknown error'}): ${abs}`,
+ );
+ }
+ if (!stat.isDirectory()) {
+ throw new ContentDirError(`--content-dir must be a directory: ${abs}`);
+ }
+ const canonRoot = safeRealpath(projectRoot);
+ const canonAbs = safeRealpath(abs);
+ const rel = relative(canonRoot, canonAbs);
+ if (rel.startsWith('..') || isAbsolute(rel)) {
+ throw new ContentDirError(
+ `--content-dir must be inside the project root (${projectRoot}); got ${abs}`,
+ );
+ }
+ return rel === '' ? '.' : rel;
+}
+
+function safeRealpath(p: string): string {
+ try {
+ return realpathSync(p);
+ } catch {
+ return p;
+ }
}
interface InitCommandResult {
@@ -388,6 +444,11 @@ interface InitCommandResult {
* Only set when `didGitInit` is also `true` AND no `.gitignore` was already
* present at `projectRoot` — pre-existing files are never touched. */
rootGitignoreCreated: boolean;
+ gitRootPromoted: boolean;
+ promotedFromDir?: string;
+ contentDir?: string;
+ contentDirRequested?: string;
+ contentScaffoldFailed: boolean;
mcpAction: 'written' | 'overwritten' | 'skipped-missing' | 'skipped-flag' | 'failed' | 'declined';
mcpPath: string;
mcpError?: string;
@@ -802,11 +863,19 @@ export async function runInit(options: InitCommandOptions = {}): Promise;
try {
- contentResult = initContent(projectRoot, { contentDir: resolution.defaultContentDir });
+ contentResult = initContent(projectRoot, { contentDir: contentDirScope });
} catch (err) {
const fallbackPath = EDITOR_TARGETS.claude.configPath(projectRoot, options.home);
return {
@@ -831,6 +900,10 @@ export async function runInit(options: InitCommandOptions = {}): Promise;
+}
+
+export function buildInitJsonSummary(
+ result: InitCommandResult,
+ opts: { contentDir: string; contentFileCount: number | null },
+): InitJsonSummary {
+ return {
+ projectRoot: result.projectRoot,
+ gitRootPromoted: result.gitRootPromoted,
+ promotedFromDir: result.promotedFromDir ?? null,
+ contentDir: opts.contentDir,
+ contentDirRequested: result.contentDirRequested ?? null,
+ contentDirApplied: result.contentDir !== undefined,
+ contentFileCount: opts.contentFileCount,
+ previewError: result.previewWarning ?? null,
+ didGitInit: result.didGitInit,
+ mcpAction: result.mcpAction,
+ editors: result.editors.map((e) => ({
+ editorId: e.editorId,
+ label: e.label,
+ action: e.action,
+ configPath: e.configPath,
+ scope: e.configScope === 'project' ? 'project' : 'user',
+ })),
+ };
+}
+
export function detectInstalledEditors(cwd: string, home?: string): EditorId[] {
const detected: EditorId[] = [];
for (const id of ALL_EDITOR_IDS) {
@@ -1319,6 +1487,23 @@ export function detectInstalledEditors(cwd: string, home?: string): EditorId[] {
return detected;
}
+function redirectStdoutConsoleToStderr(): () => void {
+ const orig = { log: console.log, info: console.info, debug: console.debug };
+ const toErr = (...args: unknown[]): void => {
+ process.stderr.write(
+ `${args.map((a) => (typeof a === 'string' ? a : inspect(a))).join(' ')}\n`,
+ );
+ };
+ console.log = toErr;
+ console.info = toErr;
+ console.debug = toErr;
+ return () => {
+ console.log = orig.log;
+ console.info = orig.info;
+ console.debug = orig.debug;
+ };
+}
+
export function initCommand(): Command {
return new Command('init')
.description(
@@ -1330,6 +1515,11 @@ export function initCommand(): Command {
'--dev-mcp',
'Register a local dev MCP entry using node + packages/cli/dist/cli.mjs with debug logging',
)
+ .option(
+ '--content-dir ',
+ `Limit content to instead of the whole project. is interpreted relative to your current directory (e.g. "." = the folder you run the command in), then saved to ${OK_DIR}/config.yml as content.dir.`,
+ )
+ .option('--json', 'Emit a structured JSON summary to stdout (diagnostics stay on stderr)')
.addOption(
new Option(
'--scope ',
@@ -1355,6 +1545,8 @@ export function initCommand(): Command {
scope?: McpScope;
shared?: boolean;
localOnly?: boolean;
+ contentDir?: string;
+ json?: boolean;
}) => {
const cwd = process.cwd();
@@ -1363,50 +1555,82 @@ export function initCommand(): Command {
: opts.localOnly
? 'local-only'
: undefined;
- let result: InitCommandResult;
+ const restoreConsole = opts.json ? redirectStdoutConsoleToStderr() : null;
try {
- result = await runInit({
- cwd,
- mcp: opts.mcp,
- devMcp: opts.devMcp,
- scope: opts.scope,
- sharing,
- });
- } catch (err) {
- if (err instanceof GitNotAvailableError || err instanceof GitTooOldError) {
- process.stderr.write(`${err.message}\n`);
- process.exitCode = 78;
- return;
+ let result: InitCommandResult;
+ try {
+ result = await runInit({
+ cwd,
+ mcp: opts.mcp,
+ devMcp: opts.devMcp,
+ scope: opts.scope,
+ sharing,
+ contentDir: opts.contentDir,
+ });
+ } catch (err) {
+ if (err instanceof ContentDirError) {
+ process.stderr.write(`${err.message}\n`);
+ process.exitCode = 64;
+ return;
+ }
+ if (err instanceof GitNotAvailableError || err instanceof GitTooOldError) {
+ process.stderr.write(`${err.message}\n`);
+ process.exitCode = 78;
+ return;
+ }
+ if (err instanceof ProjectGitInitError) {
+ process.stderr.write(
+ "open-knowledge could not initialize a git repo for this project. Re-run, or run 'git init' yourself in the project folder.\n",
+ );
+ if (err.stderr) process.stderr.write(`${err.stderr.trim()}\n`);
+ process.exitCode = 1;
+ return;
+ }
+ throw err;
}
- if (err instanceof ProjectGitInitError) {
- process.stderr.write(
- "open-knowledge could not initialize a git repo for this project. Re-run, or run 'git init' yourself in the project folder.\n",
- );
- if (err.stderr) process.stderr.write(`${err.stderr.trim()}\n`);
- process.exitCode = 1;
- return;
- }
- throw err;
- }
- try {
- const { previewContent } = await import('../content/preview.ts');
+ let effectiveContentDir = result.contentDir ?? '.';
+ let contentFileCount: number | null = null;
const { loadConfig } = await import('../config/loader.ts');
const { resolveContentDir } = await import('@inkeep/open-knowledge-server');
- const { config } = loadConfig(result.projectRoot);
- const contentDir = resolveContentDir(config, result.projectRoot);
- result.preview = previewContent({
- projectDir: result.projectRoot,
- contentDir,
- });
- } catch (e) {
- result.previewWarning = e instanceof Error ? e.message : String(e);
- }
+ let config: Awaited>['config'] | undefined;
+ try {
+ config = loadConfig(result.projectRoot).config;
+ effectiveContentDir = config.content.dir;
+ } catch (e) {
+ result.previewWarning = e instanceof Error ? e.message : String(e);
+ }
+ if (config) {
+ try {
+ const { previewContent } = await import('../content/preview.ts');
+ const contentDir = resolveContentDir(config, result.projectRoot);
+ result.preview = previewContent({
+ projectDir: result.projectRoot,
+ contentDir,
+ });
+ contentFileCount = result.preview.totalCount;
+ } catch (e) {
+ result.previewWarning = e instanceof Error ? e.message : String(e);
+ }
+ }
- process.stdout.write(`${formatInitResult(result, result.projectRoot)}\n`);
+ if (opts.json) {
+ process.stdout.write(
+ `${JSON.stringify(
+ buildInitJsonSummary(result, { contentDir: effectiveContentDir, contentFileCount }),
+ null,
+ 2,
+ )}\n`,
+ );
+ } else {
+ process.stdout.write(`${formatInitResult(result, result.projectRoot)}\n`);
+ }
- if (result.editors.some((e) => e.action === 'failed') || result.mcpAction === 'failed') {
- process.exitCode = 1;
+ if (result.editors.some((e) => e.action === 'failed') || result.mcpAction === 'failed') {
+ process.exitCode = 1;
+ }
+ } finally {
+ restoreConsole?.();
}
},
);