@grapgrap/aeira 1.0.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/README.en.md ADDED
@@ -0,0 +1,92 @@
1
+ # aeira
2
+
3
+ A CLI tool that structures markdown wikilink relationships without app dependency.
4
+
5
+ [English](README.en.md) [한국어](README.md)
6
+
7
+ ## Why
8
+
9
+ Wikilinks (`[[]]`) in markdown documents express relationships between documents.
10
+ But as wikilinks accumulate, the full picture of those relationships remains invisible. Which documents reference which, and how many steps connect two documents -- you cannot know without tracing them yourself.
11
+
12
+ aeira builds these relationships into a directed graph, navigable from the command line.
13
+
14
+ ## Commands
15
+
16
+ aeira operates through four commands.
17
+
18
+ ### init
19
+
20
+ Register a document collection by specifying a source path.
21
+
22
+ ```sh
23
+ aeira init ./my-docs
24
+ ```
25
+
26
+ ### sync
27
+
28
+ Parse wikilinks to build and update the graph. Only changed documents are processed incrementally.
29
+
30
+ ```sh
31
+ aeira sync # use cwd as source
32
+ aeira sync -s ./my-docs # specify source path
33
+ ```
34
+
35
+ ### search
36
+
37
+ Search documents by keyword and display outgoing links alongside each result.
38
+
39
+ ```sh
40
+ aeira search "keyword"
41
+ aeira search -s ./my-docs "keyword"
42
+ ```
43
+
44
+ ### graph
45
+
46
+ Three primitives for navigating the graph.
47
+
48
+ ```sh
49
+ # List 1-hop neighbors
50
+ aeira graph neighbors node-name
51
+
52
+ # Find all paths between two nodes
53
+ aeira graph path from-node to-node
54
+
55
+ # Show entire graph
56
+ aeira graph all
57
+
58
+ # Specify source path
59
+ aeira graph neighbors -s ./my-docs node-name
60
+ ```
61
+
62
+ All query commands support JSON output with the `--json` flag.
63
+
64
+ ## Getting Started
65
+
66
+ ### Prerequisites
67
+
68
+ - Node.js >= 22
69
+ - [ir](https://github.com/vlwkaos/ir) -- `brew install vlwkaos/tap/ir`
70
+
71
+ ### Installation
72
+
73
+ ```sh
74
+ npm install -g aeira
75
+ ```
76
+
77
+ ### First Use
78
+
79
+ Build the graph by specifying a source path.
80
+
81
+ ```sh
82
+ aeira init ./my-docs
83
+ cd ./my-docs
84
+ aeira sync
85
+ ```
86
+
87
+ After sync, you can navigate the graph.
88
+
89
+ ```sh
90
+ aeira graph neighbors some-document
91
+ aeira search "query"
92
+ ```
package/README.md ADDED
@@ -0,0 +1,92 @@
1
+ # aeira
2
+
3
+ 마크다운 위키링크의 관계를 앱 종속 없이 구조화하는 CLI 도구.
4
+
5
+ [English](README.en.md) [한국어](README.md)
6
+
7
+ ## 왜 만들었는가
8
+
9
+ 마크다운 문서에서 위키링크(`[[]]`)는 문서 간 관계를 표현한다.
10
+ 하지만 위키링크가 늘어나도 관계의 전체 그림은 보이지 않는다. 어떤 문서가 어떤 문서를 참조하는지, 두 문서가 몇 단계를 거쳐 연결되는지는 직접 따라가 보기 전에는 알 수 없다.
11
+
12
+ aeira는 이 관계를 방향 그래프로 구축하여 커맨드라인에서 탐색할 수 있게 한다.
13
+
14
+ ## 명령어
15
+
16
+ aeira는 네 가지 명령으로 동작한다.
17
+
18
+ ### init
19
+
20
+ source 경로를 지정하여 문서 컬렉션을 등록한다.
21
+
22
+ ```sh
23
+ aeira init ./my-docs
24
+ ```
25
+
26
+ ### sync
27
+
28
+ 위키링크를 파싱하여 그래프를 구축하고 갱신한다. 변경된 문서만 증분 처리한다.
29
+
30
+ ```sh
31
+ aeira sync # cwd를 source로 사용
32
+ aeira sync -s ./my-docs # source 경로 지정
33
+ ```
34
+
35
+ ### search
36
+
37
+ 키워드로 문서를 검색하고, 각 결과의 outgoing links를 함께 표시한다.
38
+
39
+ ```sh
40
+ aeira search "키워드"
41
+ aeira search -s ./my-docs "키워드"
42
+ ```
43
+
44
+ ### graph
45
+
46
+ 그래프를 탐색하는 세 가지 프리미티브를 제공한다.
47
+
48
+ ```sh
49
+ # 1-hop 이웃 조회
50
+ aeira graph neighbors node-name
51
+
52
+ # 두 노드 간 경로 탐색
53
+ aeira graph path from-node to-node
54
+
55
+ # 전체 그래프 출력
56
+ aeira graph all
57
+
58
+ # source 경로 지정
59
+ aeira graph neighbors -s ./my-docs node-name
60
+ ```
61
+
62
+ 모든 조회 명령은 `--json` 플래그로 JSON 출력을 지원한다.
63
+
64
+ ## 시작하기
65
+
66
+ ### 사전 요구
67
+
68
+ - Node.js >= 22
69
+ - [ir](https://github.com/vlwkaos/ir) -- `brew install vlwkaos/tap/ir`
70
+
71
+ ### 설치
72
+
73
+ ```sh
74
+ npm install -g aeira
75
+ ```
76
+
77
+ ### 첫 사용
78
+
79
+ source 경로를 지정하여 그래프를 구축한다.
80
+
81
+ ```sh
82
+ aeira init ./my-docs
83
+ cd ./my-docs
84
+ aeira sync
85
+ ```
86
+
87
+ sync 이후 그래프를 탐색할 수 있다.
88
+
89
+ ```sh
90
+ aeira graph neighbors some-document
91
+ aeira search "검색어"
92
+ ```
package/dist/cli.js ADDED
@@ -0,0 +1,583 @@
1
+ import { defineCommand, runMain } from "citty";
2
+ import { existsSync } from "node:fs";
3
+ import { basename, join, resolve } from "node:path";
4
+ import { execFileSync } from "node:child_process";
5
+ import { z } from "zod/v4";
6
+ import { homedir } from "node:os";
7
+ import Database from "better-sqlite3";
8
+ //#region src/ir.ts
9
+ function isNotFound(error) {
10
+ return error instanceof Error && "code" in error && error.code === "ENOENT";
11
+ }
12
+ const searchResultRow = z.object({
13
+ path: z.string(),
14
+ title: z.string(),
15
+ score: z.number(),
16
+ snippet: z.string().nullable().optional()
17
+ });
18
+ function searchCollection(collection, query) {
19
+ try {
20
+ const output = execFileSync("ir", [
21
+ "search",
22
+ query,
23
+ "-c",
24
+ collection,
25
+ "--json"
26
+ ], { encoding: "utf-8" });
27
+ return z.array(searchResultRow).parse(JSON.parse(output));
28
+ } catch (error) {
29
+ if (isNotFound(error)) throw new Error("ir이 설치되어 있지 않습니다. brew install vlwkaos/tap/ir로 설치하세요.", { cause: error });
30
+ throw error;
31
+ }
32
+ }
33
+ function updateCollection(collection) {
34
+ try {
35
+ execFileSync("ir", ["update", collection], { stdio: "inherit" });
36
+ } catch (error) {
37
+ if (isNotFound(error)) throw new Error("ir이 설치되어 있지 않습니다. brew install vlwkaos/tap/ir로 설치하세요.", { cause: error });
38
+ throw error;
39
+ }
40
+ }
41
+ function initCollection(collection, sourcePath) {
42
+ try {
43
+ execFileSync("ir", [
44
+ "collection",
45
+ "add",
46
+ collection,
47
+ sourcePath
48
+ ], { stdio: "inherit" });
49
+ } catch (error) {
50
+ if (isNotFound(error)) throw new Error("ir이 설치되어 있지 않습니다. brew install vlwkaos/tap/ir로 설치하세요.", { cause: error });
51
+ throw error;
52
+ }
53
+ updateCollection(collection);
54
+ }
55
+ z.object({ hash: z.string() });
56
+ const edgeRow = z.object({
57
+ source_path: z.string(),
58
+ target_path: z.string()
59
+ });
60
+ const documentContentRow = z.object({
61
+ path: z.string(),
62
+ content: z.string(),
63
+ hash: z.string()
64
+ });
65
+ const pathRow = z.object({ path: z.string() });
66
+ //#endregion
67
+ //#region src/store/utils.ts
68
+ const DEFAULT_IR_CONFIG_DIR = join(homedir(), ".config", "ir");
69
+ function getCollectionDbPath(collectionName, irConfigDir = DEFAULT_IR_CONFIG_DIR) {
70
+ return join(irConfigDir, "collections", `${collectionName}.sqlite`);
71
+ }
72
+ function parsePaths(rows) {
73
+ return z.array(pathRow).parse(rows).map((row) => row.path);
74
+ }
75
+ //#endregion
76
+ //#region src/store/store.ts
77
+ function ensureSchema(database) {
78
+ database.exec(`
79
+ CREATE TABLE IF NOT EXISTS aeira_edges (
80
+ source_path TEXT NOT NULL,
81
+ target_path TEXT NOT NULL,
82
+ PRIMARY KEY (source_path, target_path)
83
+ );
84
+ CREATE TABLE IF NOT EXISTS aeira_sync_state (
85
+ source_path TEXT PRIMARY KEY,
86
+ hash TEXT NOT NULL
87
+ );
88
+ `);
89
+ }
90
+ function createStore(database) {
91
+ ensureSchema(database);
92
+ const insertEdge = database.prepare("INSERT INTO aeira_edges (source_path, target_path) VALUES (?, ?)");
93
+ const insertSyncState = database.prepare("INSERT INTO aeira_sync_state (source_path, hash) VALUES (?, ?)");
94
+ const selectAllEdges = database.prepare("SELECT source_path, target_path FROM aeira_edges");
95
+ const selectActiveDocuments = database.prepare("SELECT path FROM documents WHERE active = 1");
96
+ const selectAddedDocuments = database.prepare(`
97
+ SELECT d.path FROM documents d
98
+ LEFT JOIN aeira_sync_state s ON d.path = s.source_path
99
+ WHERE d.active = 1 AND s.source_path IS NULL
100
+ `);
101
+ const selectChangedDocuments = database.prepare(`
102
+ SELECT d.path FROM documents d
103
+ JOIN aeira_sync_state s ON d.path = s.source_path
104
+ WHERE d.active = 1 AND d.hash != s.hash
105
+ `);
106
+ const selectRemovedDocuments = database.prepare(`
107
+ SELECT s.source_path as path FROM aeira_sync_state s
108
+ LEFT JOIN documents d ON s.source_path = d.path AND d.active = 1
109
+ WHERE d.id IS NULL
110
+ `);
111
+ const readDocumentContent = database.prepare("SELECT d.path, c.doc as content, d.hash FROM documents d JOIN content c ON d.hash = c.hash WHERE d.active = 1 AND d.path = ?");
112
+ const deleteEdgesBySource = database.prepare("DELETE FROM aeira_edges WHERE source_path = ?");
113
+ const deleteSyncStateBySource = database.prepare("DELETE FROM aeira_sync_state WHERE source_path = ?");
114
+ return {
115
+ loadEdges() {
116
+ const rows = z.array(edgeRow).parse(selectAllEdges.all());
117
+ const activeDocumentPaths = new Set(z.array(pathRow).parse(selectActiveDocuments.all()).map((row) => row.path));
118
+ const nodes = new Set(activeDocumentPaths);
119
+ const dangling = /* @__PURE__ */ new Set();
120
+ const outgoing = /* @__PURE__ */ new Map();
121
+ const incoming = /* @__PURE__ */ new Map();
122
+ for (const { source_path, target_path } of rows) {
123
+ nodes.add(source_path);
124
+ nodes.add(target_path);
125
+ if (!activeDocumentPaths.has(target_path)) dangling.add(target_path);
126
+ let targets = outgoing.get(source_path);
127
+ if (!targets) {
128
+ targets = /* @__PURE__ */ new Set();
129
+ outgoing.set(source_path, targets);
130
+ }
131
+ targets.add(target_path);
132
+ let sources = incoming.get(target_path);
133
+ if (!sources) {
134
+ sources = /* @__PURE__ */ new Set();
135
+ incoming.set(target_path, sources);
136
+ }
137
+ sources.add(source_path);
138
+ }
139
+ return {
140
+ nodes,
141
+ dangling,
142
+ outgoing,
143
+ incoming
144
+ };
145
+ },
146
+ getChangedDocuments() {
147
+ return {
148
+ added: parsePaths(selectAddedDocuments.all()),
149
+ changed: parsePaths(selectChangedDocuments.all()),
150
+ removed: parsePaths(selectRemovedDocuments.all())
151
+ };
152
+ },
153
+ getActiveDocumentPaths() {
154
+ return parsePaths(selectActiveDocuments.all());
155
+ },
156
+ readDocumentContents(paths) {
157
+ const results = [];
158
+ for (const path of paths) {
159
+ const raw = readDocumentContent.get(path);
160
+ if (raw) results.push(documentContentRow.parse(raw));
161
+ }
162
+ return results;
163
+ },
164
+ purgeDocuments(sourcePaths) {
165
+ database.transaction(() => {
166
+ for (const path of sourcePaths) {
167
+ deleteEdgesBySource.run(path);
168
+ deleteSyncStateBySource.run(path);
169
+ }
170
+ })();
171
+ },
172
+ syncDocuments(entries) {
173
+ database.transaction(() => {
174
+ for (const entry of entries) {
175
+ for (const targetPath of entry.targetPaths) insertEdge.run(entry.sourcePath, targetPath);
176
+ insertSyncState.run(entry.sourcePath, entry.hash);
177
+ }
178
+ })();
179
+ }
180
+ };
181
+ }
182
+ //#endregion
183
+ //#region src/store/readonly-store.ts
184
+ function createReadonlyStore(database) {
185
+ const selectAllEdges = database.prepare("SELECT source_path, target_path FROM aeira_edges");
186
+ const selectActiveDocuments = database.prepare("SELECT path FROM documents WHERE active = 1");
187
+ return { loadEdges() {
188
+ const rows = z.array(edgeRow).parse(selectAllEdges.all());
189
+ const activeDocumentPaths = new Set(z.array(pathRow).parse(selectActiveDocuments.all()).map((row) => row.path));
190
+ const nodes = new Set(activeDocumentPaths);
191
+ const dangling = /* @__PURE__ */ new Set();
192
+ const outgoing = /* @__PURE__ */ new Map();
193
+ const incoming = /* @__PURE__ */ new Map();
194
+ for (const { source_path, target_path } of rows) {
195
+ nodes.add(source_path);
196
+ nodes.add(target_path);
197
+ if (!activeDocumentPaths.has(target_path)) dangling.add(target_path);
198
+ let targets = outgoing.get(source_path);
199
+ if (!targets) {
200
+ targets = /* @__PURE__ */ new Set();
201
+ outgoing.set(source_path, targets);
202
+ }
203
+ targets.add(target_path);
204
+ let sources = incoming.get(target_path);
205
+ if (!sources) {
206
+ sources = /* @__PURE__ */ new Set();
207
+ incoming.set(target_path, sources);
208
+ }
209
+ sources.add(source_path);
210
+ }
211
+ return {
212
+ nodes,
213
+ dangling,
214
+ outgoing,
215
+ incoming
216
+ };
217
+ } };
218
+ }
219
+ //#endregion
220
+ //#region src/commands/init.ts
221
+ const init = defineCommand({
222
+ meta: {
223
+ name: "init",
224
+ description: "Initialize ir collection for source"
225
+ },
226
+ args: { source: {
227
+ type: "positional",
228
+ description: "source directory path",
229
+ required: true
230
+ } },
231
+ run({ args }) {
232
+ const sourcePath = resolve(args.source);
233
+ const collection = basename(sourcePath);
234
+ if (existsSync(getCollectionDbPath(collection))) {
235
+ console.log(`Already initialized: ${collection}`);
236
+ return;
237
+ }
238
+ initCollection(collection, sourcePath);
239
+ console.log(`Initialized: ${collection}`);
240
+ }
241
+ });
242
+ //#endregion
243
+ //#region src/graph/graph.ts
244
+ function buildNameIndex(paths) {
245
+ const index = /* @__PURE__ */ new Map();
246
+ for (const path of paths) {
247
+ const stem = basename(path, ".md");
248
+ if (!index.has(stem)) index.set(stem, path);
249
+ }
250
+ return index;
251
+ }
252
+ //#endregion
253
+ //#region src/graph/query.ts
254
+ function neighbors(graph, node, direction = "both") {
255
+ const outgoing = direction !== "incoming" ? graph.outgoing.get(node) : void 0;
256
+ const incoming = direction !== "outgoing" ? graph.incoming.get(node) : void 0;
257
+ return [...new Set([...outgoing ?? [], ...incoming ?? []])].toSorted();
258
+ }
259
+ function findPaths(graph, from, to, maxPaths = 20) {
260
+ const results = [];
261
+ function traverse(current, path, visited) {
262
+ if (results.length >= maxPaths) return;
263
+ if (current === to) {
264
+ results.push([...path]);
265
+ return;
266
+ }
267
+ const targets = graph.outgoing.get(current);
268
+ if (!targets) return;
269
+ for (const next of targets) {
270
+ if (visited.has(next)) continue;
271
+ visited.add(next);
272
+ path.push(next);
273
+ traverse(next, path, visited);
274
+ path.pop();
275
+ visited.delete(next);
276
+ }
277
+ }
278
+ traverse(from, [from], new Set([from]));
279
+ return results;
280
+ }
281
+ function snapshot(graph) {
282
+ const nodes = [...graph.nodes].toSorted();
283
+ let edges = [];
284
+ for (const [source, targets] of graph.outgoing) for (const target of targets) edges.push([source, target]);
285
+ edges = edges.toSorted((a, b) => a[0].localeCompare(b[0]) || a[1].localeCompare(b[1]));
286
+ return {
287
+ nodes,
288
+ edges
289
+ };
290
+ }
291
+ //#endregion
292
+ //#region src/wikilink.ts
293
+ function parseWikiLinks(text) {
294
+ const cleaned = stripExcludedRegions(text);
295
+ const pattern = /(?<!!)\[\[([^[\]|]+)(?:\|([^[\]]+))?\]\]/g;
296
+ const links = [];
297
+ let match;
298
+ while ((match = pattern.exec(cleaned)) !== null) {
299
+ const target = match[1].trim();
300
+ const alias = match[2]?.trim();
301
+ if (!target || target.includes("#") || target.includes("/")) continue;
302
+ links.push(alias ? {
303
+ target,
304
+ alias
305
+ } : { target });
306
+ }
307
+ return links;
308
+ }
309
+ function stripExcludedRegions(text) {
310
+ return text.replace(/^---\n[\s\S]*?\n---\n?/, "").replace(/^(`{3,})[^\n]*\n[\s\S]*?\n\1\s*$/gm, "").replace(/^(~{3,})[^\n]*\n[\s\S]*?\n\1\s*$/gm, "").replace(/(\n\n)((?:(?: |\t)[^\n]*(?:\n|$))+)/g, "$1").replace(/<!--[\s\S]*?-->/g, "").replace(/``(.+?)``/g, "").replace(/`([^`]+)`/g, "");
311
+ }
312
+ //#endregion
313
+ //#region src/commands/sync.ts
314
+ const sync = defineCommand({
315
+ meta: {
316
+ name: "sync",
317
+ description: "Sync wikilink graph from source"
318
+ },
319
+ args: { source: {
320
+ type: "string",
321
+ description: "source directory path",
322
+ alias: "s",
323
+ default: process.cwd()
324
+ } },
325
+ run({ args }) {
326
+ const sourcePath = resolve(args.source);
327
+ const collection = basename(sourcePath);
328
+ const dbPath = getCollectionDbPath(collection);
329
+ if (!existsSync(dbPath)) initCollection(collection, sourcePath);
330
+ else updateCollection(collection);
331
+ const db = new Database(dbPath);
332
+ try {
333
+ const store = createStore(db);
334
+ const changes = store.getChangedDocuments();
335
+ if (changes.added.length + changes.changed.length + changes.removed.length === 0) {
336
+ console.log("No changes detected.");
337
+ return;
338
+ }
339
+ const nameIndex = buildNameIndex(store.getActiveDocumentPaths());
340
+ store.purgeDocuments([...changes.removed, ...changes.changed]);
341
+ const affected = [...changes.changed, ...changes.added];
342
+ const entries = store.readDocumentContents(affected).map((doc) => {
343
+ const links = parseWikiLinks(doc.content);
344
+ const targetPaths = [...new Set(links.map((link) => nameIndex.get(link.target) ?? link.target))];
345
+ return {
346
+ sourcePath: doc.path,
347
+ targetPaths,
348
+ hash: doc.hash
349
+ };
350
+ });
351
+ store.syncDocuments(entries);
352
+ console.log(`Synced: +${changes.added.length} ~${changes.changed.length} -${changes.removed.length}`);
353
+ } finally {
354
+ db.close();
355
+ }
356
+ }
357
+ });
358
+ //#endregion
359
+ //#region src/commands/search.ts
360
+ const search = defineCommand({
361
+ meta: {
362
+ name: "search",
363
+ description: "Search documents via ir"
364
+ },
365
+ args: {
366
+ query: {
367
+ type: "positional",
368
+ description: "search query",
369
+ required: true
370
+ },
371
+ source: {
372
+ type: "string",
373
+ description: "source directory path",
374
+ alias: "s",
375
+ default: process.cwd()
376
+ },
377
+ json: {
378
+ type: "boolean",
379
+ description: "output as JSON",
380
+ default: false
381
+ }
382
+ },
383
+ run({ args }) {
384
+ const collection = basename(resolve(args.source));
385
+ const dbPath = getCollectionDbPath(collection);
386
+ if (!existsSync(dbPath)) {
387
+ console.error(`Collection not found: ${collection}. Run 'aeira sync' first.`);
388
+ process.exit(1);
389
+ }
390
+ const results = searchCollection(collection, args.query);
391
+ if (results.length === 0) {
392
+ console.log("No results found.");
393
+ return;
394
+ }
395
+ const database = new Database(dbPath, { readonly: true });
396
+ try {
397
+ const graph = createReadonlyStore(database).loadEdges();
398
+ if (args.json) {
399
+ const output = results.map((result) => ({
400
+ path: result.path,
401
+ title: result.title,
402
+ score: result.score,
403
+ snippet: result.snippet ?? void 0,
404
+ links: neighbors(graph, result.path, "outgoing")
405
+ }));
406
+ console.log(JSON.stringify(output, null, 2));
407
+ } else for (let index = 0; index < results.length; index++) {
408
+ const result = results[index];
409
+ const links = neighbors(graph, result.path, "outgoing");
410
+ console.log(`[${result.score.toFixed(2)}] ${result.path}`);
411
+ for (const link of links) console.log(` → ${link}`);
412
+ if (index < results.length - 1) console.log();
413
+ }
414
+ } finally {
415
+ database.close();
416
+ }
417
+ }
418
+ });
419
+ //#endregion
420
+ //#region src/commands/graph.ts
421
+ function openGraph(source) {
422
+ const collection = basename(resolve(source));
423
+ const dbPath = getCollectionDbPath(collection);
424
+ if (!existsSync(dbPath)) {
425
+ console.error(`Collection not found: ${collection}. Run 'aeira sync' first.`);
426
+ process.exit(1);
427
+ }
428
+ const database = new Database(dbPath, { readonly: true });
429
+ return {
430
+ database,
431
+ graph: createReadonlyStore(database).loadEdges()
432
+ };
433
+ }
434
+ const validDirections = new Set([
435
+ "outgoing",
436
+ "incoming",
437
+ "both"
438
+ ]);
439
+ //#endregion
440
+ //#region src/cli.ts
441
+ runMain(defineCommand({
442
+ meta: {
443
+ name: "aeira",
444
+ version: "0.0.0",
445
+ description: "위키링크 기반 문서 관계 그래프 도구"
446
+ },
447
+ subCommands: {
448
+ init,
449
+ sync,
450
+ search,
451
+ graph: defineCommand({
452
+ meta: {
453
+ name: "graph",
454
+ description: "Query wikilink graph"
455
+ },
456
+ subCommands: {
457
+ neighbors: defineCommand({
458
+ meta: {
459
+ name: "neighbors",
460
+ description: "List 1-hop neighbors of a node"
461
+ },
462
+ args: {
463
+ node: {
464
+ type: "positional",
465
+ description: "target node",
466
+ required: true
467
+ },
468
+ source: {
469
+ type: "string",
470
+ description: "source directory path",
471
+ alias: "s",
472
+ default: process.cwd()
473
+ },
474
+ direction: {
475
+ type: "string",
476
+ description: "outgoing | incoming | both",
477
+ default: "both"
478
+ },
479
+ json: {
480
+ type: "boolean",
481
+ description: "output as JSON",
482
+ default: false
483
+ }
484
+ },
485
+ run({ args }) {
486
+ if (!validDirections.has(args.direction)) {
487
+ console.error(`Invalid direction: ${args.direction}. Must be outgoing, incoming, or both.`);
488
+ process.exit(1);
489
+ }
490
+ const { database, graph } = openGraph(args.source);
491
+ try {
492
+ const result = neighbors(graph, args.node, args.direction);
493
+ if (args.json) console.log(JSON.stringify(result, null, 2));
494
+ else for (const node of result) console.log(node);
495
+ } finally {
496
+ database.close();
497
+ }
498
+ }
499
+ }),
500
+ path: defineCommand({
501
+ meta: {
502
+ name: "path",
503
+ description: "Find all paths between two nodes"
504
+ },
505
+ args: {
506
+ from: {
507
+ type: "positional",
508
+ description: "start node",
509
+ required: true
510
+ },
511
+ to: {
512
+ type: "positional",
513
+ description: "end node",
514
+ required: true
515
+ },
516
+ source: {
517
+ type: "string",
518
+ description: "source directory path",
519
+ alias: "s",
520
+ default: process.cwd()
521
+ },
522
+ "max-paths": {
523
+ type: "string",
524
+ description: "max number of paths",
525
+ default: "20"
526
+ },
527
+ json: {
528
+ type: "boolean",
529
+ description: "output as JSON",
530
+ default: false
531
+ }
532
+ },
533
+ run({ args }) {
534
+ const { database, graph } = openGraph(args.source);
535
+ try {
536
+ const maxPaths = Number.parseInt(args["max-paths"], 10);
537
+ const result = findPaths(graph, args.from, args.to, maxPaths);
538
+ if (args.json) console.log(JSON.stringify(result, null, 2));
539
+ else for (const pathNodes of result) console.log(pathNodes.join(" → "));
540
+ } finally {
541
+ database.close();
542
+ }
543
+ }
544
+ }),
545
+ all: defineCommand({
546
+ meta: {
547
+ name: "all",
548
+ description: "Show entire graph"
549
+ },
550
+ args: {
551
+ source: {
552
+ type: "string",
553
+ description: "source directory path",
554
+ alias: "s",
555
+ default: process.cwd()
556
+ },
557
+ json: {
558
+ type: "boolean",
559
+ description: "output as JSON",
560
+ default: false
561
+ }
562
+ },
563
+ run({ args }) {
564
+ const { database, graph } = openGraph(args.source);
565
+ try {
566
+ const result = snapshot(graph);
567
+ if (args.json) console.log(JSON.stringify(result, null, 2));
568
+ else {
569
+ const nodesInEdges = new Set(result.edges.flatMap(([source, target]) => [source, target]));
570
+ for (const [source, target] of result.edges) console.log(`${source} → ${target}`);
571
+ for (const node of result.nodes) if (!nodesInEdges.has(node)) console.log(node);
572
+ }
573
+ } finally {
574
+ database.close();
575
+ }
576
+ }
577
+ })
578
+ }
579
+ })
580
+ }
581
+ }));
582
+ //#endregion
583
+ export {};
package/package.json ADDED
@@ -0,0 +1,47 @@
1
+ {
2
+ "name": "@grapgrap/aeira",
3
+ "version": "1.0.0",
4
+ "description": "위키링크 기반 문서 관계 그래프 도구",
5
+ "license": "MIT",
6
+ "bin": "./dist/cli.js",
7
+ "files": [
8
+ "dist"
9
+ ],
10
+ "type": "module",
11
+ "scripts": {
12
+ "build": "tsdown",
13
+ "type-check": "tsc --noEmit",
14
+ "dev": "tsdown --watch",
15
+ "test": "vitest",
16
+ "lint": "oxlint",
17
+ "fmt": "oxfmt --write .",
18
+ "fmt:check": "oxfmt --check .",
19
+ "prepare": "husky"
20
+ },
21
+ "dependencies": {
22
+ "better-sqlite3": "^11.9.1",
23
+ "citty": "^0.2.2",
24
+ "zod": "^4.3.6"
25
+ },
26
+ "devDependencies": {
27
+ "@types/better-sqlite3": "^7.6.13",
28
+ "@types/node": "^22.15.2",
29
+ "husky": "^9.1.7",
30
+ "lint-staged": "^16.4.0",
31
+ "oxfmt": "^0.44.0",
32
+ "oxlint": "^1.59.0",
33
+ "tsdown": "^0.12.5",
34
+ "typescript": "^5.8.3",
35
+ "vitest": "^3.1.2"
36
+ },
37
+ "lint-staged": {
38
+ "*.{ts,js}": [
39
+ "oxlint --deny-warnings",
40
+ "oxfmt --write"
41
+ ]
42
+ },
43
+ "engines": {
44
+ "node": ">=22"
45
+ },
46
+ "packageManager": "yarn@4.13.0"
47
+ }