@pulsarinteractive/geniustasker 0.1.0-beta.1 → 0.1.0-beta.3

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/src/cli.mjs CHANGED
@@ -10,123 +10,344 @@ import { readChanges, watchChanges } from './changes.mjs';
10
10
  import { fail, safeError, exitCode } from './errors.mjs';
11
11
 
12
12
  export const commands = {
13
- 'auth login': { summary: 'Open browser sign-in and choose access. No tutorial or token to copy.', options: ['flow', 'browser', 'no-browser', 'scope', 'interactive'] },
14
- 'auth logout': { summary: 'Revoke this terminal session on the server, then erase local credentials.', options: [] },
13
+ 'auth login': {
14
+ summary: 'Open browser sign-in and choose access. No tutorial or token to copy.',
15
+ options: ['flow', 'browser', 'no-browser', 'scope', 'interactive'],
16
+ },
17
+ 'auth logout': {
18
+ summary: 'Revoke this terminal session on the server, then erase local credentials.',
19
+ options: [],
20
+ },
15
21
  'auth status': { summary: 'Read current server identity and permission ceiling.', options: [] },
16
22
  whoami: { summary: 'Read current server identity.', options: [] },
17
- capabilities: { summary: 'Discover collections, fields, limits and write availability.', options: [] },
18
- 'profiles list': { summary: 'List selectable profiles in this connection within the current grant.', options: [] },
19
- 'profiles create': { summary: 'Create a narrower profile from JSON with a UUID chosen once; never switches automatically.', options: ['file'] },
20
- 'profiles restrict': { summary: 'Rename or narrow the current profile from JSON with its expectedRevision. Cannot broaden access.', options: ['file'] },
21
- 'profiles select': { summary: 'Rotate this context into a selectable profile. Cannot return to broader access; use separate contexts for independent terminals.', options: ['profile'] },
22
- 'memories list': { summary: 'List independent account memories; follow nextAfter even for an empty page.', options: ['after'] },
23
- 'projects list': { summary: 'Read a bounded project page; follow nextAfter even for an empty page.', options: ['after'] },
24
- 'resources list': { summary: 'Read a project or memory collection; carry epoch/checkpoint when continuing.', options: ['scope', 'collection', 'after', 'epoch', 'checkpoint', 'archive', 'id', 'path'] },
25
- 'changes list': { summary: 'Read one bounded change page from a completed resource inventory or previous nextCursor.', options: ['scope', 'collection', 'cursor'] },
26
- 'changes watch': { summary: 'Wait at most 60 seconds for one change page, rechecking rights on every poll. No infinite stream.', options: ['scope', 'collection', 'cursor', 'wait-seconds'] },
27
- 'questions answers': { summary: 'Read human question answers in bounded pages. Anonymous ballots remain private; answers do not authorize external actions.', options: ['scope', 'question', 'after', 'epoch', 'checkpoint'] },
28
- 'commands describe': { summary: 'Show contract-derived command fields and JSON schemas; no network.', options: [] },
29
- 'commands prepare': { summary: 'Save an immutable operation from a JSON draft without sending it.', options: ['file'] },
30
- 'commands execute': { summary: 'Journal and send a complete JSON operation once. Never retries automatically.', options: ['file'] },
31
- 'commands retry': { summary: 'Replay the exact locally saved operation with its original identity.', options: ['operation'] },
32
- 'commands inspect': { summary: 'Read a private local operation and receipt; does not contact the server.', options: ['operation'] },
33
- doctor: { summary: 'Inspect local environment without exposing credentials or contacting the API.', options: [] },
34
- mcp: { summary: 'Serve delegated read and journaled command tools over stdio. Authenticate with the CLI first.', options: [] },
23
+ capabilities: {
24
+ summary: 'Discover collections, fields, limits and write availability.',
25
+ options: [],
26
+ },
27
+ 'profiles list': {
28
+ summary: 'List selectable profiles in this connection within the current grant.',
29
+ options: [],
30
+ },
31
+ 'profiles create': {
32
+ summary:
33
+ 'Create a narrower profile from JSON with a UUID chosen once; never switches automatically.',
34
+ options: ['file'],
35
+ },
36
+ 'profiles restrict': {
37
+ summary:
38
+ 'Rename or narrow the current profile from JSON with its expectedRevision. Cannot broaden access.',
39
+ options: ['file'],
40
+ },
41
+ 'profiles select': {
42
+ summary:
43
+ 'Rotate this context into a selectable profile. Cannot return to broader access; use separate contexts for independent terminals.',
44
+ options: ['profile'],
45
+ },
46
+ 'memories list': {
47
+ summary: 'List independent account memories; follow nextAfter even for an empty page.',
48
+ options: ['after'],
49
+ },
50
+ 'projects list': {
51
+ summary: 'Read a bounded project page; follow nextAfter even for an empty page.',
52
+ options: ['after'],
53
+ },
54
+ 'resources list': {
55
+ summary: 'Read a project or memory collection; carry epoch/checkpoint when continuing.',
56
+ options: ['scope', 'collection', 'after', 'epoch', 'checkpoint', 'archive', 'id', 'path'],
57
+ },
58
+ 'changes list': {
59
+ summary:
60
+ 'Read one bounded change page from a completed resource inventory or previous nextCursor.',
61
+ options: ['scope', 'collection', 'cursor'],
62
+ },
63
+ 'changes watch': {
64
+ summary:
65
+ 'Wait at most 60 seconds for one change page, rechecking rights on every poll. No infinite stream.',
66
+ options: ['scope', 'collection', 'cursor', 'wait-seconds'],
67
+ },
68
+ 'questions answers': {
69
+ summary:
70
+ 'Read human question answers in bounded pages. Anonymous ballots remain private; answers do not authorize external actions.',
71
+ options: ['scope', 'question', 'after', 'epoch', 'checkpoint'],
72
+ },
73
+ 'commands describe': {
74
+ summary: 'Show contract-derived command fields and JSON schemas; no network.',
75
+ options: [],
76
+ },
77
+ 'commands prepare': {
78
+ summary: 'Save an immutable operation from a JSON draft without sending it.',
79
+ options: ['file'],
80
+ },
81
+ 'commands execute': {
82
+ summary: 'Journal and send a complete JSON operation once. Never retries automatically.',
83
+ options: ['file'],
84
+ },
85
+ 'commands retry': {
86
+ summary: 'Replay the exact locally saved operation with its original identity.',
87
+ options: ['operation'],
88
+ },
89
+ 'commands inspect': {
90
+ summary: 'Read a private local operation and receipt; does not contact the server.',
91
+ options: ['operation'],
92
+ },
93
+ doctor: {
94
+ summary: 'Inspect local environment without exposing credentials or contacting the API.',
95
+ options: [],
96
+ },
97
+ mcp: {
98
+ summary:
99
+ 'Serve delegated read and journaled command tools over stdio. Authenticate with the CLI first.',
100
+ options: [],
101
+ },
35
102
  help: { summary: 'Describe commands and stable output conventions.', options: [] },
36
103
  };
