@beomjk/emdd 0.1.0 → 0.1.2

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.md CHANGED
@@ -63,13 +63,13 @@ Hypotheses move through `PROPOSED -> TESTING -> SUPPORTED / REFUTED / REVISED`.
63
63
  ## Installation
64
64
 
65
65
  ```bash
66
- npm install -g emdd
66
+ npm install -g @beomjk/emdd
67
67
  ```
68
68
 
69
69
  Or use directly with npx:
70
70
 
71
71
  ```bash
72
- npx emdd <command>
72
+ npx @beomjk/emdd <command>
73
73
  ```
74
74
 
75
75
  ## Quick Start
package/dist/cli.js CHANGED
@@ -14,6 +14,18 @@ import { graphCommand } from './commands/graph.js';
14
14
  import { backlogCommand } from './commands/backlog.js';
15
15
  import { resolveGraphDir } from './graph/loader.js';
16
16
  import { startMcpServer } from './mcp-server/index.js';
17
+ function withCliErrorHandling(fn) {
18
+ return async (...args) => {
19
+ try {
20
+ await fn(...args);
21
+ }
22
+ catch (err) {
23
+ const message = err instanceof Error ? err.message : String(err);
24
+ console.error(`Error: ${message}`);
25
+ process.exit(1);
26
+ }
27
+ };
28
+ }
17
29
  const program = new Command();
18
30
  program
19
31
  .name('emdd')
@@ -24,32 +36,32 @@ program
24
36
  .description('Initialize EMDD project')
25
37
  .option('--lang <locale>', 'Language', 'en')
26
38
  .option('--tool <tool>', 'AI tool rules to generate (claude|cursor|windsurf|cline|copilot|all)', 'claude')
27
- .action((path, options) => {
39
+ .action(withCliErrorHandling(async (path, options) => {
28
40
  initCommand(path, options);
29
- });
41
+ }));
30
42
  program
31
43
  .command('new <type> <slug>')
32
44
  .description('Create a new node')
33
45
  .option('--path <path>', 'Project path')
34
- .action(async (type, slug, options) => {
46
+ .action(withCliErrorHandling(async (type, slug, options) => {
35
47
  await newCommand(type, slug, options);
36
- });
48
+ }));
37
49
  program
38
50
  .command('lint [path]')
39
51
  .description('Validate graph schema and links')
40
- .action(async (path) => {
52
+ .action(withCliErrorHandling(async (path) => {
41
53
  await lintCommand(path);
42
- });
54
+ }));
43
55
  program
44
56
  .command('health [path]')
45
57
  .description('Show health dashboard')
46
- .action(async (path) => {
58
+ .action(withCliErrorHandling(async (path) => {
47
59
  await healthCommand(path);
48
- });
60
+ }));
49
61
  program
50
62
  .command('check [path]')
51
63
  .description('Check consolidation triggers')
52
- .action(async (path) => {
64
+ .action(withCliErrorHandling(async (path) => {
53
65
  const graphDir = resolveGraphDir(path);
54
66
  const result = await checkCommand(graphDir);
55
67
  if (result.triggers.length === 0) {
@@ -60,11 +72,11 @@ program
60
72
  console.log(`TRIGGER ${trigger.type} ${trigger.message}`);
61
73
  }
62
74
  }
63
- });
75
+ }));
64
76
  program
65
77
  .command('promote [path]')
66
78
  .description('Identify findings eligible for promotion')
67
- .action(async (path) => {
79
+ .action(withCliErrorHandling(async (path) => {
68
80
  const graphDir = resolveGraphDir(path);
69
81
  const result = await promoteCommand(graphDir);
70
82
  if (result.candidates.length === 0) {
@@ -75,13 +87,13 @@ program
75
87
  console.log(`CANDIDATE ${c.id} confidence=${c.confidence} supports=${c.supports}`);
76
88
  }
77
89
  }
78
- });
90
+ }));
79
91
  program
80
92
  .command('update <node-id>')
81
93
  .description('Update frontmatter fields on a node')
82
94
  .option('--set <key=value...>', 'Key-value pairs to set')
83
95
  .option('--path <path>', 'Project path')
84
- .action(async (nodeId, options) => {
96
+ .action(withCliErrorHandling(async (nodeId, options) => {
85
97
  const graphDir = resolveGraphDir(options.path);
86
98
  const updates = {};
87
99
  if (options.set) {
@@ -97,47 +109,47 @@ program
97
109
  }
98
110
  await updateCommand(graphDir, nodeId, updates);
99
111
  console.log(`Updated ${nodeId}`);
100
- });
112
+ }));
101
113
  program
