@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 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`.
@@ -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
  }
@@ -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"],
@@ -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.1.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: `![${escapeMarkdownAltText(resolvedAltText)}](${markdownUrl})`,
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.1.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": {