@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,301 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { hookCommand } from '../commands/hook.js';
|
|
5
|
+
import { readJsonFile, writeJsonFile } from '../core/json.js';
|
|
6
|
+
import { appendCodexHookBlocks, claudeMcpPath, claudeSettingsPath, codexConfigPath, cursorMcpPath, kiroMcpPath, mergeCodexMcpServer, mergeJsonMcpServer, qoderMcpPath, removeCodexManagedConfig, removeJsonMcpServer, } from '../core/mcp-config.js';
|
|
7
|
+
import { ensureFileDir } from '../core/paths.js';
|
|
8
|
+
import { removeCursorHookEntries, removeDirIfExists, removeFileIfExists, removeManagedHookEntries, writeJsonOrRemoveEmpty, } from './uninstall.js';
|
|
9
|
+
export function installClaude(options) {
|
|
10
|
+
mergeJsonMcpServer(claudeMcpPath(options.scope, options.workspace, options.homeDir));
|
|
11
|
+
writeSkill(path.join(rootForScope(options, '.claude'), 'skills', 'openturtle-cli', 'SKILL.md'), 'Claude');
|
|
12
|
+
if (options.withAgentHooks) {
|
|
13
|
+
mergeJsonMcpServerInSettings(claudeSettingsPath(options.scope, options.workspace, options.homeDir));
|
|
14
|
+
const settingsPath = claudeSettingsPath(options.scope, options.workspace, options.homeDir);
|
|
15
|
+
const settings = readJsonFile(settingsPath, {});
|
|
16
|
+
const hooks = settings.hooks || {};
|
|
17
|
+
settings.hooks = {
|
|
18
|
+
...hooks,
|
|
19
|
+
UserPromptSubmit: mergeManagedHookArray(hooks.UserPromptSubmit, managedHook('claude', 'UserPromptSubmit')),
|
|
20
|
+
PreToolUse: mergeManagedHookArray(hooks.PreToolUse, managedHook('claude', 'PreToolUse', '*')),
|
|
21
|
+
PostToolUse: mergeManagedHookArray(hooks.PostToolUse, managedHook('claude', 'PostToolUse', '*')),
|
|
22
|
+
Stop: mergeManagedHookArray(hooks.Stop, managedHook('claude', 'Stop')),
|
|
23
|
+
};
|
|
24
|
+
writeJsonFile(settingsPath, settings);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
export function installCodex(options) {
|
|
28
|
+
const configPath = codexConfigPath(options.scope, options.workspace, options.homeDir);
|
|
29
|
+
mergeCodexMcpServer(configPath);
|
|
30
|
+
writeSkill(path.join(rootForScope(options, '.codex'), 'skills', 'openturtle-cli', 'SKILL.md'), 'Codex');
|
|
31
|
+
appendAgentsSnippet(options.workspace);
|
|
32
|
+
if (options.withAgentHooks) {
|
|
33
|
+
appendCodexHookBlocks(configPath, [
|
|
34
|
+
codexHookBlock('PreToolUse', 'codex', 'PreToolUse'),
|
|
35
|
+
codexHookBlock('PostToolUse', 'codex', 'PostToolUse'),
|
|
36
|
+
codexHookBlock('Stop', 'codex', 'Stop'),
|
|
37
|
+
].join('\n\n'));
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
export function installCursor(options) {
|
|
41
|
+
mergeJsonMcpServer(cursorMcpPath(options.workspace));
|
|
42
|
+
writeTextFile(path.join(options.workspace, '.cursor', 'rules', 'openturtle-cli.mdc'), [
|
|
43
|
+
'---',
|
|
44
|
+
'description: Use OpenTurtle CLI MCP tools for goals, todos, reusable knowledge, source files, worklogs, and report drafts.',
|
|
45
|
+
'alwaysApply: true',
|
|
46
|
+
'---',
|
|
47
|
+
'',
|
|
48
|
+
'Prefer the `ot-cli` MCP server when reading or updating OpenTurtle goals, todos, worklogs, reports, or reusable workspace knowledge.',
|
|
49
|
+
'Use knowledge source-file tools only when you need the original files behind a knowledge object.',
|
|
50
|
+
'Todo quality: when creating or proposing todos, include source context, scope/environment, concrete steps, measurable acceptance criteria, and evidence. If any of those are unclear, use AskUserQuestion when available or ask the user one concise question before creating the todo.',
|
|
51
|
+
'',
|
|
52
|
+
].join('\n'));
|
|
53
|
+
if (options.withAgentHooks) {
|
|
54
|
+
const hooksPath = path.join(options.homeDir || os.homedir(), '.cursor', 'hooks.json');
|
|
55
|
+
const hooks = readJsonFile(hooksPath, {});
|
|
56
|
+
hooks.preToolUse = mergeManagedHookArray(hooks.preToolUse, cursorHook('cursor', 'pre_tool_use'));
|
|
57
|
+
hooks.postToolUse = mergeManagedHookArray(hooks.postToolUse, cursorHook('cursor', 'post_tool_use'));
|
|
58
|
+
writeJsonFile(hooksPath, hooks);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
export function installQoder(options) {
|
|
62
|
+
mergeJsonMcpServer(qoderMcpPath(options.scope, options.workspace, options.homeDir));
|
|
63
|
+
writeSkill(path.join(rootForScope(options, '.qoder'), 'skills', 'openturtle-cli', 'SKILL.md'), 'Qoder');
|
|
64
|
+
if (options.withAgentHooks) {
|
|
65
|
+
const settingsPath = options.scope === 'user'
|
|
66
|
+
? path.join(options.homeDir || os.homedir(), '.qoder', 'settings.json')
|
|
67
|
+
: path.join(options.workspace, '.qoder', 'settings.local.json');
|
|
68
|
+
const settings = readJsonFile(settingsPath, {});
|
|
69
|
+
const hooks = settings.hooks || {};
|
|
70
|
+
settings.hooks = {
|
|
71
|
+
...hooks,
|
|
72
|
+
UserPromptSubmit: mergeManagedHookArray(hooks.UserPromptSubmit, managedHook('qoder', 'UserPromptSubmit')),
|
|
73
|
+
PreToolUse: mergeManagedHookArray(hooks.PreToolUse, managedHook('qoder', 'PreToolUse', '*')),
|
|
74
|
+
PostToolUse: mergeManagedHookArray(hooks.PostToolUse, managedHook('qoder', 'PostToolUse', '*')),
|
|
75
|
+
PostToolUseFailure: mergeManagedHookArray(hooks.PostToolUseFailure, managedHook('qoder', 'PostToolUseFailure', '*')),
|
|
76
|
+
Stop: mergeManagedHookArray(hooks.Stop, managedHook('qoder', 'Stop')),
|
|
77
|
+
};
|
|
78
|
+
writeJsonFile(settingsPath, settings);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
export function installKiro(options) {
|
|
82
|
+
mergeJsonMcpServer(kiroMcpPath(options.scope, options.workspace, options.homeDir));
|
|
83
|
+
writeTextFile(path.join(rootForScope(options, '.kiro'), 'steering', 'openturtle-cli.md'), [
|
|
84
|
+
'# OpenTurtle CLI',
|
|
85
|
+
'',
|
|
86
|
+
'Use the `ot-cli` MCP server for OpenTurtle goals, todos, reusable knowledge, source files, worklogs, and daily report drafts.',
|
|
87
|
+
'Todo quality: when creating or proposing todos, include source context, scope/environment, concrete steps, measurable acceptance criteria, and evidence. If any of those are unclear, use AskUserQuestion when available or ask the user one concise question before creating the todo.',
|
|
88
|
+
'Hooks installed by OpenTurtle are audit-only and must not block the user workflow.',
|
|
89
|
+
'',
|
|
90
|
+
].join('\n'));
|
|
91
|
+
if (options.withAgentHooks) {
|
|
92
|
+
const agentPath = path.join(rootForScope(options, '.kiro'), 'agents', 'openturtle-cli.json');
|
|
93
|
+
const agent = readJsonFile(agentPath, {});
|
|
94
|
+
const hooks = agent.hooks || {};
|
|
95
|
+
agent.name = 'openturtle-cli';
|
|
96
|
+
agent.description = 'OpenTurtle local audit and MCP integration';
|
|
97
|
+
agent.hooks = {
|
|
98
|
+
...hooks,
|
|
99
|
+
agentSpawn: managedKiroHookList('agentSpawn'),
|
|
100
|
+
userPromptSubmit: managedKiroHookList('userPromptSubmit'),
|
|
101
|
+
preToolUse: managedKiroHookList('preToolUse', '*'),
|
|
102
|
+
postToolUse: managedKiroHookList('postToolUse', '*'),
|
|
103
|
+
stop: managedKiroHookList('stop'),
|
|
104
|
+
};
|
|
105
|
+
writeJsonFile(agentPath, agent);
|
|
106
|
+
writeKiroIdeHook(path.join(rootForScope(options, '.kiro'), 'hooks', 'openturtle-cli-audit.kiro.hook'));
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
export function uninstallClaude(options) {
|
|
110
|
+
const root = rootForScope(options, '.claude');
|
|
111
|
+
removeJsonMcpServer(claudeMcpPath(options.scope, options.workspace, options.homeDir), 'ot-cli', root);
|
|
112
|
+
const settingsPath = claudeSettingsPath(options.scope, options.workspace, options.homeDir);
|
|
113
|
+
if (fs.existsSync(settingsPath)) {
|
|
114
|
+
const settings = readJsonFile(settingsPath, {});
|
|
115
|
+
if (settings.mcpServers && typeof settings.mcpServers === 'object') {
|
|
116
|
+
delete settings.mcpServers['ot-cli'];
|
|
117
|
+
}
|
|
118
|
+
writeJsonOrRemoveEmpty(settingsPath, removeManagedHookEntries(settings), root);
|
|
119
|
+
}
|
|
120
|
+
removeDirIfExists(path.join(root, 'skills', 'openturtle-cli'), root);
|
|
121
|
+
}
|
|
122
|
+
export function uninstallCodex(options) {
|
|
123
|
+
const root = rootForScope(options, '.codex');
|
|
124
|
+
removeCodexManagedConfig(codexConfigPath(options.scope, options.workspace, options.homeDir), root);
|
|
125
|
+
removeDirIfExists(path.join(root, 'skills', 'openturtle-cli'), root);
|
|
126
|
+
removeAgentsSnippet(options.workspace);
|
|
127
|
+
}
|
|
128
|
+
export function uninstallCursor(options) {
|
|
129
|
+
removeJsonMcpServer(cursorMcpPath(options.workspace), 'ot-cli', options.workspace);
|
|
130
|
+
const root = path.join(options.workspace, '.cursor');
|
|
131
|
+
removeFileIfExists(path.join(root, 'rules', 'openturtle-cli.mdc'), root);
|
|
132
|
+
const hooksPath = path.join(options.homeDir || os.homedir(), '.cursor', 'hooks.json');
|
|
133
|
+
if (fs.existsSync(hooksPath)) {
|
|
134
|
+
const hooks = readJsonFile(hooksPath, {});
|
|
135
|
+
writeJsonOrRemoveEmpty(hooksPath, removeCursorHookEntries(hooks), path.dirname(hooksPath));
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
export function uninstallQoder(options) {
|
|
139
|
+
const root = rootForScope(options, '.qoder');
|
|
140
|
+
removeJsonMcpServer(qoderMcpPath(options.scope, options.workspace, options.homeDir), 'ot-cli', options.scope === 'user'
|
|
141
|
+
? path.dirname(qoderMcpPath(options.scope, options.workspace, options.homeDir))
|
|
142
|
+
: options.workspace);
|
|
143
|
+
removeDirIfExists(path.join(root, 'skills', 'openturtle-cli'), root);
|
|
144
|
+
const settingsPath = options.scope === 'user'
|
|
145
|
+
? path.join(options.homeDir || os.homedir(), '.qoder', 'settings.json')
|
|
146
|
+
: path.join(options.workspace, '.qoder', 'settings.local.json');
|
|
147
|
+
if (fs.existsSync(settingsPath)) {
|
|
148
|
+
const settings = readJsonFile(settingsPath, {});
|
|
149
|
+
writeJsonOrRemoveEmpty(settingsPath, removeManagedHookEntries(settings), root);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
export function uninstallKiro(options) {
|
|
153
|
+
const root = rootForScope(options, '.kiro');
|
|
154
|
+
removeJsonMcpServer(kiroMcpPath(options.scope, options.workspace, options.homeDir), 'ot-cli', root);
|
|
155
|
+
removeFileIfExists(path.join(root, 'steering', 'openturtle-cli.md'), root);
|
|
156
|
+
removeFileIfExists(path.join(root, 'agents', 'openturtle-cli.json'), root);
|
|
157
|
+
removeFileIfExists(path.join(root, 'hooks', 'openturtle-cli-audit.kiro.hook'), root);
|
|
158
|
+
}
|
|
159
|
+
function rootForScope(options, dirName) {
|
|
160
|
+
return options.scope === 'user'
|
|
161
|
+
? path.join(options.homeDir || os.homedir(), dirName)
|
|
162
|
+
: path.join(options.workspace, dirName);
|
|
163
|
+
}
|
|
164
|
+
function mergeJsonMcpServerInSettings(settingsPath) {
|
|
165
|
+
const settings = readJsonFile(settingsPath, {});
|
|
166
|
+
const servers = settings.mcpServers && typeof settings.mcpServers === 'object'
|
|
167
|
+
? settings.mcpServers
|
|
168
|
+
: {};
|
|
169
|
+
servers['ot-cli'] = { command: 'ot', args: ['mcp', 'serve'], enabled: true };
|
|
170
|
+
writeJsonFile(settingsPath, { ...settings, mcpServers: servers });
|
|
171
|
+
}
|
|
172
|
+
function managedHook(target, event, matcher) {
|
|
173
|
+
return withoutUndefined({
|
|
174
|
+
id: 'OPENTURTLE-CLI',
|
|
175
|
+
name: 'OPENTURTLE-CLI audit',
|
|
176
|
+
matcher,
|
|
177
|
+
command: hookCommand(target, event),
|
|
178
|
+
timeout_ms: 2000,
|
|
179
|
+
continue_on_error: true,
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
function managedKiroHookList(event, matcher) {
|
|
183
|
+
return [
|
|
184
|
+
withoutUndefined({
|
|
185
|
+
id: 'OPENTURTLE-CLI',
|
|
186
|
+
name: 'OPENTURTLE-CLI audit',
|
|
187
|
+
matcher,
|
|
188
|
+
command: hookCommand('kiro', event),
|
|
189
|
+
timeout_ms: 2000,
|
|
190
|
+
cache_ttl_seconds: 0,
|
|
191
|
+
continue_on_error: true,
|
|
192
|
+
}),
|
|
193
|
+
];
|
|
194
|
+
}
|
|
195
|
+
function cursorHook(target, event) {
|
|
196
|
+
return {
|
|
197
|
+
id: 'OPENTURTLE-CLI',
|
|
198
|
+
command: hookCommand(target, event),
|
|
199
|
+
timeout_ms: 2000,
|
|
200
|
+
continue_on_error: true,
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
function mergeManagedHookArray(existing, entry) {
|
|
204
|
+
const values = Array.isArray(existing) ? existing.filter((item) => item?.id !== 'OPENTURTLE-CLI') : [];
|
|
205
|
+
return [...values, entry];
|
|
206
|
+
}
|
|
207
|
+
function codexHookBlock(hook, target, event) {
|
|
208
|
+
return [
|
|
209
|
+
`[[hooks.${hook}]]`,
|
|
210
|
+
`command = "ot"`,
|
|
211
|
+
`args = ["hook", "ingest", "--target", "${target}", "--event", "${event}", "--stdin-json"]`,
|
|
212
|
+
`timeout_ms = 2000`,
|
|
213
|
+
].join('\n');
|
|
214
|
+
}
|
|
215
|
+
function writeSkill(filePath, agentName) {
|
|
216
|
+
writeTextFile(filePath, [
|
|
217
|
+
'---',
|
|
218
|
+
'name: openturtle-cli',
|
|
219
|
+
'description: Use OpenTurtle projects, team roster, project context, knowledge, meetings, goals, todos, and worklogs from an agent or terminal without requiring the Desktop client.',
|
|
220
|
+
'---',
|
|
221
|
+
'',
|
|
222
|
+
'# OpenTurtle CLI',
|
|
223
|
+
'',
|
|
224
|
+
`Use the \`ot-cli\` MCP server in ${agentName} when its tool covers the task. Use the direct \`ot\` command for projects, roster, project context, meetings, or any newer operation that is not exposed by MCP.`,
|
|
225
|
+
'If authentication is missing, ask the user to run `ot auth login --web`; never request or expose their browser token.',
|
|
226
|
+
'Run `ot --json doctor` before a remote workflow when authentication or server reachability is uncertain.',
|
|
227
|
+
'Read commands emit JSON. Before a write, run the same command with `--dry-run` and inspect `request.path` and `request.body`.',
|
|
228
|
+
'Treat knowledge as reusable memory objects. Treat source files as original materials referenced by knowledge, not as a second knowledge system.',
|
|
229
|
+
'',
|
|
230
|
+
'## Meeting workflow',
|
|
231
|
+
'',
|
|
232
|
+
'1. Resolve the project with `ot projects resolve`, then inspect `ot roster list` and `ot context get`.',
|
|
233
|
+
'2. Upload audio with `ot meetings upload` or inspect an existing meeting with `ot meetings show`, `transcript`, and `minutes`.',
|
|
234
|
+
'3. Publish complete minutes with `ot meetings publish <meeting-id>`. Meeting minutes belong in Knowledge, not WorkLog.',
|
|
235
|
+
'4. Review drafts using `ot meetings updates list <meeting-id>` and edit them when needed.',
|
|
236
|
+
'5. Confirm only reviewed updates with `ot meetings updates confirm ... --knowledge <knowledge-id>`. Never confirm without the saved meeting knowledge id.',
|
|
237
|
+
'6. Use the materialized Todo or WorkLog ids returned by confirmation as the project update evidence.',
|
|
238
|
+
'',
|
|
239
|
+
'## Todo quality',
|
|
240
|
+
'',
|
|
241
|
+
'When creating or proposing OpenTurtle todos through any available OpenTurtle MCP tool, include source context, scope/environment, concrete action steps, measurable acceptance criteria, and required evidence.',
|
|
242
|
+
'Source context means the goal, meeting, plan artifact, commit, PR, file, worklog, or user request that caused the todo.',
|
|
243
|
+
'If the source context, environment, owner, due date, measurable acceptance criteria, or evidence is unclear, ask the user first. Use AskUserQuestion when the host supports it; otherwise ask one concise question before creating the todo.',
|
|
244
|
+
'Avoid vague acceptance such as "no obvious delay", "works normally", or "verify it". Prefer concrete thresholds, environments, and evidence.',
|
|
245
|
+
'Use `--body-file <json>` or `--body-file -` for structured writes so shell quoting does not corrupt payloads.',
|
|
246
|
+
'Do not write to `.openturtle/`; CLI state lives in `.openturtle-cli/` and `~/.openturtle/cli/`.',
|
|
247
|
+
'',
|
|
248
|
+
].join('\n'));
|
|
249
|
+
}
|
|
250
|
+
function appendAgentsSnippet(workspace) {
|
|
251
|
+
const filePath = path.join(workspace, 'AGENTS.md');
|
|
252
|
+
const existing = fs.existsSync(filePath) ? fs.readFileSync(filePath, 'utf-8') : '';
|
|
253
|
+
const marker = '<!-- OPENTURTLE-CLI -->';
|
|
254
|
+
if (existing.includes(marker))
|
|
255
|
+
return;
|
|
256
|
+
const snippet = [
|
|
257
|
+
marker,
|
|
258
|
+
'',
|
|
259
|
+
'When OpenTurtle goals, todos, reusable knowledge, source files, worklogs, or report drafts are needed, prefer the `ot-cli` MCP server.',
|
|
260
|
+
'Knowledge means reusable memory objects; source files are original materials behind knowledge.',
|
|
261
|
+
'Todo quality: when creating or proposing todos, include source context, scope/environment, concrete steps, measurable acceptance criteria, and evidence. If any of those are unclear, use AskUserQuestion when available or ask the user one concise question before creating the todo.',
|
|
262
|
+
'',
|
|
263
|
+
'<!-- /OPENTURTLE-CLI -->',
|
|
264
|
+
].join('\n');
|
|
265
|
+
writeTextFile(filePath, `${existing.trimEnd()}${existing.trim() ? '\n\n' : ''}${snippet}\n`);
|
|
266
|
+
}
|
|
267
|
+
function removeAgentsSnippet(workspace) {
|
|
268
|
+
const filePath = path.join(workspace, 'AGENTS.md');
|
|
269
|
+
if (!fs.existsSync(filePath))
|
|
270
|
+
return;
|
|
271
|
+
const content = fs.readFileSync(filePath, 'utf-8');
|
|
272
|
+
const cleaned = content.replace(/<!-- OPENTURTLE-CLI -->[\s\S]*?<!-- \/OPENTURTLE-CLI -->\n?/m, '').trimEnd();
|
|
273
|
+
if (cleaned) {
|
|
274
|
+
fs.writeFileSync(filePath, `${cleaned}\n`, 'utf-8');
|
|
275
|
+
}
|
|
276
|
+
else {
|
|
277
|
+
fs.rmSync(filePath, { force: true });
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
function writeKiroIdeHook(filePath) {
|
|
281
|
+
writeTextFile(filePath, [
|
|
282
|
+
'{',
|
|
283
|
+
' "name": "OpenTurtle CLI audit",',
|
|
284
|
+
' "description": "Record Kiro hook events into .openturtle-cli/state.db",',
|
|
285
|
+
' "event": "postToolUse",',
|
|
286
|
+
' "action": {',
|
|
287
|
+
' "type": "command",',
|
|
288
|
+
` "command": "${hookCommand('kiro', 'postToolUse').replaceAll('"', '\\"')}",`,
|
|
289
|
+
' "timeout_ms": 2000',
|
|
290
|
+
' }',
|
|
291
|
+
'}',
|
|
292
|
+
'',
|
|
293
|
+
].join('\n'));
|
|
294
|
+
}
|
|
295
|
+
function writeTextFile(filePath, content) {
|
|
296
|
+
ensureFileDir(filePath);
|
|
297
|
+
fs.writeFileSync(filePath, content, 'utf-8');
|
|
298
|
+
}
|
|
299
|
+
function withoutUndefined(value) {
|
|
300
|
+
return Object.fromEntries(Object.entries(value).filter(([, item]) => item !== undefined));
|
|
301
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { ensureDir, projectWorklogDir } from '../core/paths.js';
|
|
4
|
+
const MARKER = 'OPENTURTLE-CLI';
|
|
5
|
+
export function installGitHook(workspace) {
|
|
6
|
+
const gitHooksDir = path.join(workspace, '.git', 'hooks');
|
|
7
|
+
ensureDir(gitHooksDir);
|
|
8
|
+
ensureDir(path.join(projectWorklogDir(workspace), 'hooks'));
|
|
9
|
+
const hookPath = path.join(gitHooksDir, 'post-commit');
|
|
10
|
+
const originalPath = path.join(projectWorklogDir(workspace), 'hooks', 'git-post-commit.original');
|
|
11
|
+
if (fs.existsSync(hookPath)) {
|
|
12
|
+
const current = fs.readFileSync(hookPath, 'utf-8');
|
|
13
|
+
if (current.includes(MARKER))
|
|
14
|
+
return { installed: false, hookPath };
|
|
15
|
+
fs.renameSync(hookPath, originalPath);
|
|
16
|
+
}
|
|
17
|
+
const wrapper = `#!/bin/sh
|
|
18
|
+
# >>> ${MARKER}:git-post-commit
|
|
19
|
+
original_status=0
|
|
20
|
+
if [ -x "${originalPath}" ]; then
|
|
21
|
+
"${originalPath}" "$@"
|
|
22
|
+
original_status="$?"
|
|
23
|
+
fi
|
|
24
|
+
|
|
25
|
+
if command -v ot >/dev/null 2>&1; then
|
|
26
|
+
(cd "${workspace}" && ot hook git-post-commit >/dev/null 2>&1) || true
|
|
27
|
+
fi
|
|
28
|
+
|
|
29
|
+
exit "$original_status"
|
|
30
|
+
# <<< ${MARKER}:git-post-commit
|
|
31
|
+
`;
|
|
32
|
+
fs.writeFileSync(hookPath, wrapper, 'utf-8');
|
|
33
|
+
fs.chmodSync(hookPath, 0o755);
|
|
34
|
+
return { installed: true, hookPath };
|
|
35
|
+
}
|
|
36
|
+
export function uninstallGitHook(workspace) {
|
|
37
|
+
const hookPath = path.join(workspace, '.git', 'hooks', 'post-commit');
|
|
38
|
+
const originalPath = path.join(projectWorklogDir(workspace), 'hooks', 'git-post-commit.original');
|
|
39
|
+
if (!fs.existsSync(hookPath))
|
|
40
|
+
return;
|
|
41
|
+
const current = fs.readFileSync(hookPath, 'utf-8');
|
|
42
|
+
if (!current.includes(MARKER))
|
|
43
|
+
return;
|
|
44
|
+
if (fs.existsSync(originalPath)) {
|
|
45
|
+
fs.copyFileSync(originalPath, hookPath);
|
|
46
|
+
fs.chmodSync(hookPath, 0o755);
|
|
47
|
+
}
|
|
48
|
+
else {
|
|
49
|
+
fs.unlinkSync(hookPath);
|
|
50
|
+
}
|
|
51
|
+
fs.rmSync(originalPath, { force: true });
|
|
52
|
+
removeEmptyDir(path.dirname(originalPath));
|
|
53
|
+
}
|
|
54
|
+
function removeEmptyDir(dirPath) {
|
|
55
|
+
try {
|
|
56
|
+
if (fs.existsSync(dirPath) && fs.readdirSync(dirPath).length === 0) {
|
|
57
|
+
fs.rmdirSync(dirPath);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
// Uninstall should never fail because cleanup of an auxiliary directory failed.
|
|
62
|
+
}
|
|
63
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { projectWorklogDir } from '../core/paths.js';
|
|
4
|
+
export function uninstallAllLocalState(workspace) {
|
|
5
|
+
const dir = projectWorklogDir(workspace);
|
|
6
|
+
if (!fs.existsSync(dir))
|
|
7
|
+
return { removedProjectDir: false };
|
|
8
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
9
|
+
return { removedProjectDir: true };
|
|
10
|
+
}
|
|
11
|
+
export function removeFileIfExists(filePath, pruneStopDir) {
|
|
12
|
+
if (!fs.existsSync(filePath))
|
|
13
|
+
return false;
|
|
14
|
+
fs.rmSync(filePath, { force: true });
|
|
15
|
+
pruneEmptyParents(path.dirname(filePath), pruneStopDir);
|
|
16
|
+
return true;
|
|
17
|
+
}
|
|
18
|
+
export function removeDirIfExists(dirPath, pruneStopDir) {
|
|
19
|
+
if (!fs.existsSync(dirPath))
|
|
20
|
+
return false;
|
|
21
|
+
fs.rmSync(dirPath, { recursive: true, force: true });
|
|
22
|
+
pruneEmptyParents(path.dirname(dirPath), pruneStopDir);
|
|
23
|
+
return true;
|
|
24
|
+
}
|
|
25
|
+
export function pruneEmptyParents(startDir, stopDir) {
|
|
26
|
+
let current = startDir;
|
|
27
|
+
const stop = stopDir ? path.resolve(stopDir) : undefined;
|
|
28
|
+
for (let index = 0; index < 3; index += 1) {
|
|
29
|
+
try {
|
|
30
|
+
if (stop && path.resolve(current) === stop)
|
|
31
|
+
return;
|
|
32
|
+
if (!fs.existsSync(current))
|
|
33
|
+
return;
|
|
34
|
+
if (fs.readdirSync(current).length > 0)
|
|
35
|
+
return;
|
|
36
|
+
fs.rmdirSync(current);
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
current = path.dirname(current);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
export function removeManagedHookEntries(settings) {
|
|
45
|
+
if (!settings.hooks || typeof settings.hooks !== 'object')
|
|
46
|
+
return settings;
|
|
47
|
+
const nextHooks = {};
|
|
48
|
+
for (const [event, entries] of Object.entries(settings.hooks)) {
|
|
49
|
+
if (!Array.isArray(entries)) {
|
|
50
|
+
nextHooks[event] = entries;
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
const kept = entries.filter((entry) => !isOpenTurtleEntry(entry));
|
|
54
|
+
if (kept.length > 0)
|
|
55
|
+
nextHooks[event] = kept;
|
|
56
|
+
}
|
|
57
|
+
if (Object.keys(nextHooks).length > 0) {
|
|
58
|
+
settings.hooks = nextHooks;
|
|
59
|
+
}
|
|
60
|
+
else {
|
|
61
|
+
delete settings.hooks;
|
|
62
|
+
}
|
|
63
|
+
return settings;
|
|
64
|
+
}
|
|
65
|
+
export function removeCursorHookEntries(hooks) {
|
|
66
|
+
for (const [event, entries] of Object.entries(hooks)) {
|
|
67
|
+
if (!Array.isArray(entries))
|
|
68
|
+
continue;
|
|
69
|
+
hooks[event] = entries.filter((entry) => !isOpenTurtleEntry(entry));
|
|
70
|
+
if (hooks[event].length === 0)
|
|
71
|
+
delete hooks[event];
|
|
72
|
+
}
|
|
73
|
+
return hooks;
|
|
74
|
+
}
|
|
75
|
+
export function writeJsonOrRemoveEmpty(filePath, value, pruneStopDir) {
|
|
76
|
+
const next = removeEmptyObjects(value);
|
|
77
|
+
if (Object.keys(next).length === 0) {
|
|
78
|
+
removeFileIfExists(filePath, pruneStopDir);
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
82
|
+
fs.writeFileSync(filePath, `${JSON.stringify(next, null, 2)}\n`, 'utf-8');
|
|
83
|
+
}
|
|
84
|
+
function isOpenTurtleEntry(entry) {
|
|
85
|
+
if (!entry || typeof entry !== 'object')
|
|
86
|
+
return false;
|
|
87
|
+
const value = entry;
|
|
88
|
+
return (value.id === 'OPENTURTLE-CLI' ||
|
|
89
|
+
value.name === 'OPENTURTLE-CLI audit' ||
|
|
90
|
+
(typeof value.command === 'string' && value.command.includes('ot hook ingest')));
|
|
91
|
+
}
|
|
92
|
+
function removeEmptyObjects(value) {
|
|
93
|
+
const next = {};
|
|
94
|
+
for (const [key, item] of Object.entries(value)) {
|
|
95
|
+
if (item && typeof item === 'object' && !Array.isArray(item)) {
|
|
96
|
+
const child = removeEmptyObjects(item);
|
|
97
|
+
if (Object.keys(child).length > 0)
|
|
98
|
+
next[key] = child;
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
if (Array.isArray(item) && item.length === 0)
|
|
102
|
+
continue;
|
|
103
|
+
if (item !== undefined)
|
|
104
|
+
next[key] = item;
|
|
105
|
+
}
|
|
106
|
+
return next;
|
|
107
|
+
}
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import readline from 'node:readline';
|
|
2
|
+
import { createResourceCommands, draftDailyReport } from '../commands/resources.js';
|
|
3
|
+
import { initProjectStore, listEvents, upsertEvent } from '../core/store.js';
|
|
4
|
+
const TOOLS = [
|
|
5
|
+
tool('list_my_todos', 'List my OpenTurtle todos', {
|
|
6
|
+
type: 'object',
|
|
7
|
+
properties: {
|
|
8
|
+
project_id: { type: 'string' },
|
|
9
|
+
status: { type: 'string' },
|
|
10
|
+
mine: { type: 'boolean' },
|
|
11
|
+
},
|
|
12
|
+
}),
|
|
13
|
+
tool('list_goals', 'List OpenTurtle goals', {
|
|
14
|
+
type: 'object',
|
|
15
|
+
properties: {
|
|
16
|
+
project_id: { type: 'string' },
|
|
17
|
+
status: { type: 'string' },
|
|
18
|
+
mine: { type: 'boolean' },
|
|
19
|
+
},
|
|
20
|
+
}),
|
|
21
|
+
tool('list_workspace_knowledge', 'List reusable workspace knowledge objects', {
|
|
22
|
+
type: 'object',
|
|
23
|
+
properties: { project_id: { type: 'string' }, q: { type: 'string' } },
|
|
24
|
+
}),
|
|
25
|
+
tool('search_workspace_knowledge', 'Search reusable workspace knowledge objects', {
|
|
26
|
+
type: 'object',
|
|
27
|
+
properties: { keyword: { type: 'string' } },
|
|
28
|
+
required: ['keyword'],
|
|
29
|
+
}),
|
|
30
|
+
tool('list_knowledge_source_files', 'List source files behind workspace knowledge', {
|
|
31
|
+
type: 'object',
|
|
32
|
+
properties: { path: { type: 'string' }, q: { type: 'string' } },
|
|
33
|
+
}),
|
|
34
|
+
tool('read_knowledge_source_file', 'Read a source file behind workspace knowledge', {
|
|
35
|
+
type: 'object',
|
|
36
|
+
properties: { path: { type: 'string' } },
|
|
37
|
+
required: ['path'],
|
|
38
|
+
}),
|
|
39
|
+
tool('record_worklog', 'Record a work log entry', {
|
|
40
|
+
type: 'object',
|
|
41
|
+
properties: {
|
|
42
|
+
summary: { type: 'string' },
|
|
43
|
+
type: { type: 'string' },
|
|
44
|
+
},
|
|
45
|
+
required: ['summary'],
|
|
46
|
+
}),
|
|
47
|
+
tool('list_worklogs', 'List work logs', {
|
|
48
|
+
type: 'object',
|
|
49
|
+
properties: { today: { type: 'boolean' }, window: { type: 'string' } },
|
|
50
|
+
}),
|
|
51
|
+
tool('draft_daily_report', "Draft today's local daily report", {
|
|
52
|
+
type: 'object',
|
|
53
|
+
properties: { output: { type: 'string' } },
|
|
54
|
+
}),
|
|
55
|
+
tool('update_todo_progress', 'Update todo progress description with current progress, blockers, next step, and evidence such as logs, tests, screenshots, PRs, commits, or worklog references.', {
|
|
56
|
+
type: 'object',
|
|
57
|
+
properties: {
|
|
58
|
+
todo_id: { type: 'string' },
|
|
59
|
+
message: { type: 'string' },
|
|
60
|
+
},
|
|
61
|
+
required: ['todo_id', 'message'],
|
|
62
|
+
}),
|
|
63
|
+
tool('update_todo_status', 'Update todo status', {
|
|
64
|
+
type: 'object',
|
|
65
|
+
properties: {
|
|
66
|
+
todo_id: { type: 'string' },
|
|
67
|
+
status: { type: 'string' },
|
|
68
|
+
},
|
|
69
|
+
required: ['todo_id', 'status'],
|
|
70
|
+
}),
|
|
71
|
+
];
|
|
72
|
+
export async function handleMcpRequest(msg) {
|
|
73
|
+
const { id, method, params } = msg;
|
|
74
|
+
switch (method) {
|
|
75
|
+
case 'initialize':
|
|
76
|
+
return result(id, {
|
|
77
|
+
protocolVersion: '2024-11-05',
|
|
78
|
+
capabilities: { tools: {} },
|
|
79
|
+
serverInfo: { name: 'ot-cli', version: '0.2.0' },
|
|
80
|
+
});
|
|
81
|
+
case 'notifications/initialized':
|
|
82
|
+
return { jsonrpc: '2.0' };
|
|
83
|
+
case 'tools/list':
|
|
84
|
+
return result(id, { tools: TOOLS });
|
|
85
|
+
case 'tools/call': {
|
|
86
|
+
const name = String(params?.name || '');
|
|
87
|
+
const args = (params?.arguments || {});
|
|
88
|
+
try {
|
|
89
|
+
const output = await handleTool(name, args);
|
|
90
|
+
return result(id, { content: [{ type: 'text', text: JSON.stringify(output, null, 2) }] });
|
|
91
|
+
}
|
|
92
|
+
catch (err) {
|
|
93
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
94
|
+
return result(id, { content: [{ type: 'text', text: `Error: ${message}` }], isError: true });
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
case 'ping':
|
|
98
|
+
return result(id, {});
|
|
99
|
+
default:
|
|
100
|
+
return error(id, -32601, `Method not found: ${method}`);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
export async function serveMcp() {
|
|
104
|
+
const rl = readline.createInterface({ input: process.stdin });
|
|
105
|
+
for await (const line of rl) {
|
|
106
|
+
if (!line.trim())
|
|
107
|
+
continue;
|
|
108
|
+
try {
|
|
109
|
+
const response = await handleMcpRequest(JSON.parse(line));
|
|
110
|
+
if (response.id !== undefined || response.result || response.error) {
|
|
111
|
+
process.stdout.write(`${JSON.stringify(response)}\n`);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
catch (err) {
|
|
115
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
116
|
+
process.stderr.write(`[ot-cli-mcp] parse error: ${message}\n`);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
async function handleTool(name, args) {
|
|
121
|
+
const commands = createResourceCommands();
|
|
122
|
+
try {
|
|
123
|
+
if (name === 'list_my_todos')
|
|
124
|
+
return await commands.listTodos({ ...args, mine: args.mine ?? true });
|
|
125
|
+
if (name === 'list_goals')
|
|
126
|
+
return await commands.listGoals(args);
|
|
127
|
+
if (name === 'list_workspace_knowledge')
|
|
128
|
+
return await commands.listKnowledge(args);
|
|
129
|
+
if (name === 'search_workspace_knowledge')
|
|
130
|
+
return await commands.searchKnowledge(String(args.keyword || ''));
|
|
131
|
+
if (name === 'list_knowledge_source_files')
|
|
132
|
+
return await commands.listKnowledgeSources(args);
|
|
133
|
+
if (name === 'read_knowledge_source_file')
|
|
134
|
+
return await commands.readKnowledgeSource(String(args.path || ''));
|
|
135
|
+
if (name === 'record_worklog')
|
|
136
|
+
return await commands.recordWorklog(String(args.summary || ''), String(args.type || 'note'));
|
|
137
|
+
if (name === 'list_worklogs')
|
|
138
|
+
return await commands.listWorklogs(args);
|
|
139
|
+
if (name === 'update_todo_progress') {
|
|
140
|
+
return await commands.updateTodoProgress(String(args.todo_id || ''), String(args.message || ''));
|
|
141
|
+
}
|
|
142
|
+
if (name === 'update_todo_status') {
|
|
143
|
+
return await commands.updateTodoStatus(String(args.todo_id || ''), String(args.status || ''));
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
catch (err) {
|
|
147
|
+
return localFallback(name, args, err);
|
|
148
|
+
}
|
|
149
|
+
if (name === 'draft_daily_report') {
|
|
150
|
+
return { source: 'local_cache', path: draftDailyReport(process.cwd(), { output: stringArg(args.output) }) };
|
|
151
|
+
}
|
|
152
|
+
throw new Error(`Unknown tool: ${name}`);
|
|
153
|
+
}
|
|
154
|
+
function localFallback(name, args, err) {
|
|
155
|
+
if (name === 'record_worklog') {
|
|
156
|
+
const store = initProjectStore(process.cwd());
|
|
157
|
+
upsertEvent(store, {
|
|
158
|
+
type: 'agent.session_imported',
|
|
159
|
+
source: 'codex',
|
|
160
|
+
workspacePath: process.cwd(),
|
|
161
|
+
summary: String(args.summary || ''),
|
|
162
|
+
rawJson: { tool: name, args },
|
|
163
|
+
dedupeKey: `local:record_worklog:${Date.now()}:${Math.random().toString(16).slice(2)}`,
|
|
164
|
+
});
|
|
165
|
+
return { source: 'local_cache', ok: true };
|
|
166
|
+
}
|
|
167
|
+
const store = initProjectStore(process.cwd());
|
|
168
|
+
return {
|
|
169
|
+
source: 'local_cache',
|
|
170
|
+
warning: err instanceof Error ? err.message : String(err),
|
|
171
|
+
events: listEvents(store, 50),
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
function tool(name, description, inputSchema) {
|
|
175
|
+
return { name, description, inputSchema };
|
|
176
|
+
}
|
|
177
|
+
function result(id, value) {
|
|
178
|
+
return { jsonrpc: '2.0', id: id ?? 0, result: value };
|
|
179
|
+
}
|
|
180
|
+
function error(id, code, message) {
|
|
181
|
+
return { jsonrpc: '2.0', id: id ?? 0, error: { code, message } };
|
|
182
|
+
}
|
|
183
|
+
function stringArg(value) {
|
|
184
|
+
return typeof value === 'string' && value ? value : undefined;
|
|
185
|
+
}
|