102
114
  .command('link <source> <target> <relation>')
103
115
  .description('Add a link between nodes')
104
116
  .option('--path <path>', 'Project path')
105
- .action(async (source, target, relation, options) => {
117
+ .action(withCliErrorHandling(async (source, target, relation, options) => {
106
118
  const graphDir = resolveGraphDir(options.path);
107
119
  await linkCommand(graphDir, source, target, relation);
108
120
  console.log(`Linked ${source} -> ${target} (${relation})`);
109
- });
121
+ }));
110
122
  program
111
123
  .command('done <episode-id> <item>')
112
124
  .description('Mark a checklist item as done')
113
125
  .option('--path <path>', 'Project path')
114
- .action(async (episodeId, item, options) => {
126
+ .action(withCliErrorHandling(async (episodeId, item, options) => {
115
127
  const graphDir = resolveGraphDir(options.path);
116
128
  await doneCommand(graphDir, episodeId, item);
117
129
  console.log(`Done: ${item}`);
118
- });
130
+ }));
119
131
  program
120
132
  .command('index [path]')
121
133
  .description('Generate _index.md for the graph')
122
- .action(async (path) => {
134
+ .action(withCliErrorHandling(async (path) => {
123
135
  const graphDir = resolveGraphDir(path);
124
136
  const result = await indexCommand(graphDir);
125
137
  console.log(`Index generated: ${result.nodeCount} nodes`);
126
- });
138
+ }));
127
139
  program
128
140
  .command('graph [path]')
129
141
  .description('Generate _graph.mmd Mermaid diagram')
130
- .action(async (path) => {
142
+ .action(withCliErrorHandling(async (path) => {
131
143
  const graphDir = resolveGraphDir(path);
132
144
  const result = await graphCommand(graphDir);
133
145
  console.log(`Graph generated: ${result.nodeCount} nodes, ${result.edgeCount} edges`);
134
- });
146
+ }));
135
147
  program
136
148
  .command('backlog')
137
149
  .description('Show unchecked backlog items')
138
150
  .option('--path <path>', 'Project path')
139
151
  .option('--status <status>', 'Filter by status')
140
- .action(async (options) => {
152
+ .action(withCliErrorHandling(async (options) => {
141
153
  const graphDir = resolveGraphDir(options.path);
142
154
  const result = await backlogCommand(graphDir, options.status);
143
155
  if (result.items.length === 0) {
@@ -148,11 +160,11 @@ program
148
160
  console.log(`[ ] ${item.episodeId} ${item.text}`);
149
161
  }
150
162
  }
151
- });
163
+ }));
152
164
  program
153
165
  .command('mcp')
154
166
  .description('Start MCP server over stdio')
155
- .action(async () => {
167
+ .action(withCliErrorHandling(async () => {
156
168
  await startMcpServer();
157
- });
169
+ }));
158
170
  program.parse();