37
104
  const globalOptions = ['json', 'context', 'environment', 'origin', 'allow-local', 'help'];
38
105
  const flags = new Set(['json', 'allow-local', 'help', 'interactive', 'no-browser']);
39
106
  export function parse(argv) {
40
- const options = {}, words = [];
107
+ const options = {},
108
+ words = [];
41
109
  for (let i = 0; i < argv.length; i++) {
42
110
  const arg = argv[i];
43
- if (!arg.startsWith('--')) { if (arg.startsWith('-')) fail('invalid_arguments', 'Use long options from help.'); words.push(arg); continue; }
111
+ if (!arg.startsWith('--')) {
112
+ if (arg.startsWith('-')) {
113
+ fail('invalid_arguments', 'Use long options from help.');
114
+ }
115
+ words.push(arg);
116
+ continue;
117
+ }
44
118
  const key = arg.slice(2);
45
- if (!key || key.includes('=') || Object.hasOwn(options, key)) fail('invalid_arguments', 'Options must be unique and use --name value syntax.');
46
- if (flags.has(key)) options[key] = true;
47
- else {
119
+ if (!key || key.includes('=') || Object.hasOwn(options, key)) {
120
+ fail('invalid_arguments', 'Options must be unique and use --name value syntax.');
121
+ }
122
+ if (flags.has(key)) {
123
+ options[key] = true;
124
+ } else {
48
125
  const value = argv[++i];
49
- if (!value || value.startsWith('--') || value.length > 4096) fail('invalid_arguments', 'An option value is missing or too long.');
126
+ if (!value || value.startsWith('--') || value.length > 4096) {
127
+ fail('invalid_arguments', 'An option value is missing or too long.');
128
+ }
50
129
  options[key] = value;
51
130
  }
52
131
  }
53
132
  const command = words.join(' ') || 'help';
54
133
  const definition = commands[command];
55
- if (!definition || Object.keys(options).some(key => !globalOptions.includes(key) && !definition.options.includes(key)))
134
+ if (
135
+ !definition ||
136
+ Object.keys(options).some(
137
+ (key) => !globalOptions.includes(key) && !definition.options.includes(key),
138
+ )
139
+ ) {
56
140
  fail('invalid_arguments', 'Unknown command or option. Run help.');
141
+ }
57
142
  return { command, options };
58
143
  }
59
144
  export function help(command = 'help') {
60
- return { formatVersion: 1, command, commands: command === 'help' ? commands : { [command]: commands[command] },
61
- globalOptions: { context: 'default; use separate names for separate accounts or agent sessions', environment: 'development (beta default) | production', origin: 'explicit loopback origin with --allow-local, for local tests only', json: 'one versioned JSON result on stdout; progress/errors use stderr' },
62
- loginOptions: { flow: 'browser (default, PKCE loopback) | device (SSH/headless)', browser: 'default (automatic) | chrome; --no-browser prints the link without opening it', scope: 'space-separated permission ceiling; browser defaults to read-only and requires explicit write selection', interactive: 'required when starting login without a terminal; never use to bypass human consent' },
63
- exitCodes: { 0: 'success', 1: 'failure', 2: 'usage', 3: 'authentication', 4: 'permission', 5: 'conflict', 6: 'rate limit', 7: 'transient failure' },
64
- examples: ['geniustasker auth login --environment development --context work --browser chrome', 'geniustasker projects list --environment development --context work --json', 'geniustasker resources list --scope project:ID --collection tasks --json'],
65
- privacy: 'Do not put tokens in arguments, environment variables, tickets, logs or MCP configuration. This beta uses the development environment by default; access still requires an admitted account.' };
145
+ return {
146
+ formatVersion: 1,
147
+ command,
148
+ commands: command === 'help' ? commands : { [command]: commands[command] },
149
+ globalOptions: {
150
+ context: 'default; use separate names for separate accounts or agent sessions',
151
+ environment: 'development (beta default) | production',
152
+ origin: 'explicit loopback origin with --allow-local, for local tests only',
153
+ json: 'one versioned JSON result on stdout; progress/errors use stderr',
154
+ },
155
+ loginOptions: {
156
+ flow: 'browser (default, PKCE loopback) | device (SSH/headless)',
157
+ browser: 'default (automatic) | chrome; --no-browser prints the link without opening it',
158
+ scope:
159
+ 'space-separated permission ceiling; browser defaults to read-only and requires explicit write selection',
160
+ interactive:
161
+ 'required when starting login without a terminal; never use to bypass human consent',
162
+ },
163
+ exitCodes: {
164
+ 0: 'success',
165
+ 1: 'failure',
166
+ 2: 'usage',
167
+ 3: 'authentication',
168
+ 4: 'permission',
169
+ 5: 'conflict',
170
+ 6: 'rate limit',
171
+ 7: 'transient failure',
172
+ },
173
+ examples: [
174
+ 'geniustasker auth login --environment development --context work --browser chrome',
175
+ 'geniustasker projects list --environment development --context work --json',
176
+ 'geniustasker resources list --scope project:ID --collection tasks --json',
177
+ ],
178
+ privacy:
179
+ 'Do not put tokens in arguments, environment variables, tickets, logs or MCP configuration. This beta uses the development environment by default; access still requires an admitted account.',
180
+ };
66
181
  }
67
182
  async function openConsent(url, browser) {
68
183
  await open(url, { wait: false, ...(browser === 'chrome' ? { app: { name: apps.chrome } } : {}) });
69
184
  }
70
- export async function main(argv, { stdout = process.stdout, stderr = process.stderr, isTTY = process.stdin.isTTY, directory, fetchImpl, signal, openBrowserImpl = openConsent } = {}) {
185
+ export async function main(
186
+ argv,
187
+ {
188
+ stdout = process.stdout,
189
+ stderr = process.stderr,
190
+ isTTY = process.stdin.isTTY,
191
+ directory,
192
+ fetchImpl,
193
+ signal,
194
+ openBrowserImpl = openConsent,
195
+ } = {},
196
+ ) {
71
197
  try {
72
198
  const { command, options } = parse(argv);
73
- const emit = data => stdout.write(JSON.stringify({ formatVersion: 1, ok: true, data }, null, options.json ? 0 : 2) + '\n');
74
- if (command === 'help' || options.help) { emit(help(command)); return 0; }
75
- if (options.environment && !Object.hasOwn(ORIGINS, options.environment)) fail('invalid_arguments', 'Environment must be production or development.');
76
- if (options.origin && options.environment) fail('invalid_arguments', 'Choose --environment or --origin, not both.');
77
- const origin = originOf(options.origin ?? ORIGINS[options.environment ?? 'development'], options['allow-local']);
199
+ const emit = (data) =>
200
+ stdout.write(
201
+ JSON.stringify({ formatVersion: 1, ok: true, data }, null, options.json ? 0 : 2) + '\n',
202
+ );
203
+ if (command === 'help' || options.help) {
204
+ emit(help(command));
205
+ return 0;
206
+ }
207
+ if (options.environment && !Object.hasOwn(ORIGINS, options.environment)) {
208
+ fail('invalid_arguments', 'Environment must be production or development.');
209
+ }
210
+ if (options.origin && options.environment) {
211
+ fail('invalid_arguments', 'Choose --environment or --origin, not both.');
212
+ }
213
+ const origin = originOf(
214
+ options.origin ?? ORIGINS[options.environment ?? 'development'],
215
+ options['allow-local'],
216
+ );
78
217
  const store = new CredentialStore({ context: options.context, directory });
79
218
  const client = new AgentClient(new Transport(origin, { fetchImpl, signal }), store);
80
219
  let data;
81
220
  if (command === 'auth login') {
82
- if (!isTTY && !options.interactive) fail('invalid_arguments', 'Login needs an interactive human.', { remediation: 'Run auth login in a terminal. Automated clients must use an existing connection.' });
83
- if (options.flow && !['device', 'browser'].includes(options.flow) || options.browser && !['chrome', 'default'].includes(options.browser) || options.browser && options['no-browser']) fail('invalid_arguments', 'Use --flow browser|device, with --browser default|chrome or --no-browser.');
84
- const notify = event => {
85
- if (options.json) stderr.write(JSON.stringify({ formatVersion: 1, ...event }) + '\n');
86
- else if (event.event === 'consent_required') stderr.write(`\nGeniusTasker · Connect your agent\n\n${options['no-browser'] ? 'Open this link' : 'Opening your browser. If it does not open, use this link'}:\n\n${event.url}\n${event.userCode ? `\nDevice code: ${event.userCode}\n` : ''}\nSign in, choose access, and approve. This terminal will finish automatically.\nWaiting for your decision…\n`);
87
- else if (event.event === 'browser_unavailable') stderr.write('Could not open the browser. Use the link above; login is still waiting.\n');
221
+ if (!isTTY && !options.interactive) {
222
+ fail('invalid_arguments', 'Login needs an interactive human.', {
223
+ remediation:
224
+ 'Run auth login in a terminal. Automated clients must use an existing connection.',
225
+ });
226
+ }
227
+ if (
228
+ (options.flow && !['device', 'browser'].includes(options.flow)) ||
229
+ (options.browser && !['chrome', 'default'].includes(options.browser)) ||
230
+ (options.browser && options['no-browser'])
231
+ ) {
232
+ fail(
233
+ 'invalid_arguments',
234
+ 'Use --flow browser|device, with --browser default|chrome or --no-browser.',
235
+ );
236
+ }
237
+ const notify = (event) => {
238
+ if (options.json) {
239
+ stderr.write(JSON.stringify({ formatVersion: 1, ...event }) + '\n');
240
+ } else if (event.event === 'consent_required') {
241
+ stderr.write(
242
+ `\nGeniusTasker · Connect your agent\n\n${options['no-browser'] ? 'Open this link' : 'Opening your browser. If it does not open, use this link'}:\n\n${event.url}\n${event.userCode ? `\nDevice code: ${event.userCode}\n` : ''}\nSign in, choose access, and approve. This terminal will finish automatically.\nWaiting for your decision…\n`,
243
+ );
244
+ } else if (event.event === 'browser_unavailable') {
245
+ stderr.write('Could not open the browser. Use the link above; login is still waiting.\n');
246
+ }
88
247
  };
89
- data = await (options.flow === 'device' ? deviceLogin : browserLogin)(client, { scope: options.scope ?? catalog.grants.permissions.join(' '), openBrowser: options['no-browser'] ? undefined : async url => {
90
- try { await openBrowserImpl(url, options.browser); }
91
- catch { notify({ event: 'browser_unavailable', message: 'Open the printed consent URL in your browser. Login is still waiting.' }); }
92
- },
93
- notify });
248
+ data = await (options.flow === 'device' ? deviceLogin : browserLogin)(client, {
249
+ scope: options.scope ?? catalog.grants.permissions.join(' '),
250
+ openBrowser: options['no-browser']
251
+ ? undefined
252
+ : async (url) => {
253
+ try {
254
+ await openBrowserImpl(url, options.browser);
255
+ } catch {
256
+ notify({
257
+ event: 'browser_unavailable',
258
+ message: 'Open the printed consent URL in your browser. Login is still waiting.',
259
+ });
260
+ }
261
+ },
262
+ notify,
263
+ });
94
264
  if (!options.json) {
95
- stdout.write(`\nConnected to GeniusTasker.\nYour chosen access is ready for the CLI and MCP.\nRun geniustasker auth status${options.context ? ' --context ' + options.context : ''} to check the connection.\n`);
265
+ stdout.write(
266
+ `\nConnected to GeniusTasker.\nYour chosen access is ready for the CLI and MCP.\nRun geniustasker auth status${options.context ? ' --context ' + options.context : ''} to check the connection.\n`,
267
+ );
96
268
  return 0;
97
269
  }
98
- } else if (command === 'auth logout') data = await client.logout();
99
- else if (command === 'profiles list') data = await new AgentProfiles(client).list();
100
- else if (command === 'profiles create') data = await new AgentProfiles(client).create(await readCommandFile(options.file));
101
- else if (command === 'profiles restrict') data = await new AgentProfiles(client).restrict(await readCommandFile(options.file));
102
- else if (command === 'profiles select') data = await new AgentProfiles(client).select({ profileId: options.profile });
103
- else if (command === 'commands describe') data = describeCommands();
104
- else if (command === 'commands prepare') data = await new CommandJournal(client).prepare(await readCommandFile(options.file));
105
- else if (command === 'commands execute') data = await new CommandJournal(client).execute(await readCommandFile(options.file));
106
- else if (command === 'commands retry') data = await new CommandJournal(client).retry(options.operation);
107
- else if (command === 'commands inspect') data = await new CommandJournal(client).inspect(options.operation);
108
- else if (command === 'doctor') data = await client.doctor();
109
- else if (command === 'whoami' || command === 'auth status') data = await client.get('me');
110
- else if (command === 'capabilities') data = await client.get('capabilities');
111
- else if (command === 'projects list' || command === 'memories list') data = await client.get(command === 'projects list' ? 'projects' : 'memories', { after: options.after });
112
- else if (command === 'changes list' || command === 'changes watch') {
113
- const input = { scope: options.scope, collection: options.collection, cursor: options.cursor };
114
- data = command === 'changes list' ? await readChanges(client, input) :
115
- await watchChanges(client, { ...input, ...(options['wait-seconds'] === undefined ? {} : { waitSeconds: Number(options['wait-seconds']) }) });
116
- }
117
- else if (command === 'resources list' || command === 'questions answers') {
118
- if (!(command === 'questions answers' ? options.scope?.startsWith('project:') : /^(project|memory):[^\u0000-\u001f]+$/.test(options.scope ?? '')) || !(command === 'resources list' ? options.collection : options.question) || options.after && (!options.epoch || !options.checkpoint))
119
- fail('invalid_arguments', 'Use --scope project:ID or memory:ID and --collection for resources or --question for answers; continuation needs --epoch and --checkpoint.');
120
- data = await client.get(command === 'resources list' ? 'resources' : 'answers', Object.fromEntries(commands[command].options.filter(key => options[key] !== undefined).map(key => [key, options[key]])));
270
+ } else if (command === 'auth logout') {
271
+ data = await client.logout();
272
+ } else if (command === 'profiles list') {
273
+ data = await new AgentProfiles(client).list();
274
+ } else if (command === 'profiles create') {
275
+ data = await new AgentProfiles(client).create(await readCommandFile(options.file));
276
+ } else if (command === 'profiles restrict') {
277
+ data = await new AgentProfiles(client).restrict(await readCommandFile(options.file));
278
+ } else if (command === 'profiles select') {
279
+ data = await new AgentProfiles(client).select({ profileId: options.profile });
280
+ } else if (command === 'commands describe') {
281
+ data = describeCommands();
282
+ } else if (command === 'commands prepare') {
283
+ data = await new CommandJournal(client).prepare(await readCommandFile(options.file));
284
+ } else if (command === 'commands execute') {
285
+ data = await new CommandJournal(client).execute(await readCommandFile(options.file));
286
+ } else if (command === 'commands retry') {
287
+ data = await new CommandJournal(client).retry(options.operation);
288
+ } else if (command === 'commands inspect') {
289
+ data = await new CommandJournal(client).inspect(options.operation);
290
+ } else if (command === 'doctor') {
291
+ data = await client.doctor();
292
+ } else if (command === 'whoami' || command === 'auth status') {
293
+ data = await client.get('me');
294
+ } else if (command === 'capabilities') {
295
+ data = await client.get('capabilities');
296
+ } else if (command === 'projects list' || command === 'memories list') {
297
+ data = await client.get(command === 'projects list' ? 'projects' : 'memories', {
298
+ after: options.after,
299
+ });
300
+ } else if (command === 'changes list' || command === 'changes watch') {
301
+ const input = {
302
+ scope: options.scope,
303
+ collection: options.collection,
304
+ cursor: options.cursor,
305
+ };
306
+ data =
307
+ command === 'changes list'
308
+ ? await readChanges(client, input)
309
+ : await watchChanges(client, {
310
+ ...input,
311
+ ...(options['wait-seconds'] === undefined
312
+ ? {}
313
+ : { waitSeconds: Number(options['wait-seconds']) }),
314
+ });
315
+ } else if (command === 'resources list' || command === 'questions answers') {
316
+ if (
317
+ !(command === 'questions answers'
318
+ ? options.scope?.startsWith('project:')
319
+ : // eslint-disable-next-line no-control-regex -- Reject control characters in untrusted input.
320
+ /^(project|memory):[^\u0000-\u001f]+$/.test(options.scope ?? '')) ||
321
+ !(command === 'resources list' ? options.collection : options.question) ||
322
+ (options.after && (!options.epoch || !options.checkpoint))
323
+ ) {
324
+ fail(
325
+ 'invalid_arguments',
326
+ 'Use --scope project:ID or memory:ID and --collection for resources or --question for answers; continuation needs --epoch and --checkpoint.',
327
+ );
328
+ }
329
+ data = await client.get(
330
+ command === 'resources list' ? 'resources' : 'answers',
331
+ Object.fromEntries(
332
+ commands[command].options
333
+ .filter((key) => options[key] !== undefined)
334
+ .map((key) => [key, options[key]]),
335
+ ),
336
+ );
121
337
  } else if (command === 'mcp') {
122
338
  const { runMcp } = await import('./mcp.mjs');
123
339
  const server = await runMcp(client);
124
- const close = () => { void server.close(); };
340
+ const close = () => {
341
+ void server.close();
342
+ };
125
343
  signal?.addEventListener('abort', close, { once: true });
126
- if (signal?.aborted) close();
344
+ if (signal?.aborted) {
345
+ close();
346
+ }
127
347
  return 0; // stdout belongs exclusively to JSON-RPC.
128
348
  }
129
- emit(data); return 0;
349
+ emit(data);
350
+ return 0;
130
351
  } catch (error) {
131
352
  const safe = safeError(error);
132
353
  stderr.write(JSON.stringify({ formatVersion: 1, ok: false, error: safe.toJSON() }) + '\n');