@lanonasis/cli 3.9.4 → 3.9.5

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 CHANGED
@@ -242,6 +242,12 @@ onasis memory list --type context --limit 20
242
242
  onasis memory create -t "Project Notes" -c "Important information"
243
243
  onasis memory create -t "Reference" --type reference --tags "docs,api"
244
244
 
245
+ # Create memory via JSON payload
246
+ onasis memory create --json '{"title":"Design decisions","type":"project","content":"Summary...","tags":["architecture","design"]}'
247
+
248
+ # Create memory from a file
249
+ onasis memory create -t "Session notes" --content-file ./notes.md
250
+
245
251
  # Create memories (interactive)
246
252
  onasis memory create -i # Interactive mode with inline editor
247
253
  onasis memory create # Prompts for missing fields
@@ -258,6 +264,34 @@ onasis memory delete <id> # Delete memory
258
264
  onasis memory stats # Memory statistics
259
265
  ```
260
266
 
267
+ #### `onasis memory save-session`
268
+
269
+ Save the current CLI session context (CWD + git branch/status + changed files) as a memory entry so you can persist what you worked on and pick it up later.
270
+
271
+ - `--test-summary`: Stores a human-readable test result summary (e.g., `Vitest: 53 passed, 1 skipped`) in the saved session memory.
272
+ - `--title`: Sets the memory title (default: `Session summary`).
273
+ - `--type`: Sets the memory type (default: `project`).
274
+ - `--tags`: Comma-separated tags for session metadata (default: `session,cli`).
275
+
276
+ **Examples**
277
+ ```bash
278
+ onasis memory save-session --test-summary "Vitest: 53 passed, 1 skipped"
279
+ onasis memory save-session --title "API client fixes" --type project --tags "session,cli,testing"
280
+ ```
281
+
282
+ See **Session management** below for related commands.
283
+
284
+ #### Session management
285
+
286
+ Sessions are stored as memory entries (tagged `session,cli` by default). Related commands:
287
+
288
+ ```bash
289
+ onasis memory save-session
290
+ onasis memory list-sessions
291
+ onasis memory load-session <id>
292
+ onasis memory delete-session <id>
293
+ ```
294
+
261
295
  **Create/Update Options:**
262
296
  | Short | Long | Description |
263
297
  |-------|------|-------------|
@@ -266,6 +300,8 @@ onasis memory stats # Memory statistics
266
300
  | `-i` | `--interactive` | Interactive mode |
267
301
  | | `--type` | Memory type (context, project, knowledge, etc.) |
268
302
  | | `--tags` | Comma-separated tags |
303
+ | | `--json` | JSON payload (title, content, type/memory_type, tags, topic_id) |
304
+ | | `--content-file` | Read content from a file |
269
305
 
270
306
  ### Topic Management
271
307
 
@@ -637,4 +673,3 @@ The CLI will show the authorization URL - copy and paste it into your browser ma
637
673
 
638
674
  **Token refresh failed:**
639
675
  Run `onasis auth login` to re-authenticate.
640
-
@@ -11,11 +11,13 @@ export function configCommands(program) {
11
11
  .action(async (key, value) => {
12
12
  const config = new CLIConfig();
13
13
  await config.init();
14
+ let shouldSave = true;
14
15
  // Handle special cases
15
16
  switch (key) {
16
17
  case 'api-url':
17
18
  await config.setApiUrl(value);
18
19
  console.log(chalk.green('✓ API URL updated:'), value);
20
+ shouldSave = false; // setApiUrl already persists
19
21
  break;
20
22
  case 'ai-integration':
21
23
  if (value === 'claude-mcp') {
@@ -42,11 +44,19 @@ export function configCommands(program) {
42
44
  config.set('mcpServerUrl', value);
43
45
  console.log(chalk.green('✓ MCP server URL updated:'), value);
44
46
  break;
47
+ case 'force-api':
48
+ config.set('forceApi', value === 'true');
49
+ config.set('connectionTransport', value === 'true' ? 'api' : 'auto');
50
+ console.log(chalk.green('✓ Force direct API mode:'), value === 'true' ? 'enabled' : 'disabled');
51
+ break;
45
52
  default:
46
53
  // Generic config set
47
54
  config.set(key, value);
48
55
  console.log(chalk.green(`✓ ${key} set to:`), value);
49
56
  }
57
+ if (shouldSave) {
58
+ await config.save();
59
+ }
50
60
  });
51
61
  // Generic config get command
52
62
  program
@@ -101,6 +111,7 @@ export function configCommands(program) {
101
111
  { key: 'mcp-use-remote', description: 'Use remote MCP server', current: config.get('mcpUseRemote') || false },
102
112
  { key: 'mcp-server-path', description: 'Local MCP server path', current: config.get('mcpServerPath') || 'default' },
103
113
  { key: 'mcp-server-url', description: 'Remote MCP server URL', current: config.get('mcpServerUrl') || 'https://mcp.lanonasis.com' },
114
+ { key: 'force-api', description: 'Force direct API transport', current: config.get('forceApi') || false },
104
115
  { key: 'mcpEnabled', description: 'MCP integration enabled', current: config.get('mcpEnabled') || false }
105
116
  ];
106
117
  configOptions.forEach(opt => {
@@ -8,6 +8,30 @@ import { apiClient } from '../utils/api.js';
8
8
  import { formatBytes, truncateText } from '../utils/formatting.js';
9
9
  import { CLIConfig } from '../utils/config.js';
10
10
  import { createTextInputHandler } from '../ux/index.js';
11
+ import * as fs from 'fs/promises';
12
+ import { exec as execCb } from 'node:child_process';
13
+ import { promisify } from 'node:util';
14
+ const exec = promisify(execCb);
15
+ const MEMORY_TYPE_CHOICES = [
16
+ 'context',
17
+ 'project',
18
+ 'knowledge',
19
+ 'reference',
20
+ 'personal',
21
+ 'workflow',
22
+ ];
23
+ const coerceMemoryType = (value) => {
24
+ if (typeof value !== 'string')
25
+ return undefined;
26
+ const normalized = value.trim().toLowerCase();
27
+ // Backward compatibility for older docs/examples.
28
+ if (normalized === 'conversation')
29
+ return 'context';
30
+ if (MEMORY_TYPE_CHOICES.includes(normalized)) {
31
+ return normalized;
32
+ }
33
+ return undefined;
34
+ };
11
35
  const resolveInputMode = async () => {
12
36
  const config = new CLIConfig();
13
37
  await config.init();
@@ -41,13 +65,53 @@ export function memoryCommands(program) {
41
65
  .description('Create a new memory entry')
42
66
  .option('-t, --title <title>', 'memory title')
43
67
  .option('-c, --content <content>', 'memory content')
44
- .option('--type <type>', 'memory type (conversation, knowledge, project, context, reference)', 'context')
68
+ .option('--type <type>', `memory type (${MEMORY_TYPE_CHOICES.join(', ')})`)
45
69
  .option('--tags <tags>', 'comma-separated tags')
46
70
  .option('--topic-id <id>', 'topic ID')
47
71
  .option('-i, --interactive', 'interactive mode')
72
+ .option('--json <json>', 'JSON payload (title, content, type/memory_type, tags[], topic_id)')
73
+ .option('--content-file <path>', 'Read memory content from a file (overrides --content)')
48
74
  .action(async (options) => {
49
75
  try {
50
- let { title, content, type, tags, topicId, interactive } = options;
76
+ let { title, content, type, tags, topicId, interactive, json, contentFile } = options;
77
+ // 1) JSON payload (optional)
78
+ if (json) {
79
+ let parsed;
80
+ try {
81
+ parsed = JSON.parse(json);
82
+ }
83
+ catch (err) {
84
+ const msg = err instanceof Error ? err.message : 'Invalid JSON';
85
+ throw new Error(`Invalid --json payload: ${msg}`);
86
+ }
87
+ if (!title && typeof parsed.title === 'string')
88
+ title = parsed.title;
89
+ if (!content && typeof parsed.content === 'string')
90
+ content = parsed.content;
91
+ const parsedType = parsed.memory_type ?? parsed.type;
92
+ if (!type && parsedType !== undefined) {
93
+ const coerced = coerceMemoryType(parsedType);
94
+ if (!coerced) {
95
+ throw new Error(`Invalid memory type in --json payload. Expected one of: ${MEMORY_TYPE_CHOICES.join(', ')}`);
96
+ }
97
+ type = coerced;
98
+ }
99
+ if (!tags) {
100
+ if (Array.isArray(parsed.tags)) {
101
+ tags = parsed.tags.map((t) => String(t)).join(',');
102
+ }
103
+ else if (typeof parsed.tags === 'string') {
104
+ tags = parsed.tags;
105
+ }
106
+ }
107
+ const parsedTopic = parsed.topic_id ?? parsed.topicId;
108
+ if (!topicId && typeof parsedTopic === 'string')
109
+ topicId = parsedTopic;
110
+ }
111
+ // 2) Content file (optional)
112
+ if (contentFile) {
113
+ content = await fs.readFile(contentFile, 'utf-8');
114
+ }
51
115
  if (interactive || (!title || !content)) {
52
116
  const inputMode = await resolveInputMode();
53
117
  const answers = await inquirer.prompt([
@@ -62,7 +126,7 @@ export function memoryCommands(program) {
62
126
  type: 'list',
63
127
  name: 'type',
64
128
  message: 'Memory type:',
65
- choices: ['conversation', 'knowledge', 'project', 'context', 'reference'],
129
+ choices: [...MEMORY_TYPE_CHOICES],
66
130
  default: type || 'context',
67
131
  },
68
132
  {
@@ -86,11 +150,12 @@ export function memoryCommands(program) {
86
150
  if (!content || content.trim().length === 0) {
87
151
  throw new Error('Content is required');
88
152
  }
153
+ const resolvedType = type ?? 'context';
89
154
  const spinner = ora('Creating memory...').start();
90
155
  const memoryData = {
91
156
  title,
92
157
  content,
93
- memory_type: type
158
+ memory_type: resolvedType
94
159
  };
95
160
  if (tags) {
96
161
  memoryData.tags = tags.split(',').map((tag) => tag.trim()).filter(Boolean);
@@ -115,6 +180,211 @@ export function memoryCommands(program) {
115
180
  process.exit(1);
116
181
  }
117
182
  });
183
+ // Save current working session context as a memory
184
+ program
185
+ .command('save-session')
186
+ .description('Save current session context (git branch/status + optional test summary) as a memory')
187
+ .option('-t, --title <title>', 'memory title', 'Session summary')
188
+ .option('--type <type>', `memory type (${MEMORY_TYPE_CHOICES.join(', ')})`, 'project')
189
+ .option('--tags <tags>', 'comma-separated tags', 'session,cli')
190
+ .option('--test-summary <text>', 'Optional test summary to include')
191
+ .action(async (options) => {
192
+ try {
193
+ const spinner = ora('Collecting session info...').start();
194
+ const cwd = process.cwd();
195
+ const runGit = async (cmd) => {
196
+ try {
197
+ const { stdout } = await exec(cmd, { cwd });
198
+ return String(stdout || '').trim();
199
+ }
200
+ catch {
201
+ return null;
202
+ }
203
+ };
204
+ const branch = await runGit('git rev-parse --abbrev-ref HEAD');
205
+ const status = await runGit('git status --porcelain');
206
+ const changedFiles = status
207
+ ? status
208
+ .split('\n')
209
+ .map((line) => line.trim())
210
+ .filter(Boolean)
211
+ .map((line) => line.replace(/^.. /, ''))
212
+ : [];
213
+ const lines = [];
214
+ lines.push(`# Session Summary`);
215
+ lines.push('');
216
+ lines.push(`- Date: ${new Date().toISOString()}`);
217
+ lines.push(`- CWD: ${cwd}`);
218
+ if (branch)
219
+ lines.push(`- Git branch: ${branch}`);
220
+ lines.push('');
221
+ lines.push('## Changes');
222
+ if (changedFiles.length === 0) {
223
+ lines.push('No uncommitted changes detected (or git not available).');
224
+ }
225
+ else {
226
+ lines.push(changedFiles.map((f) => `- ${f}`).join('\n'));
227
+ }
228
+ lines.push('');
229
+ if (options.testSummary) {
230
+ lines.push('## Test Summary');
231
+ lines.push(options.testSummary);
232
+ lines.push('');
233
+ }
234
+ spinner.text = 'Saving session memory...';
235
+ const resolvedType = coerceMemoryType(options.type) ?? 'project';
236
+ const memoryData = {
237
+ title: options.title || 'Session summary',
238
+ content: lines.join('\n'),
239
+ memory_type: resolvedType
240
+ };
241
+ if (options.tags) {
242
+ memoryData.tags = options.tags.split(',').map((t) => t.trim()).filter(Boolean);
243
+ }
244
+ const memory = await apiClient.createMemory(memoryData);
245
+ spinner.succeed('Session saved');
246
+ console.log();
247
+ console.log(chalk.green('✓ Memory created:'));
248
+ console.log(` ID: ${chalk.cyan(memory.id)}`);
249
+ console.log(` Title: ${memory.title}`);
250
+ console.log(` Type: ${memory.memory_type}`);
251
+ }
252
+ catch (error) {
253
+ const errorMessage = error instanceof Error ? error.message : 'Unknown error';
254
+ console.error(chalk.red('✖ Failed to save session:'), errorMessage);
255
+ process.exit(1);
256
+ }
257
+ });
258
+ // Session management helpers (sessions are stored as memory entries tagged `session,cli` by default)
259
+ program
260
+ .command('list-sessions')
261
+ .description('List saved CLI sessions (memories tagged session,cli by default)')
262
+ .option('-p, --page <page>', 'page number', '1')
263
+ .option('-l, --limit <limit>', 'number of entries per page', '20')
264
+ .option('--type <type>', 'filter by memory type', 'project')
265
+ .option('--tags <tags>', 'filter by tags (comma-separated)', 'session,cli')
266
+ .option('--sort <field>', 'sort by field (created_at, updated_at, title, last_accessed)', 'created_at')
267
+ .option('--order <order>', 'sort order (asc, desc)', 'desc')
268
+ .action(async (options) => {
269
+ try {
270
+ const spinner = ora('Fetching sessions...').start();
271
+ const params = {
272
+ page: parseInt(options.page || '1'),
273
+ limit: parseInt(options.limit || '20'),
274
+ sort: options.sort || 'created_at',
275
+ order: options.order || 'desc'
276
+ };
277
+ if (options.type)
278
+ params.memory_type = options.type;
279
+ if (options.tags)
280
+ params.tags = options.tags;
281
+ const result = await apiClient.getMemories(params);
282
+ spinner.stop();
283
+ const memories = result.memories || result.data || [];
284
+ if (memories.length === 0) {
285
+ console.log(chalk.yellow('No sessions found'));
286
+ return;
287
+ }
288
+ console.log(chalk.blue.bold(`\n📚 Sessions (${result.pagination.total} total)`));
289
+ console.log(chalk.gray(`Page ${result.pagination.page || 1} of ${result.pagination.pages || Math.ceil(result.pagination.total / result.pagination.limit)}`));
290
+ console.log();
291
+ const outputFormat = process.env.CLI_OUTPUT_FORMAT || 'table';
292
+ if (outputFormat === 'json') {
293
+ console.log(JSON.stringify(result, null, 2));
294
+ return;
295
+ }
296
+ const tableData = memories.map((memory) => [
297
+ truncateText(memory.title, 30),
298
+ memory.memory_type,
299
+ memory.tags.slice(0, 3).join(', '),
300
+ format(new Date(memory.created_at), 'MMM dd, yyyy'),
301
+ memory.access_count
302
+ ]);
303
+ const tableConfig = {
304
+ header: ['Title', 'Type', 'Tags', 'Created', 'Access'],
305
+ columnDefault: {
306
+ width: 20,
307
+ wrapWord: true
308
+ },
309
+ columns: [
310
+ { width: 30 },
311
+ { width: 12 },
312
+ { width: 20 },
313
+ { width: 12 },
314
+ { width: 8 }
315
+ ]
316
+ };
317
+ console.log(table([tableConfig.header, ...tableData], {
318
+ columnDefault: tableConfig.columnDefault,
319
+ columns: tableConfig.columns
320
+ }));
321
+ if (result.pagination.pages > 1) {
322
+ console.log(chalk.gray(`\nUse --page ${result.pagination.page + 1} for next page`));
323
+ }
324
+ }
325
+ catch (error) {
326
+ const errorMessage = error instanceof Error ? error.message : 'Unknown error';
327
+ console.error(chalk.red('✖ Failed to list sessions:'), errorMessage);
328
+ process.exit(1);
329
+ }
330
+ });
331
+ program
332
+ .command('load-session')
333
+ .description('Load a saved session by memory ID (prints the saved session context)')
334
+ .argument('<id>', 'session memory ID')
335
+ .action(async (id) => {
336
+ try {
337
+ const spinner = ora('Loading session...').start();
338
+ const memory = await apiClient.getMemory(id);
339
+ spinner.stop();
340
+ const outputFormat = process.env.CLI_OUTPUT_FORMAT || 'text';
341
+ if (outputFormat === 'json') {
342
+ console.log(JSON.stringify(memory, null, 2));
343
+ return;
344
+ }
345
+ console.log(chalk.blue.bold('\n📌 Session'));
346
+ console.log(chalk.gray(`${memory.title} (${memory.id})`));
347
+ console.log();
348
+ console.log(memory.content);
349
+ }
350
+ catch (error) {
351
+ const errorMessage = error instanceof Error ? error.message : 'Unknown error';
352
+ console.error(chalk.red('✖ Failed to load session:'), errorMessage);
353
+ process.exit(1);
354
+ }
355
+ });
356
+ program
357
+ .command('delete-session')
358
+ .description('Delete a saved session by memory ID')
359
+ .argument('<id>', 'session memory ID')
360
+ .option('-f, --force', 'skip confirmation')
361
+ .action(async (id, options) => {
362
+ try {
363
+ if (!options.force) {
364
+ const memory = await apiClient.getMemory(id);
365
+ const answer = await inquirer.prompt([
366
+ {
367
+ type: 'confirm',
368
+ name: 'confirm',
369
+ message: `Are you sure you want to delete session "${memory.title}"?`,
370
+ default: false
371
+ }
372
+ ]);
373
+ if (!answer.confirm) {
374
+ console.log(chalk.yellow('Deletion cancelled'));
375
+ return;
376
+ }
377
+ }
378
+ const spinner = ora('Deleting session...').start();
379
+ await apiClient.deleteMemory(id);
380
+ spinner.succeed('Session deleted successfully');
381
+ }
382
+ catch (error) {
383
+ const errorMessage = error instanceof Error ? error.message : 'Unknown error';
384
+ console.error(chalk.red('✖ Failed to delete session:'), errorMessage);
385
+ process.exit(1);
386
+ }
387
+ });
118
388
  // List memories
