anchormd 0.1.0

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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Sultan Valiyev
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,132 @@
1
+ # AnchorMD
2
+
3
+ Persistent project context for AI coding agents using linked markdown plans with relationship tracking and hybrid search.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ npm install -g anchormd
9
+ ```
10
+
11
+ ## Quick Start
12
+
13
+ ```bash
14
+ # Initialize in your project
15
+ anchormd init
16
+
17
+ # Edit the project overview
18
+ anchormd write anchor
19
+
20
+ # Create a plan
21
+ echo '---
22
+ name: auth
23
+ description: Authentication system
24
+ status: planned
25
+ ---
26
+ # Authentication
27
+
28
+ JWT-based auth. See [[database]] for schema.
29
+ Uses POST /api/auth/login endpoint.
30
+ Config in src/auth/config.ts.
31
+ ' | anchormd write auth
32
+
33
+ # View project context
34
+ anchormd context
35
+
36
+ # List all plans
37
+ anchormd ls
38
+
39
+ # Read a specific plan
40
+ anchormd read auth
41
+
42
+ # Read a specific section
43
+ anchormd read auth#authentication
44
+
45
+ # View project stats
46
+ anchormd status
47
+ ```
48
+
49
+ ## Commands
50
+
51
+ | Command | Description |
52
+ |---------|-------------|
53
+ | `anchormd init [--no-qmd]` | Initialize AnchorMD in the current project |
54
+ | `anchormd context` | Print project overview and plan summary table |
55
+ | `anchormd write <name> [--from <file>]` | Write or update a plan (reads from file, stdin, or editor) |
56
+ | `anchormd ls [--status <s>] [--json]` | List all plans, optionally filtered by status |
57
+ | `anchormd read <name[#section]>` | Read a plan or a specific section via deep link |
58
+ | `anchormd find <query> [--semantic] [--hybrid] [--limit <n>] [--json]` | Search plans (requires QMD) |
59
+ | `anchormd reindex` | Rebuild the index graph and search database |
60
+ | `anchormd status` | Show plan count, links, weak edges, and QMD status |
61
+
62
+ ## How It Works
63
+
64
+ ### Plans
65
+
66
+ Plans are markdown files with YAML frontmatter stored in `.anchor/plans/`. Each plan has:
67
+
68
+ - **name**: identifier used in links
69
+ - **description**: short summary
70
+ - **status**: one of `planned`, `in-progress`, `built`, `deprecated`
71
+ - **tags**: optional array of tags
72
+
73
+ ### Links
74
+
75
+ Plans reference each other using wiki-style links:
76
+
77
+ - **Strong links**: `[[plan-name]]` creates an explicit edge in the graph
78
+ - **Deep links**: `[[plan-name#section]]` links to a specific section
79
+
80
+ ### Entities
81
+
82
+ AnchorMD extracts entity references from plan content:
83
+
84
+ - **File paths**: `src/auth/config.ts`, `lib/utils.js`
85
+ - **Models**: `model User`, `UserSchema`
86
+ - **Routes**: `GET /api/users`, `POST /api/auth/login`
87
+ - **Scripts**: `deploy.sh`, `npm run build`
88
+
89
+ ### Weak Edges
90
+
91
+ When two plans reference the same entity (e.g., both mention `src/auth/config.ts`), AnchorMD creates a **weak edge** between them. This surfaces implicit relationships that weren't explicitly linked.
92
+
93
+ ### Index Graph
94
+
95
+ The index graph (`.anchor/index.json`) tracks all plans, their links, entities, and weak edges. It's rebuilt automatically when plans are written, or manually via `anchormd reindex`.
96
+
97
+ ## Configuration
98
+
99
+ Configuration is stored in `.anchor/config.json`:
100
+
101
+ ```json
102
+ {
103
+ "qmd": false
104
+ }
105
+ ```
106
+
107
+ - **qmd**: Enable/disable QMD search integration. Set to `false` by default since QMD currently requires the Bun runtime.
108
+
109
+ ## Claude Code Integration
110
+
111
+ AnchorMD ships with a skill file at `skill/SKILL.md` for integration with Claude Code. The skill teaches Claude to:
112
+
113
+ 1. Run `anchormd context` at session start
114
+ 2. Search for relevant plans before starting tasks
115
+ 3. Read plan details as needed
116
+ 4. Update plans after implementing changes
117
+
118
+ ## Project Structure
119
+
120
+ ```
121
+ .anchor/
122
+ config.json # Project configuration
123
+ index.json # Relationship graph
124
+ search.sqlite # QMD search database (gitignored)
125
+ plans/
126
+ anchor.md # Project overview (created on init)
127
+ *.md # Your plan files
128
+ ```
129
+
130
+ ## License
131
+
132
+ MIT
package/bin/anchormd ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env bun
2
+ import('../src/cli.ts');
package/package.json ADDED
@@ -0,0 +1,44 @@
1
+ {
2
+ "name": "anchormd",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "description": "Persistent project context for AI coding agents using linked markdown plans with hybrid search",
6
+ "main": "src/cli.ts",
7
+ "bin": {
8
+ "anchormd": "./bin/anchormd"
9
+ },
10
+ "scripts": {
11
+ "build": "bun build src/cli.ts --outdir dist --target bun",
12
+ "dev": "bun run src/cli.ts",
13
+ "test": "bun test"
14
+ },
15
+ "files": [
16
+ "src/",
17
+ "bin/",
18
+ "skill/"
19
+ ],
20
+ "keywords": [
21
+ "ai",
22
+ "cli",
23
+ "markdown",
24
+ "plans",
25
+ "context",
26
+ "coding-agent",
27
+ "project-management",
28
+ "knowledge-graph"
29
+ ],
30
+ "author": "Sultan Valiyev",
31
+ "license": "MIT",
32
+ "repository": {
33
+ "type": "git",
34
+ "url": "https://github.com/sultanvaliyev/anchormd.git"
35
+ },
36
+ "dependencies": {
37
+ "@tobilu/qmd": "^2.0.1",
38
+ "commander": "^13.1.0",
39
+ "yaml": "^2.8.0"
40
+ },
41
+ "devDependencies": {
42
+ "bun-types": "^1.2.0"
43
+ }
44
+ }
package/skill/SKILL.md ADDED
@@ -0,0 +1,42 @@
1
+ ---
2
+ name: anchormd
3
+ description: Persistent project context for AI coding agents using linked markdown plans
4
+ allowed-tools:
5
+ - Bash
6
+ - Read
7
+ - Write
8
+ - Edit
9
+ ---
10
+
11
+ # AnchorMD Skill
12
+
13
+ You have access to AnchorMD, a project context system that gives you persistent, queryable knowledge about the project you are working on. Use it to understand the project, find relevant plans, and update context as you work.
14
+
15
+ ## Workflow
16
+
17
+ 1. **At session start**: Run `anchormd context` to load the project overview and see all plans.
18
+ 2. **Before starting a task**: Run `anchormd find "<topic>"` to find relevant plans (or `anchormd ls` to browse).
19
+ 3. **Read details**: Use `anchormd read <plan>` to get the full content of a plan. Use `anchormd read <plan>#<section>` for a specific section.
20
+ 4. **After implementing**: Update plans with `anchormd write <plan-name>` to reflect what was built.
21
+ 5. **Track progress**: Use `anchormd ls --status in-progress` to see active work items.
22
+
23
+ ## Command Reference
24
+
25
+ | Command | Description |
26
+ |---------|-------------|
27
+ | `anchormd init` | Initialize AnchorMD in current project. Use `--no-qmd` to disable search. |
28
+ | `anchormd context` | Print project overview (anchor.md) and plan summary table. |
29
+ | `anchormd write <name>` | Write a plan. Reads from `--from <file>`, piped stdin, or opens `$EDITOR`. |
30
+ | `anchormd ls` | List all plans. Filter with `--status <status>`. Use `--json` for structured output. |
31
+ | `anchormd read <name>` | Read a plan. Supports `name#section` deep links. |
32
+ | `anchormd find <query>` | Search plans. Use `--semantic`, `--hybrid`, `--limit <n>`, `--json`. |
33
+ | `anchormd reindex` | Rebuild the index graph and search database. |
34
+ | `anchormd status` | Show plan count, link count, weak edges, and QMD status. |
35
+
36
+ ## Tips
37
+
38
+ - Use `--json` flag with `ls` and `find` for structured output you can parse programmatically.
39
+ - Plans link to each other with `[[plan-name]]` syntax. Use `[[plan#section]]` for deep links.
40
+ - The index graph tracks both explicit links and "weak edges" (plans that reference the same files, models, routes, or scripts).
41
+ - After writing or modifying plans, the index is automatically rebuilt. Run `anchormd reindex` manually if needed.
42
+ - Plan statuses: `planned`, `in-progress`, `built`, `deprecated`.
package/src/cli.ts ADDED
@@ -0,0 +1,362 @@
1
+ /**
2
+ * AnchorMD CLI
3
+ *
4
+ * Persistent project context for AI coding agents using linked markdown plans
5
+ */
6
+
7
+ import { Command } from 'commander';
8
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
9
+ import { tmpdir } from 'node:os';
10
+ import { execSync } from 'node:child_process';
11
+ import path from 'node:path';
12
+
13
+ import { ensureProjectInitialized, getAnchorDir, getPlansDir } from './config.js';
14
+ import { readPlan, writePlan, listPlans, parseFrontmatter, serializePlan } from './plan.js';
15
+ import { rebuildAndWriteIndex, readIndex } from './index-graph.js';
16
+ import { getQmdStore, reindexQmd, searchQmd } from './qmd.js';
17
+ import { scaffold } from './scaffold.js';
18
+ import { color, formatPlanTable, formatSearchResults, formatStatus } from './format.js';
19
+ import type { PlanStatus } from './types.js';
20
+ import { VALID_STATUSES } from './types.js';
21
+
22
+ const VERSION = '0.1.0';
23
+
24
+ const program = new Command();
25
+
26
+ program
27
+ .name('anchormd')
28
+ .description('Persistent project context for AI coding agents using linked markdown plans')
29
+ .version(VERSION);
30
+
31
+ // ─── init ────────────────────────────────────────────────────────────────────
32
+
33
+ program
34
+ .command('init')
35
+ .description('Initialize AnchorMD in the current project')
36
+ .option('--no-qmd', 'Disable QMD search integration')
37
+ .action(async (options) => {
38
+ const anchorDir = path.join(process.cwd(), '.anchor');
39
+
40
+ if (existsSync(anchorDir)) {
41
+ console.error(color.red('Error: .anchor/ directory already exists. Project already initialized.'));
42
+ process.exit(1);
43
+ }
44
+
45
+ await scaffold(process.cwd(), { qmd: options.qmd });
46
+ });
47
+
48
+ // ─── context ─────────────────────────────────────────────────────────────────
49
+
50
+ program
51
+ .command('context')
52
+ .description('Print project overview and plan summary')
53
+ .action(() => {
54
+ const { projectRoot } = ensureProjectInitialized();
55
+ const plansDir = getPlansDir(projectRoot);
56
+
57
+ // Print anchor.md content
58
+ const anchorPath = path.join(plansDir, 'anchor.md');
59
+ if (existsSync(anchorPath)) {
60
+ const content = readFileSync(anchorPath, 'utf-8');
61
+ console.log(content);
62
+ }
63
+
64
+ // Print plan summary table
65
+ const plans = listPlans(plansDir);
66
+ console.log(color.bold('Plans:'));
67
+ console.log(formatPlanTable(plans));
68
+ console.log('');
69
+ });
70
+
71
+ // ─── write ───────────────────────────────────────────────────────────────────
72
+
73
+ program
74
+ .command('write <name>')
75
+ .description('Write or update a plan')
76
+ .option('--from <file>', 'Read plan content from a file')
77
+ .action(async (name: string, options) => {
78
+ const { projectRoot, config } = ensureProjectInitialized();
79
+ const plansDir = getPlansDir(projectRoot);
80
+ const anchorDir = getAnchorDir(projectRoot);
81
+
82
+ let content: string;
83
+
84
+ if (options.from) {
85
+ // Read from specified file
86
+ const fromPath = path.resolve(options.from);
87
+ if (!existsSync(fromPath)) {
88
+ console.error(color.red(`Error: File not found: ${fromPath}`));
89
+ process.exit(1);
90
+ }
91
+ content = readFileSync(fromPath, 'utf-8');
92
+ } else if (!process.stdin.isTTY) {
93
+ // Read from piped stdin
94
+ content = readFileSync(0, 'utf-8');
95
+ } else {
96
+ // Spawn editor
97
+ const editor = process.env.EDITOR || 'vi';
98
+ const tmpFile = path.join(tmpdir(), `anchormd-${name}-${Date.now()}.md`);
99
+
100
+ // Provide a template
101
+ const template = serializePlan(
102
+ { name, description: 'Description of this plan', status: 'planned' },
103
+ `# ${name}\n\nDescribe the plan here.\n`
104
+ );
105
+ writeFileSync(tmpFile, template, 'utf-8');
106
+
107
+ try {
108
+ execSync(`${editor} "${tmpFile}"`, { stdio: 'inherit' });
109
+ content = readFileSync(tmpFile, 'utf-8');
110
+ } catch {
111
+ console.error(color.red('Error: Editor exited with an error'));
112
+ process.exit(1);
113
+ }
114
+ }
115
+
116
+ // Validate or wrap content with frontmatter
117
+ try {
118
+ parseFrontmatter(content);
119
+ } catch {
120
+ // Content doesn't have valid frontmatter, wrap it
121
+ content = serializePlan(
122
+ { name, description: `Plan: ${name}`, status: 'planned' },
123
+ content
124
+ );
125
+ }
126
+
127
+ // Write the plan
128
+ writePlan(plansDir, name, content);
129
+ console.log(color.green(`Plan "${name}" written.`));
130
+
131
+ // Rebuild index
132
+ const graph = rebuildAndWriteIndex(projectRoot);
133
+ const nodeCount = Object.keys(graph.nodes).length;
134
+ console.log(color.dim(`Index rebuilt (${nodeCount} plans).`));
135
+
136
+ // Reindex QMD if enabled
137
+ if (config.qmd) {
138
+ const store = await getQmdStore(anchorDir, config);
139
+ await reindexQmd(store);
140
+ }
141
+ });
142
+
143
+ // ─── ls ──────────────────────────────────────────────────────────────────────
144
+
145
+ program
146
+ .command('ls')
147
+ .description('List all plans')
148
+ .option('--status <status>', 'Filter by status (planned, in-progress, built, deprecated)')
149
+ .option('--json', 'Output as JSON')
150
+ .action((options) => {
151
+ const { projectRoot } = ensureProjectInitialized();
152
+ const plansDir = getPlansDir(projectRoot);
153
+
154
+ let plans = listPlans(plansDir);
155
+
156
+ // Filter by status
157
+ if (options.status) {
158
+ const status = options.status as PlanStatus;
159
+ if (!VALID_STATUSES.includes(status)) {
160
+ console.error(color.red(`Invalid status "${status}". Must be one of: ${VALID_STATUSES.join(', ')}`));
161
+ process.exit(1);
162
+ }
163
+ plans = plans.filter(p => p.frontmatter.status === status);
164
+ }
165
+
166
+ if (options.json) {
167
+ console.log(JSON.stringify(plans, null, 2));
168
+ } else {
169
+ console.log(formatPlanTable(plans));
170
+ }
171
+ });
172
+
173
+ // ─── read ────────────────────────────────────────────────────────────────────
174
+
175
+ program
176
+ .command('read <name>')
177
+ .description('Read a plan (supports name#section deep links)')
178
+ .action((nameArg: string) => {
179
+ const { projectRoot } = ensureProjectInitialized();
180
+ const plansDir = getPlansDir(projectRoot);
181
+
182
+ // Parse name#section
183
+ const hashIndex = nameArg.indexOf('#');
184
+ let planName: string;
185
+ let section: string | null = null;
186
+
187
+ if (hashIndex !== -1) {
188
+ planName = nameArg.substring(0, hashIndex);
189
+ section = nameArg.substring(hashIndex + 1);
190
+ } else {
191
+ planName = nameArg;
192
+ }
193
+
194
+ const plan = readPlan(plansDir, planName);
195
+
196
+ if (section) {
197
+ // Extract the specified section
198
+ const sectionContent = extractSection(plan.body, section);
199
+ if (sectionContent === null) {
200
+ console.error(color.red(`Section "${section}" not found in plan "${planName}"`));
201
+ process.exit(1);
202
+ }
203
+ console.log(sectionContent);
204
+ } else {
205
+ // Print full plan content
206
+ const fullContent = serializePlan(plan.frontmatter, plan.body);
207
+ console.log(fullContent);
208
+ }
209
+ });
210
+
211
+ // ─── find ────────────────────────────────────────────────────────────────────
212
+
213
+ program
214
+ .command('find <query>')
215
+ .description('Search plans using QMD')
216
+ .option('--semantic', 'Use semantic (vector) search')
217
+ .option('--hybrid', 'Use hybrid search (lexical + semantic)')
218
+ .option('--limit <n>', 'Maximum number of results', '10')
219
+ .option('--json', 'Output as JSON')
220
+ .action(async (query: string, options) => {
221
+ const { projectRoot, config } = ensureProjectInitialized();
222
+ const anchorDir = getAnchorDir(projectRoot);
223
+
224
+ let mode: 'lexical' | 'semantic' | 'hybrid' = 'lexical';
225
+ if (options.semantic) mode = 'semantic';
226
+ if (options.hybrid) mode = 'hybrid';
227
+
228
+ const limit = parseInt(options.limit, 10) || 10;
229
+
230
+ const store = await getQmdStore(anchorDir, config);
231
+
232
+ try {
233
+ const results = await searchQmd(store, query, { mode, limit });
234
+
235
+ if (options.json) {
236
+ console.log(JSON.stringify(results, null, 2));
237
+ } else {
238
+ console.log(formatSearchResults(results));
239
+ }
240
+ } catch (err: unknown) {
241
+ const message = err instanceof Error ? err.message : String(err);
242
+ console.error(color.red(message));
243
+ process.exit(1);
244
+ }
245
+ });
246
+
247
+ // ─── reindex ─────────────────────────────────────────────────────────────────
248
+
249
+ program
250
+ .command('reindex')
251
+ .description('Rebuild the index graph and QMD search database')
252
+ .action(async () => {
253
+ const { projectRoot, config } = ensureProjectInitialized();
254
+ const anchorDir = getAnchorDir(projectRoot);
255
+
256
+ // Rebuild index graph
257
+ const graph = rebuildAndWriteIndex(projectRoot);
258
+ const nodeCount = Object.keys(graph.nodes).length;
259
+ const linkCount = Object.values(graph.nodes).reduce((sum, n) => sum + n.links.length, 0);
260
+ const weakEdgeCount = Object.values(graph.nodes).reduce((sum, n) => sum + n.weakEdges.length, 0);
261
+
262
+ console.log(color.green('Index rebuilt.'));
263
+ console.log(` Plans: ${color.cyan(String(nodeCount))}`);
264
+ console.log(` Links: ${color.cyan(String(linkCount))}`);
265
+ console.log(` Weak edges: ${color.cyan(String(weakEdgeCount))}`);
266
+
267
+ // Reindex QMD if enabled
268
+ if (config.qmd) {
269
+ const store = await getQmdStore(anchorDir, config);
270
+ await reindexQmd(store);
271
+ console.log(color.green('QMD search index updated.'));
272
+ } else {
273
+ console.log(color.dim('QMD search: disabled'));
274
+ }
275
+ });
276
+
277
+ // ─── status ──────────────────────────────────────────────────────────────────
278
+
279
+ program
280
+ .command('status')
281
+ .description('Show project status and statistics')
282
+ .action(() => {
283
+ const { projectRoot, config } = ensureProjectInitialized();
284
+ const anchorDir = getAnchorDir(projectRoot);
285
+
286
+ const graph = readIndex(anchorDir);
287
+
288
+ let planCount = 0;
289
+ let linkCount = 0;
290
+ let weakEdgeCount = 0;
291
+
292
+ if (graph) {
293
+ const nodes = Object.values(graph.nodes);
294
+ planCount = nodes.length;
295
+ linkCount = nodes.reduce((sum, n) => sum + n.links.length, 0);
296
+ weakEdgeCount = nodes.reduce((sum, n) => sum + n.weakEdges.length, 0);
297
+ }
298
+
299
+ console.log(formatStatus({
300
+ planCount,
301
+ linkCount,
302
+ weakEdgeCount,
303
+ qmdEnabled: config.qmd,
304
+ }));
305
+ });
306
+
307
+ // ─── Section extraction helper ───────────────────────────────────────────────
308
+
309
+ /**
310
+ * Extract a section from markdown content by heading slug.
311
+ * Matches heading text case-insensitively, slugified (spaces -> hyphens, lowercase).
312
+ * Returns content from the matched heading to the next same-level or higher heading.
313
+ */
314
+ function extractSection(body: string, sectionSlug: string): string | null {
315
+ const lines = body.split('\n');
316
+ const targetSlug = sectionSlug.toLowerCase();
317
+
318
+ let capturing = false;
319
+ let captureLevel = 0;
320
+ const captured: string[] = [];
321
+
322
+ for (const line of lines) {
323
+ const headingMatch = line.match(/^(#{1,6})\s+(.+)$/);
324
+
325
+ if (headingMatch) {
326
+ const level = headingMatch[1].length;
327
+ const text = headingMatch[2];
328
+ const slug = text
329
+ .toLowerCase()
330
+ .replace(/[^a-z0-9\s-]/g, '')
331
+ .replace(/\s+/g, '-')
332
+ .replace(/-+/g, '-')
333
+ .replace(/^-|-$/g, '');
334
+
335
+ if (capturing) {
336
+ // Stop if we hit a same-level or higher-level heading
337
+ if (level <= captureLevel) {
338
+ break;
339
+ }
340
+ }
341
+
342
+ if (slug === targetSlug) {
343
+ capturing = true;
344
+ captureLevel = level;
345
+ }
346
+ }
347
+
348
+ if (capturing) {
349
+ captured.push(line);
350
+ }
351
+ }
352
+
353
+ if (captured.length === 0) {
354
+ return null;
355
+ }
356
+
357
+ return captured.join('\n').trimEnd();
358
+ }
359
+
360
+ // ─── Parse and run ───────────────────────────────────────────────────────────
361
+
362
+ program.parse(process.argv);
package/src/config.ts ADDED
@@ -0,0 +1,91 @@
1
+ /**
2
+ * Project configuration and root detection
3
+ *
4
+ * Manages .anchor/config.json and project root discovery
5
+ */
6
+
7
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
8
+ import path from 'node:path';
9
+ import type { AnchorConfig } from './types.js';
10
+
11
+ const DEFAULT_CONFIG: AnchorConfig = { qmd: false };
12
+
13
+ /**
14
+ * Walk up the directory tree looking for .anchor/ directory.
15
+ * Returns the directory containing .anchor/, or null if not found.
16
+ */
17
+ export function findProjectRoot(startDir?: string): string | null {
18
+ let dir = startDir ?? process.cwd();
19
+
20
+ while (true) {
21
+ const anchorPath = path.join(dir, '.anchor');
22
+ if (existsSync(anchorPath)) {
23
+ return dir;
24
+ }
25
+
26
+ const parent = path.dirname(dir);
27
+ if (parent === dir) {
28
+ // Hit filesystem root
29
+ return null;
30
+ }
31
+ dir = parent;
32
+ }
33
+ }
34
+
35
+ /**
36
+ * Get the .anchor directory path for a project root
37
+ */
38
+ export function getAnchorDir(projectRoot: string): string {
39
+ return path.join(projectRoot, '.anchor');
40
+ }
41
+
42
+ /**
43
+ * Get the plans directory path for a project root
44
+ */
45
+ export function getPlansDir(projectRoot: string): string {
46
+ return path.join(projectRoot, '.anchor', 'plans');
47
+ }
48
+
49
+ /**
50
+ * Load configuration from .anchor/config.json.
51
+ * Returns defaults if file is missing or malformed.
52
+ */
53
+ export function loadConfig(projectRoot: string): AnchorConfig {
54
+ const configPath = path.join(getAnchorDir(projectRoot), 'config.json');
55
+
56
+ try {
57
+ const raw = readFileSync(configPath, 'utf-8');
58
+ const parsed = JSON.parse(raw);
59
+ return {
60
+ qmd: typeof parsed.qmd === 'boolean' ? parsed.qmd : DEFAULT_CONFIG.qmd,
61
+ };
62
+ } catch {
63
+ return { ...DEFAULT_CONFIG };
64
+ }
65
+ }
66
+
67
+ /**
68
+ * Save configuration to .anchor/config.json
69
+ */
70
+ export function saveConfig(projectRoot: string, config: AnchorConfig): void {
71
+ const anchorDir = getAnchorDir(projectRoot);
72
+ mkdirSync(anchorDir, { recursive: true });
73
+ const configPath = path.join(anchorDir, 'config.json');
74
+ writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n', 'utf-8');
75
+ }
76
+
77
+ /**
78
+ * Ensure the project has been initialized with anchormd.
79
+ * Throws with a user-friendly message if .anchor/ is not found.
80
+ */
81
+ export function ensureProjectInitialized(): { projectRoot: string; config: AnchorConfig } {
82
+ const projectRoot = findProjectRoot();
83
+ if (!projectRoot) {
84
+ throw new Error(
85
+ 'No AnchorMD project found. Run `anchormd init` to initialize one.'
86
+ );
87
+ }
88
+
89
+ const config = loadConfig(projectRoot);
90
+ return { projectRoot, config };
91
+ }