@@ -12,14 +12,16 @@ export async function backlogCommand(graphDir, _statusFilter) {
12
12
  try {
13
13
  content = readFileSync(file, 'utf-8');
14
14
  }
15
- catch {
15
+ catch (err) {
16
+ console.warn(`Warning: Could not read ${file}: ${err instanceof Error ? err.message : String(err)}`);
16
17
  continue;
17
18
  }
18
19
  let parsed;
19
20
  try {
20
21
  parsed = matter(content);
21
22
  }
22
- catch {
23
+ catch (err) {
24
+ console.warn(`Warning: Could not parse ${file}: ${err instanceof Error ? err.message : String(err)}`);
23
25
  continue;
24
26
  }
25
27
  const episodeId = parsed.data?.id ?? '';
@@ -19,7 +19,11 @@ export async function updateCommand(graphDir, nodeId, updates) {
19
19
  // Apply updates
20
20
  for (const [key, value] of Object.entries(updates)) {
21
21
  if (key === 'confidence') {
22
- parsed.data[key] = parseFloat(value);
22
+ const num = parseFloat(value);
23
+ if (isNaN(num) || num < 0 || num > 1) {
24
+ throw new Error(`Invalid confidence value: "${value}" (must be 0-1)`);
25
+ }
26
+ parsed.data[key] = num;
23
27
  }
24
28
  else {
25
29
  parsed.data[key] = value;
@@ -1,4 +1,4 @@
1
- import type { Node, NodeFilter, NodeDetail, CreateNodeResult, CreateEdgeResult, HealthReport, CheckResult, PromoteCandidate } from './types.js';
1
+ import type { Node, NodeFilter, NodeDetail, CreateNodeResult, CreateEdgeResult, CreateNodePlan, CreateEdgePlan, FileOp, HealthReport, CheckResult, PromoteCandidate } from './types.js';
2
2
  /**
3
3
  * List all nodes in the graph, optionally filtered by type and/or status.
4
4
  */
@@ -8,11 +8,23 @@ export declare function listNodes(graphDir: string, filter?: NodeFilter): Promis
8
8
  * Returns null if the node is not found.
9
9
  */
10
10
  export declare function readNode(graphDir: string, nodeId: string): Promise<NodeDetail | null>;
11
+ /**
12
+ * Execute a list of file operations (mkdir / write).
13
+ */
14
+ export declare function executeOps(ops: FileOp[]): Promise<void>;
15
+ /**
16
+ * Plan the creation of a new node (pure computation, no I/O).
17
+ */
18
+ export declare function planCreateNode(graphDir: string, type: string, slug: string, lang?: string): CreateNodePlan;
11
19
  /**
12
20
  * Create a new node of the given type with the given slug.
13
21
  * Returns the created node's ID, type, and file path.
14
22
  */
15
23
  export declare function createNode(graphDir: string, type: string, slug: string, lang?: string): Promise<CreateNodeResult>;
24
+ /**
25
+ * Plan the creation of an edge (pure computation after graph load).
26
+ */
27
+ export declare function planCreateEdge(graphDir: string, source: string, target: string, relation: string): Promise<CreateEdgePlan>;
16
28
  /**
17
29
  * Add an edge (link) from source to target with the given relation.
18
30
  * Validates relation, source existence, and target existence.
@@ -1,7 +1,8 @@
1
1
  import fs from 'node:fs';
2
+ import path from 'node:path';
2
3
  import matter from 'gray-matter';
3
4
  import { loadGraph } from './loader.js';
4
- import { nextId, renderTemplate, nodePath } from './templates.js';
5
+ import { nextId, renderTemplate, nodePath, sanitizeSlug } from './templates.js';
5
6
  import { NODE_TYPES, ALL_VALID_RELATIONS, REVERSE_LABELS } from './types.js';
6
7
  // ── listNodes ───────────────────────────────────────────────────────
7
8
  /**
@@ -36,36 +37,61 @@ export async function readNode(graphDir, nodeId) {
36
37
  body: parsed.content,
37
38
  };
38
39
  }
40
+ // ── executeOps ──────────────────────────────────────────────────────
41
+ /**
42
+ * Execute a list of file operations (mkdir / write).
43
+ */
44
+ export async function executeOps(ops) {
45
+ for (const op of ops) {
46
+ switch (op.kind) {
47
+ case 'mkdir':
48
+ if (!fs.existsSync(op.path)) {
49
+ fs.mkdirSync(op.path, { recursive: true });
50
+ }
51
+ break;
52
+ case 'write':
53
+ fs.writeFileSync(op.path, op.content, 'utf-8');
54
+ break;
55
+ }
56
+ }
57
+ }
39
58
  // ── createNode ──────────────────────────────────────────────────────
40
59
  /**
41
- * Create a new node of the given type with the given slug.
42
- * Returns the created node's ID, type, and file path.
60
+ * Plan the creation of a new node (pure computation, no I/O).
43
61
  */
44
- export async function createNode(graphDir, type, slug, lang) {
62
+ export function planCreateNode(graphDir, type, slug, lang) {
45
63
  if (!NODE_TYPES.includes(type)) {
46
64
  throw new Error(`Invalid node type: ${type}. Valid types: ${NODE_TYPES.join(', ')}`);
47
65
  }
48
66
  const nodeType = type;
49
67
  const id = nextId(graphDir, nodeType);
50
- const content = renderTemplate(nodeType, slug, {
68
+ const sanitized = sanitizeSlug(slug);
69
+ const content = renderTemplate(nodeType, sanitized, {
51
70
  id,
52
71
  locale: lang ?? 'en',
53
72
  });
54
- const filePath = nodePath(graphDir, nodeType, id, slug);
55
- // Ensure directory exists
56
- const dir = filePath.substring(0, filePath.lastIndexOf('/'));
57
- if (!fs.existsSync(dir)) {
58
- fs.mkdirSync(dir, { recursive: true });
59
- }
60
- fs.writeFileSync(filePath, content, 'utf-8');
61
- return { id, type: nodeType, path: filePath };
73
+ const filePath = nodePath(graphDir, nodeType, id, sanitized);
74
+ const dir = path.dirname(filePath);
75
+ const ops = [
76
+ { kind: 'mkdir', path: dir },
77
+ { kind: 'write', path: filePath, content },
78
+ ];
79
+ return { id, type: nodeType, path: filePath, ops };
80
+ }
81
+ /**
82
+ * Create a new node of the given type with the given slug.
83
+ * Returns the created node's ID, type, and file path.
84
+ */
85
+ export async function createNode(graphDir, type, slug, lang) {
86
+ const plan = planCreateNode(graphDir, type, slug, lang);
87
+ await executeOps(plan.ops);
88
+ return { id: plan.id, type: plan.type, path: plan.path };
62
89
  }
63
90
  // ── createEdge ──────────────────────────────────────────────────────
64
91
  /**
65
- * Add an edge (link) from source to target with the given relation.
66
- * Validates relation, source existence, and target existence.
92
+ * Plan the creation of an edge (pure computation after graph load).
67
93
  */
68
- export async function createEdge(graphDir, source, target, relation) {
94
+ export async function planCreateEdge(graphDir, source, target, relation) {
69
95
  // Validate relation
70
96
  if (!ALL_VALID_RELATIONS.has(relation)) {
71
97
  const valid = [...ALL_VALID_RELATIONS].sort().join(', ');
@@ -78,7 +104,6 @@ export async function createEdge(graphDir, source, target, relation) {
78
104
  if (!sourceNode) {
79
105
  throw new Error(`Source node not found: ${source}`);
80
106
  }
81
- // Validate target exists (new behavior not in original link.ts)
82
107
  const targetNode = graph.nodes.get(target);
83
108
  if (!targetNode) {
84
109
  throw new Error(`Target node not found: ${target}`);
@@ -94,10 +119,19 @@ export async function createEdge(graphDir, source, target, relation) {
94
119
  parsed.data.links.push({ target, relation: canonical });
95
120
  // Auto-update the `updated` field
96
121
  parsed.data.updated = new Date().toISOString().slice(0, 10);
97
- // Write back
122
+ // Compute new file content
98
123
  const output = matter.stringify(parsed.content, parsed.data);
99
- fs.writeFileSync(filePath, output);
100
- return { source, target, relation: canonical };
124
+ const ops = [{ kind: 'write', path: filePath, content: output }];
125
+ return { source, target, relation: canonical, ops };
126
+ }
127
+ /**
128
+ * Add an edge (link) from source to target with the given relation.
129
+ * Validates relation, source existence, and target existence.
130
+ */
131
+ export async function createEdge(graphDir, source, target, relation) {
132
+ const plan = await planCreateEdge(graphDir, source, target, relation);
133
+ await executeOps(plan.ops);
134
+ return { source: plan.source, target: plan.target, relation: plan.relation };
101
135
  }
102
136
  // ── getHealth ───────────────────────────────────────────────────────
103
137
  /**
@@ -1,5 +1,6 @@
1
1
  import type { NodeType } from './types.js';
2
2
  import type { Locale } from '../i18n/index.js';
3
+ export declare function sanitizeSlug(slug: string): string;
3
4
  export declare function renderTemplate(type: NodeType, slug: string, options?: {
4
5
  locale?: Locale;
5
6
  user?: string;
@@ -1,6 +1,17 @@
1
1
  import path from 'node:path';
2
2
  import fs from 'node:fs';
3
3
  import { NODE_TYPE_DIRS, ID_PREFIXES, VALID_STATUSES } from './types.js';
4
+ // ── sanitizeSlug ──────────────────────────────────────────────────
5
+ export function sanitizeSlug(slug) {
6
+ let s = slug.replace(/[\/\\\.]+/g, '-');
7
+ s = s.replace(/[^a-zA-Z0-9_-]/g, '');
8
+ s = s.replace(/-{2,}/g, '-');
9
+ s = s.replace(/^-+|-+$/g, '');
10
+ s = s.slice(0, 80);
11
+ if (s.length === 0)
12
+ throw new Error('Slug is empty after sanitization');
13
+ return s;
14
+ }
4
15
  // ── Body templates per type and locale ─────────────────────────────
5
16
  const BODY_TEMPLATES = {
6
17
  en: {
@@ -57,7 +68,7 @@ export function renderTemplate(type, slug, options) {
57
68
  if (data.id)
58
69
  lines.push(`id: ${data.id}`);
59
70
  lines.push(`type: ${data.type}`);
60
- lines.push(`title: "${data.title}"`);
71
+ lines.push(`title: "${String(data.title).replace(/"/g, '\\"')}"`);
61
72
  lines.push(`status: ${data.status}`);
62
73
  if (confidence !== undefined) {
63
74
  lines.push(`confidence: ${data.confidence}`);
@@ -98,5 +109,5 @@ export function nextId(graphDir, type) {
98
109
  // ── nodePath ───────────────────────────────────────────────────────
99
110
  export function nodePath(graphDir, type, id, slug) {
100
111
  const typeDir = NODE_TYPE_DIRS[type];
101
- return path.join(graphDir, typeDir, `${id}-${slug}.md`);
112
+ return path.join(graphDir, typeDir, `${id}-${sanitizeSlug(slug)}.md`);
102
113
  }
@@ -69,3 +69,25 @@ export interface PromoteCandidate {
69
69
  confidence: number;
70
70
  supports: number;
71
71
  }
72
+ export interface WriteFileOp {
73
+ kind: 'write';
74
+ path: string;
75
+ content: string;
76
+ }
77
+ export interface MkdirOp {
78
+ kind: 'mkdir';
79
+ path: string;
80
+ }
81
+ export type FileOp = WriteFileOp | MkdirOp;
82
+ export interface CreateNodePlan {
83
+ id: string;
84
+ type: NodeType;
85
+ path: string;
86
+ ops: FileOp[];
87
+ }
88
+ export interface CreateEdgePlan {
89
+ source: string;
90
+ target: string;
91
+ relation: string;
92
+ ops: FileOp[];
93
+ }
@@ -5,7 +5,9 @@ export function registerCreateNode(server) {
5
5
  server.tool('create-node', 'Create a new node of the given type with the given slug', {
6
6
  graphDir: z.string().describe('Path to the EMDD graph directory'),
7
7
  type: z.string().describe('Node type (hypothesis, experiment, finding, knowledge, question, decision, episode)'),
8
- slug: z.string().describe('URL-friendly slug for the node'),
8
+ slug: z.string().min(1).max(80)
9
+ .regex(/^[a-zA-Z0-9][a-zA-Z0-9_-]*$/, 'Slug must be alphanumeric with hyphens/underscores')
10
+ .describe('URL-friendly slug for the node'),
9
11
  lang: z.string().optional().describe('Language locale (default: en)'),
10
12
  }, async ({ graphDir, type, slug, lang }) => withErrorHandling(async () => {
11
13
  const result = await createNode(graphDir, type, slug, lang);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@beomjk/emdd",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "CLI for Evolving Mindmap-Driven Development",
5
5
  "type": "module",
6
6
  "bin": {
@@ -14,12 +14,12 @@
14
14
  ],
15
15
  "main": "./dist/index.js",
16
16
  "scripts": {
17
- "build": "tsc",
17
+ "build": "tsc && cp src/rules/*.md dist/rules/",
18
18
  "dev": "tsx src/cli.ts",
19
19
  "test": "vitest",
20
20
  "test:run": "vitest run --passWithNoTests",
21
21
  "lint": "tsc --noEmit",
22
- "demo:record": "terminal-demo play docs/assets/demo.md --record docs/assets/demo.cast && svg-term --in docs/assets/demo.cast --out docs/assets/demo.svg --window --width 80 --height 24"
22
+ "demo:record": "npx terminal-demo play docs/assets/demo.md --record docs/assets/demo.cast && npx svg-term --in docs/assets/demo.cast --out docs/assets/demo.svg --window --width 80 --height 24"
23
23
  },
24
24
  "keywords": [
25
25
  "emdd",
@@ -29,6 +29,9 @@
29
29
  "cli"
30
30
  ],
31
31
  "license": "MIT",
32
+ "publishConfig": {
33
+ "access": "public"
34
+ },
32
35
  "dependencies": {
33
36
  "@modelcontextprotocol/sdk": "^1.27.1",
34
37
  "chalk": "^5.6.2",
@@ -41,9 +44,6 @@
41
44
  },
42
45
  "devDependencies": {
43
46
  "@types/node": "^25.5.0",
44
- "react": "^16.14.0",
45
- "svg-term-cli": "^2.1.1",
46
- "terminal-demo": "^0.1.11",
47
47
  "tsx": "^4.21.0",
48
48
  "typescript": "^5.9.3",
49
49
  "vitest": "^4.1.0"