@kitae9999/openlog-cli 0.1.0 → 1.0.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 +14 -1
- package/dist/api-client.js +55 -1
- package/dist/auth-store.js +26 -3
- package/dist/authenticated-client.js +34 -0
- package/dist/config.js +4 -0
- package/dist/index.js +26 -10
- package/dist/login.js +4 -4
- package/dist/mcp-install.js +6 -2
- package/dist/mcp-server.js +160 -13
- package/dist/post-image-upload.js +137 -0
- package/package.json +4 -2
package/README.md
CHANGED
|
@@ -8,6 +8,18 @@ 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
|
+
- `push_working_brief`
|
|
21
|
+
- `get_post_detail`
|
|
22
|
+
|
|
11
23
|
MCP client configuration:
|
|
12
24
|
|
|
13
25
|
```json
|
|
@@ -21,4 +33,5 @@ MCP client configuration:
|
|
|
21
33
|
}
|
|
22
34
|
```
|
|
23
35
|
|
|
24
|
-
Set `OPENLOG_API_BASE_URL` to point at a non-production API.
|
|
36
|
+
Set `OPENLOG_API_BASE_URL` to point at a non-production API. Set
|
|
37
|
+
`OPENLOG_WEB_BASE_URL` to control absolute post URLs returned by `publish_post`.
|
package/dist/api-client.js
CHANGED
|
@@ -12,9 +12,14 @@ export class ApiError extends Error {
|
|
|
12
12
|
export class OpenLogApiClient {
|
|
13
13
|
apiBaseUrl;
|
|
14
14
|
accessToken;
|
|
15
|
+
refreshToken;
|
|
16
|
+
onTokenRefresh;
|
|
17
|
+
refreshPromise;
|
|
15
18
|
constructor(options = {}) {
|
|
16
19
|
this.apiBaseUrl = (options.apiBaseUrl ?? getApiBaseUrl()).replace(/\/$/, "");
|
|
17
20
|
this.accessToken = options.accessToken;
|
|
21
|
+
this.refreshToken = options.refreshToken;
|
|
22
|
+
this.onTokenRefresh = options.onTokenRefresh;
|
|
18
23
|
}
|
|
19
24
|
async get(path) {
|
|
20
25
|
return this.request(path, { method: "GET" });
|
|
@@ -26,6 +31,13 @@ export class OpenLogApiClient {
|
|
|
26
31
|
body: body ? JSON.stringify(body) : undefined,
|
|
27
32
|
});
|
|
28
33
|
}
|
|
34
|
+
async put(path, body) {
|
|
35
|
+
return this.request(path, {
|
|
36
|
+
method: "PUT",
|
|
37
|
+
headers: body ? { "content-type": "application/json" } : undefined,
|
|
38
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
39
|
+
});
|
|
40
|
+
}
|
|
29
41
|
async postNoContent(path, body) {
|
|
30
42
|
await this.request(path, {
|
|
31
43
|
method: "POST",
|
|
@@ -33,7 +45,14 @@ export class OpenLogApiClient {
|
|
|
33
45
|
body: body ? JSON.stringify(body) : undefined,
|
|
34
46
|
});
|
|
35
47
|
}
|
|
36
|
-
async
|
|
48
|
+
async patchNoContent(path, body) {
|
|
49
|
+
await this.request(path, {
|
|
50
|
+
method: "PATCH",
|
|
51
|
+
headers: body ? { "content-type": "application/json" } : undefined,
|
|
52
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
async request(path, init, allowRefresh = true) {
|
|
37
56
|
const url = `${this.apiBaseUrl}${path}`;
|
|
38
57
|
const response = await fetch(url, {
|
|
39
58
|
...init,
|
|
@@ -44,6 +63,10 @@ export class OpenLogApiClient {
|
|
|
44
63
|
: {}),
|
|
45
64
|
},
|
|
46
65
|
});
|
|
66
|
+
if (response.status === 401 && allowRefresh && this.refreshToken) {
|
|
67
|
+
await this.refreshAccessToken();
|
|
68
|
+
return this.request(path, init, false);
|
|
69
|
+
}
|
|
47
70
|
if (!response.ok) {
|
|
48
71
|
throw new ApiError(response.status, url, await readErrorMessage(response));
|
|
49
72
|
}
|
|
@@ -52,6 +75,37 @@ export class OpenLogApiClient {
|
|
|
52
75
|
}
|
|
53
76
|
return (await response.json());
|
|
54
77
|
}
|
|
78
|
+
async refreshAccessToken() {
|
|
79
|
+
if (!this.refreshPromise) {
|
|
80
|
+
this.refreshPromise = this.performTokenRefresh().finally(() => {
|
|
81
|
+
this.refreshPromise = undefined;
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
await this.refreshPromise;
|
|
85
|
+
}
|
|
86
|
+
async performTokenRefresh() {
|
|
87
|
+
const response = await fetch(`${this.apiBaseUrl}/auth/device/refresh`, {
|
|
88
|
+
method: "POST",
|
|
89
|
+
headers: { "content-type": "application/json" },
|
|
90
|
+
body: JSON.stringify({ refreshToken: this.refreshToken }),
|
|
91
|
+
});
|
|
92
|
+
if (!response.ok) {
|
|
93
|
+
throw new ApiError(response.status, `${this.apiBaseUrl}/auth/device/refresh`, await readErrorMessage(response));
|
|
94
|
+
}
|
|
95
|
+
const tokens = (await response.json());
|
|
96
|
+
if (!isRefreshedTokens(tokens)) {
|
|
97
|
+
throw new Error("OpenLog refresh response is incomplete.");
|
|
98
|
+
}
|
|
99
|
+
this.accessToken = tokens.accessToken;
|
|
100
|
+
this.refreshToken = tokens.refreshToken;
|
|
101
|
+
await this.onTokenRefresh?.(tokens);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
function isRefreshedTokens(value) {
|
|
105
|
+
return Boolean(value.accessToken &&
|
|
106
|
+
value.refreshToken &&
|
|
107
|
+
Number.isFinite(value.expiresIn) &&
|
|
108
|
+
Number.isFinite(value.refreshExpiresIn));
|
|
55
109
|
}
|
|
56
110
|
async function readErrorMessage(response) {
|
|
57
111
|
try {
|
package/dist/auth-store.js
CHANGED
|
@@ -10,8 +10,11 @@ export async function readAuthFile() {
|
|
|
10
10
|
}
|
|
11
11
|
return {
|
|
12
12
|
accessToken: parsed.accessToken,
|
|
13
|
+
refreshToken: parsed.refreshToken,
|
|
13
14
|
apiBaseUrl: parsed.apiBaseUrl,
|
|
14
15
|
createdAt: parsed.createdAt,
|
|
16
|
+
accessTokenExpiresAt: parsed.accessTokenExpiresAt,
|
|
17
|
+
refreshTokenExpiresAt: parsed.refreshTokenExpiresAt,
|
|
15
18
|
};
|
|
16
19
|
}
|
|
17
20
|
catch (error) {
|
|
@@ -24,20 +27,40 @@ export async function readAuthFile() {
|
|
|
24
27
|
throw error;
|
|
25
28
|
}
|
|
26
29
|
}
|
|
27
|
-
export async function writeAuthFile(
|
|
30
|
+
export async function writeAuthFile(tokens) {
|
|
28
31
|
const authFile = {
|
|
29
|
-
accessToken,
|
|
32
|
+
accessToken: tokens.accessToken,
|
|
33
|
+
refreshToken: tokens.refreshToken,
|
|
30
34
|
apiBaseUrl: getApiBaseUrl(),
|
|
31
35
|
createdAt: new Date().toISOString(),
|
|
36
|
+
accessTokenExpiresAt: expiresAt(tokens.expiresIn),
|
|
37
|
+
refreshTokenExpiresAt: expiresAt(tokens.refreshExpiresIn),
|
|
32
38
|
};
|
|
39
|
+
await persistAuthFile(authFile);
|
|
40
|
+
return authFile;
|
|
41
|
+
}
|
|
42
|
+
export async function updateAuthFileTokens(authFile, tokens) {
|
|
43
|
+
const updatedAuthFile = {
|
|
44
|
+
...authFile,
|
|
45
|
+
accessToken: tokens.accessToken,
|
|
46
|
+
refreshToken: tokens.refreshToken,
|
|
47
|
+
accessTokenExpiresAt: expiresAt(tokens.expiresIn),
|
|
48
|
+
refreshTokenExpiresAt: expiresAt(tokens.refreshExpiresIn),
|
|
49
|
+
};
|
|
50
|
+
await persistAuthFile(updatedAuthFile);
|
|
51
|
+
return updatedAuthFile;
|
|
52
|
+
}
|
|
53
|
+
async function persistAuthFile(authFile) {
|
|
33
54
|
const authFilePath = getAuthFilePath();
|
|
34
55
|
await mkdir(path.dirname(authFilePath), { recursive: true, mode: 0o700 });
|
|
35
56
|
await writeFile(authFilePath, `${JSON.stringify(authFile, null, 2)}\n`, {
|
|
36
57
|
mode: 0o600,
|
|
37
58
|
});
|
|
38
59
|
await chmod(authFilePath, 0o600);
|
|
39
|
-
return authFile;
|
|
40
60
|
}
|
|
41
61
|
export async function deleteAuthFile() {
|
|
42
62
|
await rm(getAuthFilePath(), { force: true });
|
|
43
63
|
}
|
|
64
|
+
function expiresAt(expiresIn) {
|
|
65
|
+
return new Date(Date.now() + expiresIn * 1000).toISOString();
|
|
66
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { OpenLogApiClient } from "./api-client.js";
|
|
2
|
+
import { readAuthFile, updateAuthFileTokens, } from "./auth-store.js";
|
|
3
|
+
let cachedClient;
|
|
4
|
+
export async function createAuthenticatedApiClient() {
|
|
5
|
+
const authFile = await readAuthFile();
|
|
6
|
+
if (!authFile) {
|
|
7
|
+
throw new Error("Run `openlog login` before using OpenLog.");
|
|
8
|
+
}
|
|
9
|
+
const fingerprint = authFingerprint(authFile);
|
|
10
|
+
if (cachedClient?.fingerprint === fingerprint) {
|
|
11
|
+
return cachedClient.client;
|
|
12
|
+
}
|
|
13
|
+
const client = new OpenLogApiClient({
|
|
14
|
+
accessToken: authFile.accessToken,
|
|
15
|
+
refreshToken: authFile.refreshToken,
|
|
16
|
+
apiBaseUrl: authFile.apiBaseUrl,
|
|
17
|
+
onTokenRefresh: async (tokens) => {
|
|
18
|
+
const updatedAuthFile = await updateAuthFileTokens(authFile, tokens);
|
|
19
|
+
cachedClient = {
|
|
20
|
+
fingerprint: authFingerprint(updatedAuthFile),
|
|
21
|
+
client,
|
|
22
|
+
};
|
|
23
|
+
},
|
|
24
|
+
});
|
|
25
|
+
cachedClient = { fingerprint, client };
|
|
26
|
+
return client;
|
|
27
|
+
}
|
|
28
|
+
function authFingerprint(authFile) {
|
|
29
|
+
return [
|
|
30
|
+
authFile.apiBaseUrl,
|
|
31
|
+
authFile.accessToken,
|
|
32
|
+
authFile.refreshToken ?? "",
|
|
33
|
+
].join("\u0000");
|
|
34
|
+
}
|
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
|
@@ -4,6 +4,7 @@ import { login } from "./login.js";
|
|
|
4
4
|
import { runMcpServer } from "./mcp-server.js";
|
|
5
5
|
import { installMcp, printMcpInstallHelp } from "./mcp-install.js";
|
|
6
6
|
import { ApiError, OpenLogApiClient } from "./api-client.js";
|
|
7
|
+
import { createAuthenticatedApiClient } from "./authenticated-client.js";
|
|
7
8
|
const command = process.argv[2] ?? "help";
|
|
8
9
|
const subcommand = process.argv[3];
|
|
9
10
|
try {
|
|
@@ -11,8 +12,7 @@ try {
|
|
|
11
12
|
await login();
|
|
12
13
|
}
|
|
13
14
|
else if (command === "logout") {
|
|
14
|
-
await
|
|
15
|
-
console.log("Logged out.");
|
|
15
|
+
await logout();
|
|
16
16
|
}
|
|
17
17
|
else if (command === "whoami") {
|
|
18
18
|
await whoami();
|
|
@@ -56,17 +56,28 @@ async function installMcpCommand() {
|
|
|
56
56
|
});
|
|
57
57
|
}
|
|
58
58
|
async function whoami() {
|
|
59
|
-
const
|
|
60
|
-
if (!authFile) {
|
|
61
|
-
throw new Error("Not logged in. Run `openlog login` first.");
|
|
62
|
-
}
|
|
63
|
-
const apiClient = new OpenLogApiClient({
|
|
64
|
-
accessToken: authFile.accessToken,
|
|
65
|
-
apiBaseUrl: authFile.apiBaseUrl,
|
|
66
|
-
});
|
|
59
|
+
const apiClient = await createAuthenticatedApiClient();
|
|
67
60
|
const me = await apiClient.get("/auth/me");
|
|
68
61
|
console.log(JSON.stringify(me, null, 2));
|
|
69
62
|
}
|
|
63
|
+
async function logout() {
|
|
64
|
+
const authFile = await readAuthFile();
|
|
65
|
+
if (authFile?.refreshToken) {
|
|
66
|
+
try {
|
|
67
|
+
const apiClient = new OpenLogApiClient({
|
|
68
|
+
apiBaseUrl: authFile.apiBaseUrl,
|
|
69
|
+
});
|
|
70
|
+
await apiClient.postNoContent("/auth/device/revoke", {
|
|
71
|
+
refreshToken: authFile.refreshToken,
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
catch (error) {
|
|
75
|
+
console.error(`Warning: remote session revoke failed: ${formatCliError(error)}`);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
await deleteAuthFile();
|
|
79
|
+
console.log("Logged out.");
|
|
80
|
+
}
|
|
70
81
|
function printMcpStartupHint() {
|
|
71
82
|
if (!process.stderr.isTTY) {
|
|
72
83
|
return;
|
|
@@ -90,7 +101,11 @@ Available tools:
|
|
|
90
101
|
get_auth_status
|
|
91
102
|
get_me
|
|
92
103
|
list_my_notifications
|
|
104
|
+
list_my_posts
|
|
93
105
|
list_my_liked_posts
|
|
106
|
+
upload_post_image
|
|
107
|
+
publish_post
|
|
108
|
+
push_working_brief
|
|
94
109
|
get_post_detail
|
|
95
110
|
`);
|
|
96
111
|
}
|
|
@@ -109,6 +124,7 @@ Usage:
|
|
|
109
124
|
|
|
110
125
|
Environment:
|
|
111
126
|
OPENLOG_API_BASE_URL Override the OpenLog API base URL
|
|
127
|
+
OPENLOG_WEB_BASE_URL Override the OpenLog web base URL for published post links
|
|
112
128
|
OPENLOG_AUTH_FILE Override the local auth file path
|
|
113
129
|
`);
|
|
114
130
|
}
|
package/dist/login.js
CHANGED
|
@@ -14,18 +14,18 @@ export async function login() {
|
|
|
14
14
|
? "The approval page was opened in your browser."
|
|
15
15
|
: "Open the URL above in your browser.");
|
|
16
16
|
console.log("Waiting for approval...");
|
|
17
|
-
const
|
|
18
|
-
await writeAuthFile(
|
|
17
|
+
const tokens = await pollForTokens(apiClient, deviceLogin);
|
|
18
|
+
await writeAuthFile(tokens);
|
|
19
19
|
console.log("Login successful.");
|
|
20
20
|
}
|
|
21
|
-
async function
|
|
21
|
+
async function pollForTokens(apiClient, deviceLogin) {
|
|
22
22
|
const deadline = Date.now() + deviceLogin.expiresIn * 1000;
|
|
23
23
|
let interval = deviceLogin.interval;
|
|
24
24
|
while (Date.now() < deadline) {
|
|
25
25
|
await sleep(interval * 1000);
|
|
26
26
|
const response = await apiClient.post("/auth/device/token", { deviceCode: deviceLogin.deviceCode });
|
|
27
27
|
if (response.status === "APPROVED") {
|
|
28
|
-
return response
|
|
28
|
+
return response;
|
|
29
29
|
}
|
|
30
30
|
interval = response.interval ?? interval;
|
|
31
31
|
}
|
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
|
@@ -1,13 +1,20 @@
|
|
|
1
1
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
2
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
3
3
|
import { z } from "zod";
|
|
4
|
-
import { OpenLogApiClient } from "./api-client.js";
|
|
5
4
|
import { readAuthFile } from "./auth-store.js";
|
|
6
|
-
import {
|
|
5
|
+
import { createAuthenticatedApiClient } from "./authenticated-client.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: "
|
|
17
|
+
version: "1.0.0",
|
|
11
18
|
});
|
|
12
19
|
server.registerTool("get_auth_status", {
|
|
13
20
|
title: "Get OpenLog Auth Status",
|
|
@@ -24,7 +31,7 @@ export async function runMcpServer() {
|
|
|
24
31
|
});
|
|
25
32
|
}
|
|
26
33
|
apiBaseUrl = authFile.apiBaseUrl;
|
|
27
|
-
const me = await
|
|
34
|
+
const me = await createAuthenticatedApiClient().then((client) => client.get("/auth/me"));
|
|
28
35
|
return textResult({
|
|
29
36
|
authenticated: true,
|
|
30
37
|
apiBaseUrl: authFile.apiBaseUrl,
|
|
@@ -65,6 +72,107 @@ 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
|
+
}));
|
|
156
|
+
server.registerTool("push_working_brief", {
|
|
157
|
+
title: "Push OpenLog Working Brief",
|
|
158
|
+
description: "Overwrite the workspace Now Working brief with a short status update: what was done, what is still open, and optional task/branch context. Latest write wins.",
|
|
159
|
+
annotations: {
|
|
160
|
+
...WRITE_TOOL_ANNOTATIONS,
|
|
161
|
+
idempotentHint: true,
|
|
162
|
+
},
|
|
163
|
+
inputSchema: {
|
|
164
|
+
workspaceId: z.number().int().positive(),
|
|
165
|
+
title: z.string().min(1).max(255),
|
|
166
|
+
prose: z.string().min(1),
|
|
167
|
+
taskId: z.number().int().positive().optional(),
|
|
168
|
+
branch: z.string().max(255).optional(),
|
|
169
|
+
},
|
|
170
|
+
}, async ({ workspaceId, title, prose, taskId, branch }) => withAuthenticatedClient((client) => client.put(`/workspaces/${workspaceId}/working-brief`, {
|
|
171
|
+
title,
|
|
172
|
+
prose,
|
|
173
|
+
taskId: taskId ?? null,
|
|
174
|
+
branch: branch ?? null,
|
|
175
|
+
})));
|
|
68
176
|
server.registerTool("get_post_detail", {
|
|
69
177
|
title: "Get OpenLog Post Detail",
|
|
70
178
|
description: "Return a public OpenLog post detail by author username and post slug.",
|
|
@@ -75,19 +183,58 @@ export async function runMcpServer() {
|
|
|
75
183
|
}, async ({ username, slug }) => withAuthenticatedClient((client) => client.get(`/users/${encodeURIComponent(username)}/posts/${encodeURIComponent(slug)}`)));
|
|
76
184
|
await server.connect(new StdioServerTransport());
|
|
77
185
|
}
|
|
78
|
-
|
|
79
|
-
const
|
|
80
|
-
|
|
81
|
-
|
|
186
|
+
function normalizePostInput(input) {
|
|
187
|
+
const title = input.title.trim();
|
|
188
|
+
const description = input.description.trim();
|
|
189
|
+
const content = input.content.trim();
|
|
190
|
+
if (!title) {
|
|
191
|
+
throw new Error("title is required.");
|
|
82
192
|
}
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
193
|
+
if (!description) {
|
|
194
|
+
throw new Error("description is required.");
|
|
195
|
+
}
|
|
196
|
+
if (!content) {
|
|
197
|
+
throw new Error("content is required.");
|
|
198
|
+
}
|
|
199
|
+
return {
|
|
200
|
+
title,
|
|
201
|
+
description,
|
|
202
|
+
content,
|
|
203
|
+
topics: normalizeTopics(input.topics),
|
|
204
|
+
links: normalizeLinks(input.links),
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
function normalizeTopics(topics) {
|
|
208
|
+
const normalized = topics
|
|
209
|
+
.map((topic) => topic.trim().toLowerCase())
|
|
210
|
+
.filter(Boolean);
|
|
211
|
+
return [...new Set(normalized)];
|
|
212
|
+
}
|
|
213
|
+
function normalizeLinks(links) {
|
|
214
|
+
const normalized = [];
|
|
215
|
+
const seen = new Set();
|
|
216
|
+
for (const link of links) {
|
|
217
|
+
const label = link.label.trim();
|
|
218
|
+
const targetSlug = link.targetSlug.trim();
|
|
219
|
+
const key = `${label}\u0000${targetSlug}`;
|
|
220
|
+
if (!label || !targetSlug || seen.has(key)) {
|
|
221
|
+
continue;
|
|
222
|
+
}
|
|
223
|
+
seen.add(key);
|
|
224
|
+
normalized.push({ label, targetSlug });
|
|
225
|
+
}
|
|
226
|
+
return normalized;
|
|
227
|
+
}
|
|
228
|
+
function createContentPreview(content) {
|
|
229
|
+
const preview = content.replace(/\s+/g, " ").trim();
|
|
230
|
+
return preview.length > 500 ? `${preview.slice(0, 497)}...` : preview;
|
|
231
|
+
}
|
|
232
|
+
function buildPublicPostPath(username, slug) {
|
|
233
|
+
return `/@${encodeURIComponent(username)}/posts/${encodeURIComponent(slug)}`;
|
|
87
234
|
}
|
|
88
235
|
async function withAuthenticatedClient(callback) {
|
|
89
236
|
try {
|
|
90
|
-
const client = await
|
|
237
|
+
const client = await createAuthenticatedApiClient();
|
|
91
238
|
const result = await callback(client);
|
|
92
239
|
return textResult(result);
|
|
93
240
|
}
|
|
@@ -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": "
|
|
3
|
+
"version": "1.0.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "Official OpenLog CLI and MCP server.",
|
|
6
6
|
"type": "module",
|
|
@@ -16,7 +16,8 @@
|
|
|
16
16
|
"build": "tsc && chmod 755 dist/index.js",
|
|
17
17
|
"dev": "tsx src/index.ts",
|
|
18
18
|
"prepack": "npm run build",
|
|
19
|
-
"start": "node dist/index.js"
|
|
19
|
+
"start": "node dist/index.js",
|
|
20
|
+
"test": "tsx --test test/**/*.test.ts"
|
|
20
21
|
},
|
|
21
22
|
"files": [
|
|
22
23
|
"dist",
|
|
@@ -27,6 +28,7 @@
|
|
|
27
28
|
},
|
|
28
29
|
"dependencies": {
|
|
29
30
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
31
|
+
"sharp": "^0.34.5",
|
|
30
32
|
"zod": "^4.4.3"
|
|
31
33
|
},
|
|
32
34
|
"devDependencies": {
|