@remits/remits-cli 0.1.1

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Remits
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,49 @@
1
+ # remits-cli
2
+
3
+ Local CLI for rapid Remits component testing without push/sync cycles.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ npm install -g @remits/remits-cli
9
+ ```
10
+
11
+ ## Commands
12
+
13
+ ```bash
14
+ remits-cli auth --base-url https://your-remits-host --account-id 123
15
+ remits-cli install --skills
16
+ remits-cli tools
17
+ remits-cli tool --name "My Tool" --input '{"foo":"bar"}'
18
+ remits-cli components stage
19
+ remits-cli test run --test 45
20
+ remits-cli test run --test "My New Test" --names "test case 1,test case 2"
21
+ remits-cli components commit --message "sync passing changes"
22
+ remits-cli token --path page/my-embeddable
23
+ ```
24
+
25
+ ## Skill Install
26
+
27
+ ```bash
28
+ remits-cli install --skills
29
+ ```
30
+
31
+ Optional:
32
+
33
+ ```bash
34
+ remits-cli install --skills --target codex
35
+ remits-cli install --skills --target claude
36
+ remits-cli install --skills --target gemini
37
+ remits-cli install --skills --overwrite true
38
+ ```
39
+
40
+ ## Notes
41
+
42
+ - `components stage`/`push` reads from the current account repo working directory.
43
+ - `components commit`/`sync` performs local git commit + push, triggers server sync, then fetch/pull locally.
44
+ - `account-info.json` is used to infer `accountId` when not supplied.
45
+ - Branch defaults to current local git branch.
46
+ - Test activity is streamed from websocket `TestSuite` events and status is polled from `/cli/test`.
47
+ - All `/cli/*` calls are logged to `./.remits-cli/sessions/<current-session>.jsonl`.
48
+ - Tool responses are stored in `./.remits-cli/tool-responses/<callId>.json`.
49
+ - Available tools are cached in `./.remits-cli/tools/tools.json`.
package/index.js ADDED
@@ -0,0 +1,979 @@
1
+ #!/usr/bin/env node
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const os = require('os');
6
+ const http = require('http');
7
+ const crypto = require('crypto');
8
+ const { execSync } = require('child_process');
9
+ const axios = require('axios');
10
+ const openModule = require('open');
11
+ const { Client } = require('@stomp/stompjs');
12
+ const WebSocket = require('ws');
13
+
14
+ Object.assign(global, { WebSocket });
15
+ const openBrowser = (openModule && typeof openModule === 'function')
16
+ ? openModule
17
+ : (openModule && typeof openModule.default === 'function' ? openModule.default : null);
18
+
19
+ //const DEFAULT_BASE_URL = process.env.REMITS_BASE_URL || 'http://localhost:8080';
20
+ const DEFAULT_BASE_URL = process.env.REMITS_BASE_URL || 'https://remits-529558023549.us-east5.run.app';
21
+ const SESSION_DIR = path.join(os.homedir(), '.remits-cli');
22
+ const SESSION_FILE = path.join(SESSION_DIR, 'session.json');
23
+ const SKILL_SOURCE_FILE = path.join(__dirname, 'skills', 'remits-cli', 'SKILL.md');
24
+ const DEFAULT_DATA_MODE = 'test';
25
+
26
+ function parseArgs(argv) {
27
+ const out = { _: [] };
28
+ for (let i = 0; i < argv.length; i++) {
29
+ const arg = argv[i];
30
+ if (!arg.startsWith('--')) {
31
+ out._.push(arg);
32
+ continue;
33
+ }
34
+ const key = arg.slice(2);
35
+ const next = argv[i + 1];
36
+ if (!next || next.startsWith('--')) {
37
+ out[key] = true;
38
+ } else {
39
+ out[key] = next;
40
+ i += 1;
41
+ }
42
+ }
43
+ return out;
44
+ }
45
+
46
+ function ensureSessionDir() {
47
+ if (!fs.existsSync(SESSION_DIR)) {
48
+ fs.mkdirSync(SESSION_DIR, { recursive: true });
49
+ }
50
+ }
51
+
52
+ function readSession() {
53
+ if (!fs.existsSync(SESSION_FILE)) {
54
+ return null;
55
+ }
56
+ return JSON.parse(fs.readFileSync(SESSION_FILE, 'utf8'));
57
+ }
58
+
59
+ function writeSession(data) {
60
+ ensureSessionDir();
61
+ fs.writeFileSync(SESSION_FILE, JSON.stringify(data, null, 2));
62
+ }
63
+
64
+ function normalizeDataMode(value) {
65
+ const mode = String(value || DEFAULT_DATA_MODE).trim().toLowerCase();
66
+ return mode === 'prod' ? 'prod' : 'test';
67
+ }
68
+
69
+ function resolveDataMode(flags, session) {
70
+ if (flags && Object.prototype.hasOwnProperty.call(flags, 'data-mode')) {
71
+ return normalizeDataMode(flags['data-mode']);
72
+ }
73
+ if (flags && Object.prototype.hasOwnProperty.call(flags, 'dataMode')) {
74
+ return normalizeDataMode(flags.dataMode);
75
+ }
76
+ if (session && session.dataMode) {
77
+ return normalizeDataMode(session.dataMode);
78
+ }
79
+ return DEFAULT_DATA_MODE;
80
+ }
81
+
82
+ function ensureDir(dirPath) {
83
+ if (!fs.existsSync(dirPath)) {
84
+ fs.mkdirSync(dirPath, { recursive: true });
85
+ }
86
+ }
87
+
88
+ function localStatePaths(cwd) {
89
+ const base = path.join(cwd, '.remits-cli');
90
+ return {
91
+ base,
92
+ toolsDir: path.join(base, 'tools'),
93
+ sessionsDir: path.join(base, 'sessions'),
94
+ toolResponsesDir: path.join(base, 'tool-responses'),
95
+ currentSessionFile: path.join(base, 'current-session.txt')
96
+ };
97
+ }
98
+
99
+ function ensureLocalState(cwd) {
100
+ const paths = localStatePaths(cwd);
101
+ ensureDir(paths.base);
102
+ ensureDir(paths.toolsDir);
103
+ ensureDir(paths.sessionsDir);
104
+ ensureDir(paths.toolResponsesDir);
105
+ return paths;
106
+ }
107
+
108
+ function currentLocalSessionName(cwd) {
109
+ const paths = ensureLocalState(cwd);
110
+ if (fs.existsSync(paths.currentSessionFile)) {
111
+ const existing = fs.readFileSync(paths.currentSessionFile, 'utf8').trim();
112
+ if (existing) {
113
+ return existing;
114
+ }
115
+ }
116
+ const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
117
+ const sessionName = 'session-' + timestamp + '-' + crypto.randomUUID().slice(0, 8);
118
+ fs.writeFileSync(paths.currentSessionFile, sessionName + '\n');
119
+ return sessionName;
120
+ }
121
+
122
+ function sessionJsonlFile(cwd) {
123
+ const paths = ensureLocalState(cwd);
124
+ return path.join(paths.sessionsDir, currentLocalSessionName(cwd) + '.jsonl');
125
+ }
126
+
127
+ function sanitizeForLog(value) {
128
+ if (value === null || value === undefined) {
129
+ return value;
130
+ }
131
+ if (Array.isArray(value)) {
132
+ return value.map(sanitizeForLog);
133
+ }
134
+ if (typeof value !== 'object') {
135
+ return value;
136
+ }
137
+ const out = {};
138
+ for (const [k, v] of Object.entries(value)) {
139
+ if (k.toLowerCase() === 'token') {
140
+ out[k] = '[redacted]';
141
+ } else {
142
+ out[k] = sanitizeForLog(v);
143
+ }
144
+ }
145
+ return out;
146
+ }
147
+
148
+ function appendSessionLog(cwd, entry) {
149
+ const file = sessionJsonlFile(cwd);
150
+ fs.appendFileSync(file, JSON.stringify(entry) + '\n');
151
+ return file;
152
+ }
153
+
154
+ function writeToolResponseFile(cwd, callId, responsePayload) {
155
+ const paths = ensureLocalState(cwd);
156
+ const file = path.join(paths.toolResponsesDir, callId + '.json');
157
+ fs.writeFileSync(file, JSON.stringify(responsePayload, null, 2));
158
+ return file;
159
+ }
160
+
161
+ function normalizeName(name) {
162
+ return name.replace(/([a-z])([A-Z])/g, '$1 $2').replace(/\s+/g, ' ').trim();
163
+ }
164
+
165
+ function stableStringify(obj) {
166
+ if (obj === null || typeof obj !== 'object') {
167
+ return JSON.stringify(obj);
168
+ }
169
+ if (Array.isArray(obj)) {
170
+ return '[' + obj.map(stableStringify).join(',') + ']';
171
+ }
172
+ const keys = Object.keys(obj).sort();
173
+ return '{' + keys.map((k) => JSON.stringify(k) + ':' + stableStringify(obj[k])).join(',') + '}';
174
+ }
175
+
176
+ function sha256(value) {
177
+ return crypto.createHash('sha256').update(value).digest('hex');
178
+ }
179
+
180
+ function currentBranch(cwd) {
181
+ try {
182
+ return execSync('git rev-parse --abbrev-ref HEAD', { cwd, stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim();
183
+ } catch (err) {
184
+ return 'main';
185
+ }
186
+ }
187
+
188
+ function shellQuote(value) {
189
+ return '\'' + String(value).replace(/'/g, '\'\"\'\"\'' ) + '\'';
190
+ }
191
+
192
+ function runGit(cwd, command) {
193
+ try {
194
+ return execSync(command, { cwd, stdio: ['ignore', 'pipe', 'pipe'] }).toString().trim();
195
+ } catch (err) {
196
+ const stderr = err && err.stderr ? err.stderr.toString().trim() : '';
197
+ const stdout = err && err.stdout ? err.stdout.toString().trim() : '';
198
+ const details = stderr || stdout || err.message;
199
+ throw new Error('Git command failed: ' + command + (details ? ' :: ' + details : ''));
200
+ }
201
+ }
202
+
203
+ function loadAccountId(cwd) {
204
+ const file = path.join(cwd, 'account-info.json');
205
+ if (!fs.existsSync(file)) {
206
+ throw new Error('account-info.json not found in current directory');
207
+ }
208
+ const data = JSON.parse(fs.readFileSync(file, 'utf8'));
209
+ return Number(data.id || data.accountId || (data.account && data.account.id));
210
+ }
211
+
212
+ function resolvePreferredAccountId(cwd, flags, session) {
213
+ try {
214
+ const fromRepo = loadAccountId(cwd);
215
+ if (Number.isFinite(fromRepo) && fromRepo > 0) {
216
+ return fromRepo;
217
+ }
218
+ } catch (_) {
219
+ // Fall back to explicit flags/session when not in an account repo.
220
+ }
221
+
222
+ const fallback = Number(flags['account-id'] || (session && session.accountId));
223
+ if (Number.isFinite(fallback) && fallback > 0) {
224
+ return fallback;
225
+ }
226
+
227
+ throw new Error('Unable to resolve account id. Use an account repo with account-info.json or pass --account-id.');
228
+ }
229
+
230
+ function collectComponents(cwd) {
231
+ const mapping = {
232
+ schemas: 'schema',
233
+ readers: 'reader',
234
+ actions: 'action',
235
+ embeddables: 'embeddable',
236
+ rules: 'rule',
237
+ templates: 'htmltemplate',
238
+ agents: 'utility',
239
+ tools: 'tool',
240
+ prompts: 'prompt',
241
+ tests: 'test'
242
+ };
243
+
244
+ const byKey = new Map();
245
+ for (const dirName of Object.keys(mapping)) {
246
+ const type = mapping[dirName];
247
+ const dir = path.join(cwd, 'components', dirName);
248
+ if (!fs.existsSync(dir)) {
249
+ continue;
250
+ }
251
+
252
+ const entries = fs.readdirSync(dir, { withFileTypes: true }).filter((e) => e.isFile());
253
+ for (const entry of entries) {
254
+ const fileName = entry.name;
255
+ const match = fileName.match(/^(.+?)_(.+)\.([^.]+)$/);
256
+ if (!match) {
257
+ continue;
258
+ }
259
+ const prefix = match[1];
260
+ const rawName = match[2];
261
+ const ext = match[3].toLowerCase();
262
+ const id = /^\d+$/.test(prefix) ? Number(prefix) : null;
263
+ const name = prefix.startsWith('new') || !id ? normalizeName(rawName) : normalizeName(rawName);
264
+ const key = type + ':' + (id ? 'id:' + id : 'name:' + name.toLowerCase());
265
+
266
+ if (!byKey.has(key)) {
267
+ byKey.set(key, { type, id, name });
268
+ }
269
+ const component = byKey.get(key);
270
+ const filePath = path.join(dir, fileName);
271
+ const content = fs.readFileSync(filePath, 'utf8');
272
+
273
+ if (ext === 'groovy' || ext === 'md') {
274
+ component.source = content;
275
+ } else if (type === 'embeddable' && ext === 'html') {
276
+ component.html = content;
277
+ } else if (type === 'embeddable' && ext === 'js') {
278
+ component.javascript = content;
279
+ } else if (type === 'htmltemplate' && ext === 'html') {
280
+ component.html = content;
281
+ } else if (type === 'htmltemplate' && ext === 'json') {
282
+ component.previewData = content;
283
+ } else if (type === 'tool' && ext === 'json') {
284
+ component.inputSchema = content;
285
+ } else if (type === 'schema' && ext === 'json') {
286
+ component.schema = content;
287
+ }
288
+ }
289
+ }
290
+
291
+ const components = [];
292
+ for (const component of byKey.values()) {
293
+ const fingerprint = {
294
+ type: component.type,
295
+ id: component.id || null,
296
+ name: component.name || null,
297
+ source: component.source || null,
298
+ html: component.html || null,
299
+ javascript: component.javascript || null,
300
+ previewData: component.previewData || null,
301
+ inputSchema: component.inputSchema || null,
302
+ schema: component.schema || null
303
+ };
304
+ component.hash = sha256(stableStringify(fingerprint));
305
+ components.push(component);
306
+ }
307
+
308
+ return components;
309
+ }
310
+
311
+ function buildAxios(baseUrl, token) {
312
+ const headers = token ? { Authorization: 'Bearer ' + token } : {};
313
+ return axios.create({ baseURL: baseUrl, timeout: 60000, headers });
314
+ }
315
+
316
+ function saveToolsSnapshot(cwd, data) {
317
+ const paths = ensureLocalState(cwd);
318
+ const file = path.join(paths.toolsDir, 'tools.json');
319
+ fs.writeFileSync(file, JSON.stringify(data, null, 2));
320
+ return file;
321
+ }
322
+
323
+ async function loggedPost(api, cwd, endpoint, payload, options = {}) {
324
+ const requestId = options.requestId || crypto.randomUUID();
325
+ const started = Date.now();
326
+ let responseData = null;
327
+ let error = null;
328
+ let responseFile = null;
329
+
330
+ try {
331
+ responseData = await api.post(endpoint, payload).then((r) => r.data);
332
+ if (options.toolResponseFile) {
333
+ const callId = options.toolCallId || (responseData && responseData.callId) || requestId;
334
+ responseFile = writeToolResponseFile(cwd, callId, responseData);
335
+ }
336
+ return { data: responseData, requestId, responseFile };
337
+ } catch (err) {
338
+ responseData = err && err.response ? err.response.data : null;
339
+ error = err;
340
+ throw err;
341
+ } finally {
342
+ const status = error ? 'error' : 'success';
343
+ const responseForLog = responseFile
344
+ ? { responseFile, note: 'Tool response stored externally' }
345
+ : sanitizeForLog(responseData);
346
+ appendSessionLog(cwd, {
347
+ ts: new Date().toISOString(),
348
+ requestId,
349
+ endpoint,
350
+ method: 'POST',
351
+ status,
352
+ durationMs: Date.now() - started,
353
+ request: sanitizeForLog(payload),
354
+ response: responseForLog,
355
+ error: error ? String(error.message || error) : null
356
+ });
357
+ }
358
+ }
359
+
360
+ async function loggedGet(api, cwd, endpoint, params = {}) {
361
+ const requestId = crypto.randomUUID();
362
+ const started = Date.now();
363
+ let responseData = null;
364
+ let error = null;
365
+ try {
366
+ responseData = await api.get(endpoint, { params }).then((r) => r.data);
367
+ return { data: responseData, requestId };
368
+ } catch (err) {
369
+ responseData = err && err.response ? err.response.data : null;
370
+ error = err;
371
+ throw err;
372
+ } finally {
373
+ appendSessionLog(cwd, {
374
+ ts: new Date().toISOString(),
375
+ requestId,
376
+ endpoint,
377
+ method: 'GET',
378
+ status: error ? 'error' : 'success',
379
+ durationMs: Date.now() - started,
380
+ request: sanitizeForLog(params),
381
+ response: sanitizeForLog(responseData),
382
+ error: error ? String(error.message || error) : null
383
+ });
384
+ }
385
+ }
386
+
387
+ function resolveSkillTargets(target) {
388
+ const value = String(target || 'all').toLowerCase();
389
+ const home = os.homedir();
390
+ const targets = {
391
+ codex: path.join(home, '.codex', 'skills', 'remits-cli', 'SKILL.md'),
392
+ claude: path.join(home, '.claude', 'skills', 'remits-cli', 'SKILL.md'),
393
+ gemini: path.join(home, '.gemini', 'skills', 'remits-cli.md')
394
+ };
395
+
396
+ if (value === 'all') {
397
+ return Object.entries(targets);
398
+ }
399
+ if (!targets[value]) {
400
+ throw new Error('Unknown --target value: ' + target + ' (expected codex|claude|gemini|all)');
401
+ }
402
+ return [[value, targets[value]]];
403
+ }
404
+
405
+ async function installSkillsCommand(flags) {
406
+ if (!flags.skills) {
407
+ throw new Error('Missing --skills flag. Usage: remits-cli install --skills');
408
+ }
409
+ if (!fs.existsSync(SKILL_SOURCE_FILE)) {
410
+ throw new Error('Skill source file not found: ' + SKILL_SOURCE_FILE);
411
+ }
412
+
413
+ const overwrite = flags.overwrite === true || flags.overwrite === 'true';
414
+ const targets = resolveSkillTargets(flags.target || 'all');
415
+ const sourceBody = fs.readFileSync(SKILL_SOURCE_FILE, 'utf8');
416
+ const installed = [];
417
+ const skipped = [];
418
+
419
+ for (const [agent, targetPath] of targets) {
420
+ ensureDir(path.dirname(targetPath));
421
+ if (fs.existsSync(targetPath) && !overwrite) {
422
+ skipped.push({ agent, path: targetPath, reason: 'already exists (use --overwrite true)' });
423
+ continue;
424
+ }
425
+ fs.writeFileSync(targetPath, sourceBody);
426
+ installed.push({ agent, path: targetPath });
427
+ }
428
+
429
+ console.log('Skill installation complete.');
430
+ if (installed.length) {
431
+ console.log('Installed:');
432
+ for (const item of installed) {
433
+ console.log(' -', item.agent + ':', item.path);
434
+ }
435
+ }
436
+ if (skipped.length) {
437
+ console.log('Skipped:');
438
+ for (const item of skipped) {
439
+ console.log(' -', item.agent + ':', item.path, '(' + item.reason + ')');
440
+ }
441
+ }
442
+ }
443
+
444
+ async function authCommand(flags) {
445
+ const cwd = process.cwd();
446
+ ensureLocalState(cwd);
447
+ const existingSession = readSession() || {};
448
+ const dataMode = resolveDataMode(flags, existingSession);
449
+ const baseUrl = flags['base-url'] || DEFAULT_BASE_URL;
450
+ const accountId = flags['account-id'] || resolvePreferredAccountId(cwd, flags, {});
451
+ const port = Number(flags.port || 8765);
452
+ const state = crypto.randomUUID();
453
+ const redirectUri = 'http://localhost:' + port + '/callback';
454
+
455
+ const callbackPromise = new Promise((resolve, reject) => {
456
+ const server = http.createServer((req, res) => {
457
+ const reqUrl = new URL(req.url, 'http://localhost:' + port);
458
+ if (reqUrl.pathname !== '/callback') {
459
+ res.statusCode = 404;
460
+ res.end('Not found');
461
+ return;
462
+ }
463
+
464
+ const payload = {
465
+ token: reqUrl.searchParams.get('token'),
466
+ state: reqUrl.searchParams.get('state'),
467
+ userId: reqUrl.searchParams.get('userId'),
468
+ userUuid: reqUrl.searchParams.get('userUuid')
469
+ };
470
+
471
+ res.statusCode = 200;
472
+ res.setHeader('Content-Type', 'text/html');
473
+ res.end('<html><body><h2>Remits CLI Auth Complete</h2><p>You can close this tab.</p></body></html>');
474
+ server.close();
475
+ resolve(payload);
476
+ });
477
+
478
+ server.on('error', reject);
479
+ server.listen(port, '127.0.0.1');
480
+ });
481
+
482
+ const params = new URLSearchParams();
483
+ params.set('redirect_uri', redirectUri);
484
+ params.set('state', state);
485
+ if (accountId) {
486
+ params.set('accountId', String(accountId));
487
+ }
488
+ params.set('dataMode', dataMode);
489
+
490
+ const loginUrl = baseUrl.replace(/\/$/, '') + '/cli/auth?' + params.toString();
491
+ console.log('Opening browser for auth:', loginUrl);
492
+ if (openBrowser) {
493
+ await openBrowser(loginUrl);
494
+ } else {
495
+ console.log('Browser auto-open is unavailable. Open this URL manually:', loginUrl);
496
+ }
497
+
498
+ const timeoutPromise = new Promise((_, reject) =>
499
+ setTimeout(() => reject(new Error('Auth callback timeout (120s)')), 120000)
500
+ );
501
+ const result = await Promise.race([callbackPromise, timeoutPromise]);
502
+
503
+ if (result.state !== state) {
504
+ throw new Error('Invalid auth callback state');
505
+ }
506
+
507
+ const api = buildAxios(baseUrl, result.token);
508
+ const me = await loggedGet(api, cwd, '/cli/auth', {
509
+ accountId: accountId || undefined,
510
+ dataMode
511
+ }).then((r) => r.data);
512
+
513
+ const session = {
514
+ ...existingSession,
515
+ baseUrl,
516
+ token: result.token,
517
+ accountId: me.account ? me.account.id : Number(accountId),
518
+ user: me.user,
519
+ websocketTopic: me.websocketTopic,
520
+ dataMode: normalizeDataMode(me.dataMode || dataMode),
521
+ updatedAt: new Date().toISOString()
522
+ };
523
+
524
+ writeSession(session);
525
+ console.log('Authenticated as user', session.user ? session.user.id : 'unknown', 'for account', session.accountId);
526
+ console.log('Data mode:', session.dataMode);
527
+
528
+ try {
529
+ const toolsData = await loggedPost(api, cwd, '/cli/tools', {
530
+ token: session.token,
531
+ accountId: session.accountId,
532
+ dataMode: session.dataMode
533
+ }).then((r) => r.data);
534
+ if (toolsData && toolsData.success) {
535
+ const toolsFile = saveToolsSnapshot(cwd, {
536
+ accountId: session.accountId,
537
+ dataMode: toolsData.dataMode || session.dataMode,
538
+ fetchedAt: new Date().toISOString(),
539
+ tools: toolsData.tools || []
540
+ });
541
+ console.log('Tools cached at:', toolsFile);
542
+ }
543
+ } catch (err) {
544
+ console.log('Warning: failed to refresh tools after auth:', err.message);
545
+ }
546
+ }
547
+
548
+ async function pushComponentsCommand(flags) {
549
+ const cwd = process.cwd();
550
+ ensureLocalState(cwd);
551
+ const session = readSession();
552
+ if (!session || !session.token) {
553
+ throw new Error('Not authenticated. Run: remits-cli auth');
554
+ }
555
+
556
+ const baseUrl = flags['base-url'] || session.baseUrl || DEFAULT_BASE_URL;
557
+ const accountId = resolvePreferredAccountId(cwd, flags, session);
558
+ const branchName = flags.branch || currentBranch(cwd);
559
+ const dataMode = resolveDataMode(flags, session);
560
+ const mode = String(flags.mode || 'stage').toLowerCase();
561
+ const components = collectComponents(cwd);
562
+
563
+ const api = buildAxios(baseUrl, session.token);
564
+ const response = await loggedPost(api, cwd, '/cli/components', {
565
+ token: session.token,
566
+ accountId,
567
+ branchName,
568
+ dataMode,
569
+ mode,
570
+ components
571
+ }).then((r) => r.data);
572
+
573
+ console.log('Data mode:', response.dataMode || dataMode);
574
+ console.log('Mode:', response.mode || mode);
575
+ console.log('Components sync:', JSON.stringify(response, null, 2));
576
+ }
577
+
578
+ async function commitComponentsCommand(flags) {
579
+ const cwd = process.cwd();
580
+ ensureLocalState(cwd);
581
+ const session = readSession();
582
+ if (!session || !session.token) {
583
+ throw new Error('Not authenticated. Run: remits-cli auth');
584
+ }
585
+
586
+ const baseUrl = flags['base-url'] || session.baseUrl || DEFAULT_BASE_URL;
587
+ const accountId = resolvePreferredAccountId(cwd, flags, session);
588
+ const branchName = flags.branch || currentBranch(cwd);
589
+ const dataMode = resolveDataMode(flags, session);
590
+ const commitMessage = String(flags.message || ('remits-cli commit sync ' + new Date().toISOString()));
591
+ const allowEmpty = flags['allow-empty'] === true || flags['allow-empty'] === 'true';
592
+ const skipGit = flags['skip-git'] === true || flags['skip-git'] === 'true';
593
+ const components = collectComponents(cwd);
594
+
595
+ if (!skipGit) {
596
+ const status = runGit(cwd, 'git status --porcelain');
597
+ if (status || allowEmpty) {
598
+ runGit(cwd, 'git add -A');
599
+ const commitCmd = 'git commit ' + (allowEmpty ? '--allow-empty ' : '') + '-m ' + shellQuote(commitMessage);
600
+ try {
601
+ runGit(cwd, commitCmd);
602
+ } catch (err) {
603
+ if (!allowEmpty && String(err.message || '').toLowerCase().includes('nothing to commit')) {
604
+ console.log('No staged changes to commit.');
605
+ } else {
606
+ throw err;
607
+ }
608
+ }
609
+ } else {
610
+ console.log('No local changes detected; skipping local git commit.');
611
+ }
612
+
613
+ try {
614
+ runGit(cwd, 'git push origin ' + shellQuote(branchName));
615
+ } catch (err) {
616
+ runGit(cwd, 'git push --set-upstream origin ' + shellQuote(branchName));
617
+ }
618
+ }
619
+
620
+ const api = buildAxios(baseUrl, session.token);
621
+ const response = await loggedPost(api, cwd, '/cli/components', {
622
+ token: session.token,
623
+ accountId,
624
+ branchName,
625
+ dataMode,
626
+ mode: 'commit',
627
+ components
628
+ }).then((r) => r.data);
629
+
630
+ if (!response.success) {
631
+ throw new Error(response.message || 'Commit sync failed');
632
+ }
633
+
634
+ if (!skipGit) {
635
+ runGit(cwd, 'git fetch origin ' + shellQuote(branchName));
636
+ runGit(cwd, 'git pull --ff-only origin ' + shellQuote(branchName));
637
+ }
638
+
639
+ console.log('Data mode:', response.dataMode || dataMode);
640
+ console.log('Mode:', response.mode || 'commit');
641
+ console.log('Components commit/sync:', JSON.stringify(response, null, 2));
642
+ }
643
+
644
+ function webSocketUrl(baseUrl) {
645
+ const u = new URL(baseUrl);
646
+ const wsProtocol = u.protocol === 'https:' ? 'wss:' : 'ws:';
647
+ return wsProtocol + '//' + u.host + '/stomp';
648
+ }
649
+
650
+ async function waitForStatus(api, cwd, accountId, branchName, taskId, token, dataMode) {
651
+ while (true) {
652
+ const status = await loggedPost(api, cwd, '/cli/test', {
653
+ token,
654
+ command: 'status',
655
+ accountId,
656
+ branchName,
657
+ taskId,
658
+ dataMode
659
+ }).then((r) => r.data);
660
+
661
+ if (status.status === 'completed' || status.status === 'failed') {
662
+ return status;
663
+ }
664
+ await new Promise((r) => setTimeout(r, 1000));
665
+ }
666
+ }
667
+
668
+ function watchWebsocket(baseUrl, topicId, taskId) {
669
+ const client = new Client({
670
+ brokerURL: webSocketUrl(baseUrl),
671
+ reconnectDelay: 3000,
672
+ heartbeatIncoming: 10000,
673
+ heartbeatOutgoing: 10000,
674
+ debug: () => {}
675
+ });
676
+
677
+ client.onConnect = () => {
678
+ client.subscribe('/topic/messages/' + topicId, (msg) => {
679
+ try {
680
+ const data = JSON.parse(msg.body);
681
+ if (data.type === 'TestSuite') {
682
+ const payload = data.payload || {};
683
+ if (payload.tests || payload.name || payload.total != null) {
684
+ console.log('[ws][TestSuite]', JSON.stringify(payload));
685
+ }
686
+ }
687
+ } catch (err) {
688
+ console.error('[ws] parse error:', err.message);
689
+ }
690
+ });
691
+ };
692
+
693
+ client.activate();
694
+ return () => {
695
+ client.deactivate();
696
+ };
697
+ }
698
+
699
+ async function testCommand(flags) {
700
+ const cwd = process.cwd();
701
+ ensureLocalState(cwd);
702
+ const session = readSession();
703
+ if (!session || !session.token) {
704
+ throw new Error('Not authenticated. Run: remits-cli auth');
705
+ }
706
+
707
+ const baseUrl = flags['base-url'] || session.baseUrl || DEFAULT_BASE_URL;
708
+ const accountId = resolvePreferredAccountId(cwd, flags, session);
709
+ const branchName = flags.branch || currentBranch(cwd);
710
+ const dataMode = resolveDataMode(flags, session);
711
+ const testRef = flags.test || flags['test-id'] || flags.name;
712
+
713
+ if (!testRef) {
714
+ throw new Error('Missing --test <id-or-name>');
715
+ }
716
+
717
+ const names = flags.names ? String(flags.names).split(',').map((s) => s.trim()).filter(Boolean) : [];
718
+
719
+ const api = buildAxios(baseUrl, session.token);
720
+ const start = await loggedPost(api, cwd, '/cli/test', {
721
+ token: session.token,
722
+ accountId,
723
+ branchName,
724
+ dataMode,
725
+ testId: /^\d+$/.test(String(testRef)) ? Number(testRef) : undefined,
726
+ testName: /^\d+$/.test(String(testRef)) ? undefined : String(testRef),
727
+ tests: names
728
+ }).then((r) => r.data);
729
+
730
+ if (!start.success) {
731
+ throw new Error('Failed to start test run');
732
+ }
733
+
734
+ console.log('Test run started:', start.taskId);
735
+
736
+ let stopWs = null;
737
+ if (flags.watch !== 'false') {
738
+ const topic = session.websocketTopic || start.websocketTopic || (session.user && String(session.user.uuid || '').replace(/-/g, ''));
739
+ if (topic) {
740
+ stopWs = watchWebsocket(baseUrl, topic, start.taskId);
741
+ }
742
+ }
743
+
744
+ const status = await waitForStatus(api, cwd, accountId, branchName, start.taskId, session.token, dataMode);
745
+ if (stopWs) {
746
+ stopWs();
747
+ }
748
+
749
+ console.log('Final status:', JSON.stringify(status, null, 2));
750
+ if (status.status !== 'completed') {
751
+ process.exitCode = 1;
752
+ }
753
+ }
754
+
755
+ async function tokenCommand(flags) {
756
+ const cwd = process.cwd();
757
+ ensureLocalState(cwd);
758
+ const session = readSession();
759
+ if (!session || !session.token) {
760
+ throw new Error('Not authenticated. Run: remits-cli auth');
761
+ }
762
+
763
+ const baseUrl = flags['base-url'] || session.baseUrl || DEFAULT_BASE_URL;
764
+ const accountId = resolvePreferredAccountId(cwd, flags, session);
765
+ const branchName = flags.branch || currentBranch(cwd);
766
+ const dataMode = resolveDataMode(flags, session);
767
+
768
+ const api = buildAxios(baseUrl, session.token);
769
+ const data = await loggedPost(api, cwd, '/cli/token', {
770
+ token: session.token,
771
+ accountId,
772
+ branchName,
773
+ dataMode
774
+ }).then((r) => r.data);
775
+
776
+ if (!data.success) {
777
+ throw new Error('Failed to generate token key');
778
+ }
779
+
780
+ const base = baseUrl.replace(/\/$/, '');
781
+ const embeddablePath = flags.path || flags['embeddable-path'];
782
+ const embeddableUrl = embeddablePath
783
+ ? (base + '/s/' + data.tokenKey + '/' + String(embeddablePath).replace(/^\/+/, ''))
784
+ : null;
785
+
786
+ const output = {
787
+ success: true,
788
+ tokenKey: data.tokenKey,
789
+ accountId: data.accountId,
790
+ branchName: data.branchName,
791
+ dataMode: data.dataMode || dataMode,
792
+ shortBasePath: data.shortBasePath,
793
+ embeddableUrl
794
+ };
795
+
796
+ console.log(JSON.stringify(output, null, 2));
797
+ }
798
+
799
+ async function toolsCommand(flags) {
800
+ const cwd = process.cwd();
801
+ ensureLocalState(cwd);
802
+ const session = readSession();
803
+ if (!session || !session.token) {
804
+ throw new Error('Not authenticated. Run: remits-cli auth');
805
+ }
806
+
807
+ const baseUrl = flags['base-url'] || session.baseUrl || DEFAULT_BASE_URL;
808
+ const accountId = resolvePreferredAccountId(cwd, flags, session);
809
+ const dataMode = resolveDataMode(flags, session);
810
+ const api = buildAxios(baseUrl, session.token);
811
+
812
+ const data = await loggedPost(api, cwd, '/cli/tools', {
813
+ token: session.token,
814
+ accountId,
815
+ dataMode
816
+ }).then((r) => r.data);
817
+
818
+ if (!data.success) {
819
+ throw new Error('Failed to fetch tools');
820
+ }
821
+
822
+ const toolsFile = saveToolsSnapshot(cwd, {
823
+ accountId,
824
+ dataMode: data.dataMode || dataMode,
825
+ fetchedAt: new Date().toISOString(),
826
+ tools: data.tools || []
827
+ });
828
+
829
+ console.log('Tools refreshed.');
830
+ console.log('Data mode:', data.dataMode || dataMode);
831
+ console.log('Tools file:', toolsFile);
832
+ console.log('Tool count:', (data.tools || []).length);
833
+ }
834
+
835
+ function parseToolInput(flags) {
836
+ if (flags['input-file']) {
837
+ const file = path.resolve(process.cwd(), String(flags['input-file']));
838
+ return JSON.parse(fs.readFileSync(file, 'utf8'));
839
+ }
840
+ if (flags.input) {
841
+ return JSON.parse(String(flags.input));
842
+ }
843
+ return {};
844
+ }
845
+
846
+ async function toolCommand(flags) {
847
+ const cwd = process.cwd();
848
+ ensureLocalState(cwd);
849
+ const session = readSession();
850
+ if (!session || !session.token) {
851
+ throw new Error('Not authenticated. Run: remits-cli auth');
852
+ }
853
+
854
+ const toolName = flags.name || flags.tool;
855
+ if (!toolName) {
856
+ throw new Error('Missing --name <toolName>');
857
+ }
858
+
859
+ const input = parseToolInput(flags);
860
+ const baseUrl = flags['base-url'] || session.baseUrl || DEFAULT_BASE_URL;
861
+ const accountId = resolvePreferredAccountId(cwd, flags, session);
862
+ const branchName = flags.branch || currentBranch(cwd);
863
+ const dataMode = resolveDataMode(flags, session);
864
+ const callId = crypto.randomUUID();
865
+ const api = buildAxios(baseUrl, session.token);
866
+
867
+ const response = await loggedPost(api, cwd, '/cli/tool', {
868
+ token: session.token,
869
+ accountId,
870
+ branchName,
871
+ dataMode,
872
+ name: String(toolName),
873
+ input,
874
+ callId
875
+ }, { toolResponseFile: true, toolCallId: callId });
876
+
877
+ const data = response.data;
878
+ if (!data.success) {
879
+ throw new Error(data.message || 'Tool execution failed');
880
+ }
881
+
882
+ console.log('Tool call succeeded.');
883
+ console.log('Call ID:', callId);
884
+ console.log('Data mode:', dataMode);
885
+ console.log('Session log:', sessionJsonlFile(cwd));
886
+ console.log('Tool response file:', response.responseFile);
887
+ }
888
+
889
+ async function dataModeCommand(flags, subcommand) {
890
+ const session = readSession() || {};
891
+ const nextMode = flags.mode || flags.value || flags._[2];
892
+ if (subcommand === 'set') {
893
+ if (!nextMode) {
894
+ throw new Error('Missing mode. Use: remits-cli data-mode set test|prod');
895
+ }
896
+ const dataMode = normalizeDataMode(nextMode);
897
+ writeSession({
898
+ ...session,
899
+ dataMode,
900
+ updatedAt: new Date().toISOString()
901
+ });
902
+ console.log('Data mode set to:', dataMode);
903
+ return;
904
+ }
905
+
906
+ const currentMode = resolveDataMode(flags, session);
907
+ console.log(JSON.stringify({ dataMode: currentMode }, null, 2));
908
+ }
909
+
910
+ async function main() {
911
+ const args = parseArgs(process.argv.slice(2));
912
+ const [command, subcommand] = args._;
913
+
914
+ if (!command || command === 'help' || command === '--help') {
915
+ console.log('Usage: remits-cli <command>');
916
+ console.log(' remits-cli auth [--base-url URL] [--account-id ID] [--port 8765] [--data-mode test|prod]');
917
+ console.log(' remits-cli data-mode [set test|prod]');
918
+ console.log(' remits-cli install --skills [--target codex|claude|gemini|all] [--overwrite true]');
919
+ console.log(' remits-cli tools [--base-url URL] [--account-id ID] [--data-mode test|prod]');
920
+ console.log(' remits-cli tool --name <toolName> [--input \"{...}\"|--input-file file.json] [--data-mode test|prod]');
921
+ console.log(' remits-cli components push|stage [--base-url URL] [--account-id ID] [--branch BRANCH] [--data-mode test|prod]');
922
+ console.log(' remits-cli components commit|sync [--message \"msg\"] [--allow-empty true|false] [--skip-git true|false] [--branch BRANCH] [--data-mode test|prod]');
923
+ console.log(' remits-cli test run --test <id|name> [--names name1,name2] [--watch true|false] [--data-mode test|prod]');
924
+ console.log(' remits-cli token [--branch BRANCH] [--path embeddable/path] [--data-mode test|prod]');
925
+ process.exit(0);
926
+ }
927
+
928
+ if (command === 'auth') {
929
+ await authCommand(args);
930
+ return;
931
+ }
932
+
933
+ if (command === 'components' && (subcommand === 'push' || subcommand === 'stage')) {
934
+ await pushComponentsCommand(args);
935
+ return;
936
+ }
937
+
938
+ if (command === 'components' && (subcommand === 'commit' || subcommand === 'sync')) {
939
+ await commitComponentsCommand(args);
940
+ return;
941
+ }
942
+
943
+ if (command === 'data-mode' || command === 'datamode' || command === 'data-model' || command === 'datamodel') {
944
+ await dataModeCommand(args, subcommand);
945
+ return;
946
+ }
947
+
948
+ if (command === 'install') {
949
+ await installSkillsCommand(args);
950
+ return;
951
+ }
952
+
953
+ if (command === 'test' && subcommand === 'run') {
954
+ await testCommand(args);
955
+ return;
956
+ }
957
+
958
+ if (command === 'token') {
959
+ await tokenCommand(args);
960
+ return;
961
+ }
962
+
963
+ if (command === 'tools') {
964
+ await toolsCommand(args);
965
+ return;
966
+ }
967
+
968
+ if (command === 'tool') {
969
+ await toolCommand(args);
970
+ return;
971
+ }
972
+
973
+ throw new Error('Unknown command: ' + args._.join(' '));
974
+ }
975
+
976
+ main().catch((err) => {
977
+ console.error('remits-cli error:', err.message);
978
+ process.exit(1);
979
+ });
package/package.json ADDED
@@ -0,0 +1,39 @@
1
+ {
2
+ "name": "@remits/remits-cli",
3
+ "version": "0.1.1",
4
+ "description": "Local CLI for auth, component sync, and live test execution against Remits",
5
+ "license": "MIT",
6
+ "private": false,
7
+ "bin": {
8
+ "remits-cli": "./index.js"
9
+ },
10
+ "main": "index.js",
11
+ "type": "commonjs",
12
+ "files": [
13
+ "index.js",
14
+ "README.md",
15
+ "skills/remits-cli/SKILL.md"
16
+ ],
17
+ "publishConfig": {
18
+ "access": "public"
19
+ },
20
+ "engines": {
21
+ "node": ">=18"
22
+ },
23
+ "keywords": [
24
+ "remits",
25
+ "cli",
26
+ "grails",
27
+ "testing",
28
+ "automation"
29
+ ],
30
+ "scripts": {
31
+ "start": "node index.js"
32
+ },
33
+ "dependencies": {
34
+ "@stomp/stompjs": "^7.2.0",
35
+ "axios": "^1.8.4",
36
+ "open": "^10.1.0",
37
+ "ws": "^8.18.1"
38
+ }
39
+ }
@@ -0,0 +1,271 @@
1
+ # remits-cli
2
+
3
+ Use this skill when working in a local Remits account repository and you need server-backed validation for in-flight component changes without full git push/sync cycles.
4
+
5
+ ## What remits-cli Does
6
+
7
+ `remits-cli` is the primary development and support tool for Remits account repositories. It enables two workflows:
8
+
9
+ 1. **Development workflow** (test mode) - Build and iterate on components with isolated test data. Stage local changes, run tests, and commit once passing.
10
+ 2. **Production support workflow** (prod mode) - Investigate production data, run tools against live data, and debug issues on real accounts.
11
+
12
+ Every CLI command and every server response includes `dataMode` so you always know which data context you are operating in.
13
+
14
+ ## Preconditions
15
+
16
+ - You are in a Remits account repo root that contains `account-info.json` and `components/`.
17
+ - `remits-cli` is installed and available in PATH.
18
+ - User has valid access to the Remits platform account.
19
+
20
+ ## Authentication
21
+
22
+ Authenticate once per session. Opens a browser for OAuth, then stores the session token at `~/.remits-cli/session.json`.
23
+
24
+ ```bash
25
+ remits-cli auth --base-url <REMITS_URL> --account-id <ACCOUNT_ID>
26
+ ```
27
+
28
+ - `--base-url` defaults to remits platform endpoint. Exclude this unless instructed otherwise by the user.
29
+ - `--account-id` is auto-resolved from `account-info.json` if present
30
+ - Auth also caches available tools automatically
31
+
32
+ If any command returns a 401 error, re-run `remits-cli auth` to get a fresh token.
33
+
34
+ ## Data Mode (test vs prod)
35
+
36
+ Data mode controls whether operations use isolated test data or production data. **Default is `test`.**
37
+
38
+ ```bash
39
+ remits-cli data-mode # Show current mode
40
+ remits-cli data-mode set test # Persist test mode
41
+ remits-cli data-mode set prod # Persist prod mode
42
+ ```
43
+
44
+ One-off override on any command (does NOT change the persisted mode):
45
+
46
+ ```bash
47
+ remits-cli test run --test 3 --data-mode test
48
+ remits-cli tool --name "System Logs" --input '{}' --data-mode prod
49
+ ```
50
+
51
+ **Rules:**
52
+ - Use `test` mode for development: building components, running tests, staging changes.
53
+ - Use `prod` mode for support: investigating production data, querying logs, viewing records.
54
+ - Always check the `dataMode` field in command output to confirm which context you are in.
55
+
56
+ ## Development Fast Loop
57
+
58
+ This is the core iteration cycle for building and modifying components:
59
+
60
+ ```bash
61
+ # 1. Authenticate (once per session)
62
+ remits-cli auth --base-url <REMITS_URL> --account-id <ACCOUNT_ID>
63
+
64
+ # 2. Confirm data mode is test
65
+ remits-cli data-mode
66
+
67
+ # 3. Make local edits to component files under components/
68
+
69
+ # 4. Stage local changes to the platform's in-flight cache
70
+ remits-cli components stage
71
+
72
+ # 5. Run tests against the staged snapshot
73
+ remits-cli test run --test <TEST_ID_OR_NAME>
74
+
75
+ # 6. If tests fail: fix code, re-stage, re-test (repeat 3-5)
76
+
77
+ # 7. Once tests pass: commit and sync to platform
78
+ remits-cli components commit --message "description of changes"
79
+ ```
80
+
81
+ ### Staging Components
82
+
83
+ ```bash
84
+ remits-cli components stage
85
+ remits-cli components push # alias for stage
86
+ ```
87
+
88
+ - Scans `components/` directory and uploads all component source to the platform's branch cache.
89
+ - Uses SHA256 hash-based change detection: only modified components are re-uploaded on subsequent calls.
90
+ - Response shows `updated` count (changed) and `unchanged` count (skipped).
91
+ - **Always stage after local edits and before running tests.** Tests run against the staged snapshot, not the local filesystem.
92
+
93
+ ### Running Tests
94
+
95
+ ```bash
96
+ remits-cli test run --test <ID_OR_NAME>
97
+ remits-cli test run --test "Example Tests"
98
+ remits-cli test run --test 3
99
+ ```
100
+
101
+ Run specific test cases within a test suite:
102
+
103
+ ```bash
104
+ remits-cli test run --test "Example Tests" --names "test case 1,test case 2"
105
+ ```
106
+
107
+ **Output:**
108
+ - Real-time WebSocket streaming of individual test results as they complete.
109
+ - Final status JSON with `passed`, `failed`, `total` counts and per-test details.
110
+ - Failed tests include the `error` message and assertion expression.
111
+ - Exit code 1 if any test fails.
112
+
113
+ Disable WebSocket streaming (polling only):
114
+
115
+ ```bash
116
+ remits-cli test run --test 3 --watch false
117
+ ```
118
+
119
+ ### Committing to Platform
120
+
121
+ ```bash
122
+ remits-cli components commit --message "feature complete"
123
+ remits-cli components sync # alias for commit
124
+ ```
125
+
126
+ This performs three steps:
127
+ 1. **Git commit + push**: Stages all changes (`git add -A`), commits with the provided message, pushes to origin.
128
+ 2. **Platform sync**: Sends components to the platform with `mode: commit`, triggering a full sync.
129
+ 3. **Git pull**: Pulls back any platform-generated changes (e.g., updated `account-info.json`).
130
+
131
+ Options:
132
+
133
+ ```bash
134
+ --message "commit msg" # Custom commit message (default: auto-generated with timestamp)
135
+ --skip-git true # Skip git operations, only sync to platform
136
+ --allow-empty true # Allow empty git commits
137
+ --branch <name> # Override branch (default: current git branch)
138
+ ```
139
+
140
+ ## Embeddable Testing with Playwright
141
+
142
+ Generate a branch-scoped short token for accessing embeddables in the browser:
143
+
144
+ ```bash
145
+ remits-cli token
146
+ remits-cli token --path embeddable/index/41
147
+ ```
148
+
149
+ The response includes:
150
+ - `tokenKey` - Short token for URL construction
151
+ - `embeddableUrl` - Full URL ready to open (when `--path` is provided)
152
+ - `dataMode` - Confirms test or prod context
153
+
154
+ **URL pattern:** `<REMITS_URL>/s/<tokenKey>/embeddable/index/<embeddableId>`
155
+
156
+ ### testMode Verification
157
+
158
+ When using a CLI-generated token in test mode, the platform injects `testMode` metadata in two places:
159
+
160
+ 1. **In the HTML** - A hidden `remits-session-info` element:
161
+ ```
162
+ TestMode: { "cliUserId": 3, "accountId": 1, "branchName": "main", "dataMode": "test" }
163
+ ```
164
+
165
+ 2. **In embeddable action JSON responses** - A `testMode` field alongside `dataMode`:
166
+ ```json
167
+ { "testMode": "{...}", "dataMode": "test", ... }
168
+ ```
169
+
170
+ Use Playwright to verify testMode is present when testing embeddables:
171
+
172
+ ```bash
173
+ # Generate token
174
+ remits-cli token --path embeddable/index/<ID>
175
+
176
+ # Open in Playwright
177
+ playwright-cli open --headed "<embeddableUrl from above>"
178
+
179
+ # Check snapshot for testMode in remits-session-info element
180
+ playwright-cli snapshot
181
+
182
+ # Or extract programmatically
183
+ playwright-cli eval "() => document.querySelector('.remits-session-info').textContent"
184
+ ```
185
+
186
+ ## Tool Calling
187
+
188
+ Tools are server-side operations available for the current account.
189
+
190
+ ### Refresh and List Tools
191
+
192
+ ```bash
193
+ remits-cli tools
194
+ ```
195
+
196
+ Fetches available tools and caches them at `./.remits-cli/tools/tools.json`.
197
+
198
+ ### Execute a Tool
199
+
200
+ ```bash
201
+ remits-cli tool --name "<Tool Name>" --input '{"key": "value"}'
202
+ remits-cli tool --name "<Tool Name>" --input-file ./payload.json
203
+ ```
204
+
205
+ - Response is saved to `./.remits-cli/tool-responses/<callId>.json`.
206
+ - The response file path is printed after each call.
207
+ - All calls are logged to the session JSONL file.
208
+
209
+ ### Tool Usage Guidance
210
+
211
+ For production investigations, use explicit `--data-mode prod`:
212
+
213
+ ```bash
214
+ remits-cli tool --name "system_logs" --input '{"node":"remitsAdmin-east5"}' --data-mode prod
215
+ ```
216
+
217
+ Preferred tools for investigations:
218
+ - `system_logs` - Server-side log correlation
219
+ - `object_activity` - Document activity history
220
+ - `record_view` - View document data
221
+
222
+ Do not use account/component inspection tools for local repo context - component and account files are already in the working directory.
223
+
224
+ ## Local State Files
225
+
226
+ The CLI maintains state in two locations:
227
+
228
+ **Global session** (`~/.remits-cli/`):
229
+ - `session.json` - Auth token, accountId, user, dataMode, websocketTopic
230
+
231
+ **Per-repo state** (`./.remits-cli/` in the account repo):
232
+ - `tools/tools.json` - Cached tool definitions with accountId, dataMode, tool list
233
+ - `sessions/<session-name>.jsonl` - Request/response log (all `/cli/*` API calls, tokens redacted)
234
+ - `tool-responses/<callId>.json` - Individual tool execution results
235
+ - `current-session.txt` - Current session identifier
236
+
237
+ ## Command Reference
238
+
239
+ ```
240
+ remits-cli auth [--base-url URL] [--account-id ID] [--port 8765] [--data-mode test|prod]
241
+ remits-cli data-mode [set test|prod]
242
+ remits-cli components stage|push [--branch <name>] [--data-mode test|prod]
243
+ remits-cli components commit|sync [--message "msg"] [--allow-empty true|false] [--skip-git true|false] [--branch <name>] [--data-mode test|prod]
244
+ remits-cli test run --test <id|name> [--names "a,b"] [--watch true|false] [--data-mode test|prod]
245
+ remits-cli token [--branch <name>] [--path <embeddablePathOrId>] [--data-mode test|prod]
246
+ remits-cli tools [--data-mode test|prod]
247
+ remits-cli tool --name <toolName> [--input "{...}" | --input-file file.json] [--data-mode test|prod]
248
+ remits-cli install --skills [--target codex|claude|gemini|all] [--overwrite true]
249
+ ```
250
+
251
+ ## Efficiency Rules
252
+
253
+ - **Always stage before test.** `components stage` then `test run` after any local edit.
254
+ - **Target specific tests.** Use `--names` to run individual test cases for faster feedback.
255
+ - **Keep the same branch.** Stay on one branch to preserve a coherent staged snapshot.
256
+ - **Commit only after tests pass.** `components commit` performs git + platform sync together.
257
+ - **Re-auth on 401.** Session tokens expire on server restart or timeout.
258
+ - **Refresh tools when they change.** Run `remits-cli tools` after tool definitions are updated.
259
+ - **Check dataMode on every response.** Confirm you are in the intended data context before acting on results.
260
+
261
+ ## Troubleshooting
262
+
263
+ | Symptom | Fix |
264
+ |---|---|
265
+ | `Not authenticated` or 401 error | Run `remits-cli auth` |
266
+ | `account-info.json not found` | Run from the account repo root directory |
267
+ | Test not found | Verify `--test` value matches an ID or exact name from `account-info.json` TestSuites |
268
+ | Wrong branch behavior | Pass `--branch <branch>` explicitly |
269
+ | Stage shows 0 updated | No local changes detected since last stage (hash-based dedup) |
270
+ | Tool response missing | Check `./.remits-cli/tool-responses/` for the callId file |
271
+ | Need to see all API calls | Read `./.remits-cli/sessions/<session>.jsonl` |