119
389
  program
120
390
  .command('list')
@@ -294,7 +564,7 @@ export function memoryCommands(program) {
294
564
  .argument('<id>', 'memory ID')
295
565
  .option('-t, --title <title>', 'new title')
296
566
  .option('-c, --content <content>', 'new content')
297
- .option('--type <type>', 'new memory type')
567
+ .option('--type <type>', `new memory type (${MEMORY_TYPE_CHOICES.join(', ')})`)
298
568
  .option('--tags <tags>', 'new tags (comma-separated)')
299
569
  .option('-i, --interactive', 'interactive mode')
300
570
  .action(async (id, options) => {
@@ -317,7 +587,7 @@ export function memoryCommands(program) {
317
587
  type: 'list',
318
588
  name: 'type',
319
589
  message: 'Memory type:',
320
- choices: ['conversation', 'knowledge', 'project', 'context', 'reference'],
590
+ choices: [...MEMORY_TYPE_CHOICES],
321
591
  default: currentMemory.memory_type,
322
592
  },
323
593
  {
@@ -64,12 +64,23 @@ program
64
64
  process.env.MEMORY_API_URL = opts.apiUrl;
65
65
  }
66
66
  process.env.CLI_OUTPUT_FORMAT = opts.output;
67
+ const forceApiFromEnv = process.env.LANONASIS_FORCE_API === 'true' ||
68
+ process.env.CLI_FORCE_API === 'true' ||
69
+ process.env.ONASIS_FORCE_API === 'true';
70
+ const forceApiFromConfig = cliConfig.get('forceApi') === true ||
71
+ cliConfig.get('connectionTransport') === 'api';
72
+ const forceDirectApi = forceApiFromEnv || forceApiFromConfig || opts.mcp === false;
73
+ if (forceDirectApi) {
74
+ process.env.LANONASIS_FORCE_API = 'true';
75
+ }
67
76
  // Auto-initialize MCP unless disabled
68
77
  const isMcpFlow = actionCommand.name() === 'mcp' ||
69
78
  actionCommand.parent?.name?.() === 'mcp' ||
70
79
  actionCommand.name() === 'mcp-server' ||
71
80
  actionCommand.parent?.name?.() === 'mcp-server';
72
- if (opts.mcp !== false && !isMcpFlow && !['init', 'auth', 'login', 'health', 'status'].includes(actionCommand.name())) {
81
+ const isConfigFlow = actionCommand.name() === 'config' ||
82
+ actionCommand.parent?.name?.() === 'config';
83
+ if (!forceDirectApi && !isMcpFlow && !isConfigFlow && !['init', 'auth', 'login', 'health', 'status'].includes(actionCommand.name())) {
73
84
  try {
74
85
  const client = getMCPClient();
75
86
  if (!client.isConnectedToServer()) {
@@ -87,6 +98,9 @@ program
87
98
  }
88
99
  }
89
100
  }
101
+ else if (forceDirectApi && process.env.CLI_VERBOSE === 'true') {
102
+ console.log(colors.muted('MCP auto-connect skipped (force direct API enabled)'));
103
+ }
90
104
  });
91
105
  // Enhanced global error handler
92
106
  process.on('uncaughtException', (error) => {
@@ -324,11 +338,10 @@ const topicCmd = program
324
338
  .description('Topic management commands');
325
339
  requireAuth(topicCmd);
326
340
  topicCommands(topicCmd);
327
- // Configuration commands (require auth)
341
+ // Configuration commands (no auth required)
328
342
  const configCmd = program
329
343
  .command('config')
330
344
  .description('Configuration management');
331
- requireAuth(configCmd);
332
345
  configCommands(configCmd);
333
346
  // Organization commands (require auth)
334
347
  const orgCmd = program
package/dist/index.js CHANGED
@@ -53,6 +53,18 @@ program
53
53
  process.env.MEMORY_API_URL = opts.apiUrl;
54
54
  }
55
55
  process.env.CLI_OUTPUT_FORMAT = opts.output;
56
+ const forceApiFromEnv = process.env.LANONASIS_FORCE_API === 'true' ||
57
+ process.env.CLI_FORCE_API === 'true' ||
58
+ process.env.ONASIS_FORCE_API === 'true';
59
+ const forceApiFromConfig = cliConfig.get('forceApi') === true ||
60
+ cliConfig.get('connectionTransport') === 'api';
61
+ const forceDirectApi = forceApiFromEnv || forceApiFromConfig || opts.mcp === false;
62
+ if (process.env.CLI_VERBOSE === 'true') {
63
+ console.log(colors.muted(`transport flags: env=${forceApiFromEnv} config=${forceApiFromConfig} no_mcp=${opts.mcp === false}`));
64
+ }
65
+ if (forceDirectApi) {
66
+ process.env.LANONASIS_FORCE_API = 'true';
67
+ }
56
68
  const skipOnboarding = actionCommand.name() === 'init' ||
57
69
  actionCommand.name() === 'auth' ||
58
70
  actionCommand.parent?.name?.() === 'auth';
@@ -76,7 +88,9 @@ program
76
88
  actionCommand.parent?.name?.() === 'mcp' ||
77
89
  actionCommand.name() === 'mcp-server' ||
78
90
  actionCommand.parent?.name?.() === 'mcp-server';
79
- if (opts.mcp !== false && !isMcpFlow && !['init', 'auth', 'login', 'health', 'status'].includes(actionCommand.name())) {
91
+ const isConfigFlow = actionCommand.name() === 'config' ||
92
+ actionCommand.parent?.name?.() === 'config';
93
+ if (!forceDirectApi && !isMcpFlow && !isConfigFlow && !['init', 'auth', 'login', 'health', 'status'].includes(actionCommand.name())) {
80
94
  try {
81
95
  const client = getMCPClient();
82
96
  if (!client.isConnectedToServer()) {
@@ -94,6 +108,9 @@ program
94
108
  }
95
109
  }
96
110
  }
111
+ else if (forceDirectApi && process.env.CLI_VERBOSE === 'true') {
112
+ console.log(colors.muted('MCP auto-connect skipped (force direct API enabled)'));
113
+ }
97
114
  });
98
115
  // Enhanced global error handler
99
116
  process.on('uncaughtException', (error) => {
@@ -399,11 +416,10 @@ const topicCmd = program
399
416
  .description('Topic management commands');
400
417
  requireAuth(topicCmd);
401
418
  topicCommands(topicCmd);
402
- // Configuration commands (require auth)
419
+ // Configuration commands (no auth required)
403
420
  const configCmd = program
404
421
  .command('config')
405
422
  .description('Configuration management');
406
- requireAuth(configCmd);
407
423
  configCommands(configCmd);
408
424
  // Organization commands (require auth)
409
425
  const orgCmd = program
@@ -91,18 +91,18 @@ export declare const MemoryListSchema: z.ZodObject<{
91
91
  tags?: string[];
92
92
  limit?: number;
93
93
  topic_id?: string;
94
+ order?: "desc" | "asc";
94
95
  memory_type?: "context" | "reference" | "note";
95
96
  offset?: number;
96
97
  sort_by?: "title" | "created_at" | "updated_at";
97
- order?: "desc" | "asc";
98
98
  }, {
99
99
  tags?: string[];
100
100
  limit?: number;
101
101
  topic_id?: string;
102
+ order?: "desc" | "asc";
102
103
  memory_type?: "context" | "reference" | "note";
103
104
  offset?: number;
104
105
  sort_by?: "title" | "created_at" | "updated_at";
105
- order?: "desc" | "asc";
106
106
  }>;
107
107
  export declare const TopicCreateSchema: z.ZodObject<{
108
108
  name: z.ZodString;
@@ -201,14 +201,14 @@ export declare const SystemConfigSchema: z.ZodObject<{
201
201
  scope: z.ZodDefault<z.ZodEnum<["user", "global"]>>;
202
202
  }, "strip", z.ZodTypeAny, {
203
203
  value?: any;
204
- key?: string;
205
204
  action?: "get" | "set" | "reset";
206
205
  scope?: "user" | "global";
206
+ key?: string;
207
207
  }, {
208
208
  value?: any;
209
- key?: string;
210
209
  action?: "get" | "set" | "reset";
211
210
  scope?: "user" | "global";
211
+ key?: string;
212
212
  }>;
213
213
  export declare const BulkOperationSchema: z.ZodObject<{
214
214
  operation: z.ZodEnum<["create", "update", "delete"]>;
@@ -463,18 +463,18 @@ export declare const MCPSchemas: {
463
463
  tags?: string[];
464
464
  limit?: number;
465
465
  topic_id?: string;
466
+ order?: "desc" | "asc";
466
467
  memory_type?: "context" | "reference" | "note";
467
468
  offset?: number;
468
469
  sort_by?: "title" | "created_at" | "updated_at";
469
- order?: "desc" | "asc";
470
470
  }, {
471
471
  tags?: string[];
472
472
  limit?: number;
473
473
  topic_id?: string;
474
+ order?: "desc" | "asc";
474
475
  memory_type?: "context" | "reference" | "note";
475
476
  offset?: number;
476
477
  sort_by?: "title" | "created_at" | "updated_at";
477
- order?: "desc" | "asc";
478
478
  }>;
479
479
  };
480
480
  topic: {
@@ -579,14 +579,14 @@ export declare const MCPSchemas: {
579
579
  scope: z.ZodDefault<z.ZodEnum<["user", "global"]>>;
580
580
  }, "strip", z.ZodTypeAny, {
581
581
  value?: any;
582
- key?: string;
583
582
  action?: "get" | "set" | "reset";
584
583
  scope?: "user" | "global";
584
+ key?: string;
585
585
  }, {
586
586
  value?: any;
587
- key?: string;
588
587
  action?: "get" | "set" | "reset";
589
588
  scope?: "user" | "global";
589
+ key?: string;
590
590
  }>;
591
591
  };
592
592
  operations: {
@@ -54,11 +54,15 @@ export interface UpdateMemoryRequest {
54
54
  metadata?: Record<string, unknown>;
55
55
  }
56
56
  export interface GetMemoriesParams {
57
+ page?: number;
57
58
  limit?: number;
58
59
  offset?: number;
59
60
  memory_type?: MemoryType;
60
61
  tags?: string[] | string;
61
62
  topic_id?: string;
63
+ user_id?: string;
64
+ sort?: 'created_at' | 'updated_at' | 'last_accessed' | 'access_count' | 'title';
65
+ order?: 'asc' | 'desc';
62
66
  sort_by?: 'created_at' | 'updated_at' | 'last_accessed' | 'access_count';
63
67
  sort_order?: 'asc' | 'desc';
64
68
  }
@@ -148,6 +152,7 @@ export interface ApiErrorResponse {
148
152
  export declare class APIClient {
149
153
  private client;
150
154
  private config;
155
+ private normalizeMemoryEntry;
151
156
  constructor();
152
157
  login(email: string, password: string): Promise<AuthResponse>;
153
158
  register(email: string, password: string, organizationName?: string): Promise<AuthResponse>;
package/dist/utils/api.js CHANGED
@@ -5,6 +5,26 @@ import { CLIConfig } from './config.js';
5
5
  export class APIClient {
6
6
  client;
7
7
  config;
8
+ normalizeMemoryEntry(payload) {
9
+ // API responses are inconsistent across gateways:
10
+ // - Some return the memory entry directly
11
+ // - Some wrap it in `{ data: <memory>, message?: string }`
12
+ if (payload && typeof payload === 'object') {
13
+ const obj = payload;
14
+ const directId = obj.id;
15
+ if (typeof directId === 'string' && directId.length > 0) {
16
+ return payload;
17
+ }
18
+ const data = obj.data;
19
+ if (data && typeof data === 'object') {
20
+ const dataObj = data;
21
+ if (typeof dataObj.id === 'string' && dataObj.id.length > 0) {
22
+ return data;
23
+ }
24
+ }
25
+ }
26
+ return payload;
27
+ }
8
28
  constructor() {
9
29
  this.config = new CLIConfig();
10
30
  this.client = axios.create({
@@ -13,6 +33,8 @@ export class APIClient {
13
33
  // Setup request interceptor to add auth token and headers
14
34
  this.client.interceptors.request.use(async (config) => {
15
35
  await this.config.init();
36
+ // Keep OAuth sessions alive automatically (prevents intermittent "auth required" cutouts).
37
+ await this.config.refreshTokenIfNeeded();
16
38
  // Service Discovery
17
39
  await this.config.discoverServices();
18
40
  // Use appropriate base URL based on endpoint and auth method
@@ -20,17 +42,26 @@ export class APIClient {
20
42
  const discoveredServices = this.config.get('discoveredServices');
21
43
  const authMethod = this.config.get('authMethod');
22
44
  const vendorKey = await this.config.getVendorKeyAsync();
45
+ const token = this.config.getToken();
46
+ const forceApiFromEnv = process.env.LANONASIS_FORCE_API === 'true'
47
+ || process.env.CLI_FORCE_API === 'true'
48
+ || process.env.ONASIS_FORCE_API === 'true';
49
+ const forceApiFromConfig = this.config.get('forceApi') === true
50
+ || this.config.get('connectionTransport') === 'api';
51
+ const forceDirectApi = forceApiFromEnv || forceApiFromConfig;
52
+ const prefersTokenAuth = Boolean(token) && (authMethod === 'jwt' || authMethod === 'oauth' || authMethod === 'oauth2');
53
+ const useVendorKeyAuth = Boolean(vendorKey) && !prefersTokenAuth;
23
54
  // Determine the correct API base URL:
24
55
  // - Auth endpoints -> auth.lanonasis.com
25
56
  // - JWT auth (no vendor key) -> mcp.lanonasis.com (supports JWT tokens)
26
57
  // - Vendor key auth -> api.lanonasis.com (requires vendor key)
27
58
  let apiBaseUrl;
28
- const useMcpServer = !vendorKey && (authMethod === 'jwt' || authMethod === 'oauth' || authMethod === 'oauth2');
59
+ const useMcpServer = !forceDirectApi && prefersTokenAuth && !useVendorKeyAuth;
29
60
  if (isAuthEndpoint) {
30
61
  apiBaseUrl = discoveredServices?.auth_base || 'https://auth.lanonasis.com';
31
62
  }
32
- else if (vendorKey) {
33
- // Vendor key works with api.lanonasis.com
63
+ else if (forceDirectApi) {
64
+ // Force direct REST API mode to bypass MCP routing for troubleshooting.
34
65
  apiBaseUrl = this.config.getApiUrl();
35
66
  }
36
67
  else if (useMcpServer) {
@@ -51,8 +82,22 @@ export class APIClient {
51
82
  config.headers['X-Project-Scope'] = 'lanonasis-maas';
52
83
  }
53
84
  // Enhanced Authentication Support
54
- const token = this.config.getToken();
55
- if (vendorKey) {
85
+ // Even in forced direct-API mode, prefer bearer token auth when available.
86
+ // This avoids accidentally sending an OAuth access token as X-API-Key (we store it
87
+ // in secure storage for MCP/WebSocket usage), which can cause 401s.
88
+ const preferVendorKeyInDirectApiMode = forceDirectApi && Boolean(vendorKey) && !prefersTokenAuth;
89
+ if (preferVendorKeyInDirectApiMode) {
90
+ // Vendor key authentication (validated server-side)
91
+ // Send raw key - server handles hashing for comparison
92
+ config.headers['X-API-Key'] = vendorKey;
93
+ config.headers['X-Auth-Method'] = 'vendor_key';
94
+ }
95
+ else if (prefersTokenAuth) {
96
+ // JWT/OAuth token authentication takes precedence when both are present.
97
+ config.headers.Authorization = `Bearer ${token}`;
98
+ config.headers['X-Auth-Method'] = 'jwt';
99
+ }
100
+ else if (vendorKey) {
56
101
  // Vendor key authentication (validated server-side)
57
102
  // Send raw key - server handles hashing for comparison
58
103
  config.headers['X-API-Key'] = vendorKey;
@@ -69,7 +114,10 @@ export class APIClient {
69
114
  // Add project scope for Golden Contract compliance
70
115
  config.headers['X-Project-Scope'] = 'lanonasis-maas';
71
116
  if (process.env.CLI_VERBOSE === 'true') {
117
+ const transportMode = forceDirectApi ? 'api-forced' : (useMcpServer ? 'mcp-http' : 'api');
118
+ config.headers['X-Transport-Mode'] = transportMode;
72
119
  console.log(chalk.dim(`→ ${config.method?.toUpperCase()} ${config.url} [${requestId}]`));
120
+ console.log(chalk.dim(` transport=${transportMode} baseURL=${config.baseURL}`));
73
121
  }
74
122
  return config;
75
123
  });
@@ -84,7 +132,7 @@ export class APIClient {
84
132
  const { status, data } = error.response;
85
133
  if (status === 401) {
86
134
  console.error(chalk.red('✖ Authentication failed'));
87
- console.log(chalk.yellow('Please run:'), chalk.white('memory login'));
135
+ console.log(chalk.yellow('Please run:'), chalk.white('lanonasis auth login'));
88
136
  process.exit(1);
89
137
  }
90
138
  if (status === 403) {
@@ -127,19 +175,88 @@ export class APIClient {
127
175
  // All memory endpoints use /api/v1/memories path (plural, per REST conventions)
128
176
  async createMemory(data) {
129
177
  const response = await this.client.post('/api/v1/memories', data);
130
- return response.data;
178
+ return this.normalizeMemoryEntry(response.data);
131
179
  }
132
180
  async getMemories(params = {}) {
133
- const response = await this.client.get('/api/v1/memories', { params });
134
- return response.data;
181
+ try {
182
+ const response = await this.client.get('/api/v1/memories', { params });
183
+ return response.data;
184
+ }
185
+ catch (error) {
186
+ // Backward-compatible fallback: newer API contracts may reject GET list and prefer search-only.
187
+ if (error?.response?.status === 405) {
188
+ const limit = Number(params.limit || 20);
189
+ const page = Number(params.page || 1);
190
+ const offset = Number(params.offset ?? Math.max(0, (page - 1) * limit));
191
+ const searchPayload = {
192
+ query: '*',
193
+ limit,
194
+ threshold: 0
195
+ };
196
+ if (params.memory_type) {
197
+ searchPayload.memory_types = [params.memory_type];
198
+ }
199
+ if (params.tags) {
200
+ searchPayload.tags = Array.isArray(params.tags)
201
+ ? params.tags
202
+ : String(params.tags).split(',').map((tag) => tag.trim()).filter(Boolean);
203
+ }
204
+ if (params.topic_id) {
205
+ searchPayload.topic_id = params.topic_id;
206
+ }
207
+ if (offset > 0) {
208
+ searchPayload.offset = offset;
209
+ }
210
+ const fallback = await this.client.post('/api/v1/memories/search', searchPayload);
211
+ const payload = fallback.data || {};
212
+ const resultsArray = Array.isArray(payload.data)
213
+ ? payload.data
214
+ : Array.isArray(payload.results)
215
+ ? payload.results
216
+ : [];
217
+ const memories = resultsArray
218
+ .map((entry) => {
219
+ // Some gateways/search endpoints wrap results as `{ data: <memory> }`.
220
+ if (entry && typeof entry === 'object') {
221
+ const obj = entry;
222
+ const data = obj.data;
223
+ if (data && typeof data === 'object') {
224
+ const dataObj = data;
225
+ if (typeof dataObj.id === 'string' && dataObj.id.length > 0) {
226
+ return data;
227
+ }
228
+ }
229
+ }
230
+ return entry;
231
+ })
232
+ .map((entry) => this.normalizeMemoryEntry(entry));
233
+ const total = Number.isFinite(payload.total) ? Number(payload.total) : memories.length;
234
+ const pages = Math.max(1, Math.ceil(total / limit));
235
+ const currentPage = Math.max(1, Math.floor(offset / limit) + 1);
236
+ return {
237
+ ...payload,
238
+ data: memories,
239
+ memories,
240
+ pagination: {
241
+ total,
242
+ limit,
243
+ offset,
244
+ has_more: (offset + memories.length) < total,
245
+ page: currentPage,
246
+ pages
247
+ }
248
+ };
249
+ }
250
+ throw error;
251
+ }
135
252
  }
136
253
  async getMemory(id) {
137
254
  const response = await this.client.get(`/api/v1/memories/${id}`);
138
- return response.data;
255
+ return this.normalizeMemoryEntry(response.data);
139
256
  }
140
257
  async updateMemory(id, data) {
141
258
  const response = await this.client.put(`/api/v1/memories/${id}`, data);
142
- return response.data;
259
+ return this.normalizeMemoryEntry(response.data);
143
260
  }
144
261
  async deleteMemory(id) {
145
262
  await this.client.delete(`/api/v1/memories/${id}`);
@@ -678,6 +678,9 @@ export class CLIConfig {
678
678
  return this.config.user;
679
679
  }
680
680
  async isAuthenticated() {
681
+ // Attempt refresh for OAuth sessions before checks (prevents intermittent auth dropouts).
682
+ // This is safe to call even when not using OAuth; it will no-op.
683
+ await this.refreshTokenIfNeeded();
681
684
  // Check if using vendor key authentication
682
685
  if (this.config.authMethod === 'vendor_key') {
683
686
  // Use async method to read from encrypted ApiKeyStorage
@@ -736,6 +739,16 @@ export class CLIConfig {
736
739
  const token = this.getToken();
737
740
  if (!token)
738
741
  return false;
742
+ // OAuth tokens are often opaque (not JWT). Prefer local expiry metadata when present.
743
+ if (this.config.authMethod === 'oauth') {
744
+ const tokenExpiresAt = this.get('token_expires_at');
745
+ if (typeof tokenExpiresAt === 'number') {
746
+ const isValid = Date.now() < tokenExpiresAt;
747
+ this.authCheckCache = { isValid, timestamp: Date.now() };
748
+ return isValid;
749
+ }
750
+ // Fall through to legacy validation when we don't have expiry metadata.
751
+ }
739
752
  // Check cache first
740
753
  if (this.authCheckCache && (Date.now() - this.authCheckCache.timestamp) < this.AUTH_CACHE_TTL) {
741
754
  return this.authCheckCache.isValid;
@@ -968,11 +981,85 @@ export class CLIConfig {
968
981
  return;
969
982
  }
970
983
  try {
984
+ // OAuth token refresh (opaque tokens + refresh_token + token_expires_at)
985
+ if (this.config.authMethod === 'oauth') {
986
+ const refreshToken = this.get('refresh_token');
987
+ if (!refreshToken) {
988
+ return;
989
+ }
990
+ const tokenExpiresAtRaw = this.get('token_expires_at');
991
+ const tokenExpiresAt = (() => {
992
+ const n = typeof tokenExpiresAtRaw === 'number'
993
+ ? tokenExpiresAtRaw
994
+ : typeof tokenExpiresAtRaw === 'string'
995
+ ? Number(tokenExpiresAtRaw)
996
+ : undefined;
997
+ if (typeof n !== 'number' || !Number.isFinite(n) || n <= 0) {
998
+ return undefined;
999
+ }
1000
+ // Support both seconds and milliseconds since epoch.
1001
+ // Seconds are ~1.7e9; ms are ~1.7e12.
1002
+ return n < 1e11 ? n * 1000 : n;
1003
+ })();
1004
+ const nowMs = Date.now();
1005
+ const refreshWindowMs = 5 * 60 * 1000; // 5 minutes
1006
+ // If we don't know expiry, don't force a refresh.
1007
+ if (typeof tokenExpiresAt !== 'number' || nowMs < (tokenExpiresAt - refreshWindowMs)) {
1008
+ return;
1009
+ }
1010
+ await this.discoverServices();
1011
+ const authBase = this.getDiscoveredApiUrl();
1012
+ const resp = await axios.post(`${authBase}/oauth/token`, {
1013
+ grant_type: 'refresh_token',
1014
+ refresh_token: refreshToken,
1015
+ client_id: 'lanonasis-cli'
1016
+ }, {
1017
+ headers: { 'Content-Type': 'application/json' },
1018
+ timeout: 10000,
1019
+ proxy: false
1020
+ });
1021
+ // Some gateways wrap responses as `{ data: { ... } }`.
1022
+ const raw = resp?.data;
1023
+ const payload = raw && typeof raw === 'object' && raw.data && typeof raw.data === 'object'
1024
+ ? raw.data
1025
+ : raw;
1026
+ const accessToken = payload?.access_token ?? payload?.token;
1027
+ const refreshedRefreshToken = payload?.refresh_token;
1028
+ const expiresIn = payload?.expires_in;
1029
+ if (typeof accessToken !== 'string' || accessToken.length === 0) {
1030
+ throw new Error('Token refresh response missing access_token');
1031
+ }
1032
+ // setToken() assumes JWT by default; ensure authMethod stays oauth after storing.
1033
+ await this.setToken(accessToken);
1034
+ this.config.authMethod = 'oauth';
1035
+ if (typeof refreshedRefreshToken === 'string' && refreshedRefreshToken.length > 0) {
1036
+ this.config.refresh_token = refreshedRefreshToken;
1037
+ }
1038
+ if (typeof expiresIn === 'number' && Number.isFinite(expiresIn)) {
1039
+ this.config.token_expires_at = Date.now() + (expiresIn * 1000);
1040
+ }
1041
+ // Keep the encrypted "vendor key" in sync for MCP/WebSocket clients that use X-API-Key.
1042
+ // This does not change authMethod away from oauth (setVendorKey guards against that).
1043
+ try {
1044
+ await this.setVendorKey(accessToken);
1045
+ }
1046
+ catch {
1047
+ // Non-fatal: bearer token refresh still helps API calls.
1048
+ }
1049
+ await this.save().catch(() => { });
1050
+ return;
1051
+ }
971
1052
  // Check if token is JWT and if it's close to expiry
972
1053
  if (token.startsWith('cli_')) {
973
1054
  // CLI tokens don't need refresh, they're long-lived
974
1055
  return;
975
1056
  }
1057
+ // Only attempt JWT refresh for tokens that look like JWTs.
1058
+ // OAuth access tokens in this system can be opaque strings; treating them as JWTs
1059
+ // creates noisy failures and can cause unwanted state writes.
1060
+ if (token.split('.').length !== 3) {
1061
+ return;
1062
+ }
976
1063
  const decoded = jwtDecode(token);
977
1064
  const now = Date.now() / 1000;
978
1065
  const exp = typeof decoded.exp === 'number' ? decoded.exp : 0;
@@ -47,6 +47,7 @@ export declare class TextInputHandlerImpl implements TextInputHandler {
47
47
  * Parse raw key event from buffer
48
48
  */
49
49
  private parseKeyEvent;
50
+ private resumeStdinIfSupported;
50
51
  /**
51
52
  * Check if a key event matches a key pattern
52
53
  */
@@ -152,7 +152,7 @@ export class TextInputHandlerImpl {
152
152
  };
153
153
  // Ensure stdin is flowing before adding listener
154
154
  // This is critical after inquirer prompts which may pause stdin
155
- process.stdin.resume();
155
+ this.resumeStdinIfSupported();
156
156
  process.stdin.on('data', handleKeypress);
157
157
  }
158
158
  catch (error) {
@@ -168,13 +168,13 @@ export class TextInputHandlerImpl {
168
168
  if (!this.isRawModeEnabled && process.stdin.isTTY) {
169
169
  this.originalStdinMode = process.stdin.isRaw;
170
170
  process.stdin.setRawMode(true);
171
- process.stdin.resume(); // Ensure stdin is flowing to receive data events
171
+ this.resumeStdinIfSupported(); // Ensure stdin is flowing to receive data events
172
172
  this.isRawModeEnabled = true;
173
173
  }
174
174
  else if (!process.stdin.isTTY) {
175
175
  // Non-TTY mode - can't use raw mode, fall back to line mode
176
176
  console.error('Warning: Not a TTY, inline text input may not work correctly');
177
- process.stdin.resume();
177
+ this.resumeStdinIfSupported();
178
178
  }
179
179
  }
180
180
  /**
@@ -302,6 +302,11 @@ export class TextInputHandlerImpl {
302
302
  }
303
303
  return key;
304
304
  }
305
+ resumeStdinIfSupported() {
306
+ if (typeof process.stdin.resume === 'function') {
307
+ process.stdin.resume();
308
+ }
309
+ }
305
310
  /**
306
311
  * Check if a key event matches a key pattern
307
312
  */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lanonasis/cli",
3
- "version": "3.9.4",
3
+ "version": "3.9.5",
4
4
  "description": "Professional CLI for LanOnasis Memory as a Service (MaaS) with MCP support, seamless inline editing, and enterprise-grade security",
5
5
  "keywords": [
6
6
  "lanonasis",
@@ -88,7 +88,7 @@
88
88
  "build": "rimraf dist && tsc -p tsconfig.json",
89
89
  "prepublishOnly": "npm run build",
90
90
  "postinstall": "node scripts/postinstall.js",
91
- "test": "node --experimental-vm-modules node_modules/jest/bin/jest.js",
91
+ "test": "[ -f node_modules/jest/bin/jest.js ] || bun install --no-save; node --experimental-vm-modules node_modules/jest/bin/jest.js",
92
92
  "test:watch": "npm test -- --watch",
93
93
  "test:coverage": "npm test -- --coverage"
94
94
  }