@openturtle/cli 0.3.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 +674 -0
- package/dist/commands/collaboration.js +315 -0
- package/dist/commands/hook.js +162 -0
- package/dist/commands/resources.js +118 -0
- package/dist/core/api-client.js +221 -0
- package/dist/core/auth.js +35 -0
- package/dist/core/defaults.js +1 -0
- package/dist/core/input.js +37 -0
- package/dist/core/json.js +34 -0
- package/dist/core/managed-block.js +25 -0
- package/dist/core/mcp-config.js +129 -0
- package/dist/core/paths.js +32 -0
- package/dist/core/store.js +102 -0
- package/dist/core/types.js +1 -0
- package/dist/core/version.js +33 -0
- package/dist/core/web-auth.js +115 -0
- package/dist/index.js +469 -0
- package/dist/installers/agents.js +301 -0
- package/dist/installers/git.js +63 -0
- package/dist/installers/uninstall.js +107 -0
- package/dist/mcp/server.js +185 -0
- package/dist/watchers/importers.js +69 -0
- package/package.json +48 -0
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import nodePath from 'node:path';
|
|
3
|
+
import { readAuth, writeAuth } from './auth.js';
|
|
4
|
+
import { DEFAULT_SERVER_URL } from './defaults.js';
|
|
5
|
+
export class ApiError extends Error {
|
|
6
|
+
method;
|
|
7
|
+
path;
|
|
8
|
+
status;
|
|
9
|
+
detail;
|
|
10
|
+
code = 'api_error';
|
|
11
|
+
constructor(message, method, path, status, detail) {
|
|
12
|
+
super(message);
|
|
13
|
+
this.method = method;
|
|
14
|
+
this.path = path;
|
|
15
|
+
this.status = status;
|
|
16
|
+
this.detail = detail;
|
|
17
|
+
this.name = 'ApiError';
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
export class ApiClient {
|
|
21
|
+
options;
|
|
22
|
+
constructor(options = {}) {
|
|
23
|
+
this.options = options;
|
|
24
|
+
}
|
|
25
|
+
async request(method, path, body, query) {
|
|
26
|
+
const { url, token } = this.requestContext(path, query);
|
|
27
|
+
const response = await this.fetchResponse(method, url, {
|
|
28
|
+
headers: {
|
|
29
|
+
...(body == null ? {} : { 'content-type': 'application/json' }),
|
|
30
|
+
...(token ? { authorization: `Bearer ${token}` } : {}),
|
|
31
|
+
},
|
|
32
|
+
...(body == null ? {} : { body: JSON.stringify(body) }),
|
|
33
|
+
});
|
|
34
|
+
return this.parseResponse(method, url, response);
|
|
35
|
+
}
|
|
36
|
+
get(path, query) {
|
|
37
|
+
return this.request('GET', path, undefined, query);
|
|
38
|
+
}
|
|
39
|
+
post(path, body) {
|
|
40
|
+
return this.request('POST', path, body);
|
|
41
|
+
}
|
|
42
|
+
patch(path, body) {
|
|
43
|
+
return this.request('PATCH', path, body);
|
|
44
|
+
}
|
|
45
|
+
delete(path, body) {
|
|
46
|
+
return this.request('DELETE', path, body);
|
|
47
|
+
}
|
|
48
|
+
async upload(method, pathname, files, fields = {}) {
|
|
49
|
+
const form = new FormData();
|
|
50
|
+
for (const [key, value] of Object.entries(fields)) {
|
|
51
|
+
if (value == null || value === '')
|
|
52
|
+
continue;
|
|
53
|
+
form.append(key, typeof value === 'string' ? value : JSON.stringify(value));
|
|
54
|
+
}
|
|
55
|
+
for (const file of files) {
|
|
56
|
+
if (!fs.existsSync(file.filePath) || !fs.statSync(file.filePath).isFile()) {
|
|
57
|
+
throw new Error(`File not found: ${file.filePath}`);
|
|
58
|
+
}
|
|
59
|
+
const bytes = fs.readFileSync(file.filePath);
|
|
60
|
+
form.append(file.field, new Blob([bytes], { type: contentTypeFor(file.filePath) }), nodePath.basename(file.filePath));
|
|
61
|
+
}
|
|
62
|
+
const { url, token } = this.requestContext(pathname);
|
|
63
|
+
const response = await this.fetchResponse(method, url, {
|
|
64
|
+
headers: token ? { authorization: `Bearer ${token}` } : {},
|
|
65
|
+
body: form,
|
|
66
|
+
});
|
|
67
|
+
return this.parseResponse(method, url, response);
|
|
68
|
+
}
|
|
69
|
+
async download(pathname, outputPath, force = false) {
|
|
70
|
+
const { url, token } = this.requestContext(pathname);
|
|
71
|
+
const response = await this.fetchResponse('GET', url, {
|
|
72
|
+
headers: token ? { authorization: `Bearer ${token}` } : {},
|
|
73
|
+
});
|
|
74
|
+
if (!response.ok)
|
|
75
|
+
return this.parseResponse('GET', url, response);
|
|
76
|
+
const filename = responseFilename(response) || 'download';
|
|
77
|
+
const resolvedOutput = nodePath.resolve(outputPath || filename);
|
|
78
|
+
if (fs.existsSync(resolvedOutput) && !force) {
|
|
79
|
+
throw new Error(`Refusing to overwrite existing file: ${resolvedOutput}. Pass --force to replace it.`);
|
|
80
|
+
}
|
|
81
|
+
fs.mkdirSync(nodePath.dirname(resolvedOutput), { recursive: true });
|
|
82
|
+
const bytes = Buffer.from(await response.arrayBuffer());
|
|
83
|
+
fs.writeFileSync(resolvedOutput, bytes);
|
|
84
|
+
return {
|
|
85
|
+
downloaded: true,
|
|
86
|
+
path: resolvedOutput,
|
|
87
|
+
bytes: bytes.length,
|
|
88
|
+
content_type: response.headers.get('content-type'),
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
async probe(path = '/api/teams/me') {
|
|
92
|
+
const { url, token, server } = this.requestContext(path);
|
|
93
|
+
try {
|
|
94
|
+
const response = await fetch(url, {
|
|
95
|
+
method: 'GET',
|
|
96
|
+
headers: token ? { authorization: `Bearer ${token}` } : {},
|
|
97
|
+
});
|
|
98
|
+
return {
|
|
99
|
+
server,
|
|
100
|
+
reachable: true,
|
|
101
|
+
authenticated: response.ok,
|
|
102
|
+
status: response.status,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
catch (error) {
|
|
106
|
+
return {
|
|
107
|
+
server,
|
|
108
|
+
reachable: false,
|
|
109
|
+
authenticated: false,
|
|
110
|
+
error: error instanceof Error ? error.message : String(error),
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
async loginWithToken(server, token) {
|
|
115
|
+
writeAuth({ server, token }, this.options.homeDir);
|
|
116
|
+
}
|
|
117
|
+
requestContext(pathname, query) {
|
|
118
|
+
const auth = readAuth(this.options.homeDir);
|
|
119
|
+
const server = normalizeServerUrl(this.options.server || auth.server || DEFAULT_SERVER_URL);
|
|
120
|
+
const token = this.options.token || auth.token;
|
|
121
|
+
const url = resolveApiUrl(server, pathname);
|
|
122
|
+
for (const [key, value] of Object.entries(query || {})) {
|
|
123
|
+
if (value == null || value === '')
|
|
124
|
+
continue;
|
|
125
|
+
if (Array.isArray(value)) {
|
|
126
|
+
for (const item of value)
|
|
127
|
+
url.searchParams.append(key, String(item));
|
|
128
|
+
}
|
|
129
|
+
else {
|
|
130
|
+
url.searchParams.set(key, String(value));
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return { url, token, server };
|
|
134
|
+
}
|
|
135
|
+
async fetchResponse(method, url, init) {
|
|
136
|
+
try {
|
|
137
|
+
return await fetch(url, { ...init, method });
|
|
138
|
+
}
|
|
139
|
+
catch (error) {
|
|
140
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
141
|
+
throw new ApiError(`${method} ${url.pathname} failed: ${detail}`, method, url.pathname, 0, detail);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
async parseResponse(method, url, response) {
|
|
145
|
+
if (!response.ok) {
|
|
146
|
+
const raw = await response.text().catch(() => '');
|
|
147
|
+
let detail = raw;
|
|
148
|
+
try {
|
|
149
|
+
detail = raw ? JSON.parse(raw) : undefined;
|
|
150
|
+
}
|
|
151
|
+
catch {
|
|
152
|
+
// Keep non-JSON error payloads as text.
|
|
153
|
+
}
|
|
154
|
+
const summary = errorSummary(detail);
|
|
155
|
+
throw new ApiError(`${method} ${url.pathname} failed: ${response.status}${summary ? ` ${summary}` : ''}`, method, url.pathname, response.status, detail);
|
|
156
|
+
}
|
|
157
|
+
if (response.status === 204)
|
|
158
|
+
return undefined;
|
|
159
|
+
const text = await response.text();
|
|
160
|
+
if (!text)
|
|
161
|
+
return undefined;
|
|
162
|
+
try {
|
|
163
|
+
return JSON.parse(text);
|
|
164
|
+
}
|
|
165
|
+
catch {
|
|
166
|
+
return text;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
export function resolveApiUrl(server, pathname) {
|
|
171
|
+
const baseUrl = new URL(normalizeServerUrl(server));
|
|
172
|
+
baseUrl.pathname = `${baseUrl.pathname.replace(/\/+$/, '')}/`;
|
|
173
|
+
return new URL(pathname.replace(/^\/+/, ''), baseUrl);
|
|
174
|
+
}
|
|
175
|
+
function normalizeServerUrl(value) {
|
|
176
|
+
const url = new URL(value);
|
|
177
|
+
url.pathname = url.pathname.replace(/\/+$/, '') || '/';
|
|
178
|
+
url.search = '';
|
|
179
|
+
url.hash = '';
|
|
180
|
+
return url.toString().replace(/\/$/, '');
|
|
181
|
+
}
|
|
182
|
+
export function listQuery(options) {
|
|
183
|
+
return Object.fromEntries(Object.entries(options).filter(([, value]) => value != null && value !== ''));
|
|
184
|
+
}
|
|
185
|
+
function errorSummary(detail) {
|
|
186
|
+
if (typeof detail === 'string')
|
|
187
|
+
return detail;
|
|
188
|
+
if (detail && typeof detail === 'object' && 'detail' in detail) {
|
|
189
|
+
const value = detail.detail;
|
|
190
|
+
return typeof value === 'string' ? value : JSON.stringify(value);
|
|
191
|
+
}
|
|
192
|
+
return detail == null ? '' : JSON.stringify(detail);
|
|
193
|
+
}
|
|
194
|
+
function contentTypeFor(filePath) {
|
|
195
|
+
const extension = nodePath.extname(filePath).toLowerCase();
|
|
196
|
+
return ({
|
|
197
|
+
'.json': 'application/json',
|
|
198
|
+
'.md': 'text/markdown',
|
|
199
|
+
'.txt': 'text/plain',
|
|
200
|
+
'.pdf': 'application/pdf',
|
|
201
|
+
'.wav': 'audio/wav',
|
|
202
|
+
'.mp3': 'audio/mpeg',
|
|
203
|
+
'.m4a': 'audio/mp4',
|
|
204
|
+
'.ogg': 'audio/ogg',
|
|
205
|
+
'.webm': 'audio/webm',
|
|
206
|
+
'.png': 'image/png',
|
|
207
|
+
'.jpg': 'image/jpeg',
|
|
208
|
+
'.jpeg': 'image/jpeg',
|
|
209
|
+
}[extension] || 'application/octet-stream');
|
|
210
|
+
}
|
|
211
|
+
function responseFilename(response) {
|
|
212
|
+
const disposition = response.headers.get('content-disposition') || '';
|
|
213
|
+
const encoded = disposition.match(/filename\*=UTF-8''([^;]+)/i)?.[1];
|
|
214
|
+
if (encoded)
|
|
215
|
+
return nodePath.basename(decodeURIComponent(encoded));
|
|
216
|
+
const quoted = disposition.match(/filename="([^"]+)"/i)?.[1];
|
|
217
|
+
if (quoted)
|
|
218
|
+
return nodePath.basename(quoted);
|
|
219
|
+
const plain = disposition.match(/filename=([^;]+)/i)?.[1]?.trim();
|
|
220
|
+
return plain ? nodePath.basename(plain) : undefined;
|
|
221
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { cliGlobalDir } from './paths.js';
|
|
5
|
+
import { readJsonFile, writeJsonFile } from './json.js';
|
|
6
|
+
export function authPath(homeDir = os.homedir()) {
|
|
7
|
+
return path.join(cliGlobalDir(homeDir), 'auth.json');
|
|
8
|
+
}
|
|
9
|
+
export function readAuth(homeDir = os.homedir()) {
|
|
10
|
+
const envToken = process.env.OPENTURTLE_TOKEN;
|
|
11
|
+
const envServer = process.env.OPENTURTLE_SERVER_URL || process.env.SERVER_URL;
|
|
12
|
+
const config = readJsonFile(authPath(homeDir), {});
|
|
13
|
+
return {
|
|
14
|
+
...config,
|
|
15
|
+
...(envServer ? { server: envServer } : {}),
|
|
16
|
+
...(envToken ? { token: envToken } : {}),
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
export function writeAuth(config, homeDir = os.homedir()) {
|
|
20
|
+
const filePath = authPath(homeDir);
|
|
21
|
+
writeJsonFile(filePath, config);
|
|
22
|
+
fs.chmodSync(filePath, 0o600);
|
|
23
|
+
}
|
|
24
|
+
export function clearAuth(homeDir = os.homedir()) {
|
|
25
|
+
const filePath = authPath(homeDir);
|
|
26
|
+
if (fs.existsSync(filePath))
|
|
27
|
+
fs.unlinkSync(filePath);
|
|
28
|
+
}
|
|
29
|
+
export function redactToken(token) {
|
|
30
|
+
if (!token)
|
|
31
|
+
return undefined;
|
|
32
|
+
if (token.length <= 8)
|
|
33
|
+
return '***';
|
|
34
|
+
return `${token.slice(0, 4)}...${token.slice(-4)}`;
|
|
35
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export const DEFAULT_SERVER_URL = 'https://lavertest.kingdee.com/openturtle';
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
export async function readJsonBody(filePath) {
|
|
3
|
+
if (!filePath)
|
|
4
|
+
throw new Error('Missing required option --body-file');
|
|
5
|
+
const text = filePath === '-' ? await readStdin() : fs.readFileSync(filePath, 'utf-8');
|
|
6
|
+
let parsed;
|
|
7
|
+
try {
|
|
8
|
+
parsed = JSON.parse(text);
|
|
9
|
+
}
|
|
10
|
+
catch (error) {
|
|
11
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
12
|
+
throw new Error(`Invalid JSON in ${filePath}: ${detail}`);
|
|
13
|
+
}
|
|
14
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
15
|
+
throw new Error(`JSON body in ${filePath} must be an object`);
|
|
16
|
+
}
|
|
17
|
+
return parsed;
|
|
18
|
+
}
|
|
19
|
+
export function collectOption(value, previous) {
|
|
20
|
+
return [...previous, value];
|
|
21
|
+
}
|
|
22
|
+
export function parseKeyValueOptions(values) {
|
|
23
|
+
const result = {};
|
|
24
|
+
for (const value of values) {
|
|
25
|
+
const separator = value.indexOf('=');
|
|
26
|
+
if (separator <= 0)
|
|
27
|
+
throw new Error(`Expected key=value, received: ${value}`);
|
|
28
|
+
result[value.slice(0, separator)] = value.slice(separator + 1);
|
|
29
|
+
}
|
|
30
|
+
return result;
|
|
31
|
+
}
|
|
32
|
+
async function readStdin() {
|
|
33
|
+
const chunks = [];
|
|
34
|
+
for await (const chunk of process.stdin)
|
|
35
|
+
chunks.push(Buffer.from(chunk));
|
|
36
|
+
return Buffer.concat(chunks).toString('utf-8');
|
|
37
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import { ensureFileDir } from './paths.js';
|
|
3
|
+
export function readJsonFile(filePath, fallback) {
|
|
4
|
+
if (!fs.existsSync(filePath))
|
|
5
|
+
return fallback;
|
|
6
|
+
try {
|
|
7
|
+
return JSON.parse(fs.readFileSync(filePath, 'utf-8'));
|
|
8
|
+
}
|
|
9
|
+
catch {
|
|
10
|
+
return fallback;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
export function writeJsonFile(filePath, value) {
|
|
14
|
+
ensureFileDir(filePath);
|
|
15
|
+
fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`, 'utf-8');
|
|
16
|
+
}
|
|
17
|
+
export function parseJsonObject(value) {
|
|
18
|
+
const parsed = JSON.parse(value);
|
|
19
|
+
return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
|
|
20
|
+
? parsed
|
|
21
|
+
: { value: parsed };
|
|
22
|
+
}
|
|
23
|
+
export function compactJson(value) {
|
|
24
|
+
if (value == null)
|
|
25
|
+
return '';
|
|
26
|
+
if (typeof value === 'string')
|
|
27
|
+
return value;
|
|
28
|
+
try {
|
|
29
|
+
return JSON.stringify(value);
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
return String(value);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export const MANAGED_MARKER = 'OPENTURTLE-CLI';
|
|
2
|
+
export function managedHeader(id) {
|
|
3
|
+
return `# >>> ${MANAGED_MARKER}:${id}`;
|
|
4
|
+
}
|
|
5
|
+
export function managedFooter(id) {
|
|
6
|
+
return `# <<< ${MANAGED_MARKER}:${id}`;
|
|
7
|
+
}
|
|
8
|
+
export function replaceManagedBlock(content, id, block) {
|
|
9
|
+
const header = managedHeader(id);
|
|
10
|
+
const footer = managedFooter(id);
|
|
11
|
+
const normalizedBlock = [header, block.trimEnd(), footer].join('\n');
|
|
12
|
+
const pattern = new RegExp(`${escapeRegExp(header)}[\\s\\S]*?${escapeRegExp(footer)}\\n?`, 'm');
|
|
13
|
+
const trimmed = content.trimEnd();
|
|
14
|
+
if (pattern.test(content)) {
|
|
15
|
+
return `${content.replace(pattern, `${normalizedBlock}\n`).trimEnd()}\n`;
|
|
16
|
+
}
|
|
17
|
+
return `${[trimmed, normalizedBlock].filter(Boolean).join('\n\n')}\n`;
|
|
18
|
+
}
|
|
19
|
+
export function removeManagedBlock(content, id) {
|
|
20
|
+
const pattern = new RegExp(`${escapeRegExp(managedHeader(id))}[\\s\\S]*?${escapeRegExp(managedFooter(id))}\\n?`, 'm');
|
|
21
|
+
return `${content.replace(pattern, '').trimEnd()}\n`;
|
|
22
|
+
}
|
|
23
|
+
function escapeRegExp(value) {
|
|
24
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
25
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { readJsonFile, writeJsonFile } from './json.js';
|
|
5
|
+
import { ensureFileDir } from './paths.js';
|
|
6
|
+
export const MCP_SERVER_NAME = 'ot-cli';
|
|
7
|
+
export function otMcpConfig() {
|
|
8
|
+
return { command: 'ot', args: ['mcp', 'serve'], enabled: true };
|
|
9
|
+
}
|
|
10
|
+
export function mergeJsonMcpServer(filePath, name = MCP_SERVER_NAME) {
|
|
11
|
+
const parsed = readJsonFile(filePath, {});
|
|
12
|
+
const servers = parsed.mcpServers && typeof parsed.mcpServers === 'object'
|
|
13
|
+
? parsed.mcpServers
|
|
14
|
+
: {};
|
|
15
|
+
servers[name] = otMcpConfig();
|
|
16
|
+
writeJsonFile(filePath, { ...parsed, mcpServers: servers });
|
|
17
|
+
}
|
|
18
|
+
export function removeJsonMcpServer(filePath, name = MCP_SERVER_NAME, pruneStopDir) {
|
|
19
|
+
if (!fs.existsSync(filePath))
|
|
20
|
+
return;
|
|
21
|
+
const parsed = readJsonFile(filePath, {});
|
|
22
|
+
const servers = parsed.mcpServers && typeof parsed.mcpServers === 'object'
|
|
23
|
+
? { ...parsed.mcpServers }
|
|
24
|
+
: {};
|
|
25
|
+
delete servers[name];
|
|
26
|
+
const next = { ...parsed, mcpServers: servers };
|
|
27
|
+
if (Object.keys(servers).length === 0)
|
|
28
|
+
delete next.mcpServers;
|
|
29
|
+
if (Object.keys(next).length === 0) {
|
|
30
|
+
fs.rmSync(filePath, { force: true });
|
|
31
|
+
pruneEmptyParents(path.dirname(filePath), pruneStopDir);
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
writeJsonFile(filePath, next);
|
|
35
|
+
}
|
|
36
|
+
export function claudeMcpPath(scope, workspace, homeDir = os.homedir()) {
|
|
37
|
+
return scope === 'user'
|
|
38
|
+
? path.join(homeDir, '.claude', 'mcp_servers.json')
|
|
39
|
+
: path.join(workspace, '.claude', 'mcp_servers.json');
|
|
40
|
+
}
|
|
41
|
+
export function claudeSettingsPath(scope, workspace, homeDir = os.homedir()) {
|
|
42
|
+
return scope === 'user'
|
|
43
|
+
? path.join(homeDir, '.claude', 'settings.json')
|
|
44
|
+
: path.join(workspace, '.claude', 'settings.json');
|
|
45
|
+
}
|
|
46
|
+
export function codexConfigPath(scope, workspace, homeDir = os.homedir()) {
|
|
47
|
+
return scope === 'user' ? path.join(homeDir, '.codex', 'config.toml') : path.join(workspace, '.codex', 'config.toml');
|
|
48
|
+
}
|
|
49
|
+
export function cursorMcpPath(workspace) {
|
|
50
|
+
return path.join(workspace, '.mcp.json');
|
|
51
|
+
}
|
|
52
|
+
export function qoderMcpPath(scope, workspace, homeDir = os.homedir()) {
|
|
53
|
+
return scope === 'user' ? path.join(homeDir, '.qoder.json') : path.join(workspace, '.mcp.json');
|
|
54
|
+
}
|
|
55
|
+
export function kiroMcpPath(scope, workspace, homeDir = os.homedir()) {
|
|
56
|
+
return scope === 'user'
|
|
57
|
+
? path.join(homeDir, '.kiro', 'settings', 'mcp.json')
|
|
58
|
+
: path.join(workspace, '.kiro', 'settings', 'mcp.json');
|
|
59
|
+
}
|
|
60
|
+
export function mergeCodexMcpServer(filePath) {
|
|
61
|
+
ensureFileDir(filePath);
|
|
62
|
+
const existing = fs.existsSync(filePath) ? fs.readFileSync(filePath, 'utf-8') : '';
|
|
63
|
+
const withoutServer = stripTomlTable(existing, `mcp_servers.${MCP_SERVER_NAME}`);
|
|
64
|
+
const block = [
|
|
65
|
+
`[mcp_servers.${MCP_SERVER_NAME}]`,
|
|
66
|
+
`command = "ot"`,
|
|
67
|
+
`args = ["mcp", "serve"]`,
|
|
68
|
+
`enabled = true`,
|
|
69
|
+
].join('\n');
|
|
70
|
+
fs.writeFileSync(filePath, `${[withoutServer.trimEnd(), block].filter(Boolean).join('\n\n')}\n`, 'utf-8');
|
|
71
|
+
}
|
|
72
|
+
export function appendCodexHookBlocks(filePath, hookBlocks) {
|
|
73
|
+
ensureFileDir(filePath);
|
|
74
|
+
const existing = fs.existsSync(filePath) ? fs.readFileSync(filePath, 'utf-8') : '';
|
|
75
|
+
const cleaned = removeManagedTomlBlock(existing, 'hooks');
|
|
76
|
+
const block = [`# >>> OPENTURTLE-CLI:hooks`, hookBlocks.trimEnd(), `# <<< OPENTURTLE-CLI:hooks`].join('\n');
|
|
77
|
+
fs.writeFileSync(filePath, `${[cleaned.trimEnd(), block].filter(Boolean).join('\n\n')}\n`, 'utf-8');
|
|
78
|
+
}
|
|
79
|
+
export function removeCodexManagedConfig(filePath, pruneStopDir) {
|
|
80
|
+
if (!fs.existsSync(filePath))
|
|
81
|
+
return;
|
|
82
|
+
const existing = fs.readFileSync(filePath, 'utf-8');
|
|
83
|
+
const withoutHooks = removeManagedTomlBlock(existing, 'hooks');
|
|
84
|
+
const withoutServer = stripTomlTable(withoutHooks, `mcp_servers.${MCP_SERVER_NAME}`);
|
|
85
|
+
const next = withoutServer.trim();
|
|
86
|
+
if (!next) {
|
|
87
|
+
fs.rmSync(filePath, { force: true });
|
|
88
|
+
pruneEmptyParents(path.dirname(filePath), pruneStopDir);
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
fs.writeFileSync(filePath, `${next}\n`, 'utf-8');
|
|
92
|
+
}
|
|
93
|
+
function removeManagedTomlBlock(content, id) {
|
|
94
|
+
return content.replace(new RegExp(`\\n?# >>> OPENTURTLE-CLI:${id}[\\s\\S]*?# <<< OPENTURTLE-CLI:${id}\\r?\\n?`, 'm'), '');
|
|
95
|
+
}
|
|
96
|
+
function stripTomlTable(content, tableName) {
|
|
97
|
+
const lines = content.split(/\r?\n/);
|
|
98
|
+
const kept = [];
|
|
99
|
+
let skipping = false;
|
|
100
|
+
const tablePattern = new RegExp(`^\\s*\\[${escapeRegExp(tableName)}\\]\\s*$`);
|
|
101
|
+
for (const line of lines) {
|
|
102
|
+
if (/^\s*\[/.test(line)) {
|
|
103
|
+
skipping = tablePattern.test(line);
|
|
104
|
+
}
|
|
105
|
+
if (!skipping)
|
|
106
|
+
kept.push(line);
|
|
107
|
+
}
|
|
108
|
+
return kept.join('\n');
|
|
109
|
+
}
|
|
110
|
+
function escapeRegExp(value) {
|
|
111
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
112
|
+
}
|
|
113
|
+
function pruneEmptyParents(startDir, stopDir) {
|
|
114
|
+
let current = startDir;
|
|
115
|
+
const stop = stopDir ? path.resolve(stopDir) : undefined;
|
|
116
|
+
for (let index = 0; index < 3; index += 1) {
|
|
117
|
+
try {
|
|
118
|
+
if (stop && path.resolve(current) === stop)
|
|
119
|
+
return;
|
|
120
|
+
if (!fs.existsSync(current) || fs.readdirSync(current).length > 0)
|
|
121
|
+
return;
|
|
122
|
+
fs.rmdirSync(current);
|
|
123
|
+
current = path.dirname(current);
|
|
124
|
+
}
|
|
125
|
+
catch {
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
export const PROJECT_DIR_NAME = '.openturtle-cli';
|
|
5
|
+
export const GLOBAL_DIR_PARTS = ['.openturtle', 'cli'];
|
|
6
|
+
export function cliGlobalDir(homeDir = os.homedir()) {
|
|
7
|
+
return path.join(homeDir, ...GLOBAL_DIR_PARTS);
|
|
8
|
+
}
|
|
9
|
+
export function projectWorklogDir(workspacePath) {
|
|
10
|
+
return path.join(workspacePath, PROJECT_DIR_NAME);
|
|
11
|
+
}
|
|
12
|
+
export function findWorkspaceRoot(startPath = process.cwd()) {
|
|
13
|
+
let current = path.resolve(startPath);
|
|
14
|
+
if (fs.existsSync(current) && fs.statSync(current).isFile()) {
|
|
15
|
+
current = path.dirname(current);
|
|
16
|
+
}
|
|
17
|
+
for (;;) {
|
|
18
|
+
if (fs.existsSync(path.join(current, PROJECT_DIR_NAME)) || fs.existsSync(path.join(current, '.git'))) {
|
|
19
|
+
return current;
|
|
20
|
+
}
|
|
21
|
+
const parent = path.dirname(current);
|
|
22
|
+
if (parent === current)
|
|
23
|
+
return path.resolve(startPath);
|
|
24
|
+
current = parent;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
export function ensureDir(dirPath) {
|
|
28
|
+
fs.mkdirSync(dirPath, { recursive: true });
|
|
29
|
+
}
|
|
30
|
+
export function ensureFileDir(filePath) {
|
|
31
|
+
ensureDir(path.dirname(filePath));
|
|
32
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { createRequire } from 'node:module';
|
|
4
|
+
import { projectWorklogDir } from './paths.js';
|
|
5
|
+
export function initProjectStore(workspacePath) {
|
|
6
|
+
const workspace = path.resolve(workspacePath);
|
|
7
|
+
const dir = projectWorklogDir(workspace);
|
|
8
|
+
fs.mkdirSync(path.join(dir, 'hooks'), { recursive: true });
|
|
9
|
+
fs.mkdirSync(path.join(dir, 'drafts'), { recursive: true });
|
|
10
|
+
fs.mkdirSync(path.join(dir, 'cache'), { recursive: true });
|
|
11
|
+
const configPath = path.join(dir, 'config.json');
|
|
12
|
+
if (!fs.existsSync(configPath)) {
|
|
13
|
+
fs.writeFileSync(configPath, `${JSON.stringify({
|
|
14
|
+
version: 1,
|
|
15
|
+
mcpServerName: 'ot-cli',
|
|
16
|
+
createdAt: new Date().toISOString(),
|
|
17
|
+
}, null, 2)}\n`, 'utf-8');
|
|
18
|
+
}
|
|
19
|
+
const db = createDatabase(path.join(dir, 'state.db'));
|
|
20
|
+
db.exec('PRAGMA journal_mode = WAL');
|
|
21
|
+
db.exec('PRAGMA busy_timeout = 2000');
|
|
22
|
+
migrate(db);
|
|
23
|
+
return { workspace, dir, db };
|
|
24
|
+
}
|
|
25
|
+
function migrate(db) {
|
|
26
|
+
db.exec(`
|
|
27
|
+
CREATE TABLE IF NOT EXISTS events (
|
|
28
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
29
|
+
type TEXT NOT NULL,
|
|
30
|
+
source TEXT NOT NULL,
|
|
31
|
+
source_id TEXT,
|
|
32
|
+
workspace_path TEXT,
|
|
33
|
+
summary TEXT,
|
|
34
|
+
raw_json TEXT,
|
|
35
|
+
occurred_at TEXT NOT NULL,
|
|
36
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
37
|
+
dedupe_key TEXT NOT NULL UNIQUE
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
CREATE TABLE IF NOT EXISTS hook_runs (
|
|
41
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
42
|
+
target TEXT NOT NULL,
|
|
43
|
+
event_type TEXT NOT NULL,
|
|
44
|
+
exit_code INTEGER NOT NULL,
|
|
45
|
+
duration_ms INTEGER NOT NULL,
|
|
46
|
+
raw_input_json TEXT,
|
|
47
|
+
error TEXT,
|
|
48
|
+
occurred_at TEXT NOT NULL
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
CREATE TABLE IF NOT EXISTS commits (
|
|
52
|
+
hash TEXT PRIMARY KEY,
|
|
53
|
+
message TEXT,
|
|
54
|
+
author TEXT,
|
|
55
|
+
diff_stat TEXT,
|
|
56
|
+
changed_files_json TEXT,
|
|
57
|
+
occurred_at TEXT NOT NULL,
|
|
58
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
CREATE TABLE IF NOT EXISTS sync_queue (
|
|
62
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
63
|
+
kind TEXT NOT NULL,
|
|
64
|
+
payload_json TEXT NOT NULL,
|
|
65
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
66
|
+
last_error TEXT
|
|
67
|
+
);
|
|
68
|
+
`);
|
|
69
|
+
}
|
|
70
|
+
function createDatabase(filePath) {
|
|
71
|
+
const require = createRequire(import.meta.url);
|
|
72
|
+
const sqlite = require('node:sqlite');
|
|
73
|
+
return new sqlite.DatabaseSync(filePath);
|
|
74
|
+
}
|
|
75
|
+
export function upsertEvent(store, event) {
|
|
76
|
+
const occurredAt = event.occurredAt || new Date().toISOString();
|
|
77
|
+
const result = store.db
|
|
78
|
+
.prepare(`INSERT OR IGNORE INTO events
|
|
79
|
+
(type, source, source_id, workspace_path, summary, raw_json, occurred_at, dedupe_key)
|
|
80
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
81
|
+
.run(event.type, event.source, event.sourceId || null, event.workspacePath || null, event.summary || null, event.rawJson == null ? null : JSON.stringify(event.rawJson), occurredAt, event.dedupeKey);
|
|
82
|
+
return { inserted: result.changes > 0 };
|
|
83
|
+
}
|
|
84
|
+
export function recordHookRun(store, run) {
|
|
85
|
+
store.db
|
|
86
|
+
.prepare(`INSERT INTO hook_runs
|
|
87
|
+
(target, event_type, exit_code, duration_ms, raw_input_json, error, occurred_at)
|
|
88
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)`)
|
|
89
|
+
.run(run.target, run.eventType, run.exitCode, run.durationMs, run.rawInputJson || null, run.error || null, run.occurredAt || new Date().toISOString());
|
|
90
|
+
}
|
|
91
|
+
export function listEvents(store, limit = 100) {
|
|
92
|
+
return store.db
|
|
93
|
+
.prepare('SELECT * FROM events ORDER BY occurred_at ASC, id ASC LIMIT ?')
|
|
94
|
+
.all(limit);
|
|
95
|
+
}
|
|
96
|
+
export function recordCommit(store, commit) {
|
|
97
|
+
store.db
|
|
98
|
+
.prepare(`INSERT OR REPLACE INTO commits
|
|
99
|
+
(hash, message, author, diff_stat, changed_files_json, occurred_at)
|
|
100
|
+
VALUES (?, ?, ?, ?, ?, ?)`)
|
|
101
|
+
.run(commit.hash, commit.message || '', commit.author || '', commit.diffStat || '', JSON.stringify(commit.changedFiles || []), commit.occurredAt || new Date().toISOString());
|
|
102
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import { fileURLToPath } from 'node:url';
|
|
3
|
+
const packageJsonPath = fileURLToPath(new URL('../../package.json', import.meta.url));
|
|
4
|
+
export function cliVersion() {
|
|
5
|
+
const metadata = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
|
|
6
|
+
if (!metadata.version)
|
|
7
|
+
throw new Error('CLI package version is missing');
|
|
8
|
+
return metadata.version;
|
|
9
|
+
}
|
|
10
|
+
export function nextReleaseVersion(repositoryVersion, publishedVersion) {
|
|
11
|
+
const repository = parseStableVersion(repositoryVersion);
|
|
12
|
+
if (!publishedVersion)
|
|
13
|
+
return formatVersion(repository);
|
|
14
|
+
const published = parseStableVersion(publishedVersion);
|
|
15
|
+
const nextPublished = { ...published, patch: published.patch + 1 };
|
|
16
|
+
return formatVersion(compareVersions(repository, nextPublished) > 0 ? repository : nextPublished);
|
|
17
|
+
}
|
|
18
|
+
function parseStableVersion(value) {
|
|
19
|
+
const match = /^(\d+)\.(\d+)\.(\d+)$/.exec(value.trim());
|
|
20
|
+
if (!match)
|
|
21
|
+
throw new Error(`Expected a stable semantic version, received: ${value}`);
|
|
22
|
+
return {
|
|
23
|
+
major: Number(match[1]),
|
|
24
|
+
minor: Number(match[2]),
|
|
25
|
+
patch: Number(match[3]),
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
function compareVersions(left, right) {
|
|
29
|
+
return left.major - right.major || left.minor - right.minor || left.patch - right.patch;
|
|
30
|
+
}
|
|
31
|
+
function formatVersion(version) {
|
|
32
|
+
return `${version.major}.${version.minor}.${version.patch}`;
|
|
33
|
+
}
|