@open-agent-toolkit/cli 0.2.55 → 0.2.57

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/assets/agents/oat-phase-implementer.md +123 -9
  2. package/assets/bundle-metadata.json +1 -1
  3. package/assets/docs/cli-utilities/configuration.md +12 -2
  4. package/assets/docs/docs-tooling/commands.md +36 -5
  5. package/assets/docs/reference/oat-directory-structure.md +56 -4
  6. package/assets/docs/workflows/projects/implementation-execution.md +5 -1
  7. package/assets/docs/workflows/skills/explainer-kit.md +8 -5
  8. package/assets/public-package-versions.json +4 -4
  9. package/assets/skills/analyze/SKILL.md +18 -17
  10. package/assets/skills/oat-brainstorm/SKILL.md +2 -2
  11. package/assets/skills/oat-doctor/SKILL.md +49 -36
  12. package/assets/skills/oat-idea-summarize/SKILL.md +2 -2
  13. package/assets/skills/oat-project-autonomous/SKILL.md +17 -10
  14. package/assets/skills/oat-project-autonomous/references/gate-inventory.md +3 -3
  15. package/assets/skills/oat-project-complete/SKILL.md +18 -8
  16. package/assets/skills/oat-project-discover/SKILL.md +3 -3
  17. package/assets/skills/oat-project-document/references/docs/autonomy-contract.md +3 -3
  18. package/assets/skills/oat-project-implement/SKILL.md +1 -1
  19. package/assets/skills/oat-project-implement/references/completion-and-closeout.md +12 -3
  20. package/assets/skills/oat-project-implement/references/docs/autonomy-contract.md +3 -3
  21. package/assets/skills/oat-project-implement/references/phase-execution.md +218 -4
  22. package/assets/skills/oat-project-implement/references/plan-and-resume.md +5 -2
  23. package/assets/skills/oat-project-implement/scripts/capture-dirty-tree.mjs +1069 -0
  24. package/assets/skills/oat-project-import-plan/SKILL.md +12 -6
  25. package/assets/skills/oat-project-next/SKILL.md +7 -5
  26. package/assets/skills/oat-project-plan/SKILL.md +24 -12
  27. package/assets/skills/oat-project-pr-final/SKILL.md +2 -2
  28. package/assets/skills/oat-project-pr-final/references/docs/autonomy-contract.md +3 -3
  29. package/assets/skills/oat-project-quick-start/SKILL.md +11 -6
  30. package/assets/skills/oat-project-quick-start/references/docs/autonomy-contract.md +3 -3
  31. package/assets/skills/oat-project-retro/SKILL.md +4 -2
  32. package/assets/skills/oat-project-review-receive/SKILL.md +9 -5
  33. package/assets/skills/oat-project-revise/SKILL.md +6 -6
  34. package/dist/commands/config/index.d.ts +1 -0
  35. package/dist/commands/config/index.d.ts.map +1 -1
  36. package/dist/commands/config/index.js +51 -4
  37. package/dist/commands/docs/index-generate/generator.d.ts +43 -1
  38. package/dist/commands/docs/index-generate/generator.d.ts.map +1 -1
  39. package/dist/commands/docs/index-generate/generator.js +169 -3
  40. package/dist/commands/docs/index-generate/index.d.ts +8 -3
  41. package/dist/commands/docs/index-generate/index.d.ts.map +1 -1
  42. package/dist/commands/docs/index-generate/index.js +340 -24
  43. package/dist/commands/docs/init/scaffold.d.ts +11 -1
  44. package/dist/commands/docs/init/scaffold.d.ts.map +1 -1
  45. package/dist/commands/docs/init/scaffold.js +11 -2
  46. package/dist/config/oat-config.d.ts +14 -0
  47. package/dist/config/oat-config.d.ts.map +1 -1
  48. package/dist/config/oat-config.js +61 -0
  49. package/dist/fs/assets.d.ts +33 -4
  50. package/dist/fs/assets.d.ts.map +1 -1
  51. package/dist/fs/assets.js +127 -12
  52. package/package.json +2 -2
@@ -1,5 +1,155 @@
1
1
  import { readdir, readFile } from 'node:fs/promises';
2
2
  import { join } from 'node:path';
