@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.
@@ -0,0 +1,115 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { ApiClient } from './api-client.js';
3
+ import { readAuth, writeAuth } from './auth.js';
4
+ import { DEFAULT_SERVER_URL } from './defaults.js';
5
+ const DEFAULT_LOCAL_WEB_PORT = '5174';
6
+ export async function loginWithWeb(options, dependencies = {}) {
7
+ const current = readAuth(options.homeDir);
8
+ const server = normalizeServer(options.server || current.server || DEFAULT_SERVER_URL);
9
+ const verificationUri = resolveWebLoginUrl(server, options.webUrl);
10
+ const client = dependencies.client || new ApiClient({ server, homeDir: options.homeDir });
11
+ const sleep = dependencies.sleep || ((milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)));
12
+ const now = dependencies.now || Date.now;
13
+ const open = dependencies.openBrowser || openBrowser;
14
+ const device = await client.post('/api/auth/cli/device', {
15
+ client_name: 'OpenTurtle CLI',
16
+ verification_uri: verificationUri,
17
+ });
18
+ dependencies.onDeviceCreated?.(device);
19
+ const browserOpened = options.noOpen ? false : await open(device.verification_uri_complete);
20
+ const expiresAt = now() + device.expires_in * 1000;
21
+ let intervalSeconds = Math.max(1, device.interval || 2);
22
+ while (now() < expiresAt) {
23
+ const result = await client.post('/api/auth/cli/token', {
24
+ device_code: device.device_code,
25
+ });
26
+ if (result.status === 'authorization_pending') {
27
+ intervalSeconds = Math.max(1, result.interval || intervalSeconds);
28
+ await sleep(intervalSeconds * 1000);
29
+ continue;
30
+ }
31
+ const token = result.access_token || '';
32
+ const refreshToken = result.refresh_token || '';
33
+ if (!token || !refreshToken) {
34
+ throw new Error('Web login response did not include access and refresh tokens');
35
+ }
36
+ writeAuth({
37
+ server,
38
+ token,
39
+ refreshToken,
40
+ username: result.username,
41
+ teamId: result.team_id,
42
+ }, options.homeDir);
43
+ return {
44
+ logged_in: true,
45
+ server,
46
+ username: result.username || null,
47
+ team_id: result.team_id || null,
48
+ browser_opened: browserOpened,
49
+ };
50
+ }
51
+ throw new Error('Web login expired before authorization completed');
52
+ }
53
+ export function resolveWebLoginUrl(server, override) {
54
+ if (override)
55
+ return normalizeWebUrl(override);
56
+ const apiUrl = new URL(normalizeServer(server));
57
+ if (apiUrl.hostname === '127.0.0.1' || apiUrl.hostname === 'localhost' || apiUrl.hostname === '::1') {
58
+ apiUrl.port = DEFAULT_LOCAL_WEB_PORT;
59
+ apiUrl.pathname = '/cli-auth';
60
+ apiUrl.search = '';
61
+ apiUrl.hash = '';
62
+ return apiUrl.toString();
63
+ }
64
+ apiUrl.pathname = `${apiUrl.pathname.replace(/\/$/, '')}/cli-auth`.replace(/\/+/g, '/');
65
+ apiUrl.search = '';
66
+ apiUrl.hash = '';
67
+ return apiUrl.toString();
68
+ }
69
+ export async function openBrowser(url, platform = process.platform, spawnBrowser = spawn) {
70
+ const launch = browserLaunchCommand(url, platform);
71
+ if (!launch)
72
+ return false;
73
+ return new Promise((resolve) => {
74
+ let child;
75
+ try {
76
+ child = spawnBrowser(launch.command, launch.args, {
77
+ detached: true,
78
+ stdio: 'ignore',
79
+ windowsHide: true,
80
+ });
81
+ }
82
+ catch {
83
+ resolve(false);
84
+ return;
85
+ }
86
+ child.once('spawn', () => {
87
+ child.unref();
88
+ resolve(true);
89
+ });
90
+ child.once('error', () => resolve(false));
91
+ });
92
+ }
93
+ export function browserLaunchCommand(url, platform = process.platform) {
94
+ if (platform === 'darwin')
95
+ return { command: 'open', args: [url] };
96
+ if (platform === 'linux')
97
+ return { command: 'xdg-open', args: [url] };
98
+ if (platform === 'win32')
99
+ return { command: 'cmd', args: ['/c', 'start', '', url] };
100
+ return null;
101
+ }
102
+ function normalizeServer(value) {
103
+ const url = new URL(value);
104
+ url.pathname = url.pathname.replace(/\/$/, '') || '/';
105
+ url.search = '';
106
+ url.hash = '';
107
+ return url.toString().replace(/\/$/, '');
108
+ }
109
+ function normalizeWebUrl(value) {
110
+ const url = new URL(value);
111
+ if (!['http:', 'https:'].includes(url.protocol)) {
112
+ throw new Error('--web-url must use http or https');
113
+ }
114
+ return url.toString();
115
+ }
package/dist/index.js ADDED
@@ -0,0 +1,469 @@
1
+ #!/usr/bin/env node
2
+ import fs from 'node:fs';
3
+ import { Command } from 'commander';
4
+ import { registerCollaborationCommands } from './commands/collaboration.js';
5
+ import { ingestRawHook, recordGitPostCommit } from './commands/hook.js';
6
+ import { doctor, draftDailyReport, createResourceCommands } from './commands/resources.js';
7
+ import { ApiClient, ApiError } from './core/api-client.js';
8
+ import { clearAuth, readAuth, redactToken, writeAuth } from './core/auth.js';
9
+ import { DEFAULT_SERVER_URL } from './core/defaults.js';
10
+ import { findWorkspaceRoot } from './core/paths.js';
11
+ import { initProjectStore } from './core/store.js';
12
+ import { cliVersion } from './core/version.js';
13
+ import { loginWithWeb } from './core/web-auth.js';
14
+ import { installClaude, installCodex, installCursor, installKiro, installQoder, uninstallClaude, uninstallCodex, uninstallCursor, uninstallKiro, uninstallQoder, } from './installers/agents.js';
15
+ import { installGitHook, uninstallGitHook } from './installers/git.js';
16
+ import { uninstallAllLocalState } from './installers/uninstall.js';
17
+ import { serveMcp } from './mcp/server.js';
18
+ import { candidateHistoryPaths, importHistory } from './watchers/importers.js';
19
+ const program = new Command();
20
+ program
21
+ .name('ot')
22
+ .description('OpenTurtle collaboration, knowledge, meeting, and local agent CLI')
23
+ .version(cliVersion());
24
+ program.option('-j, --json', 'emit machine-readable JSON, including errors');
25
+ program
26
+ .command('init')
27
+ .option('--update-gitignore', 'append .openturtle-cli to .gitignore')
28
+ .action(action((options) => {
29
+ const workspace = findWorkspaceRoot();
30
+ const store = initProjectStore(workspace);
31
+ if (options.updateGitignore)
32
+ updateGitignore(workspace);
33
+ print({ initialized: true, project_dir: store.dir });
34
+ }));
35
+ program
36
+ .command('doctor')
37
+ .option('--target <target>')
38
+ .option('--json')
39
+ .action(action(async (options) => {
40
+ const result = doctor(findWorkspaceRoot(), options.target);
41
+ const api = await new ApiClient().probe();
42
+ print({ ...result, api }, options.json);
43
+ }));
44
+ const auth = program.command('auth');
45
+ auth
46
+ .command('login')
47
+ .option('--server <url>')
48
+ .option('--token <jwt>')
49
+ .option('--username <name>')
50
+ .option('--password <password>')
51
+ .option('--web', 'log in through OpenTurtle Web Admin')
52
+ .option('--web-url <url>', 'override the Web authorization page URL')
53
+ .option('--no-open', 'print the authorization URL without opening a browser')
54
+ .action(action(async (options) => {
55
+ if (options.web) {
56
+ if (options.token || options.username || options.password) {
57
+ throw new Error('--web cannot be combined with token or password login');
58
+ }
59
+ const result = await loginWithWeb({
60
+ server: options.server,
61
+ webUrl: options.webUrl,
62
+ noOpen: options.open === false,
63
+ }, {
64
+ onDeviceCreated: (session) => {
65
+ process.stderr.write(`Open ${session.verification_uri_complete}\n`);
66
+ process.stderr.write(`Device code: ${session.user_code}\n`);
67
+ },
68
+ });
69
+ print(result);
70
+ return;
71
+ }
72
+ if (options.webUrl || options.open === false) {
73
+ throw new Error('--web-url and --no-open require --web');
74
+ }
75
+ const server = options.server || DEFAULT_SERVER_URL;
76
+ if (options.token) {
77
+ writeAuth({ server, token: options.token, username: options.username });
78
+ print({ logged_in: true, server, token: redactToken(options.token) });
79
+ return;
80
+ }
81
+ if (!options.username || !options.password) {
82
+ throw new Error('Use --token or provide --username and --password');
83
+ }
84
+ const client = new ApiClient({ server });
85
+ const result = await client.post('/api/auth/login', {
86
+ username: options.username,
87
+ password: options.password,
88
+ });
89
+ const token = String(result.access_token || result.token || '');
90
+ if (!token)
91
+ throw new Error('Login response did not include token');
92
+ const refreshToken = String(result.refresh_token || '');
93
+ writeAuth({
94
+ server,
95
+ token,
96
+ refreshToken: refreshToken || undefined,
97
+ username: options.username,
98
+ });
99
+ print({ logged_in: true, server, token: redactToken(token) });
100
+ }));
101
+ auth.command('status').action(action(() => {
102
+ const current = readAuth();
103
+ print({
104
+ server: current.server || null,
105
+ username: current.username || null,
106
+ team_id: current.teamId || null,
107
+ token: redactToken(current.token),
108
+ has_refresh_token: Boolean(current.refreshToken),
109
+ logged_in: Boolean(current.token),
110
+ });
111
+ }));
112
+ auth.command('logout').action(action(() => {
113
+ clearAuth();
114
+ print({ logged_out: true });
115
+ }));
116
+ program
117
+ .command('install')
118
+ .option('--target <target>')
119
+ .option('--scope <scope>', 'project|user', 'project')
120
+ .option('--with-agent-hooks')
121
+ .action(action((options) => {
122
+ requireOption(options.target, '--target');
123
+ const workspace = findWorkspaceRoot();
124
+ for (const target of expandTargets(options.target)) {
125
+ installTarget(target, workspace, options.scope, Boolean(options.withAgentHooks));
126
+ }
127
+ print({
128
+ installed: options.target,
129
+ mcp_server_name: 'ot-cli',
130
+ with_agent_hooks: Boolean(options.withAgentHooks),
131
+ });
132
+ }));
133
+ program
134
+ .command('uninstall')
135
+ .option('--target <target>')
136
+ .option('--scope <scope>', 'project|user', 'project')
137
+ .action(action((options) => {
138
+ requireOption(options.target, '--target');
139
+ const workspace = findWorkspaceRoot();
140
+ for (const target of expandTargets(options.target)) {
141
+ uninstallTarget(target, workspace, options.scope);
142
+ }
143
+ const local_state = options.target === 'all' ? uninstallAllLocalState(workspace) : { removedProjectDir: false };
144
+ print({ uninstalled: options.target, scope: options.scope, local_state });
145
+ }));
146
+ const hook = program.command('hook');
147
+ hook
148
+ .command('ingest')
149
+ .option('--target <target>')
150
+ .option('--event <event>')
151
+ .option('--stdin-json')
152
+ .action(action(async (options) => {
153
+ requireOption(options.target, '--target');
154
+ requireOption(options.event, '--event');
155
+ const stdin = options.stdinJson ? await readStdin() : '{}';
156
+ const result = ingestRawHook({
157
+ target: options.target,
158
+ event: options.event,
159
+ stdin,
160
+ workspace: findWorkspaceRoot(),
161
+ });
162
+ if (!result.ok)
163
+ process.exitCode = 0;
164
+ }));
165
+ hook.command('git-post-commit').action(action(() => {
166
+ recordGitPostCommit(findWorkspaceRoot());
167
+ }));
168
+ hook.command('doctor').action(action(() => print(doctor(findWorkspaceRoot()))));
169
+ program
170
+ .command('import-history')
171
+ .option('--target <target>')
172
+ .action(action((options) => {
173
+ requireOption(options.target, '--target');
174
+ const workspace = findWorkspaceRoot();
175
+ if (options.target === 'all') {
176
+ print(['claude', 'codex', 'cursor', 'qoder', 'kiro'].map((target) => importHistory(target, workspace)));
177
+ return;
178
+ }
179
+ print(importHistory(options.target, workspace));
180
+ }));
181
+ program
182
+ .command('watch')
183
+ .option('--target <target>')
184
+ .action(action((options) => {
185
+ requireOption(options.target, '--target');
186
+ print({
187
+ target: options.target,
188
+ watcher_paths: candidateHistoryPaths(options.target),
189
+ note: 'v1 watch uses import-history polling semantics; native hooks are preferred where supported.',
190
+ });
191
+ }));
192
+ const resources = createResourceCommands();
193
+ const goals = program.command('goals');
194
+ goals
195
+ .command('list')
196
+ .option('--project <id>')
197
+ .option('--mine')
198
+ .option('--status <status>')
199
+ .option('--json')
200
+ .action(action(async (options) => print(await resources.listGoals({ project_id: options.project, mine: options.mine, status: options.status }), options.json)));
201
+ goals
202
+ .command('show <goalId>')
203
+ .option('--json')
204
+ .action(action(async (goalId, options) => print(await resources.showGoal(goalId), options.json)));
205
+ const todos = program.command('todos');
206
+ todos
207
+ .command('list')
208
+ .option('--project <id>')
209
+ .option('--mine')
210
+ .option('--status <status>')
211
+ .option('--json')
212
+ .action(action(async (options) => print(await resources.listTodos({ project_id: options.project, mine: options.mine, status: options.status }), options.json)));
213
+ todos
214
+ .command('show <todoId>')
215
+ .option('--json')
216
+ .action(action(async (todoId, options) => print(await resources.showTodo(todoId), options.json)));
217
+ todos
218
+ .command('status <todoId> <status>')
219
+ .option('--dry-run')
220
+ .action(action(async (todoId, status, options) => {
221
+ if (options.dryRun) {
222
+ print({
223
+ dry_run: true,
224
+ request: {
225
+ method: 'PATCH',
226
+ path: `/api/todos/${encodeURIComponent(todoId)}/status`,
227
+ body: { status },
228
+ },
229
+ });
230
+ return;
231
+ }
232
+ print(await resources.updateTodoStatus(todoId, status));
233
+ }));
234
+ todos
235
+ .command('progress <todoId>')
236
+ .option('--message <text>')
237
+ .option('--dry-run')
238
+ .action(action(async (todoId, options) => {
239
+ requireOption(options.message, '--message');
240
+ if (options.dryRun) {
241
+ print({
242
+ dry_run: true,
243
+ request: {
244
+ method: 'PATCH',
245
+ path: `/api/todos/${encodeURIComponent(todoId)}/progress-description`,
246
+ body: { progress_description: options.message },
247
+ },
248
+ });
249
+ return;
250
+ }
251
+ print(await resources.updateTodoProgress(todoId, options.message));
252
+ }));
253
+ const knowledge = program.command('knowledge');
254
+ knowledge
255
+ .command('list')
256
+ .option('--project <id>')
257
+ .option('--q <keyword>')
258
+ .option('--kind <kind>')
259
+ .option('--status <status>')
260
+ .option('--space <space>', 'personal|team')
261
+ .option('--person <name-or-id>')
262
+ .option('--page <number>', 'page number', Number)
263
+ .option('--page-size <number>', 'items per page', Number)
264
+ .option('--json')
265
+ .action(action(async (options) => print(await resources.listKnowledge(options), options.json)));
266
+ knowledge
267
+ .command('show <knowledgeId>')
268
+ .option('--json')
269
+ .action(action(async (knowledgeId, options) => print(await resources.showKnowledge(knowledgeId), options.json)));
270
+ knowledge
271
+ .command('search <keyword>')
272
+ .option('--project <id>')
273
+ .option('--person <name-or-id>')
274
+ .option('--scope <scope>', 'team|managed|participating|assigned', 'team')
275
+ .option('--limit <number>', 'maximum results', Number)
276
+ .option('--json')
277
+ .action(action(async (keyword, options) => print(await resources.searchKnowledge(keyword, options), options.json)));
278
+ knowledge
279
+ .command('sources')
280
+ .option('--path <path>')
281
+ .option('--q <keyword>')
282
+ .option('--json')
283
+ .action(action(async (options) => print(await resources.listKnowledgeSources({ path: options.path, q: options.q }), options.json)));
284
+ knowledge
285
+ .command('source <path>')
286
+ .option('--json')
287
+ .action(action(async (filePath, options) => print(await resources.readKnowledgeSource(filePath), options.json)));
288
+ const worklogs = program.command('worklogs');
289
+ worklogs
290
+ .command('list')
291
+ .option('--today')
292
+ .option('--window <window>')
293
+ .option('--json')
294
+ .action(action(async (options) => print(await resources.listWorklogs(options), options.json)));
295
+ worklogs
296
+ .command('record')
297
+ .option('--summary <text>')
298
+ .option('--type <type>', 'progress|blocker|decision|commit|note|meeting', 'note')
299
+ .option('--dry-run')
300
+ .action(action(async (options) => {
301
+ requireOption(options.summary, '--summary');
302
+ if (options.dryRun) {
303
+ print({
304
+ dry_run: true,
305
+ request: {
306
+ method: 'POST',
307
+ path: '/api/work-logs',
308
+ body: {
309
+ summary: options.summary,
310
+ event_type: options.type,
311
+ source: 'manual',
312
+ metadata: { source_client: 'ot-cli' },
313
+ },
314
+ },
315
+ });
316
+ return;
317
+ }
318
+ print(await resources.recordWorklog(options.summary, options.type));
319
+ }));
320
+ registerCollaborationCommands(program, { goals, todos, knowledge, worklogs }, { print, fatal });
321
+ program
322
+ .command('report [subcommand]')
323
+ .description('daily report commands')
324
+ .allowUnknownOption()
325
+ .action(action((subcommand) => {
326
+ if (subcommand === 'draft') {
327
+ const options = parseSimpleOptions(process.argv.slice(4));
328
+ print({ path: draftDailyReport(findWorkspaceRoot(), options) });
329
+ return;
330
+ }
331
+ throw new Error('Usage: ot report draft [--today] [--output <path>]');
332
+ }));
333
+ program
334
+ .command('mcp [subcommand]')
335
+ .description('MCP server commands')
336
+ .allowUnknownOption()
337
+ .action(action(async (subcommand) => {
338
+ if (subcommand === 'serve') {
339
+ await serveMcp();
340
+ return;
341
+ }
342
+ throw new Error('Usage: ot mcp serve');
343
+ }));
344
+ if (!runSpecialNestedCommand(process.argv)) {
345
+ try {
346
+ program.parse(process.argv);
347
+ }
348
+ catch (err) {
349
+ if (!isExpectedCommanderExit(err))
350
+ fatal(err);
351
+ }
352
+ }
353
+ function action(fn) {
354
+ return (...args) => {
355
+ Promise.resolve(fn(...args)).catch(fatal);
356
+ };
357
+ }
358
+ function fatal(err) {
359
+ const message = err instanceof Error ? err.message : String(err);
360
+ if (process.argv.includes('--json') || process.argv.includes('-j')) {
361
+ const error = err instanceof ApiError
362
+ ? {
363
+ code: err.code,
364
+ message,
365
+ method: err.method,
366
+ path: err.path,
367
+ status: err.status,
368
+ detail: err.detail,
369
+ }
370
+ : { code: 'cli_error', message };
371
+ process.stderr.write(`${JSON.stringify({ ok: false, error }, null, 2)}\n`);
372
+ }
373
+ else {
374
+ process.stderr.write(`ot: ${message}\n`);
375
+ }
376
+ process.exit(1);
377
+ }
378
+ function installTarget(target, workspace, scope, withAgentHooks) {
379
+ if (target === 'git')
380
+ installGitHook(workspace);
381
+ if (target === 'claude')
382
+ installClaude({ workspace, scope, withAgentHooks });
383
+ if (target === 'codex')
384
+ installCodex({ workspace, scope, withAgentHooks });
385
+ if (target === 'cursor')
386
+ installCursor({ workspace, scope, withAgentHooks });
387
+ if (target === 'qoder')
388
+ installQoder({ workspace, scope, withAgentHooks });
389
+ if (target === 'kiro')
390
+ installKiro({ workspace, scope, withAgentHooks });
391
+ }
392
+ function uninstallTarget(target, workspace, scope) {
393
+ const options = { workspace, scope, withAgentHooks: true };
394
+ if (target === 'git')
395
+ uninstallGitHook(workspace);
396
+ if (target === 'claude')
397
+ uninstallClaude(options);
398
+ if (target === 'codex')
399
+ uninstallCodex(options);
400
+ if (target === 'cursor')
401
+ uninstallCursor(options);
402
+ if (target === 'qoder')
403
+ uninstallQoder(options);
404
+ if (target === 'kiro')
405
+ uninstallKiro(options);
406
+ }
407
+ function requireOption(value, name) {
408
+ if (value == null || value === '') {
409
+ throw new Error(`Missing required option ${name}`);
410
+ }
411
+ }
412
+ function expandTargets(target) {
413
+ if (target === 'all')
414
+ return ['git', 'claude', 'codex', 'cursor', 'qoder', 'kiro'];
415
+ return [target];
416
+ }
417
+ function print(value, asJson = true) {
418
+ if (asJson) {
419
+ process.stdout.write(`${JSON.stringify(value, null, 2)}\n`);
420
+ return;
421
+ }
422
+ process.stdout.write(`${JSON.stringify(value, null, 2)}\n`);
423
+ }
424
+ async function readStdin() {
425
+ const chunks = [];
426
+ for await (const chunk of process.stdin)
427
+ chunks.push(Buffer.from(chunk));
428
+ return Buffer.concat(chunks).toString('utf-8');
429
+ }
430
+ function updateGitignore(workspace) {
431
+ const gitignorePath = `${workspace}/.gitignore`;
432
+ const existing = fs.existsSync(gitignorePath) ? fs.readFileSync(gitignorePath, 'utf-8') : '';
433
+ if (existing.split(/\r?\n/).includes('.openturtle-cli/'))
434
+ return;
435
+ fs.writeFileSync(gitignorePath, `${existing.trimEnd()}${existing.trim() ? '\n' : ''}.openturtle-cli/\n`, 'utf-8');
436
+ }
437
+ function runSpecialNestedCommand(argv) {
438
+ const [, , first, second, ...rest] = argv;
439
+ if (first === 'mcp' && second === 'serve') {
440
+ void serveMcp().catch(fatal);
441
+ return true;
442
+ }
443
+ if (first === 'report' && second === 'draft') {
444
+ const options = parseSimpleOptions(rest);
445
+ print({ path: draftDailyReport(findWorkspaceRoot(), options) });
446
+ process.exit(0);
447
+ return true;
448
+ }
449
+ return false;
450
+ }
451
+ function parseSimpleOptions(args) {
452
+ const options = {};
453
+ for (let index = 0; index < args.length; index += 1) {
454
+ const arg = args[index];
455
+ if (arg === '--today')
456
+ options.today = true;
457
+ if (arg === '--output') {
458
+ options.output = args[index + 1];
459
+ index += 1;
460
+ }
461
+ }
462
+ return options;
463
+ }
464
+ function isExpectedCommanderExit(err) {
465
+ return Boolean(err &&
466
+ typeof err === 'object' &&
467
+ 'code' in err &&
468
+ String(err.code).startsWith('commander.'));
469
+ }