@aprovan/hardcopy 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/.eslintrc.json +22 -0
- package/.github/workflows/publish.yml +41 -0
- package/.prettierignore +17 -0
- package/LICENSE +21 -0
- package/README.md +183 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +2950 -0
- package/dist/index.d.ts +406 -0
- package/dist/index.js +2737 -0
- package/dist/mcp-server.d.ts +7 -0
- package/dist/mcp-server.js +2665 -0
- package/docs/research/crdt.md +777 -0
- package/docs/research/github-issues.md +684 -0
- package/docs/research/gql.md +876 -0
- package/docs/research/index.md +19 -0
- package/docs/specs/conflict-resolution.md +1254 -0
- package/docs/specs/hardcopy.md +742 -0
- package/docs/specs/patchwork-integration.md +227 -0
- package/docs/specs/plugin-architecture.md +747 -0
- package/mcp.json +8 -0
- package/package.json +64 -0
- package/scripts/install-graphqlite.ts +156 -0
- package/src/cli.ts +356 -0
- package/src/config.ts +104 -0
- package/src/conflict-store.ts +136 -0
- package/src/conflict.ts +147 -0
- package/src/crdt.ts +100 -0
- package/src/db.ts +600 -0
- package/src/env.ts +34 -0
- package/src/format.ts +72 -0
- package/src/formats/github-issue.ts +55 -0
- package/src/hardcopy/core.ts +78 -0
- package/src/hardcopy/diff.ts +188 -0
- package/src/hardcopy/index.ts +67 -0
- package/src/hardcopy/init.ts +24 -0
- package/src/hardcopy/push.ts +444 -0
- package/src/hardcopy/sync.ts +37 -0
- package/src/hardcopy/types.ts +49 -0
- package/src/hardcopy/views.ts +199 -0
- package/src/hardcopy.ts +1 -0
- package/src/index.ts +13 -0
- package/src/llm-merge.ts +109 -0
- package/src/mcp-server.ts +388 -0
- package/src/merge.ts +75 -0
- package/src/provider.ts +40 -0
- package/src/providers/a2a/index.ts +166 -0
- package/src/providers/git/index.ts +212 -0
- package/src/providers/github/index.ts +236 -0
- package/src/providers/github/issues.ts +66 -0
- package/src/providers.ts +7 -0
- package/src/types.ts +101 -0
- package/tsconfig.json +21 -0
- package/tsup.config.ts +10 -0
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import { join } from "path";
|
|
2
|
+
import { mkdir, writeFile, readFile, rm, readdir, stat } from "fs/promises";
|
|
3
|
+
import { setDocContent, setDocAttrs, getDocContent } from "../crdt";
|
|
4
|
+
import { renderNode, parseFile } from "../format";
|
|
5
|
+
import type { ViewConfig } from "../config";
|
|
6
|
+
import type { Node, IndexState } from "../types";
|
|
7
|
+
import type { Hardcopy } from "./core";
|
|
8
|
+
import type { RefreshResult } from "./types";
|
|
9
|
+
|
|
10
|
+
export async function getViews(this: Hardcopy): Promise<string[]> {
|
|
11
|
+
const config = await this.loadConfig();
|
|
12
|
+
return config.views.map((v) => v.path);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export async function refreshView(
|
|
16
|
+
this: Hardcopy,
|
|
17
|
+
viewPath: string,
|
|
18
|
+
options: { clean?: boolean } = {},
|
|
19
|
+
): Promise<RefreshResult> {
|
|
20
|
+
const config = await this.loadConfig();
|
|
21
|
+
const view = config.views.find((v) => v.path === viewPath);
|
|
22
|
+
if (!view) throw new Error(`View not found: ${viewPath}`);
|
|
23
|
+
|
|
24
|
+
const viewDir = join(this.root, view.path);
|
|
25
|
+
await mkdir(viewDir, { recursive: true });
|
|
26
|
+
|
|
27
|
+
const db = this.getDatabase();
|
|
28
|
+
|
|
29
|
+
const params: Record<string, unknown> = {};
|
|
30
|
+
const me = process.env["HARDCOPY_ME"] ?? process.env["GITHUB_USER"];
|
|
31
|
+
if (me) params["me"] = me;
|
|
32
|
+
|
|
33
|
+
const nodes = await db.queryViewNodes(
|
|
34
|
+
view.query,
|
|
35
|
+
Object.keys(params).length ? params : undefined,
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
const indexState: IndexState = {
|
|
39
|
+
loaded: nodes.length,
|
|
40
|
+
pageSize: 10,
|
|
41
|
+
lastFetch: new Date().toISOString(),
|
|
42
|
+
ttl: 300,
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
await writeFile(
|
|
46
|
+
join(viewDir, ".index"),
|
|
47
|
+
JSON.stringify(indexState, null, 2),
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
const expectedFiles = new Set<string>();
|
|
51
|
+
|
|
52
|
+
for (const node of nodes) {
|
|
53
|
+
const renderedPaths = await renderNodeToFile.call(this, node, view, viewDir);
|
|
54
|
+
for (const p of renderedPaths) {
|
|
55
|
+
expectedFiles.add(p);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const existingFiles = await listViewFiles(viewDir);
|
|
60
|
+
const orphanedFiles = existingFiles.filter((f) => !expectedFiles.has(f));
|
|
61
|
+
|
|
62
|
+
if (options.clean && orphanedFiles.length > 0) {
|
|
63
|
+
await cleanupOrphanedFiles.call(this, viewDir, orphanedFiles);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return {
|
|
67
|
+
rendered: expectedFiles.size,
|
|
68
|
+
orphaned: orphanedFiles,
|
|
69
|
+
cleaned: options.clean ?? false,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function renderNodeToFile(
|
|
74
|
+
this: Hardcopy,
|
|
75
|
+
node: Node,
|
|
76
|
+
view: ViewConfig,
|
|
77
|
+
viewDir: string,
|
|
78
|
+
): Promise<string[]> {
|
|
79
|
+
const renderedPaths: string[] = [];
|
|
80
|
+
const crdt = this.getCRDTStore();
|
|
81
|
+
const db = this.getDatabase();
|
|
82
|
+
|
|
83
|
+
for (const renderConfig of view.render) {
|
|
84
|
+
const filePath = resolveRenderPath(renderConfig.path, node);
|
|
85
|
+
const fullPath = join(viewDir, filePath);
|
|
86
|
+
await mkdir(join(fullPath, ".."), { recursive: true });
|
|
87
|
+
|
|
88
|
+
let content: string;
|
|
89
|
+
if (renderConfig.template) {
|
|
90
|
+
content = renderNode(node, renderConfig.template);
|
|
91
|
+
} else if (renderConfig.type) {
|
|
92
|
+
content = renderNode({ ...node, type: renderConfig.type });
|
|
93
|
+
} else {
|
|
94
|
+
content = renderNode(node);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const doc = await crdt.loadOrCreate(node.id);
|
|
98
|
+
const body = (node.attrs["body"] as string) ?? "";
|
|
99
|
+
setDocContent(doc, body);
|
|
100
|
+
setDocAttrs(doc, node.attrs as Record<string, unknown>);
|
|
101
|
+
await crdt.save(node.id, doc);
|
|
102
|
+
|
|
103
|
+
await writeFile(fullPath, content);
|
|
104
|
+
|
|
105
|
+
const fileStat = await stat(fullPath);
|
|
106
|
+
await db.upsertNode({ ...node, syncedAt: fileStat.mtimeMs });
|
|
107
|
+
|
|
108
|
+
renderedPaths.push(filePath);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return renderedPaths;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function resolveRenderPath(template: string, node: Node): string {
|
|
115
|
+
return template.replace(/\{\{([^}]+)\}\}/g, (_, path: string) => {
|
|
116
|
+
const parts = path.trim().split(".");
|
|
117
|
+
let current: unknown = { ...node, ...node.attrs };
|
|
118
|
+
for (const part of parts) {
|
|
119
|
+
if (current === null || current === undefined) return "";
|
|
120
|
+
current = (current as Record<string, unknown>)[part];
|
|
121
|
+
}
|
|
122
|
+
return String(current ?? "");
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export async function listViewFiles(viewDir: string): Promise<string[]> {
|
|
127
|
+
const files: string[] = [];
|
|
128
|
+
|
|
129
|
+
async function walk(dir: string, base: string): Promise<void> {
|
|
130
|
+
let entries;
|
|
131
|
+
try {
|
|
132
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
133
|
+
} catch {
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
for (const entry of entries) {
|
|
138
|
+
const relPath = base ? `${base}/${entry.name}` : entry.name;
|
|
139
|
+
if (entry.name.startsWith(".")) continue;
|
|
140
|
+
|
|
141
|
+
if (entry.isDirectory()) {
|
|
142
|
+
await walk(join(dir, entry.name), relPath);
|
|
143
|
+
} else if (entry.isFile() && entry.name.endsWith(".md")) {
|
|
144
|
+
files.push(relPath);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
await walk(viewDir, "");
|
|
150
|
+
return files;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
async function cleanupOrphanedFiles(
|
|
154
|
+
this: Hardcopy,
|
|
155
|
+
viewDir: string,
|
|
156
|
+
orphanedFiles: string[],
|
|
157
|
+
): Promise<void> {
|
|
158
|
+
for (const relPath of orphanedFiles) {
|
|
159
|
+
const fullPath = join(viewDir, relPath);
|
|
160
|
+
await syncFileBeforeDelete.call(this, fullPath);
|
|
161
|
+
|
|
162
|
+
try {
|
|
163
|
+
await rm(fullPath);
|
|
164
|
+
console.log(`Deleted orphaned file: ${relPath}`);
|
|
165
|
+
} catch (err) {
|
|
166
|
+
console.error(`Failed to delete ${relPath}: ${err}`);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
async function syncFileBeforeDelete(
|
|
172
|
+
this: Hardcopy,
|
|
173
|
+
fullPath: string,
|
|
174
|
+
): Promise<void> {
|
|
175
|
+
try {
|
|
176
|
+
const content = await readFile(fullPath, "utf-8");
|
|
177
|
+
const parsed = parseFile(content, "generic");
|
|
178
|
+
const nodeId = (parsed.attrs._id ?? parsed.attrs.id) as string | undefined;
|
|
179
|
+
|
|
180
|
+
if (!nodeId) return;
|
|
181
|
+
|
|
182
|
+
const crdt = this.getCRDTStore();
|
|
183
|
+
const doc = await crdt.load(nodeId);
|
|
184
|
+
|
|
185
|
+
if (!doc) return;
|
|
186
|
+
|
|
187
|
+
const crdtContent = getDocContent(doc);
|
|
188
|
+
if (parsed.body !== crdtContent) {
|
|
189
|
+
console.warn(
|
|
190
|
+
`Warning: File for ${nodeId} has local changes that may be lost. ` +
|
|
191
|
+
`Run 'hardcopy push' first to preserve changes.`,
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
await crdt.delete(nodeId);
|
|
196
|
+
} catch {
|
|
197
|
+
// File might not be parseable, skip sync
|
|
198
|
+
}
|
|
199
|
+
}
|
package/src/hardcopy.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "./hardcopy/index";
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export * from "./types";
|
|
2
|
+
export * from "./provider";
|
|
3
|
+
export * from "./format";
|
|
4
|
+
export * from "./config";
|
|
5
|
+
export * from "./db";
|
|
6
|
+
export * from "./crdt";
|
|
7
|
+
export * from "./hardcopy";
|
|
8
|
+
export * from "./providers";
|
|
9
|
+
export * from "./conflict";
|
|
10
|
+
export * from "./conflict-store";
|
|
11
|
+
export * from "./merge";
|
|
12
|
+
export * from "./llm-merge";
|
|
13
|
+
export { createMcpServer, serveMcp } from "./mcp-server";
|
package/src/llm-merge.ts
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LLM-based merge for conflicts that can't be auto-resolved.
|
|
3
|
+
*
|
|
4
|
+
* Uses an OpenAI-compatible endpoint (e.g., copilot-proxy) to intelligently
|
|
5
|
+
* merge conflicting changes by understanding semantic intent.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export interface LLMMergeOptions {
|
|
9
|
+
/** OpenAI-compatible API base URL (default: OPENAI_BASE_URL or http://localhost:6433) */
|
|
10
|
+
baseURL?: string;
|
|
11
|
+
/** Model to use (default: OPENAI_MODEL or gpt-4o) */
|
|
12
|
+
model?: string;
|
|
13
|
+
/** API key for authentication (default: OPENAI_API_KEY) */
|
|
14
|
+
apiKey?: string;
|
|
15
|
+
/** Temperature for generation (default: 0) */
|
|
16
|
+
temperature?: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface ChatMessage {
|
|
20
|
+
role: "system" | "user" | "assistant";
|
|
21
|
+
content: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
interface ChatCompletionResponse {
|
|
25
|
+
choices: Array<{
|
|
26
|
+
message: {
|
|
27
|
+
content: string;
|
|
28
|
+
};
|
|
29
|
+
}>;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const MERGE_SYSTEM_PROMPT = `You are a precise text merge assistant. Your task is to intelligently merge two versions of text that have both been modified from a common base.
|
|
33
|
+
|
|
34
|
+
Rules:
|
|
35
|
+
1. Preserve ALL meaningful changes from both versions
|
|
36
|
+
2. When both versions change the same content differently, combine the intents (e.g., if one adds bold and one restructures, do both)
|
|
37
|
+
3. Never lose information - if text was added on either side, include it
|
|
38
|
+
4. Maintain consistent formatting and style
|
|
39
|
+
5. Output ONLY the merged text, no explanations or markdown code blocks`;
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Attempts to merge conflicting text using an LLM.
|
|
43
|
+
*
|
|
44
|
+
* @param base - The common ancestor text
|
|
45
|
+
* @param local - The local (your) version
|
|
46
|
+
* @param remote - The remote (their) version
|
|
47
|
+
* @param options - LLM configuration options
|
|
48
|
+
* @returns The merged text, or null if the LLM call fails
|
|
49
|
+
*/
|
|
50
|
+
export async function llmMergeText(
|
|
51
|
+
base: string,
|
|
52
|
+
local: string,
|
|
53
|
+
remote: string,
|
|
54
|
+
options: LLMMergeOptions = {},
|
|
55
|
+
): Promise<string | null> {
|
|
56
|
+
const baseURL =
|
|
57
|
+
options.baseURL ?? process.env.OPENAI_BASE_URL ?? "http://localhost:6433";
|
|
58
|
+
const model = options.model ?? process.env.OPENAI_MODEL ?? "gpt-4o";
|
|
59
|
+
const apiKey = options.apiKey ?? process.env.OPENAI_API_KEY;
|
|
60
|
+
const temperature = options.temperature ?? 0;
|
|
61
|
+
|
|
62
|
+
const messages: ChatMessage[] = [
|
|
63
|
+
{ role: "system", content: MERGE_SYSTEM_PROMPT },
|
|
64
|
+
{
|
|
65
|
+
role: "user",
|
|
66
|
+
content: `Merge these two versions that diverged from a common base.
|
|
67
|
+
|
|
68
|
+
=== BASE (original) ===
|
|
69
|
+
${base}
|
|
70
|
+
|
|
71
|
+
=== LOCAL (my changes) ===
|
|
72
|
+
${local}
|
|
73
|
+
|
|
74
|
+
=== REMOTE (their changes) ===
|
|
75
|
+
${remote}
|
|
76
|
+
|
|
77
|
+
=== MERGED OUTPUT ===`,
|
|
78
|
+
},
|
|
79
|
+
];
|
|
80
|
+
|
|
81
|
+
try {
|
|
82
|
+
const headers: Record<string, string> = {
|
|
83
|
+
"Content-Type": "application/json",
|
|
84
|
+
};
|
|
85
|
+
if (apiKey) {
|
|
86
|
+
headers["Authorization"] = `Bearer ${apiKey}`;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const response = await fetch(`${baseURL}/chat/completions`, {
|
|
90
|
+
method: "POST",
|
|
91
|
+
headers,
|
|
92
|
+
body: JSON.stringify({
|
|
93
|
+
model,
|
|
94
|
+
messages,
|
|
95
|
+
temperature,
|
|
96
|
+
max_tokens: 4096,
|
|
97
|
+
}),
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
if (!response.ok) {
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const data = (await response.json()) as ChatCompletionResponse;
|
|
105
|
+
return data.choices?.[0]?.message?.content ?? null;
|
|
106
|
+
} catch {
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
@@ -0,0 +1,388 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
4
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
5
|
+
import {
|
|
6
|
+
CallToolRequestSchema,
|
|
7
|
+
ErrorCode,
|
|
8
|
+
ListToolsRequestSchema,
|
|
9
|
+
McpError,
|
|
10
|
+
} from "@modelcontextprotocol/sdk/types.js";
|
|
11
|
+
|
|
12
|
+
import { Hardcopy } from "./hardcopy";
|
|
13
|
+
|
|
14
|
+
export function createMcpServer(root: string): Server {
|
|
15
|
+
const server = new Server(
|
|
16
|
+
{ name: "hardcopy", version: "0.1.0" },
|
|
17
|
+
{ capabilities: { tools: {} } },
|
|
18
|
+
);
|
|
19
|
+
|
|
20
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
21
|
+
tools: [
|
|
22
|
+
{
|
|
23
|
+
name: "hardcopy_sync",
|
|
24
|
+
description: "Sync all configured remote sources to local database",
|
|
25
|
+
inputSchema: {
|
|
26
|
+
type: "object",
|
|
27
|
+
properties: {},
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
name: "hardcopy_status",
|
|
32
|
+
description: "Show sync status including changed files and conflicts",
|
|
33
|
+
inputSchema: {
|
|
34
|
+
type: "object",
|
|
35
|
+
properties: {},
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
name: "hardcopy_refresh",
|
|
40
|
+
description: "Refresh local files from database for a view pattern",
|
|
41
|
+
inputSchema: {
|
|
42
|
+
type: "object",
|
|
43
|
+
properties: {
|
|
44
|
+
pattern: {
|
|
45
|
+
type: "string",
|
|
46
|
+
description:
|
|
47
|
+
"View pattern to refresh (supports glob, e.g. docs/issues)",
|
|
48
|
+
},
|
|
49
|
+
clean: {
|
|
50
|
+
type: "boolean",
|
|
51
|
+
description: "Remove files that no longer match the view",
|
|
52
|
+
default: false,
|
|
53
|
+
},
|
|
54
|
+
syncFirst: {
|
|
55
|
+
type: "boolean",
|
|
56
|
+
description: "Sync data from remote before refreshing",
|
|
57
|
+
default: false,
|
|
58
|
+
},
|
|
59
|
+
},
|
|
60
|
+
required: ["pattern"],
|
|
61
|
+
},
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
name: "hardcopy_diff",
|
|
65
|
+
description:
|
|
66
|
+
"Show local changes vs synced state for files matching pattern",
|
|
67
|
+
inputSchema: {
|
|
68
|
+
type: "object",
|
|
69
|
+
properties: {
|
|
70
|
+
pattern: {
|
|
71
|
+
type: "string",
|
|
72
|
+
description: "File pattern to check (supports glob)",
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
},
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
name: "hardcopy_push",
|
|
79
|
+
description: "Push local changes to remote sources",
|
|
80
|
+
inputSchema: {
|
|
81
|
+
type: "object",
|
|
82
|
+
properties: {
|
|
83
|
+
pattern: {
|
|
84
|
+
type: "string",
|
|
85
|
+
description: "File pattern to push (supports glob)",
|
|
86
|
+
},
|
|
87
|
+
force: {
|
|
88
|
+
type: "boolean",
|
|
89
|
+
description: "Push even if conflicts are detected",
|
|
90
|
+
default: false,
|
|
91
|
+
},
|
|
92
|
+
},
|
|
93
|
+
},
|
|
94
|
+
},
|
|
95
|
+
{
|
|
96
|
+
name: "hardcopy_conflicts",
|
|
97
|
+
description: "List all unresolved conflicts",
|
|
98
|
+
inputSchema: {
|
|
99
|
+
type: "object",
|
|
100
|
+
properties: {},
|
|
101
|
+
},
|
|
102
|
+
},
|
|
103
|
+
{
|
|
104
|
+
name: "hardcopy_resolve",
|
|
105
|
+
description: "Resolve a specific conflict",
|
|
106
|
+
inputSchema: {
|
|
107
|
+
type: "object",
|
|
108
|
+
properties: {
|
|
109
|
+
nodeId: {
|
|
110
|
+
type: "string",
|
|
111
|
+
description: "The node ID of the conflict to resolve",
|
|
112
|
+
},
|
|
113
|
+
resolution: {
|
|
114
|
+
type: "object",
|
|
115
|
+
description:
|
|
116
|
+
'Map of field names to resolution choice ("local" or "remote")',
|
|
117
|
+
additionalProperties: {
|
|
118
|
+
type: "string",
|
|
119
|
+
enum: ["local", "remote"],
|
|
120
|
+
},
|
|
121
|
+
},
|
|
122
|
+
},
|
|
123
|
+
required: ["nodeId", "resolution"],
|
|
124
|
+
},
|
|
125
|
+
},
|
|
126
|
+
],
|
|
127
|
+
}));
|
|
128
|
+
|
|
129
|
+
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
130
|
+
const { name, arguments: args = {} } = request.params;
|
|
131
|
+
|
|
132
|
+
const hc = new Hardcopy({ root });
|
|
133
|
+
|
|
134
|
+
try {
|
|
135
|
+
await hc.initialize();
|
|
136
|
+
await hc.loadConfig();
|
|
137
|
+
|
|
138
|
+
switch (name) {
|
|
139
|
+
case "hardcopy_sync":
|
|
140
|
+
return await handleSync(hc);
|
|
141
|
+
case "hardcopy_status":
|
|
142
|
+
return await handleStatus(hc);
|
|
143
|
+
case "hardcopy_refresh":
|
|
144
|
+
return await handleRefresh(hc, args as unknown as RefreshArgs);
|
|
145
|
+
case "hardcopy_diff":
|
|
146
|
+
return await handleDiff(hc, args as unknown as DiffArgs);
|
|
147
|
+
case "hardcopy_push":
|
|
148
|
+
return await handlePush(hc, args as unknown as PushArgs);
|
|
149
|
+
case "hardcopy_conflicts":
|
|
150
|
+
return await handleConflicts(hc);
|
|
151
|
+
case "hardcopy_resolve":
|
|
152
|
+
return await handleResolve(hc, args as unknown as ResolveArgs);
|
|
153
|
+
default:
|
|
154
|
+
throw new McpError(ErrorCode.MethodNotFound, `Unknown tool: ${name}`);
|
|
155
|
+
}
|
|
156
|
+
} catch (error) {
|
|
157
|
+
if (error instanceof McpError) throw error;
|
|
158
|
+
throw new McpError(
|
|
159
|
+
ErrorCode.InternalError,
|
|
160
|
+
`Failed to execute ${name}: ${error instanceof Error ? error.message : String(error)}`,
|
|
161
|
+
);
|
|
162
|
+
} finally {
|
|
163
|
+
await hc.close();
|
|
164
|
+
}
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
return server;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
interface RefreshArgs {
|
|
171
|
+
pattern: string;
|
|
172
|
+
clean?: boolean;
|
|
173
|
+
syncFirst?: boolean;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
interface DiffArgs {
|
|
177
|
+
pattern?: string;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
interface PushArgs {
|
|
181
|
+
pattern?: string;
|
|
182
|
+
force?: boolean;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
interface ResolveArgs {
|
|
186
|
+
nodeId: string;
|
|
187
|
+
resolution: Record<string, "local" | "remote">;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
async function handleSync(hc: Hardcopy) {
|
|
191
|
+
const stats = await hc.sync();
|
|
192
|
+
return {
|
|
193
|
+
content: [
|
|
194
|
+
{
|
|
195
|
+
type: "text" as const,
|
|
196
|
+
text: JSON.stringify(
|
|
197
|
+
{
|
|
198
|
+
synced: { nodes: stats.nodes, edges: stats.edges },
|
|
199
|
+
errors: stats.errors,
|
|
200
|
+
},
|
|
201
|
+
null,
|
|
202
|
+
2,
|
|
203
|
+
),
|
|
204
|
+
},
|
|
205
|
+
],
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
async function handleStatus(hc: Hardcopy) {
|
|
210
|
+
const status = await hc.status();
|
|
211
|
+
return {
|
|
212
|
+
content: [
|
|
213
|
+
{
|
|
214
|
+
type: "text" as const,
|
|
215
|
+
text: JSON.stringify(
|
|
216
|
+
{
|
|
217
|
+
nodes: status.totalNodes,
|
|
218
|
+
edges: status.totalEdges,
|
|
219
|
+
byType: status.nodesByType,
|
|
220
|
+
changedFiles: status.changedFiles.map((f) => ({
|
|
221
|
+
path: f.path,
|
|
222
|
+
status: f.status,
|
|
223
|
+
nodeId: f.nodeId,
|
|
224
|
+
})),
|
|
225
|
+
conflicts: status.conflicts.map((c) => ({
|
|
226
|
+
nodeId: c.nodeId,
|
|
227
|
+
fields: c.fields.map((f) => f.field),
|
|
228
|
+
})),
|
|
229
|
+
},
|
|
230
|
+
null,
|
|
231
|
+
2,
|
|
232
|
+
),
|
|
233
|
+
},
|
|
234
|
+
],
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
async function handleRefresh(hc: Hardcopy, args: RefreshArgs) {
|
|
239
|
+
const { pattern, clean = false, syncFirst = false } = args;
|
|
240
|
+
|
|
241
|
+
if (syncFirst) {
|
|
242
|
+
await hc.sync();
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const views = await hc.getViews();
|
|
246
|
+
const matching = views.filter(
|
|
247
|
+
(v) =>
|
|
248
|
+
v === pattern || v.startsWith(pattern) || pattern.includes("*"),
|
|
249
|
+
);
|
|
250
|
+
|
|
251
|
+
if (matching.length === 0) {
|
|
252
|
+
return {
|
|
253
|
+
content: [
|
|
254
|
+
{
|
|
255
|
+
type: "text" as const,
|
|
256
|
+
text: JSON.stringify(
|
|
257
|
+
{ error: `No views match pattern: ${pattern}`, available: views },
|
|
258
|
+
null,
|
|
259
|
+
2,
|
|
260
|
+
),
|
|
261
|
+
},
|
|
262
|
+
],
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const results: Array<{
|
|
267
|
+
view: string;
|
|
268
|
+
rendered: number;
|
|
269
|
+
orphaned: number;
|
|
270
|
+
cleaned: boolean;
|
|
271
|
+
}> = [];
|
|
272
|
+
for (const view of matching) {
|
|
273
|
+
const result = await hc.refreshView(view, { clean });
|
|
274
|
+
results.push({
|
|
275
|
+
view,
|
|
276
|
+
rendered: result.rendered,
|
|
277
|
+
orphaned: result.orphaned.length,
|
|
278
|
+
cleaned: result.cleaned,
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
return {
|
|
283
|
+
content: [{ type: "text" as const, text: JSON.stringify(results, null, 2) }],
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
async function handleDiff(hc: Hardcopy, args: DiffArgs) {
|
|
288
|
+
const diffs = await hc.diff(args.pattern);
|
|
289
|
+
return {
|
|
290
|
+
content: [
|
|
291
|
+
{
|
|
292
|
+
type: "text" as const,
|
|
293
|
+
text: JSON.stringify(
|
|
294
|
+
diffs.map((d) => ({
|
|
295
|
+
nodeId: d.nodeId,
|
|
296
|
+
nodeType: d.nodeType,
|
|
297
|
+
filePath: d.filePath,
|
|
298
|
+
changes: d.changes.map((c) => ({
|
|
299
|
+
field: c.field,
|
|
300
|
+
old: truncate(c.oldValue),
|
|
301
|
+
new: truncate(c.newValue),
|
|
302
|
+
})),
|
|
303
|
+
})),
|
|
304
|
+
null,
|
|
305
|
+
2,
|
|
306
|
+
),
|
|
307
|
+
},
|
|
308
|
+
],
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
async function handlePush(hc: Hardcopy, args: PushArgs) {
|
|
313
|
+
const stats = await hc.push(args.pattern, { force: args.force });
|
|
314
|
+
return {
|
|
315
|
+
content: [
|
|
316
|
+
{
|
|
317
|
+
type: "text" as const,
|
|
318
|
+
text: JSON.stringify(
|
|
319
|
+
{
|
|
320
|
+
pushed: stats.pushed,
|
|
321
|
+
skipped: stats.skipped,
|
|
322
|
+
conflicts: stats.conflicts,
|
|
323
|
+
errors: stats.errors,
|
|
324
|
+
},
|
|
325
|
+
null,
|
|
326
|
+
2,
|
|
327
|
+
),
|
|
328
|
+
},
|
|
329
|
+
],
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
async function handleConflicts(hc: Hardcopy) {
|
|
334
|
+
const conflicts = await hc.listConflicts();
|
|
335
|
+
return {
|
|
336
|
+
content: [
|
|
337
|
+
{
|
|
338
|
+
type: "text" as const,
|
|
339
|
+
text: JSON.stringify(
|
|
340
|
+
conflicts.map((c) => ({
|
|
341
|
+
nodeId: c.nodeId,
|
|
342
|
+
nodeType: c.nodeType,
|
|
343
|
+
filePath: c.filePath,
|
|
344
|
+
fields: c.fields.map((f) => ({
|
|
345
|
+
field: f.field,
|
|
346
|
+
status: f.status,
|
|
347
|
+
canAutoMerge: f.canAutoMerge,
|
|
348
|
+
})),
|
|
349
|
+
})),
|
|
350
|
+
null,
|
|
351
|
+
2,
|
|
352
|
+
),
|
|
353
|
+
},
|
|
354
|
+
],
|
|
355
|
+
};
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
async function handleResolve(hc: Hardcopy, args: ResolveArgs) {
|
|
359
|
+
await hc.resolveConflict(args.nodeId, args.resolution);
|
|
360
|
+
return {
|
|
361
|
+
content: [
|
|
362
|
+
{
|
|
363
|
+
type: "text" as const,
|
|
364
|
+
text: JSON.stringify({ resolved: args.nodeId }, null, 2),
|
|
365
|
+
},
|
|
366
|
+
],
|
|
367
|
+
};
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function truncate(value: unknown, maxLen = 100): string {
|
|
371
|
+
const str = typeof value === "string" ? value : JSON.stringify(value);
|
|
372
|
+
if (str.length <= maxLen) return str;
|
|
373
|
+
return str.slice(0, maxLen) + "...";
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
export async function serveMcp(root: string): Promise<void> {
|
|
377
|
+
const server = createMcpServer(root);
|
|
378
|
+
const transport = new StdioServerTransport();
|
|
379
|
+
console.error("Hardcopy MCP Server running on stdio");
|
|
380
|
+
await server.connect(transport);
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
384
|
+
serveMcp(process.cwd()).catch((error) => {
|
|
385
|
+
console.error("Fatal error in MCP server:", error);
|
|
386
|
+
process.exit(1);
|
|
387
|
+
});
|
|
388
|
+
}
|