3
+ const NO_EXCLUSIONS = {
4
+ excludesFile: () => false,
5
+ excludesDirectory: () => false,
6
+ };
7
+ /**
8
+ * Match one path segment against one pattern segment, where `*` is the only
9
+ * metacharacter and never crosses `/`.
10
+ *
11
+ * This is the classic greedy two-pointer wildcard match, deliberately not a
12
+ * compiled regular expression: a pattern like `*a*a*a...` translates to
13
+ * nested-quantifier alternatives that backtrack exponentially, and exclusion
14
+ * patterns come from config and flags where a typo should not hang generation.
15
+ * Restarting from the last `*` instead bounds the work at O(pattern x text).
16
+ */
17
+ function matchSegment(pattern, text) {
18
+ let patternIndex = 0;
19
+ let textIndex = 0;
20
+ let starPatternIndex = -1;
21
+ let starTextIndex = 0;
22
+ while (textIndex < text.length) {
23
+ if (pattern[patternIndex] === '*') {
24
+ // Collapse a run of stars: within a segment they are all equivalent.
25
+ while (pattern[patternIndex] === '*') {
26
+ patternIndex += 1;
27
+ }
28
+ if (patternIndex === pattern.length) {
29
+ return true;
30
+ }
31
+ starPatternIndex = patternIndex;
32
+ starTextIndex = textIndex;
33
+ continue;
34
+ }
35
+ if (patternIndex < pattern.length &&
36
+ pattern[patternIndex] === text[textIndex]) {
37
+ patternIndex += 1;
38
+ textIndex += 1;
39
+ continue;
40
+ }
41
+ if (starPatternIndex === -1) {
42
+ return false;
43
+ }
44
+ // Give the last star one more character and retry from just after it.
45
+ starTextIndex += 1;
46
+ patternIndex = starPatternIndex;
47
+ textIndex = starTextIndex;
48
+ }
49
+ while (pattern[patternIndex] === '*') {
50
+ patternIndex += 1;
51
+ }
52
+ return patternIndex === pattern.length;
53
+ }
54
+ /**
55
+ * Match a docs-root-relative path against a pattern, segment by segment, where
56
+ * a whole-segment `**` spans zero or more path segments.
57
+ *
58
+ * Same greedy-restart shape as {@link matchSegment}, one level up, so a pattern
59
+ * full of `**` segments stays bounded too.
60
+ */
61
+ function matchPath(patternSegments, pathSegments) {
62
+ let patternIndex = 0;
63
+ let pathIndex = 0;
64
+ let starPatternIndex = -1;
65
+ let starPathIndex = 0;
66
+ while (pathIndex < pathSegments.length) {
67
+ if (patternSegments[patternIndex] === '**') {
68
+ while (patternSegments[patternIndex] === '**') {
69
+ patternIndex += 1;
70
+ }
71
+ if (patternIndex === patternSegments.length) {
72
+ return true;
73
+ }
74
+ starPatternIndex = patternIndex;
75
+ starPathIndex = pathIndex;
76
+ continue;
77
+ }
78
+ if (patternIndex < patternSegments.length &&
79
+ matchSegment(patternSegments[patternIndex], pathSegments[pathIndex])) {
80
+ patternIndex += 1;
81
+ pathIndex += 1;
82
+ continue;
83
+ }
84
+ if (starPatternIndex === -1) {
85
+ return false;
86
+ }
87
+ starPathIndex += 1;
88
+ patternIndex = starPatternIndex;
89
+ pathIndex = starPathIndex;
90
+ }
91
+ while (patternSegments[patternIndex] === '**') {
92
+ patternIndex += 1;
93
+ }
94
+ return patternIndex === patternSegments.length;
95
+ }
96
+ /**
97
+ * Compile exclusion globs matched against **docs-root-relative POSIX paths**
98
+ * (`api/auth.md`, `api/nested`), never against absolute or CWD-relative ones.
99
+ *
100
+ * Grammar, defined once and documented in
101
+ * `apps/oat-docs/docs/reference/oat-directory-structure.md`:
102
+ *
103
+ * - `*` matches any run of characters inside a single path segment.
104
+ * - `**` spans `/` only as a whole segment, where it covers zero or more
105
+ * segments: `**\/CLAUDE.md` matches `CLAUDE.md` and `api/CLAUDE.md`. Inside a
106
+ * segment (`a**b`) it is an ordinary single-segment wildcard.
107
+ * - Patterns are anchored at both ends against the whole relative path, so a
108
+ * bare `CLAUDE.md` matches only the root-level file. Use `**\/CLAUDE.md` for
109
+ * every depth.
110
+ * - A trailing `/` restricts a pattern to directories: `drafts/` prunes the
111
+ * `drafts` directory and everything beneath it, and never matches a file.
112
+ * Without it, a pattern that matches a directory path prunes it too.
113
+ * - Matching is case-sensitive, `/` is the separator on every platform, and
114
+ * `*` is the only metacharacter — `.`, `+`, and friends are literal.
115
+ *
116
+ * Entries are trimmed; a leading `./` or `/` is stripped so both spellings
117
+ * anchor at the docs root. Blank entries are ignored.
118
+ */
119
+ export function createExclusionMatcher(patterns) {
120
+ const filePatterns = [];
121
+ const directoryPatterns = [];
122
+ for (const rawPattern of patterns) {
123
+ const trimmedPattern = rawPattern.trim();
124
+ if (!trimmedPattern) {
125
+ continue;
126
+ }
127
+ const directoryOnly = trimmedPattern.endsWith('/');
128
+ const normalized = trimmedPattern
129
+ .replace(/\/+$/, '')
130
+ .replace(/^\.\//, '')
131
+ .replace(/^\/+/, '');
132
+ if (!normalized) {
133
+ continue;
134
+ }
135
+ const segments = normalized.split('/');
136
+ directoryPatterns.push(segments);
137
+ if (!directoryOnly) {
138
+ filePatterns.push(segments);
139
+ }
140
+ }
141
+ if (directoryPatterns.length === 0) {
142
+ return NO_EXCLUSIONS;
143
+ }
144
+ const matches = (compiled, relativePath) => {
145
+ const pathSegments = relativePath.split('/');
146
+ return compiled.some((segments) => matchPath(segments, pathSegments));
147
+ };
148
+ return {
149
+ excludesFile: (relativePath) => matches(filePatterns, relativePath),
150
+ excludesDirectory: (relativePath) => matches(directoryPatterns, relativePath),
151
+ };
152
+ }
3
153
  const FRONTMATTER_RE = /^---\n([\s\S]*?)\n---/;
4
154
  const HEADING_RE = /^#\s+(.+)$/m;
5
155
  function fileNameToTitle(fileName) {
@@ -60,15 +210,25 @@ function prefixEntryPath(entry, prefix) {
60
210
  children: entry.children?.map((child) => prefixEntryPath(child, prefix)),
61
211
  };
62
212
  }
63
- export async function generateIndex(docsDir) {
213
+ /** Docs-root-relative POSIX path used for matching, never for reading files. */
214
+ function relativeKey(base, name) {
215
+ return base ? `${base}/${name}` : name;
216
+ }
217
+ async function collectEntries(docsDir, relativeBase, matcher) {
64
218
  const entries = [];
65
219
  const subdirs = [];
66
220
  const dirEntries = await readdir(docsDir, { withFileTypes: true });
67
221
  for (const entry of dirEntries) {
222
+ const relativePath = relativeKey(relativeBase, entry.name);
68
223
  if (entry.isDirectory()) {
69
- subdirs.push(entry.name);
224
+ if (!matcher.excludesDirectory(relativePath)) {
225
+ subdirs.push(entry.name);
226
+ }
70
227
  }
71
228
  else if (entry.isFile() && entry.name.endsWith('.md')) {
229
+ if (matcher.excludesFile(relativePath)) {
230
+ continue;
231
+ }
72
232
  const filePath = join(docsDir, entry.name);
73
233
  const content = await readFile(filePath, 'utf8');
74
234
  const { title, description } = resolveTitle(content, entry.name);
@@ -80,7 +240,10 @@ export async function generateIndex(docsDir) {
80
240
  }
81
241
  }
82
242
  for (const subdir of subdirs.sort()) {
83
- const children = await generateIndex(join(docsDir, subdir));
243
+ const children = await collectEntries(join(docsDir, subdir), relativeKey(relativeBase, subdir), matcher);
244
+ // A directory with no surviving children emits no heading. This already
245
+ // pruned genuinely empty directories; exclusion simply empties more of
246
+ // them, and it composes upward through the recursion.
84
247
  if (children.length > 0) {
85
248
  entries.push({
86
249
  title: fileNameToTitle(subdir),
@@ -91,6 +254,9 @@ export async function generateIndex(docsDir) {
91
254
  }
92
255
  return sortEntries(entries);
93
256
  }
257
+ export async function generateIndex(docsDir, options = {}) {
258
+ return collectEntries(docsDir, '', createExclusionMatcher(options.excludes ?? []));
259
+ }
94
260
  function renderEntries(entries, indent) {
95
261
  const lines = [];
96
262
  const prefix = ' '.repeat(indent);
@@ -1,13 +1,18 @@
1
1
  import { type CommandContext, type GlobalOptions } from '../../../app/command-context.js';
2
+ import { type OatConfig } from '../../../config/oat-config.js';
2
3
  import { Command } from 'commander';
3
4
  export declare const GENERATED_INDEX_WARNING = "<!-- AUTOGENERATED by `oat docs generate-index`. Do not hand-edit; changes are clobbered on every `predev` / `prebuild`. -->";
4
5
  interface IndexGenerateFileDependencies {
5
- generateIndex: (docsDir: string) => Promise<import('./generator.js').IndexEntry[]>;
6
+ generateIndex: (docsDir: string, options: import('./generator.js').GenerateIndexOptions) => Promise<import('./generator.js').IndexEntry[]>;
6
7
  renderIndex: (entries: import('./generator.js').IndexEntry[]) => string;
7
8
  writeFile: (path: string, content: string, encoding: BufferEncoding) => Promise<void>;
8
- readOatConfig: (repoRoot: string) => Promise<import('../../../config/oat-config.js').OatConfig>;
9
- writeOatConfig: (repoRoot: string, config: import('../../../config/oat-config.js').OatConfig) => Promise<void>;
9
+ readOatConfig: (repoRoot: string) => Promise<OatConfig>;
10
+ writeOatConfig: (repoRoot: string, config: OatConfig) => Promise<void>;
10
11
  resolveRepoRoot: (cwd: string) => Promise<string>;
12
+ dirExists: (path: string) => Promise<boolean>;
13
+ readFileIfPresent: (path: string) => Promise<string | null>;
14
+ realpath: (path: string) => Promise<string>;
15
+ readLinkIfSymlink: (path: string) => Promise<string | null>;
11
16
  }
12
17
  interface IndexGenerateDependencies {
13
18
  buildCommandContext: (options: GlobalOptions) => CommandContext;
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../../src/commands/docs/index-generate/index.ts"],"names":[],"mappings":"AAGA,OAAO,EAEL,KAAK,cAAc,EACnB,KAAK,aAAa,EACnB,MAAM,sBAAsB,CAAC;AAI9B,OAAO,EAAE,OAAO,EAAU,MAAM,WAAW,CAAC;AAI5C,eAAO,MAAM,uBAAuB,iIAC4F,CAAC;AAOjI,UAAU,6BAA6B;IACrC,aAAa,EAAE,CACb,OAAO,EAAE,MAAM,KACZ,OAAO,CAAC,OAAO,aAAa,EAAE,UAAU,EAAE,CAAC,CAAC;IACjD,WAAW,EAAE,CAAC,OAAO,EAAE,OAAO,aAAa,EAAE,UAAU,EAAE,KAAK,MAAM,CAAC;IACrE,SAAS,EAAE,CACT,IAAI,EAAE,MAAM,EACZ,OAAO,EAAE,MAAM,EACf,QAAQ,EAAE,cAAc,KACrB,OAAO,CAAC,IAAI,CAAC,CAAC;IACnB,aAAa,EAAE,CACb,QAAQ,EAAE,MAAM,KACb,OAAO,CAAC,OAAO,oBAAoB,EAAE,SAAS,CAAC,CAAC;IACrD,cAAc,EAAE,CACd,QAAQ,EAAE,MAAM,EAChB,MAAM,EAAE,OAAO,oBAAoB,EAAE,SAAS,KAC3C,OAAO,CAAC,IAAI,CAAC,CAAC;IACnB,eAAe,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,OAAO,CAAC,MAAM,CAAC,CAAC;CACnD;AAED,UAAU,yBAAyB;IACjC,mBAAmB,EAAE,CAAC,OAAO,EAAE,aAAa,KAAK,cAAc,CAAC;IAChE,QAAQ,EAAE,6BAA6B,CAAC;CACzC;AAqFD,wBAAgB,8BAA8B,CAC5C,SAAS,GAAE,OAAO,CAAC,yBAAyB,CAAM,GACjD,OAAO,CAsBT"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../../src/commands/docs/index-generate/index.ts"],"names":[],"mappings":"AAWA,OAAO,EAEL,KAAK,cAAc,EACnB,KAAK,aAAa,EACnB,MAAM,sBAAsB,CAAC;AAE9B,OAAO,EACL,KAAK,SAAS,EAGf,MAAM,oBAAoB,CAAC;AAI5B,OAAO,EAAE,OAAO,EAAU,MAAM,WAAW,CAAC;AAI5C,eAAO,MAAM,uBAAuB,iIAC4F,CAAC;AAuBjI,UAAU,6BAA6B;IACrC,aAAa,EAAE,CACb,OAAO,EAAE,MAAM,EACf,OAAO,EAAE,OAAO,aAAa,EAAE,oBAAoB,KAChD,OAAO,CAAC,OAAO,aAAa,EAAE,UAAU,EAAE,CAAC,CAAC;IACjD,WAAW,EAAE,CAAC,OAAO,EAAE,OAAO,aAAa,EAAE,UAAU,EAAE,KAAK,MAAM,CAAC;IACrE,SAAS,EAAE,CACT,IAAI,EAAE,MAAM,EACZ,OAAO,EAAE,MAAM,EACf,QAAQ,EAAE,cAAc,KACrB,OAAO,CAAC,IAAI,CAAC,CAAC;IACnB,aAAa,EAAE,CAAC,QAAQ,EAAE,MAAM,KAAK,OAAO,CAAC,SAAS,CAAC,CAAC;IACxD,cAAc,EAAE,CAAC,QAAQ,EAAE,MAAM,EAAE,MAAM,EAAE,SAAS,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IACvE,eAAe,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,OAAO,CAAC,MAAM,CAAC,CAAC;IAClD,SAAS,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,OAAO,CAAC,OAAO,CAAC,CAAC;IAC9C,iBAAiB,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAAC;IAC5D,QAAQ,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,OAAO,CAAC,MAAM,CAAC,CAAC;IAC5C,iBAAiB,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAAC;CAC7D;AAED,UAAU,yBAAyB;IACjC,mBAAmB,EAAE,CAAC,OAAO,EAAE,aAAa,KAAK,cAAc,CAAC;IAChE,QAAQ,EAAE,6BAA6B,CAAC;CACzC;AAggBD,wBAAgB,8BAA8B,CAC5C,SAAS,GAAE,OAAO,CAAC,yBAAyB,CAAM,GACjD,OAAO,CAgCT"}
@@ -1,12 +1,48 @@
1
- import { writeFile } from 'node:fs/promises';
2
- import { join, relative } from 'node:path';
1
+ import { readFile, readlink, realpath, writeFile } from 'node:fs/promises';
2
+ import { basename, dirname, isAbsolute, join, relative, resolve, sep, } from 'node:path';
3
3
  import { buildCommandContext, } from '../../../app/command-context.js';
4
4
  import { readGlobalOptions } from '../../shared/shared.utils.js';
5
- import { readOatConfig, writeOatConfig } from '../../../config/oat-config.js';
6
- import { resolveProjectRoot } from '../../../fs/paths.js';
5
+ import { readOatConfig, writeOatConfig, } from '../../../config/oat-config.js';
6
+ import { CliError } from '../../../errors/cli-error.js';
7
+ import { dirExists } from '../../../fs/io.js';
8
+ import { normalizeToPosixPath, resolveProjectRoot } from '../../../fs/paths.js';
7
9
  import { Command, Option } from 'commander';
8
10
  import { generateIndex, renderIndex } from './generator.js';
9
11
  export const GENERATED_INDEX_WARNING = '<!-- AUTOGENERATED by `oat docs generate-index`. Do not hand-edit; changes are clobbered on every `predev` / `prebuild`. -->';
12
+ const YAML_EXTENSIONS = ['.yml', '.yaml'];
13
+ /**
14
+ * Exit codes, per the `packages/cli/AGENTS.md` taxonomy.
15
+ *
16
+ * A refused output target is an actionable user error: the operator passed a
17
+ * path the command will not write to, and the message names what to pass
18
+ * instead. Unusable path *configuration* keeps the exit code the external plan
19
+ * mandates in its step 1, matching the `oat-config.ts` precedent for an invalid
20
+ * repository config value paired with a repair command.
21
+ */
22
+ const REFUSAL_EXIT_CODE = 1;
23
+ const CONFIGURATION_EXIT_CODE = 2;
24
+ async function readFileIfPresent(path) {
25
+ try {
26
+ return await readFile(path, 'utf8');
27
+ }
28
+ catch (error) {
29
+ const code = error.code;
30
+ if (code === 'ENOENT' || code === 'ENOTDIR') {
31
+ return null;
32
+ }
33
+ throw error;
34
+ }
35
+ }
36
+ /** The link destination when `path` is a symlink (dangling or not), else null. */
37
+ async function readLinkIfSymlink(path) {
38
+ try {
39
+ return await readlink(path);
40
+ }
41
+ catch {
42
+ // EINVAL (not a symlink) and ENOENT (absent) are both "nothing to follow".
43
+ return null;
44
+ }
45
+ }
10
46
  const DEFAULT_FILE_DEPS = {
11
47
  generateIndex,
12
48
  renderIndex,
@@ -14,6 +50,10 @@ const DEFAULT_FILE_DEPS = {
14
50
  readOatConfig,
15
51
  writeOatConfig,
16
52
  resolveRepoRoot: resolveProjectRoot,
53
+ dirExists,
54
+ readFileIfPresent,
55
+ realpath,
56
+ readLinkIfSymlink,
17
57
  };
18
58
  const DEFAULT_DEPENDENCIES = {
19
59
  buildCommandContext,
@@ -25,35 +65,310 @@ function buildGeneratedIndexOutput(content) {
25
65
  }
26
66
  return `${GENERATED_INDEX_WARNING}\n\n${content}`;
27
67
  }
68
+ /**
69
+ * True when the relative path escapes its base. A bare `startsWith('..')` test
70
+ * is wrong: it also matches ordinary children such as `..draft/page.md`, which
71
+ * would let a target inside the indexed tree pass the safety checks.
72
+ */
73
+ function escapesBase(relativePath) {
74
+ return (isAbsolute(relativePath) ||
75
+ relativePath === '..' ||
76
+ relativePath.startsWith(`..${sep}`));
77
+ }
78
+ /**
79
+ * True when `candidate` is `parent` itself or lives underneath it. Used for the
80
+ * output-safety checks, so an exact match must count as "inside".
81
+ */
82
+ function isAtOrInside(parent, candidate) {
83
+ const relativePath = relative(parent, candidate);
84
+ return relativePath === '' || !escapesBase(relativePath);
85
+ }
86
+ /** True when `candidate` lives strictly underneath `parent`. */
87
+ function isInside(parent, candidate) {
88
+ const relativePath = relative(parent, candidate);
89
+ return relativePath !== '' && !escapesBase(relativePath);
90
+ }
91
+ /**
92
+ * Maximum symlink hops followed while canonicalizing, mirroring the kernel's
93
+ * own bound. `realpath` handles resolvable chains; this only bounds the
94
+ * dangling-link hops resolved below.
95
+ */
96
+ const MAX_SYMLINK_HOPS = 32;
97
+ /**
98
+ * Absolute path with symlinks resolved, so a symlinked parent cannot smuggle a
99
+ * write into a protected tree past the lexical containment checks.
100
+ *
101
+ * Output paths usually do not exist yet, so `realpath` fails and the nearest
102
+ * existing ancestor is resolved instead. A *dangling* symlink also fails
103
+ * `realpath`, yet `writeFile` still follows it, so read its destination
104
+ * explicitly. Components are resolved parent-first, in filesystem order, so a
105
+ * relative link destination is joined to the parent's *real* location rather
106
+ * than its lexical spelling.
107
+ */
108
+ async function canonicalize(target, deps, linkHops = 0) {
109
+ const absolute = resolve(target);
110
+ try {
111
+ return await deps.realpath(absolute);
112
+ }
113
+ catch (error) {
114
+ const code = error.code;
115
+ if (code !== 'ENOENT' && code !== 'ENOTDIR') {
116
+ throw error;
117
+ }
118
+ }
119
+ const parent = dirname(absolute);
120
+ if (parent === absolute) {
121
+ return absolute;
122
+ }
123
+ const canonicalParent = await canonicalize(parent, deps, linkHops);
124
+ const candidate = join(canonicalParent, basename(absolute));
125
+ const linkTarget = await deps.readLinkIfSymlink(candidate);
126
+ if (linkTarget === null) {
127
+ return candidate;
128
+ }
129
+ if (linkHops >= MAX_SYMLINK_HOPS) {
130
+ // Fail closed: falling through would hand the writer an unresolved link.
131
+ throw new CliError(`Refusing to resolve ${target}: its symlink chain exceeds ${MAX_SYMLINK_HOPS} hops. ` +
132
+ 'Pass --output with a path that is not a deep symlink chain.', REFUSAL_EXIT_CODE);
133
+ }
134
+ return canonicalize(resolve(canonicalParent, linkTarget), deps, linkHops + 1);
135
+ }
136
+ function trimmed(value) {
137
+ const trimmedValue = value?.trim();
138
+ return trimmedValue ? trimmedValue : null;
139
+ }
140
+ /** Commander collector for the repeatable `--exclude` flag. */
141
+ function collectExclude(value, previous) {
142
+ return [...(previous ?? []), value];
143
+ }
144
+ /**
145
+ * The exclusion list handed to the generator: `documentation.excludes` first,
146
+ * then the `--exclude` flags.
147
+ *
148
+ * Flags **extend** the configured defaults rather than replacing them, so a
149
+ * one-off exclusion on the command line cannot silently republish the pages a
150
+ * repository deliberately excluded. Entries are trimmed, blanks are dropped,
151
+ * and duplicates collapse while preserving first-seen order.
152
+ */
153
+ function resolveExcludes(config, flagValues) {
154
+ const merged = [];
155
+ for (const value of [
156
+ ...(config.documentation?.excludes ?? []),
157
+ ...(flagValues ?? []),
158
+ ]) {
159
+ const entry = value.trim();
160
+ if (entry && !merged.includes(entry)) {
161
+ merged.push(entry);
162
+ }
163
+ }
164
+ return merged;
165
+ }
166
+ function missingRootError(flag) {
167
+ return new CliError(`Cannot resolve ${flag === '--docs-dir' ? 'the docs directory' : 'the output path'}: ` +
168
+ '`documentation.root` is not set in .oat/config.json. ' +
169
+ 'Set it with `oat config set documentation.root <path>` (or run `oat docs init`), ' +
170
+ `or pass ${flag} <path> explicitly.`, CONFIGURATION_EXIT_CODE);
171
+ }
172
+ /**
173
+ * Resolve the docs directory and output path.
174
+ *
175
+ * Omitted options come from repository configuration; explicitly supplied
176
+ * options stay CWD-relative for backward compatibility. `documentation.root`
177
+ * canonically names the docs **app root** written by `oat docs init`. The
178
+ * `<root>/docs` preference is compatibility behavior for legacy configs whose
179
+ * `root` names a docs *source* directory; `--docs-dir` overrides it.
180
+ */
181
+ async function resolveIndexGeneratePaths(context, options, deps) {
182
+ const repoRoot = await deps.resolveRepoRoot(context.cwd);
183
+ const config = await deps.readOatConfig(repoRoot);
184
+ const configuredRootValue = trimmed(config.documentation?.root);
185
+ const configuredRoot = configuredRootValue
186
+ ? resolve(repoRoot, configuredRootValue)
187
+ : null;
188
+ // Either omitted option needs a usable configured root, so validate it once
189
+ // up front rather than only on the path that happens to be derived first.
190
+ if (options.docsDir === undefined || options.output === undefined) {
191
+ if (!configuredRoot) {
192
+ throw missingRootError(options.docsDir === undefined ? '--docs-dir' : '--output');
193
+ }
194
+ if (!(await deps.dirExists(configuredRoot))) {
195
+ // Fail with the config-repair path rather than letting the generator or
196
+ // the writer surface a raw ENOENT under the generic exit code.
197
+ throw new CliError(`Cannot resolve documentation paths: \`documentation.root\` is set to "${configuredRootValue}" ` +
198
+ `in .oat/config.json, but ${configuredRoot} is not a directory. ` +
199
+ 'Repair it with `oat config set documentation.root <path>`, or pass --docs-dir and --output explicitly.', CONFIGURATION_EXIT_CODE);
200
+ }
201
+ }
202
+ let docsDir;
203
+ let docsDirSource;
204
+ if (options.docsDir !== undefined) {
205
+ docsDir = resolve(context.cwd, options.docsDir);
206
+ docsDirSource = 'flag';
207
+ }
208
+ else if (await deps.dirExists(join(configuredRoot, 'docs'))) {
209
+ docsDir = join(configuredRoot, 'docs');
210
+ docsDirSource = 'config-docs-subdirectory';
211
+ }
212
+ else {
213
+ docsDir = configuredRoot;
214
+ docsDirSource = 'config-root';
215
+ }
216
+ const outputIsExplicit = options.output !== undefined;
217
+ const outputPath = outputIsExplicit
218
+ ? resolve(context.cwd, options.output)
219
+ : join(configuredRoot, 'index.md');
220
+ return {
221
+ repoRoot,
222
+ config,
223
+ configuredRoot,
224
+ docsDir,
225
+ docsDirSource,
226
+ outputPath,
227
+ outputIsExplicit,
228
+ excludes: resolveExcludes(config, options.exclude),
229
+ };
230
+ }
231
+ /** `canonicalize`, but an unresolvable path yields null instead of throwing. */
232
+ async function canonicalizeOrNull(target, deps) {
233
+ try {
234
+ return await canonicalize(target, deps);
235
+ }
236
+ catch {
237
+ return null;
238
+ }
239
+ }
240
+ async function canonicalizeIndexGeneratePaths(resolved, deps) {
241
+ const documentationConfigValue = trimmed(resolved.config.documentation?.config);
242
+ const lexicalDocumentationConfig = documentationConfigValue
243
+ ? resolve(resolved.repoRoot, documentationConfigValue)
244
+ : null;
245
+ return {
246
+ docsDir: await canonicalize(resolved.docsDir, deps),
247
+ outputPath: await canonicalize(resolved.outputPath, deps),
248
+ // An unusable configured root only makes the config write ineligible; it
249
+ // must not fail a run whose paths were both supplied explicitly.
250
+ configuredRoot: resolved.configuredRoot
251
+ ? await canonicalizeOrNull(resolved.configuredRoot, deps)
252
+ : null,
253
+ lexicalDocumentationConfig,
254
+ // Falling back to the lexical path keeps the refusal guard intact when the
255
+ // configured tool config cannot be canonicalized.
256
+ documentationConfig: lexicalDocumentationConfig
257
+ ? ((await canonicalizeOrNull(lexicalDocumentationConfig, deps)) ??
258
+ lexicalDocumentationConfig)
259
+ : null,
260
+ };
261
+ }
262
+ /**
263
+ * Reject unsafe output targets before anything is generated or written. Applies
264
+ * to derived and explicit paths alike, and compares canonical paths so a
265
+ * symlinked target cannot slip past. Messages name the operator-supplied path.
266
+ */
267
+ async function assertOutputIsSafe(resolved, canonical, deps) {
268
+ const { docsDir, outputPath, outputIsExplicit } = resolved;
269
+ if (isAtOrInside(canonical.docsDir, canonical.outputPath)) {
270
+ throw new CliError(`Refusing to write the generated index inside the docs directory it indexes: ` +
271
+ `${outputPath} is inside ${docsDir}. Pass --output with a path outside the docs directory.`, REFUSAL_EXIT_CODE);
272
+ }
273
+ const configuredConfigPaths = new Set([
274
+ canonical.documentationConfig,
275
+ canonical.lexicalDocumentationConfig,
276
+ ].filter((value) => value !== null));
277
+ if (configuredConfigPaths.has(canonical.outputPath) ||
278
+ configuredConfigPaths.has(outputPath)) {
279
+ throw new CliError(`Refusing to overwrite the documentation tool config at ${outputPath} ` +
280
+ '(`documentation.config` in .oat/config.json). Pass --output with a different path.', REFUSAL_EXIT_CODE);
281
+ }
282
+ const yamlCandidates = [
283
+ outputPath.toLowerCase(),
284
+ canonical.outputPath.toLowerCase(),
285
+ ];
286
+ if (YAML_EXTENSIONS.some((extension) => yamlCandidates.some((candidate) => candidate.endsWith(extension)))) {
287
+ throw new CliError(`Refusing to write the generated Markdown index to a YAML file: ${outputPath}. ` +
288
+ 'Pass --output with a Markdown path.', REFUSAL_EXIT_CODE);
289
+ }
290
+ if (outputIsExplicit) {
291
+ return;
292
+ }
293
+ const existing = await deps.readFileIfPresent(outputPath);
294
+ if (existing !== null && !existing.includes(GENERATED_INDEX_WARNING)) {
295
+ throw new CliError(`Refusing to overwrite ${outputPath}: the file exists and is not a generated index ` +
296
+ '(it has no `oat docs generate-index` AUTOGENERATED header). ' +
297
+ 'Move or delete it, or pass --output to name it explicitly.', REFUSAL_EXIT_CODE);
298
+ }
299
+ }
300
+ /**
301
+ * The only configuration write this command performs: the Fumadocs bootstrap
302
+ * transition that records the generated app-root manifest in
303
+ * `documentation.index`. MkDocs (and any other declared tooling) never writes.
304
+ */
305
+ function isFumadocsManifestTransition(resolved, canonical) {
306
+ const { config } = resolved;
307
+ if (!canonical.configuredRoot ||
308
+ !isInside(canonical.configuredRoot, canonical.outputPath)) {
309
+ return false;
310
+ }
311
+ const tooling = trimmed(config.documentation?.tooling)?.toLowerCase();
312
+ if (tooling) {
313
+ return tooling === 'fumadocs';
314
+ }
315
+ return trimmed(config.documentation?.config) === null;
316
+ }
317
+ /**
318
+ * The repo-relative value recorded in `documentation.index`.
319
+ *
320
+ * Eligibility is decided on canonical paths, so an operator who supplied an
321
+ * aliased spelling of the output (`/private/tmp/...` for `/tmp/...`) would
322
+ * otherwise persist a machine-specific path into tracked config. Take the
323
+ * canonical suffix inside the configured root and rebase it onto that root's
324
+ * configured spelling, which is repo-relative by construction.
325
+ */
326
+ function buildDocumentationIndexValue(resolved, canonical) {
327
+ const suffix = relative(canonical.configuredRoot, canonical.outputPath);
328
+ const portableOutput = join(resolved.configuredRoot, suffix);
329
+ const relativeOutput = relative(resolved.repoRoot, portableOutput);
330
+ // An empty relative path means the output *is* the repository root directory,
331
+ // which is never written. A `documentation.root` outside the repository would
332
+ // persist an absolute or `../` path. Withhold the key in both cases rather
333
+ // than record a value pointing at a file this run did not produce.
334
+ if (!relativeOutput || escapesBase(relativeOutput)) {
335
+ return null;
336
+ }
337
+ return normalizeToPosixPath(relativeOutput);
338
+ }
28
339
  async function runIndexGenerate(context, options, deps) {
29
- const docsDir = join(context.cwd, options.docsDir);
30
- const outputPath = options.output
31
- ? join(context.cwd, options.output)
32
- : join(context.cwd, 'index.md');
33
- const entries = await deps.generateIndex(docsDir);
340
+ const resolved = await resolveIndexGeneratePaths(context, options, deps);
341
+ const canonical = await canonicalizeIndexGeneratePaths(resolved, deps);
342
+ await assertOutputIsSafe(resolved, canonical, deps);
343
+ const { config, docsDir, outputPath, repoRoot } = resolved;
344
+ const entries = await deps.generateIndex(docsDir, {
345
+ excludes: resolved.excludes,
346
+ });
34
347
  const content = buildGeneratedIndexOutput(deps.renderIndex(entries));
35
348
  await deps.writeFile(outputPath, content, 'utf8');
36
- const repoRoot = await deps.resolveRepoRoot(context.cwd);
37
- const config = await deps.readOatConfig(repoRoot);
38
- const indexPath = relative(repoRoot, outputPath) || 'index.md';
39
- if (config.documentation?.index !== indexPath) {
40
- await deps.writeOatConfig(repoRoot, {
41
- ...config,
42
- documentation: {
43
- ...config.documentation,
44
- index: indexPath,
45
- },
46
- });
349
+ if (isFumadocsManifestTransition(resolved, canonical)) {
350
+ const indexPath = buildDocumentationIndexValue(resolved, canonical);
351
+ if (indexPath !== null && config.documentation?.index !== indexPath) {
352
+ await deps.writeOatConfig(repoRoot, {
353
+ ...config,
354
+ documentation: {
355
+ ...config.documentation,
356
+ index: indexPath,
357
+ },
358
+ });
359
+ }
47
360
  }
48
361
  if (context.json) {
49
362
  context.logger.json({
50
363
  status: 'ok',
51
364
  entriesGenerated: entries.length,
365
+ docsDir,
366
+ docsDirSource: resolved.docsDirSource,
52
367
  outputPath,
53
368
  });
54
369
  return;
55
370
  }
56
- context.logger.info(`Generated index with ${entries.length} entries → ${outputPath}`);
371
+ context.logger.info(`Generated index with ${entries.length} entries from ${docsDir} → ${outputPath}`);
57
372
  }
58
373
  async function runIndexGenerateCommand(context, options, dependencies) {
59
374
  try {
@@ -68,7 +383,7 @@ async function runIndexGenerateCommand(context, options, dependencies) {
68
383
  else {
69
384
  context.logger.error(message);
70
385
  }
71
- process.exitCode = 1;
386
+ process.exitCode = error instanceof CliError ? error.exitCode : 1;
72
387
  }
73
388
  }
74
389
  export function createDocsGenerateIndexCommand(overrides = {}) {
@@ -78,8 +393,9 @@ export function createDocsGenerateIndexCommand(overrides = {}) {
78
393
  };
79
394
  return new Command('generate-index')
80
395
  .description('Generate a docs index from markdown files')
81
- .addOption(new Option('--docs-dir <path>', 'Documentation source directory').default('docs'))
82
- .addOption(new Option('--output <path>', 'Output file path (default: index.md)'))
396
+ .addOption(new Option('--docs-dir <path>', 'Documentation source directory, resolved from the CWD (default: `<documentation.root>/docs` when that directory exists, otherwise `<documentation.root>`)'))
397
+ .addOption(new Option('--output <path>', 'Output file path, resolved from the CWD (default: `<documentation.root>/index.md`)'))
398
+ .addOption(new Option('--exclude <glob>', 'Exclude matching paths, relative to the docs directory (`*` within a segment, `**` across segments, trailing `/` for directories only). Repeatable, and additive to `documentation.excludes`').argParser(collectExclude))
83
399
  .action(async (options, command) => {
84
400
  const context = dependencies.buildCommandContext(readGlobalOptions(command));
85
401
  await runIndexGenerateCommand(context, options, dependencies);
@@ -1,5 +1,5 @@
1
1
  import type { OatDocumentationConfig } from '../../../config/oat-config.js';
2
- import type { DocsInitResolvedOptions } from './resolve-options.js';
2
+ import type { DocsFramework, DocsInitResolvedOptions } from './resolve-options.js';
3
3
  export interface ScaffoldDocsAppOptions extends DocsInitResolvedOptions {
4
4
  assetsRoot: string;
5
5
  }
@@ -21,6 +21,16 @@ export interface ScaffoldDocsAppDependencies {
21
21
  export declare const PNPM_FALLBACK = "10.13.1";
22
22
  export declare function detectIsOatRepo(repoRoot: string): Promise<boolean>;
23
23
  export declare function resolveOatDepContext(repoRoot: string, assetsRoot: string): Promise<OatDepContext>;
24
+ /**
25
+ * Seed `documentation.*` for a freshly scaffolded app.
26
+ *
27
+ * For Fumadocs, `index` names the generated app-root manifest that
28
+ * `oat docs generate-index` writes (`<root>/index.md`), not the authored source
29
+ * page at `<root>/docs/index.md`: the scaffold must be correct before the first
30
+ * build rather than depending on a later config mutation. The MkDocs seed keeps
31
+ * pointing at the nav/config YAML, which is that framework's entry point.
32
+ */
33
+ export declare function buildDocumentationConfig(framework: DocsFramework, targetDir: string): OatDocumentationConfig;
24
34
  export declare function scaffoldDocsApp(options: ScaffoldDocsAppOptions, overrides?: Partial<ScaffoldDocsAppDependencies>): Promise<ScaffoldDocsAppResult>;
25
35
  export {};
26
36
  //# sourceMappingURL=scaffold.d.ts.map