echoctl 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 +21 -0
- package/README.md +171 -0
- package/bin/echoctl.js +2 -0
- package/package.json +56 -0
- package/scripts/annotate.js +73 -0
- package/scripts/build-docs.js +805 -0
- package/scripts/cli/commands/capture.js +20 -0
- package/scripts/cli/commands/constants.js +70 -0
- package/scripts/cli/commands/doctor.js +10 -0
- package/scripts/cli/commands/helpers.js +27 -0
- package/scripts/cli/commands/hook.js +48 -0
- package/scripts/cli/commands/import_cmd.js +184 -0
- package/scripts/cli/commands/init.js +45 -0
- package/scripts/cli/commands/mcp.js +16 -0
- package/scripts/cli/commands/migrate.js +65 -0
- package/scripts/cli/commands/pipeline.js +26 -0
- package/scripts/cli/commands/project.js +35 -0
- package/scripts/cli/commands/refresh.js +14 -0
- package/scripts/cli/commands/search.js +28 -0
- package/scripts/cli/commands/serve.js +73 -0
- package/scripts/cli/commands/status.js +11 -0
- package/scripts/cli/commands/stop.js +136 -0
- package/scripts/cli/commands/tag.js +89 -0
- package/scripts/cli/echoctl.js +44 -0
- package/scripts/convert.js +55 -0
- package/scripts/import-sessions.js +213 -0
- package/scripts/index.js +92 -0
- package/scripts/lib/cli/names.js +33 -0
- package/scripts/lib/domain/anchor.js +78 -0
- package/scripts/lib/domain/echo-format.js +265 -0
- package/scripts/lib/domain/errors.js +8 -0
- package/scripts/lib/domain/validation.js +126 -0
- package/scripts/lib/hooks/capture.js +401 -0
- package/scripts/lib/hooks/status.js +78 -0
- package/scripts/lib/i18n/format.js +183 -0
- package/scripts/lib/i18n/messages/en.js +41 -0
- package/scripts/lib/i18n/messages/zh-CN.js +40 -0
- package/scripts/lib/import/manifest.js +87 -0
- package/scripts/lib/import/providers/claude-code.js +272 -0
- package/scripts/lib/import/scanner.js +128 -0
- package/scripts/lib/infra/config.js +36 -0
- package/scripts/lib/infra/echo-paths.js +44 -0
- package/scripts/lib/infra/markdown-store.js +161 -0
- package/scripts/lib/infra/query-log.js +27 -0
- package/scripts/lib/infra/read-stdin.js +11 -0
- package/scripts/lib/infra/workspace.js +93 -0
- package/scripts/lib/interfaces/mcp/server.js +151 -0
- package/scripts/lib/interfaces/mcp/tools.js +152 -0
- package/scripts/lib/mcp-server.js +3 -0
- package/scripts/lib/usecases/aggregate-all-projects.js +45 -0
- package/scripts/lib/usecases/convert-buffer.js +43 -0
- package/scripts/lib/usecases/discover-claude-imports.js +80 -0
- package/scripts/lib/usecases/import-claude-project.js +89 -0
- package/scripts/lib/usecases/init-workspace.js +52 -0
- package/scripts/lib/usecases/install-claude-hook.js +139 -0
- package/scripts/lib/usecases/legacy-candidates.js +134 -0
- package/scripts/lib/usecases/live-session-state.js +109 -0
- package/scripts/lib/usecases/migrate-legacy-buffer.js +209 -0
- package/scripts/lib/usecases/project-registry.js +170 -0
- package/scripts/lib/usecases/query-articles.js +380 -0
- package/scripts/lib/usecases/refresh-serve.js +77 -0
- package/scripts/lib/usecases/run-doctor.js +213 -0
- package/scripts/lib/usecases/run-pipeline.js +104 -0
- package/scripts/lib/usecases/snapshot-manifest.js +48 -0
- package/scripts/lib/usecases/status-collector.js +142 -0
- package/scripts/lib/usecases/strip-comments.js +7 -0
- package/scripts/lib/usecases/write-comment.js +122 -0
- package/scripts/resolve.js +65 -0
- package/scripts/search.js +98 -0
- package/scripts/serve.js +778 -0
- package/scripts/validate.js +79 -0
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
// Echo usecase — article query operations for MCP tools
|
|
2
|
+
// Each handler receives (args, deps) where deps = { dirs, store }
|
|
3
|
+
|
|
4
|
+
const path = require("path");
|
|
5
|
+
const { ensureDir } = require("../infra/workspace");
|
|
6
|
+
const { NotFoundError } = require("../domain/errors");
|
|
7
|
+
|
|
8
|
+
let _projectRegistry;
|
|
9
|
+
function getProjectRegistry() {
|
|
10
|
+
if (!_projectRegistry) {
|
|
11
|
+
try { _projectRegistry = require("./project-registry"); } catch (_) { _projectRegistry = null; }
|
|
12
|
+
}
|
|
13
|
+
return _projectRegistry;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function searchArticles(args, deps) {
|
|
17
|
+
const { dirs, store } = deps;
|
|
18
|
+
ensureDir(dirs.articlesDir);
|
|
19
|
+
const keyword = (args.keyword || "").toLowerCase();
|
|
20
|
+
const tag = (args.tag || "").toLowerCase();
|
|
21
|
+
const projectFilter = args.project;
|
|
22
|
+
|
|
23
|
+
// Determine which project directories to search
|
|
24
|
+
const projectDirs = [];
|
|
25
|
+
const currentProjectId = dirs.projectId;
|
|
26
|
+
|
|
27
|
+
if (projectFilter && projectFilter !== "all" && projectFilter !== currentProjectId) {
|
|
28
|
+
// Search only the specified external project
|
|
29
|
+
try {
|
|
30
|
+
const reg = getProjectRegistry();
|
|
31
|
+
const { listProjects } = reg || {};
|
|
32
|
+
const projects = listProjects();
|
|
33
|
+
const target = projects.find((p) => p.projectId === projectFilter);
|
|
34
|
+
if (target) {
|
|
35
|
+
projectDirs.push({ articlesDir: path.join(target.dataRoot, "articles"), projectId: target.projectId });
|
|
36
|
+
}
|
|
37
|
+
} catch (_) {}
|
|
38
|
+
// If project not found, fall through with empty projectDirs (returns no results)
|
|
39
|
+
} else if (projectFilter === "all") {
|
|
40
|
+
// Search current project + all registered projects
|
|
41
|
+
projectDirs.push({ articlesDir: dirs.articlesDir, projectId: currentProjectId });
|
|
42
|
+
try {
|
|
43
|
+
const reg = getProjectRegistry();
|
|
44
|
+
const { listProjects } = reg || {};
|
|
45
|
+
const projects = listProjects();
|
|
46
|
+
for (const p of projects) {
|
|
47
|
+
if (p.projectId === currentProjectId) continue;
|
|
48
|
+
projectDirs.push({ articlesDir: path.join(p.dataRoot, "articles"), projectId: p.projectId });
|
|
49
|
+
}
|
|
50
|
+
} catch (_) {}
|
|
51
|
+
} else {
|
|
52
|
+
// Default: current project only
|
|
53
|
+
projectDirs.push({ articlesDir: dirs.articlesDir, projectId: currentProjectId });
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Load articles from all determined project directories
|
|
57
|
+
const articles = [];
|
|
58
|
+
for (const pd of projectDirs) {
|
|
59
|
+
store.loadArticles(pd.articlesDir).forEach((a) => {
|
|
60
|
+
articles.push({
|
|
61
|
+
...a.data,
|
|
62
|
+
_file: a.relPath,
|
|
63
|
+
_content: a.content,
|
|
64
|
+
project: a.data.project || pd.projectId || "",
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
let results = articles;
|
|
70
|
+
|
|
71
|
+
if (projectFilter && projectFilter !== "all") {
|
|
72
|
+
results = results.filter((a) => a.project === projectFilter);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (tag) {
|
|
76
|
+
results = results.filter((a) =>
|
|
77
|
+
(a.tags || []).some((t) => t.toLowerCase() === tag)
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (keyword) {
|
|
82
|
+
results = results
|
|
83
|
+
.map((a) => {
|
|
84
|
+
const body = a._content.toLowerCase();
|
|
85
|
+
const aliasMatch = (a.alias || "").toLowerCase().includes(keyword);
|
|
86
|
+
const idx = body.indexOf(keyword);
|
|
87
|
+
if (idx === -1 && !aliasMatch) return null;
|
|
88
|
+
const start = Math.max(0, idx - 80);
|
|
89
|
+
const end = Math.min(body.length, idx + keyword.length + 80);
|
|
90
|
+
let snippet = a._content.slice(start, end).replace(/\n/g, " ");
|
|
91
|
+
if (start > 0) snippet = "..." + snippet;
|
|
92
|
+
if (end < body.length) snippet = snippet + "...";
|
|
93
|
+
return { ...a, _snippet: snippet };
|
|
94
|
+
})
|
|
95
|
+
.filter(Boolean);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return results.map((a) => ({
|
|
99
|
+
id: a.id,
|
|
100
|
+
title: a.title || a.id,
|
|
101
|
+
alias: a.alias || "",
|
|
102
|
+
file: a._file,
|
|
103
|
+
created_at: a.created_at,
|
|
104
|
+
tags: a.tags || [],
|
|
105
|
+
summary: a.summary || "",
|
|
106
|
+
snippet: a._snippet || "",
|
|
107
|
+
ai_model: a.ai_model || "",
|
|
108
|
+
project: a.project || "",
|
|
109
|
+
}));
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function getArticle(args, deps) {
|
|
113
|
+
const { dirs, store } = deps;
|
|
114
|
+
ensureDir(dirs.articlesDir);
|
|
115
|
+
const article = store.loadArticleById(dirs.articlesDir, args.id);
|
|
116
|
+
if (!article) throw new NotFoundError(`Article "${args.id}" not found`);
|
|
117
|
+
|
|
118
|
+
return {
|
|
119
|
+
id: article.data.id,
|
|
120
|
+
title: article.data.title || article.data.id,
|
|
121
|
+
alias: article.data.alias || "",
|
|
122
|
+
created_at: article.data.created_at,
|
|
123
|
+
updated_at: article.data.updated_at,
|
|
124
|
+
tags: article.data.tags || [],
|
|
125
|
+
summary: article.data.summary || "",
|
|
126
|
+
content: article.content.trim(),
|
|
127
|
+
file: article.relPath,
|
|
128
|
+
ai_model: article.data.ai_model || "",
|
|
129
|
+
evolution: article.data.evolution || null,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function getArticleContext(args, deps) {
|
|
134
|
+
const { dirs, store } = deps;
|
|
135
|
+
ensureDir(dirs.articlesDir);
|
|
136
|
+
const article = store.loadArticleById(dirs.articlesDir, args.id);
|
|
137
|
+
if (!article) throw new NotFoundError(`Article "${args.id}" not found`);
|
|
138
|
+
|
|
139
|
+
ensureDir(dirs.commentsDir);
|
|
140
|
+
const comments = store.loadComments(dirs.commentsDir).filter(
|
|
141
|
+
(c) => c.target && c.target.article_id === args.id
|
|
142
|
+
);
|
|
143
|
+
|
|
144
|
+
let evolutionChain = [];
|
|
145
|
+
const evo = article.data.evolution;
|
|
146
|
+
|
|
147
|
+
if (evo) {
|
|
148
|
+
const visited = new Set();
|
|
149
|
+
let cursor = evo;
|
|
150
|
+
while (cursor && cursor.of) {
|
|
151
|
+
if (visited.has(cursor.of)) break;
|
|
152
|
+
visited.add(cursor.of);
|
|
153
|
+
const prev = store.loadArticleById(dirs.articlesDir, cursor.of);
|
|
154
|
+
if (prev) {
|
|
155
|
+
evolutionChain.unshift({
|
|
156
|
+
id: prev.data.id,
|
|
157
|
+
title: prev.data.title || prev.data.id,
|
|
158
|
+
direction: cursor.direction || "expands",
|
|
159
|
+
});
|
|
160
|
+
cursor = prev.data.evolution;
|
|
161
|
+
} else {
|
|
162
|
+
break;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
evolutionChain.push({
|
|
168
|
+
id: article.data.id,
|
|
169
|
+
title: article.data.title || article.data.id,
|
|
170
|
+
direction: null,
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
const allArticles = store.loadArticles(dirs.articlesDir);
|
|
174
|
+
const forward = allArticles.filter(
|
|
175
|
+
(a) => a.data.evolution && a.data.evolution.of === args.id
|
|
176
|
+
);
|
|
177
|
+
for (const f of forward) {
|
|
178
|
+
evolutionChain.push({
|
|
179
|
+
id: f.data.id,
|
|
180
|
+
title: f.data.title || f.data.id,
|
|
181
|
+
direction: f.data.evolution.direction || "expands",
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
return {
|
|
186
|
+
id: article.data.id,
|
|
187
|
+
title: article.data.title || article.data.id,
|
|
188
|
+
created_at: article.data.created_at,
|
|
189
|
+
tags: article.data.tags || [],
|
|
190
|
+
summary: article.data.summary || "",
|
|
191
|
+
content_preview: article.content.trim().slice(0, 500),
|
|
192
|
+
evolution_chain: evolutionChain,
|
|
193
|
+
comments: comments.map((c) => ({
|
|
194
|
+
id: c.id,
|
|
195
|
+
author: c.author || "anonymous",
|
|
196
|
+
created_at: c.created_at,
|
|
197
|
+
target_article_id: c.target?.article_id || "",
|
|
198
|
+
anchor_quote: c.anchor?.quote || "",
|
|
199
|
+
comment: (c.content || "").trim(),
|
|
200
|
+
})),
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function listTags(_args, deps) {
|
|
205
|
+
const { dirs, store } = deps;
|
|
206
|
+
ensureDir(dirs.articlesDir);
|
|
207
|
+
const articles = store.loadArticles(dirs.articlesDir);
|
|
208
|
+
const tagCounts = {};
|
|
209
|
+
|
|
210
|
+
for (const a of articles) {
|
|
211
|
+
for (const tag of a.data.tags || []) {
|
|
212
|
+
tagCounts[tag] = (tagCounts[tag] || 0) + 1;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
return Object.entries(tagCounts)
|
|
217
|
+
.sort((a, b) => b[1] - a[1])
|
|
218
|
+
.map(([tag, count]) => ({ tag, count }));
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function addTags(args, deps) {
|
|
222
|
+
const { dirs, store } = deps;
|
|
223
|
+
ensureDir(dirs.articlesDir);
|
|
224
|
+
const article = store.loadArticleById(dirs.articlesDir, args.id);
|
|
225
|
+
if (!article) throw new NotFoundError(`Article "${args.id}" not found`);
|
|
226
|
+
|
|
227
|
+
const newTags = (args.tags || []).map((t) => t.trim()).filter(Boolean);
|
|
228
|
+
if (newTags.length === 0) return { id: article.data.id, tags: article.data.tags || [] };
|
|
229
|
+
|
|
230
|
+
const existingTags = article.data.tags || [];
|
|
231
|
+
const merged = [...new Set([...existingTags, ...newTags])];
|
|
232
|
+
|
|
233
|
+
article.data.tags = merged;
|
|
234
|
+
store.writeArticleFile(article.absPath, article.data, article.content);
|
|
235
|
+
|
|
236
|
+
return { id: article.data.id, tags: merged, added: newTags };
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function updateSummary(args, deps) {
|
|
240
|
+
const { dirs, store } = deps;
|
|
241
|
+
ensureDir(dirs.articlesDir);
|
|
242
|
+
const article = store.loadArticleById(dirs.articlesDir, args.id);
|
|
243
|
+
if (!article) throw new NotFoundError(`Article "${args.id}" not found`);
|
|
244
|
+
|
|
245
|
+
article.data.summary = (args.summary || "").trim() || undefined;
|
|
246
|
+
store.writeArticleFile(article.absPath, article.data, article.content);
|
|
247
|
+
|
|
248
|
+
return { id: article.data.id, summary: article.data.summary || "" };
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function removeTags(args, deps) {
|
|
252
|
+
const { dirs, store } = deps;
|
|
253
|
+
ensureDir(dirs.articlesDir);
|
|
254
|
+
const article = store.loadArticleById(dirs.articlesDir, args.id);
|
|
255
|
+
if (!article) throw new NotFoundError(`Article "${args.id}" not found`);
|
|
256
|
+
|
|
257
|
+
const toRemove = new Set((args.tags || []).map((t) => t.trim()).filter(Boolean));
|
|
258
|
+
if (toRemove.size === 0) return { id: article.data.id, tags: article.data.tags || [] };
|
|
259
|
+
|
|
260
|
+
const existingTags = article.data.tags || [];
|
|
261
|
+
const kept = existingTags.filter((t) => !toRemove.has(t));
|
|
262
|
+
|
|
263
|
+
article.data.tags = kept;
|
|
264
|
+
store.writeArticleFile(article.absPath, article.data, article.content);
|
|
265
|
+
|
|
266
|
+
return { id: article.data.id, tags: kept, removed: [...toRemove] };
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function renameTag(args, deps) {
|
|
270
|
+
const { dirs, store } = deps;
|
|
271
|
+
const oldTag = (args.oldTag || "").trim();
|
|
272
|
+
const newTag = (args.newTag || "").trim();
|
|
273
|
+
if (!oldTag || !newTag) throw new Error("oldTag and newTag are required");
|
|
274
|
+
if (oldTag === newTag) throw new Error("oldTag and newTag must be different");
|
|
275
|
+
|
|
276
|
+
ensureDir(dirs.articlesDir);
|
|
277
|
+
const articles = store.loadArticles(dirs.articlesDir);
|
|
278
|
+
|
|
279
|
+
let renamed = 0;
|
|
280
|
+
for (const article of articles) {
|
|
281
|
+
const tags = article.data.tags || [];
|
|
282
|
+
if (tags.includes(oldTag)) {
|
|
283
|
+
article.data.tags = tags.map((t) => (t === oldTag ? newTag : t));
|
|
284
|
+
store.writeArticleFile(article.absPath, article.data, article.content);
|
|
285
|
+
renamed++;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
if (renamed === 0) throw new NotFoundError(`Tag "${oldTag}" not found in any article`);
|
|
290
|
+
|
|
291
|
+
return { oldTag, newTag, renamed };
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function purgeTag(args, deps) {
|
|
295
|
+
const { dirs, store } = deps;
|
|
296
|
+
const tag = (args.tag || "").trim();
|
|
297
|
+
if (!tag) throw new Error("tag is required");
|
|
298
|
+
|
|
299
|
+
ensureDir(dirs.articlesDir);
|
|
300
|
+
const articles = store.loadArticles(dirs.articlesDir);
|
|
301
|
+
|
|
302
|
+
let purged = 0;
|
|
303
|
+
for (const article of articles) {
|
|
304
|
+
const tags = article.data.tags || [];
|
|
305
|
+
if (tags.includes(tag)) {
|
|
306
|
+
article.data.tags = tags.filter((t) => t !== tag);
|
|
307
|
+
store.writeArticleFile(article.absPath, article.data, article.content);
|
|
308
|
+
purged++;
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
if (purged === 0) throw new NotFoundError(`Tag "${tag}" not found in any article`);
|
|
313
|
+
|
|
314
|
+
return { tag, purged };
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function listRecent(args, deps) {
|
|
318
|
+
const { dirs, store } = deps;
|
|
319
|
+
ensureDir(dirs.articlesDir);
|
|
320
|
+
const raw = args.limit != null ? parseInt(args.limit, 10) : 20;
|
|
321
|
+
const limit = Math.max(1, Math.min(isNaN(raw) ? 20 : raw, 100));
|
|
322
|
+
|
|
323
|
+
const articles = store.loadArticles(dirs.articlesDir);
|
|
324
|
+
articles.sort((a, b) => {
|
|
325
|
+
const da = a.data.created_at ? new Date(a.data.created_at) : new Date(0);
|
|
326
|
+
const db = b.data.created_at ? new Date(b.data.created_at) : new Date(0);
|
|
327
|
+
return db - da;
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
return articles.slice(0, limit).map((a) => ({
|
|
331
|
+
id: a.data.id,
|
|
332
|
+
title: a.data.title || a.data.id,
|
|
333
|
+
alias: a.data.alias || "",
|
|
334
|
+
created_at: a.data.created_at,
|
|
335
|
+
tags: a.data.tags || [],
|
|
336
|
+
summary: a.data.summary || "",
|
|
337
|
+
file: a.relPath,
|
|
338
|
+
ai_model: a.data.ai_model || "",
|
|
339
|
+
}));
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function listProjects(_args, _deps) {
|
|
343
|
+
const reg = getProjectRegistry();
|
|
344
|
+
const listAll = reg ? reg.listProjects : null;
|
|
345
|
+
if (!listAll) return [];
|
|
346
|
+
return listAll().map((p) => ({
|
|
347
|
+
projectId: p.projectId,
|
|
348
|
+
root: p.root,
|
|
349
|
+
dataRoot: p.dataRoot,
|
|
350
|
+
registeredAt: p.registeredAt,
|
|
351
|
+
}));
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function getProject(args, _deps) {
|
|
355
|
+
const reg = getProjectRegistry();
|
|
356
|
+
const findProjectById = reg ? reg.findProjectById : null;
|
|
357
|
+
if (!findProjectById) throw new NotFoundError(`Project "${args.id}" not found`);
|
|
358
|
+
const project = findProjectById(args.id);
|
|
359
|
+
if (!project) throw new NotFoundError(`Project "${args.id}" not found`);
|
|
360
|
+
return {
|
|
361
|
+
projectId: project.projectId,
|
|
362
|
+
root: project.projectRoot,
|
|
363
|
+
dataRoot: project.dataRoot,
|
|
364
|
+
};
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
module.exports = {
|
|
368
|
+
searchArticles,
|
|
369
|
+
getArticle,
|
|
370
|
+
getArticleContext,
|
|
371
|
+
listTags,
|
|
372
|
+
listRecent,
|
|
373
|
+
addTags,
|
|
374
|
+
removeTags,
|
|
375
|
+
renameTag,
|
|
376
|
+
purgeTag,
|
|
377
|
+
updateSummary,
|
|
378
|
+
listProjects,
|
|
379
|
+
getProject,
|
|
380
|
+
};
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
const fs = require("fs");
|
|
2
|
+
const http = require("http");
|
|
3
|
+
const path = require("path");
|
|
4
|
+
const { resolveEchoHomePath } = require("../infra/workspace");
|
|
5
|
+
|
|
6
|
+
const HOST = "127.0.0.1";
|
|
7
|
+
|
|
8
|
+
function serveInfoPath() {
|
|
9
|
+
return path.join(resolveEchoHomePath(), ".serve.json");
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function isPidRunning(pid) {
|
|
13
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
14
|
+
try {
|
|
15
|
+
process.kill(pid, 0);
|
|
16
|
+
return true;
|
|
17
|
+
} catch (err) {
|
|
18
|
+
if (err.code === "ESRCH") return false;
|
|
19
|
+
if (err.code === "EPERM") return true;
|
|
20
|
+
throw err;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function getRunningServeInfo() {
|
|
25
|
+
let info;
|
|
26
|
+
try {
|
|
27
|
+
info = JSON.parse(fs.readFileSync(serveInfoPath(), "utf-8"));
|
|
28
|
+
} catch (_) {
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
if (info.identity !== "echo-serve") return null;
|
|
32
|
+
if (!isPidRunning(info.pid)) return null;
|
|
33
|
+
if (!Number.isInteger(info.apiPort) || info.apiPort <= 0) return null;
|
|
34
|
+
return info;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function requestRunningServeRefresh(opts = {}) {
|
|
38
|
+
const timeoutMs = opts.timeoutMs || 15000;
|
|
39
|
+
const info = opts.info || getRunningServeInfo();
|
|
40
|
+
if (!info) {
|
|
41
|
+
return Promise.resolve({ attempted: false, ok: false, message: "serve is not running" });
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return new Promise((resolve) => {
|
|
45
|
+
const req = http.request({
|
|
46
|
+
host: HOST,
|
|
47
|
+
port: info.apiPort,
|
|
48
|
+
path: "/api/rebuild-docs",
|
|
49
|
+
method: "POST",
|
|
50
|
+
timeout: timeoutMs,
|
|
51
|
+
}, (res) => {
|
|
52
|
+
const chunks = [];
|
|
53
|
+
res.on("data", (chunk) => chunks.push(chunk));
|
|
54
|
+
res.on("end", () => {
|
|
55
|
+
const body = Buffer.concat(chunks).toString("utf-8");
|
|
56
|
+
resolve({
|
|
57
|
+
attempted: true,
|
|
58
|
+
ok: res.statusCode >= 200 && res.statusCode < 300,
|
|
59
|
+
statusCode: res.statusCode,
|
|
60
|
+
message: body || res.statusMessage || "",
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
req.on("timeout", () => {
|
|
65
|
+
req.destroy(new Error("refresh timed out"));
|
|
66
|
+
});
|
|
67
|
+
req.on("error", (err) => {
|
|
68
|
+
resolve({ attempted: true, ok: false, message: err.message });
|
|
69
|
+
});
|
|
70
|
+
req.end();
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
module.exports = {
|
|
75
|
+
getRunningServeInfo,
|
|
76
|
+
requestRunningServeRefresh,
|
|
77
|
+
};
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
const fs = require("fs");
|
|
2
|
+
const path = require("path");
|
|
3
|
+
const os = require("os");
|
|
4
|
+
const { resolveWorkspace, resolveEchoHomePath } = require("../infra/workspace");
|
|
5
|
+
const { isCaptureEnabled } = require("../infra/config");
|
|
6
|
+
const { findProjectForPath } = require("./project-registry");
|
|
7
|
+
const { commandFor, isKnownCliCommand, cliNames } = require("../cli/names");
|
|
8
|
+
|
|
9
|
+
function check(name, status, message) {
|
|
10
|
+
return { name, status, message };
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function ok(name, message) { return check(name, "ok", message); }
|
|
14
|
+
function warn(name, message) { return check(name, "warn", message); }
|
|
15
|
+
function error(name, message) { return check(name, "error", message); }
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
function extractHookCommand(entry, matches = () => true) {
|
|
19
|
+
if (Array.isArray(entry.hooks)) {
|
|
20
|
+
const hook = entry.hooks.find(
|
|
21
|
+
(h) => typeof h.command === "string" && matches(h.command)
|
|
22
|
+
);
|
|
23
|
+
if (hook) return hook.command;
|
|
24
|
+
}
|
|
25
|
+
if (typeof entry.command === "string" && matches(entry.command)) return entry.command;
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function runDoctor({ hookOnly } = {}) {
|
|
30
|
+
const ws = resolveWorkspace();
|
|
31
|
+
const results = [];
|
|
32
|
+
|
|
33
|
+
if (!hookOnly) {
|
|
34
|
+
// 1. Workspace exists
|
|
35
|
+
if (fs.existsSync(ws)) {
|
|
36
|
+
results.push(ok("Workspace", `exists: ${ws}`));
|
|
37
|
+
} else {
|
|
38
|
+
results.push(error("Workspace", `not found: ${ws}`));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// 2. Workspace writable (real temp file test)
|
|
42
|
+
try {
|
|
43
|
+
const probe = path.join(ws, ".echo-doctor-probe");
|
|
44
|
+
fs.writeFileSync(probe, "");
|
|
45
|
+
fs.unlinkSync(probe);
|
|
46
|
+
results.push(ok("Workspace writable", "write test passed"));
|
|
47
|
+
} catch (_) {
|
|
48
|
+
results.push(error("Workspace writable", "cannot write to workspace"));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// 3. Subdirectories
|
|
52
|
+
for (const d of ["articles", "comments", "session-buffer", "index"]) {
|
|
53
|
+
const full = path.join(ws, d);
|
|
54
|
+
if (!fs.existsSync(full)) {
|
|
55
|
+
results.push(warn("Subdirectory", `${d}/ missing — run ${commandFor(["init"])}`));
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
if (results.filter(r => r.name === "Subdirectory").length === 0) {
|
|
59
|
+
results.push(ok("Subdirectories", "all present"));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// 4. echo.json valid JSON (direct parse, not getConfig)
|
|
63
|
+
const configPath = path.join(ws, "echo.json");
|
|
64
|
+
if (fs.existsSync(configPath)) {
|
|
65
|
+
try {
|
|
66
|
+
const cfg = JSON.parse(fs.readFileSync(configPath, "utf-8"));
|
|
67
|
+
if (cfg.workspace) {
|
|
68
|
+
results.push(ok("echo.json", `valid (workspace=${cfg.workspace})`));
|
|
69
|
+
} else {
|
|
70
|
+
results.push(warn("echo.json", "valid JSON but no workspace field"));
|
|
71
|
+
}
|
|
72
|
+
} catch (_) {
|
|
73
|
+
results.push(error("echo.json", `invalid JSON — run ${commandFor(["init"])}`));
|
|
74
|
+
}
|
|
75
|
+
} else {
|
|
76
|
+
results.push(warn("echo.json", `missing — run ${commandFor(["init"])}`));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// 5. Capture status
|
|
80
|
+
const captureOn = isCaptureEnabled();
|
|
81
|
+
if (captureOn) {
|
|
82
|
+
results.push(ok("Capture", "enabled"));
|
|
83
|
+
} else {
|
|
84
|
+
results.push(warn("Capture", `disabled — run: ${commandFor(["capture", "on"])}`));
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// 6. Legacy ~/.echo-buffer (warning only)
|
|
88
|
+
const legacyBuffer = path.join(os.homedir(), ".echo-buffer");
|
|
89
|
+
if (fs.existsSync(legacyBuffer)) {
|
|
90
|
+
results.push(warn("Legacy buffer", `${legacyBuffer} exists — run ${commandFor(["migrate", "legacy-buffer"])}`));
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// 7. Echo home (global)
|
|
94
|
+
const echoHome = resolveEchoHomePath();
|
|
95
|
+
if (fs.existsSync(echoHome)) {
|
|
96
|
+
results.push(ok("Echo home", `exists: ${echoHome}`));
|
|
97
|
+
} else {
|
|
98
|
+
results.push(warn("Echo home", `not found: ${echoHome} — run ${commandFor(["init"])}`));
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// 8. registry.json
|
|
102
|
+
const registryPath = path.join(echoHome, "registry.json");
|
|
103
|
+
let registry = null;
|
|
104
|
+
if (fs.existsSync(registryPath)) {
|
|
105
|
+
try {
|
|
106
|
+
registry = JSON.parse(fs.readFileSync(registryPath, "utf-8"));
|
|
107
|
+
const count = Object.keys(registry.projects || {}).length;
|
|
108
|
+
results.push(ok("registry.json", `valid (${count} project${count !== 1 ? "s" : ""})`));
|
|
109
|
+
} catch (_) {
|
|
110
|
+
results.push(error("registry.json", "invalid JSON"));
|
|
111
|
+
}
|
|
112
|
+
} else {
|
|
113
|
+
results.push(warn("registry.json", `missing — run ${commandFor(["init"])}`));
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// 9. Current project registration
|
|
117
|
+
let currentProject = null;
|
|
118
|
+
if (registry) {
|
|
119
|
+
const cwd = process.cwd();
|
|
120
|
+
const current = findProjectForPath(cwd, { echoHome });
|
|
121
|
+
if (current) {
|
|
122
|
+
currentProject = current;
|
|
123
|
+
results.push(ok("Current project", `${current.projectId} (data: ${current.dataRoot})`));
|
|
124
|
+
|
|
125
|
+
// 10. Project data directories
|
|
126
|
+
const missing = [];
|
|
127
|
+
for (const d of ["session-buffer", "articles", "comments", "index"]) {
|
|
128
|
+
if (!fs.existsSync(path.join(current.dataRoot, d))) {
|
|
129
|
+
missing.push(d);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
if (missing.length > 0) {
|
|
133
|
+
results.push(warn("Project data dirs", `missing: ${missing.join(", ")} — run ${commandFor(["init", "project"])}`));
|
|
134
|
+
} else {
|
|
135
|
+
results.push(ok("Project data dirs", "all present"));
|
|
136
|
+
}
|
|
137
|
+
} else {
|
|
138
|
+
// Check if cwd is inside Echo's data directory first
|
|
139
|
+
const projectsDir = path.join(echoHome, "projects");
|
|
140
|
+
if (cwd.startsWith(projectsDir + path.sep)) {
|
|
141
|
+
let foundProjectRoot = null;
|
|
142
|
+
for (const [id, entry] of Object.entries(registry.projects || {})) {
|
|
143
|
+
const dataRoot = path.join(echoHome, "projects", id);
|
|
144
|
+
if (cwd.startsWith(dataRoot + path.sep) || cwd === dataRoot) {
|
|
145
|
+
foundProjectRoot = entry.root;
|
|
146
|
+
break;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
if (foundProjectRoot) {
|
|
150
|
+
results.push(warn("Data directory detected",
|
|
151
|
+
`This is an Echo internal data directory.\n` +
|
|
152
|
+
` Use the registered project root instead:\n` +
|
|
153
|
+
` cd ${foundProjectRoot}`));
|
|
154
|
+
} else {
|
|
155
|
+
results.push(warn("Data directory detected",
|
|
156
|
+
`This appears to be inside Echo's data storage (~/.echo-workspace/projects/).\n` +
|
|
157
|
+
` Run ${commandFor(["doctor"])} from your project directory instead.`));
|
|
158
|
+
}
|
|
159
|
+
} else {
|
|
160
|
+
results.push(warn("Current project", `${cwd} not registered — run ${commandFor(["init", "project"])}`));
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// 7. Hook configuration
|
|
167
|
+
const settingsPath = path.join(os.homedir(), ".claude", "settings.json");
|
|
168
|
+
if (fs.existsSync(settingsPath)) {
|
|
169
|
+
try {
|
|
170
|
+
const settings = JSON.parse(fs.readFileSync(settingsPath, "utf-8"));
|
|
171
|
+
const hooks = settings.hooks || {};
|
|
172
|
+
|
|
173
|
+
for (const event of ["UserPromptSubmit", "Stop", "StopFailure", "SessionStart"]) {
|
|
174
|
+
const entries = Array.isArray(hooks[event]) ? hooks[event] : [];
|
|
175
|
+
const hasEchoMcp = entries.some((e) => {
|
|
176
|
+
const cmd = extractHookCommand(e, (command) => isKnownCliCommand(command));
|
|
177
|
+
return typeof cmd === "string" && isKnownCliCommand(cmd);
|
|
178
|
+
});
|
|
179
|
+
const hasSh = entries.some((e) => {
|
|
180
|
+
const cmd = extractHookCommand(e, (command) => command.includes(".sh"));
|
|
181
|
+
return typeof cmd === "string" && cmd.includes(".sh");
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
if (hasEchoMcp) {
|
|
185
|
+
const cmds = entries.filter((e) => {
|
|
186
|
+
const cmd = extractHookCommand(e, (command) => isKnownCliCommand(command));
|
|
187
|
+
return typeof cmd === "string" && isKnownCliCommand(cmd);
|
|
188
|
+
}).map((e) => extractHookCommand(e, (command) => isKnownCliCommand(command)));
|
|
189
|
+
results.push(ok(`Hook: ${event}`, `CLI: ${cmds.join(", ")}`));
|
|
190
|
+
} else if (hasSh) {
|
|
191
|
+
const cmds = entries.filter((e) => {
|
|
192
|
+
const cmd = extractHookCommand(e, (command) => command.includes(".sh"));
|
|
193
|
+
return typeof cmd === "string" && cmd.includes(".sh");
|
|
194
|
+
}).map((e) => extractHookCommand(e, (command) => command.includes(".sh")));
|
|
195
|
+
results.push(warn(`Hook: ${event}`, `legacy .sh: ${cmds.join(", ")} — run ${commandFor(["hook", "install", "claude", "--write"])}`));
|
|
196
|
+
} else {
|
|
197
|
+
results.push(warn(`Hook: ${event}`, `not configured — run ${commandFor(["hook", "install", "claude", "--write"])}`));
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
} catch (_) {
|
|
201
|
+
results.push(error("Hook config", `${settingsPath} is invalid JSON`));
|
|
202
|
+
}
|
|
203
|
+
} else {
|
|
204
|
+
results.push(warn("Hook config", `~/.claude/settings.json not found — run ${commandFor(["hook", "install", "claude"])}`));
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// 8. CLI resolvability
|
|
208
|
+
results.push(ok("CLI", `${cliNames.canonicalName} is in PATH; echo-mcp remains supported as alias`));
|
|
209
|
+
|
|
210
|
+
return results;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
module.exports = { runDoctor };
|