@kitae9999/openlog-cli 0.1.0 → 0.2.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.md +13 -1
- package/dist/api-client.js +7 -0
- package/dist/config.js +4 -0
- package/dist/index.js +4 -0
- package/dist/mcp-install.js +6 -2
- package/dist/mcp-server.js +139 -2
- package/dist/post-image-upload.js +137 -0
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -8,6 +8,17 @@ npx -y @kitae9999/openlog-cli whoami
|
|
|
8
8
|
npx -y @kitae9999/openlog-cli mcp
|
|
9
9
|
```
|
|
10
10
|
|
|
11
|
+
MCP tools:
|
|
12
|
+
|
|
13
|
+
- `get_auth_status`
|
|
14
|
+
- `get_me`
|
|
15
|
+
- `list_my_notifications`
|
|
16
|
+
- `list_my_posts`
|
|
17
|
+
- `list_my_liked_posts`
|
|
18
|
+
- `upload_post_image`
|
|
19
|
+
- `publish_post`
|
|
20
|
+
- `get_post_detail`
|
|
21
|
+
|
|
11
22
|
MCP client configuration:
|
|
12
23
|
|
|
13
24
|
```json
|
|
@@ -21,4 +32,5 @@ MCP client configuration:
|
|
|
21
32
|
}
|
|
22
33
|
```
|
|
23
34
|
|
|
24
|
-
Set `OPENLOG_API_BASE_URL` to point at a non-production API.
|
|
35
|
+
Set `OPENLOG_API_BASE_URL` to point at a non-production API. Set
|
|
36
|
+
`OPENLOG_WEB_BASE_URL` to control absolute post URLs returned by `publish_post`.
|
package/dist/api-client.js
CHANGED
|
@@ -33,6 +33,13 @@ export class OpenLogApiClient {
|
|
|
33
33
|
body: body ? JSON.stringify(body) : undefined,
|
|
34
34
|
});
|
|
35
35
|
}
|
|
36
|
+
async patchNoContent(path, body) {
|
|
37
|
+
await this.request(path, {
|
|
38
|
+
method: "PATCH",
|
|
39
|
+
headers: body ? { "content-type": "application/json" } : undefined,
|
|
40
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
41
|
+
});
|
|
42
|
+
}
|
|
36
43
|
async request(path, init) {
|
|
37
44
|
const url = `${this.apiBaseUrl}${path}`;
|
|
38
45
|
const response = await fetch(url, {
|
package/dist/config.js
CHANGED
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
import os from "node:os";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
export const DEFAULT_API_BASE_URL = "https://api.openlog.kr/api";
|
|
4
|
+
export const DEFAULT_WEB_BASE_URL = "https://openlog.kr";
|
|
4
5
|
export function getApiBaseUrl() {
|
|
5
6
|
return (process.env.OPENLOG_API_BASE_URL ?? DEFAULT_API_BASE_URL).replace(/\/$/, "");
|
|
6
7
|
}
|
|
8
|
+
export function getWebBaseUrl() {
|
|
9
|
+
return (process.env.OPENLOG_WEB_BASE_URL ?? DEFAULT_WEB_BASE_URL).replace(/\/$/, "");
|
|
10
|
+
}
|
|
7
11
|
export function getAuthFilePath() {
|
|
8
12
|
return (process.env.OPENLOG_AUTH_FILE ??
|
|
9
13
|
path.join(os.homedir(), ".openlog", "auth.json"));
|
package/dist/index.js
CHANGED
|
@@ -90,7 +90,10 @@ Available tools:
|
|
|
90
90
|
get_auth_status
|
|
91
91
|
get_me
|
|
92
92
|
list_my_notifications
|
|
93
|
+
list_my_posts
|
|
93
94
|
list_my_liked_posts
|
|
95
|
+
upload_post_image
|
|
96
|
+
publish_post
|
|
94
97
|
get_post_detail
|
|
95
98
|
`);
|
|
96
99
|
}
|
|
@@ -109,6 +112,7 @@ Usage:
|
|
|
109
112
|
|
|
110
113
|
Environment:
|
|
111
114
|
OPENLOG_API_BASE_URL Override the OpenLog API base URL
|
|
115
|
+
OPENLOG_WEB_BASE_URL Override the OpenLog web base URL for published post links
|
|
112
116
|
OPENLOG_AUTH_FILE Override the local auth file path
|
|
113
117
|
`);
|
|
114
118
|
}
|
package/dist/mcp-install.js
CHANGED
|
@@ -2,7 +2,7 @@ import { spawn } from "node:child_process";
|
|
|
2
2
|
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
3
3
|
import os from "node:os";
|
|
4
4
|
import path from "node:path";
|
|
5
|
-
import { DEFAULT_API_BASE_URL, getApiBaseUrl } from "./config.js";
|
|
5
|
+
import { DEFAULT_API_BASE_URL, DEFAULT_WEB_BASE_URL, getApiBaseUrl, getWebBaseUrl, } from "./config.js";
|
|
6
6
|
export async function installMcp(options) {
|
|
7
7
|
const serverConfig = buildServerConfig();
|
|
8
8
|
if (options.printOnly) {
|
|
@@ -28,15 +28,19 @@ Aliases:
|
|
|
28
28
|
claude Same as claude-code
|
|
29
29
|
|
|
30
30
|
Notes:
|
|
31
|
-
If OPENLOG_API_BASE_URL is set, it is added to the client config.
|
|
31
|
+
If OPENLOG_API_BASE_URL or OPENLOG_WEB_BASE_URL is set, it is added to the client config.
|
|
32
32
|
`);
|
|
33
33
|
}
|
|
34
34
|
function buildServerConfig() {
|
|
35
35
|
const apiBaseUrl = getApiBaseUrl();
|
|
36
|
+
const webBaseUrl = getWebBaseUrl();
|
|
36
37
|
const env = {};
|
|
37
38
|
if (apiBaseUrl !== DEFAULT_API_BASE_URL) {
|
|
38
39
|
env.OPENLOG_API_BASE_URL = apiBaseUrl;
|
|
39
40
|
}
|
|
41
|
+
if (webBaseUrl !== DEFAULT_WEB_BASE_URL) {
|
|
42
|
+
env.OPENLOG_WEB_BASE_URL = webBaseUrl;
|
|
43
|
+
}
|
|
40
44
|
return {
|
|
41
45
|
command: "openlog",
|
|
42
46
|
args: ["mcp"],
|
package/dist/mcp-server.js
CHANGED
|
@@ -3,11 +3,18 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
|
|
|
3
3
|
import { z } from "zod";
|
|
4
4
|
import { OpenLogApiClient } from "./api-client.js";
|
|
5
5
|
import { readAuthFile } from "./auth-store.js";
|
|
6
|
-
import { getApiBaseUrl } from "./config.js";
|
|
6
|
+
import { getApiBaseUrl, getWebBaseUrl } from "./config.js";
|
|
7
|
+
import { uploadPostImage } from "./post-image-upload.js";
|
|
8
|
+
const WRITE_TOOL_ANNOTATIONS = {
|
|
9
|
+
readOnlyHint: false,
|
|
10
|
+
destructiveHint: false,
|
|
11
|
+
idempotentHint: false,
|
|
12
|
+
openWorldHint: true,
|
|
13
|
+
};
|
|
7
14
|
export async function runMcpServer() {
|
|
8
15
|
const server = new McpServer({
|
|
9
16
|
name: "openlog",
|
|
10
|
-
version: "0.
|
|
17
|
+
version: "0.2.0",
|
|
11
18
|
});
|
|
12
19
|
server.registerTool("get_auth_status", {
|
|
13
20
|
title: "Get OpenLog Auth Status",
|
|
@@ -65,6 +72,87 @@ export async function runMcpServer() {
|
|
|
65
72
|
}
|
|
66
73
|
return withAuthenticatedClient((client) => client.get(`/users/me/liked-posts?${params}`));
|
|
67
74
|
});
|
|
75
|
+
server.registerTool("list_my_posts", {
|
|
76
|
+
title: "List My OpenLog Posts",
|
|
77
|
+
description: "Return posts authored by the authenticated OpenLog user.",
|
|
78
|
+
inputSchema: {
|
|
79
|
+
size: z.number().int().min(1).max(100).default(20),
|
|
80
|
+
},
|
|
81
|
+
}, async ({ size }) => withAuthenticatedClient(async (client) => {
|
|
82
|
+
const me = await client.get("/auth/me");
|
|
83
|
+
const username = me.username?.trim();
|
|
84
|
+
if (!username) {
|
|
85
|
+
throw new Error("Complete OpenLog onboarding before listing your posts.");
|
|
86
|
+
}
|
|
87
|
+
const posts = await client.get(`/users/${encodeURIComponent(username)}/posts`);
|
|
88
|
+
return {
|
|
89
|
+
username,
|
|
90
|
+
total: posts.length,
|
|
91
|
+
size,
|
|
92
|
+
hasMore: posts.length > size,
|
|
93
|
+
posts: posts.slice(0, size),
|
|
94
|
+
};
|
|
95
|
+
}));
|
|
96
|
+
server.registerTool("upload_post_image", {
|
|
97
|
+
title: "Upload OpenLog Post Image",
|
|
98
|
+
description: "Convert a local image file to WebP, upload it to OpenLog, and return markdown for post content.",
|
|
99
|
+
annotations: WRITE_TOOL_ANNOTATIONS,
|
|
100
|
+
inputSchema: {
|
|
101
|
+
filePath: z.string().min(1),
|
|
102
|
+
altText: z.string().optional(),
|
|
103
|
+
},
|
|
104
|
+
}, async ({ filePath, altText }) => withAuthenticatedClient((client) => uploadPostImage(client, {
|
|
105
|
+
filePath,
|
|
106
|
+
altText,
|
|
107
|
+
})));
|
|
108
|
+
server.registerTool("publish_post", {
|
|
109
|
+
title: "Publish OpenLog Post",
|
|
110
|
+
description: "Publish a new post to the authenticated OpenLog account. Requires confirm: true unless skipConfirmation: true is explicitly provided.",
|
|
111
|
+
annotations: WRITE_TOOL_ANNOTATIONS,
|
|
112
|
+
inputSchema: {
|
|
113
|
+
title: z.string().min(1),
|
|
114
|
+
description: z.string().min(1),
|
|
115
|
+
content: z.string().min(1),
|
|
116
|
+
topics: z.array(z.string()).default([]),
|
|
117
|
+
links: z
|
|
118
|
+
.array(z.object({
|
|
119
|
+
label: z.string().min(1),
|
|
120
|
+
targetSlug: z.string().min(1),
|
|
121
|
+
}))
|
|
122
|
+
.default([]),
|
|
123
|
+
confirm: z.boolean().optional(),
|
|
124
|
+
skipConfirmation: z.boolean().optional(),
|
|
125
|
+
},
|
|
126
|
+
}, async ({ title, description, content, topics, links, confirm, skipConfirmation, }) => withAuthenticatedClient(async (client) => {
|
|
127
|
+
const post = normalizePostInput({
|
|
128
|
+
title,
|
|
129
|
+
description,
|
|
130
|
+
content,
|
|
131
|
+
topics,
|
|
132
|
+
links,
|
|
133
|
+
});
|
|
134
|
+
if (confirm !== true && skipConfirmation !== true) {
|
|
135
|
+
return {
|
|
136
|
+
requiresConfirmation: true,
|
|
137
|
+
preview: {
|
|
138
|
+
title: post.title,
|
|
139
|
+
description: post.description,
|
|
140
|
+
contentPreview: createContentPreview(post.content),
|
|
141
|
+
contentLength: post.content.length,
|
|
142
|
+
topics: post.topics,
|
|
143
|
+
links: post.links,
|
|
144
|
+
},
|
|
145
|
+
nextStep: "Call publish_post again with confirm: true, or skipConfirmation: true if the user explicitly requested publishing without confirmation.",
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
const published = await client.post("/posts", post);
|
|
149
|
+
const postPath = buildPublicPostPath(published.authorUsername, published.slug);
|
|
150
|
+
return {
|
|
151
|
+
...published,
|
|
152
|
+
path: postPath,
|
|
153
|
+
url: new URL(postPath, `${getWebBaseUrl()}/`).toString(),
|
|
154
|
+
};
|
|
155
|
+
}));
|
|
68
156
|
server.registerTool("get_post_detail", {
|
|
69
157
|
title: "Get OpenLog Post Detail",
|
|
70
158
|
description: "Return a public OpenLog post detail by author username and post slug.",
|
|
@@ -75,6 +163,55 @@ export async function runMcpServer() {
|
|
|
75
163
|
}, async ({ username, slug }) => withAuthenticatedClient((client) => client.get(`/users/${encodeURIComponent(username)}/posts/${encodeURIComponent(slug)}`)));
|
|
76
164
|
await server.connect(new StdioServerTransport());
|
|
77
165
|
}
|
|
166
|
+
function normalizePostInput(input) {
|
|
167
|
+
const title = input.title.trim();
|
|
168
|
+
const description = input.description.trim();
|
|
169
|
+
const content = input.content.trim();
|
|
170
|
+
if (!title) {
|
|
171
|
+
throw new Error("title is required.");
|
|
172
|
+
}
|
|
173
|
+
if (!description) {
|
|
174
|
+
throw new Error("description is required.");
|
|
175
|
+
}
|
|
176
|
+
if (!content) {
|
|
177
|
+
throw new Error("content is required.");
|
|
178
|
+
}
|
|
179
|
+
return {
|
|
180
|
+
title,
|
|
181
|
+
description,
|
|
182
|
+
content,
|
|
183
|
+
topics: normalizeTopics(input.topics),
|
|
184
|
+
links: normalizeLinks(input.links),
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
function normalizeTopics(topics) {
|
|
188
|
+
const normalized = topics
|
|
189
|
+
.map((topic) => topic.trim().toLowerCase())
|
|
190
|
+
.filter(Boolean);
|
|
191
|
+
return [...new Set(normalized)];
|
|
192
|
+
}
|
|
193
|
+
function normalizeLinks(links) {
|
|
194
|
+
const normalized = [];
|
|
195
|
+
const seen = new Set();
|
|
196
|
+
for (const link of links) {
|
|
197
|
+
const label = link.label.trim();
|
|
198
|
+
const targetSlug = link.targetSlug.trim();
|
|
199
|
+
const key = `${label}\u0000${targetSlug}`;
|
|
200
|
+
if (!label || !targetSlug || seen.has(key)) {
|
|
201
|
+
continue;
|
|
202
|
+
}
|
|
203
|
+
seen.add(key);
|
|
204
|
+
normalized.push({ label, targetSlug });
|
|
205
|
+
}
|
|
206
|
+
return normalized;
|
|
207
|
+
}
|
|
208
|
+
function createContentPreview(content) {
|
|
209
|
+
const preview = content.replace(/\s+/g, " ").trim();
|
|
210
|
+
return preview.length > 500 ? `${preview.slice(0, 497)}...` : preview;
|
|
211
|
+
}
|
|
212
|
+
function buildPublicPostPath(username, slug) {
|
|
213
|
+
return `/@${encodeURIComponent(username)}/posts/${encodeURIComponent(slug)}`;
|
|
214
|
+
}
|
|
78
215
|
async function createAuthenticatedClient() {
|
|
79
216
|
const authFile = await readAuthFile();
|
|
80
217
|
if (!authFile) {
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { stat } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import sharp from "sharp";
|
|
4
|
+
const MAX_POST_IMAGE_WIDTH = 1600;
|
|
5
|
+
const MAX_WEBP_BYTES = 10 * 1024 * 1024;
|
|
6
|
+
const WEBP_QUALITY = 84;
|
|
7
|
+
export async function uploadPostImage(client, { filePath, altText, }) {
|
|
8
|
+
const resolvedPath = resolveLocalPath(filePath);
|
|
9
|
+
const originalFileName = path.basename(resolvedPath);
|
|
10
|
+
const resolvedAltText = normalizeAltText(altText, originalFileName);
|
|
11
|
+
await assertReadableFile(resolvedPath);
|
|
12
|
+
const webp = await createWebpPostImage(resolvedPath);
|
|
13
|
+
if (webp.byteLength > MAX_WEBP_BYTES) {
|
|
14
|
+
throw new Error("Converted image must be 10 MB or smaller.");
|
|
15
|
+
}
|
|
16
|
+
const uploadTarget = await client.post("/media/upload-url", {
|
|
17
|
+
fileName: `${sanitizeFileStem(resolvedAltText)}.webp`,
|
|
18
|
+
originalFileName,
|
|
19
|
+
contentType: "image/webp",
|
|
20
|
+
sizeBytes: webp.byteLength,
|
|
21
|
+
purpose: "POST_BODY_IMAGE",
|
|
22
|
+
});
|
|
23
|
+
await putImage(uploadTarget, webp);
|
|
24
|
+
const assetId = String(uploadTarget.assetId);
|
|
25
|
+
await client.patchNoContent(`/media/assets/${encodeURIComponent(assetId)}/completion`);
|
|
26
|
+
const markdownUrl = getMarkdownUrl(uploadTarget);
|
|
27
|
+
if (!markdownUrl) {
|
|
28
|
+
throw new Error("Upload completed without a markdown image URL.");
|
|
29
|
+
}
|
|
30
|
+
return {
|
|
31
|
+
assetId,
|
|
32
|
+
markdownUrl,
|
|
33
|
+
markdown: ``,
|
|
34
|
+
altText: resolvedAltText,
|
|
35
|
+
originalFileName,
|
|
36
|
+
webpSizeBytes: webp.byteLength,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
function resolveLocalPath(filePath) {
|
|
40
|
+
const trimmedPath = filePath.trim();
|
|
41
|
+
if (!trimmedPath) {
|
|
42
|
+
throw new Error("filePath is required.");
|
|
43
|
+
}
|
|
44
|
+
return path.isAbsolute(trimmedPath)
|
|
45
|
+
? trimmedPath
|
|
46
|
+
: path.resolve(process.cwd(), trimmedPath);
|
|
47
|
+
}
|
|
48
|
+
async function assertReadableFile(filePath) {
|
|
49
|
+
let fileStat;
|
|
50
|
+
try {
|
|
51
|
+
fileStat = await stat(filePath);
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
throw new Error(`Image file does not exist: ${filePath}`);
|
|
55
|
+
}
|
|
56
|
+
if (!fileStat.isFile()) {
|
|
57
|
+
throw new Error(`Image path is not a file: ${filePath}`);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
async function createWebpPostImage(filePath) {
|
|
61
|
+
try {
|
|
62
|
+
return await sharp(filePath)
|
|
63
|
+
.rotate()
|
|
64
|
+
.resize({
|
|
65
|
+
width: MAX_POST_IMAGE_WIDTH,
|
|
66
|
+
withoutEnlargement: true,
|
|
67
|
+
})
|
|
68
|
+
.webp({ quality: WEBP_QUALITY })
|
|
69
|
+
.toBuffer();
|
|
70
|
+
}
|
|
71
|
+
catch (error) {
|
|
72
|
+
const detail = error instanceof Error ? ` ${error.message}` : "";
|
|
73
|
+
throw new Error(`Could not convert image to WebP.${detail}`);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
async function putImage(uploadTarget, webp) {
|
|
77
|
+
const response = await fetch(uploadTarget.uploadUrl, {
|
|
78
|
+
method: "PUT",
|
|
79
|
+
headers: {
|
|
80
|
+
"Content-Type": "image/webp",
|
|
81
|
+
...(uploadTarget.headers ?? {}),
|
|
82
|
+
},
|
|
83
|
+
body: new Uint8Array(webp),
|
|
84
|
+
});
|
|
85
|
+
if (!response.ok) {
|
|
86
|
+
throw new Error(`Image upload failed with ${response.status}: ${await readResponseText(response)}`);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
async function readResponseText(response) {
|
|
90
|
+
const text = await response.text().catch(() => "");
|
|
91
|
+
return text.trim() || response.statusText || "No response body";
|
|
92
|
+
}
|
|
93
|
+
function getMarkdownUrl(uploadTarget) {
|
|
94
|
+
if (uploadTarget.markdownUrl) {
|
|
95
|
+
return uploadTarget.markdownUrl;
|
|
96
|
+
}
|
|
97
|
+
if (uploadTarget.assetUrl) {
|
|
98
|
+
return uploadTarget.assetUrl;
|
|
99
|
+
}
|
|
100
|
+
if (uploadTarget.imageUrl) {
|
|
101
|
+
return uploadTarget.imageUrl;
|
|
102
|
+
}
|
|
103
|
+
if (uploadTarget.publicUrl) {
|
|
104
|
+
return uploadTarget.publicUrl;
|
|
105
|
+
}
|
|
106
|
+
if (uploadTarget.assetId !== undefined) {
|
|
107
|
+
return `/media/assets/${encodeURIComponent(String(uploadTarget.assetId))}`;
|
|
108
|
+
}
|
|
109
|
+
return undefined;
|
|
110
|
+
}
|
|
111
|
+
function normalizeAltText(providedAltText, originalFileName) {
|
|
112
|
+
const normalized = (providedAltText ?? "")
|
|
113
|
+
.trim()
|
|
114
|
+
.replace(/[\r\n]+/g, " ");
|
|
115
|
+
if (normalized) {
|
|
116
|
+
return normalized;
|
|
117
|
+
}
|
|
118
|
+
return stripExtension(originalFileName).replace(/[_-]+/g, " ").trim() || "image";
|
|
119
|
+
}
|
|
120
|
+
function sanitizeFileStem(value) {
|
|
121
|
+
const normalized = value
|
|
122
|
+
.trim()
|
|
123
|
+
.toLowerCase()
|
|
124
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
125
|
+
.replace(/^-+|-+$/g, "");
|
|
126
|
+
return normalized || "image";
|
|
127
|
+
}
|
|
128
|
+
function stripExtension(fileName) {
|
|
129
|
+
return fileName.replace(/\.[^.]+$/, "");
|
|
130
|
+
}
|
|
131
|
+
function escapeMarkdownAltText(value) {
|
|
132
|
+
return value
|
|
133
|
+
.replace(/\\/g, "\\\\")
|
|
134
|
+
.replace(/\[/g, "\\[")
|
|
135
|
+
.replace(/\]/g, "\\]")
|
|
136
|
+
.replace(/[\r\n]+/g, " ");
|
|
137
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kitae9999/openlog-cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "Official OpenLog CLI and MCP server.",
|
|
6
6
|
"type": "module",
|
|
@@ -27,6 +27,7 @@
|
|
|
27
27
|
},
|
|
28
28
|
"dependencies": {
|
|
29
29
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
30
|
+
"sharp": "^0.34.5",
|
|
30
31
|
"zod": "^4.4.3"
|
|
31
32
|
},
|
|
32
33
|
"devDependencies": {
|