gipity 1.0.417 → 1.0.420
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 +2 -2
- package/dist/adopt-cwd.js +9 -6
- package/dist/commands/claude.js +28 -12
- package/dist/commands/generate.js +51 -1
- package/dist/commands/init.js +35 -2
- package/dist/commands/page-inspect.js +3 -0
- package/dist/commands/page-screenshot.js +50 -9
- package/dist/commands/sandbox.js +96 -26
- package/dist/commands/service.js +1 -1
- package/dist/commands/storage.js +43 -1
- package/dist/hooks/capture-runner.js +139 -6
- package/dist/knowledge.js +3 -1
- package/dist/relay/daemon.js +6 -1
- package/dist/setup.js +1 -1
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -138,7 +138,7 @@ gipity sync down # Pull remote changes
|
|
|
138
138
|
| `gipity chat <message>` | Send a message to your Gipity agent |
|
|
139
139
|
| `gipity db` | Query, list, create, or drop project databases |
|
|
140
140
|
| `gipity memory` | Read/write agent and project memory |
|
|
141
|
-
| `gipity sandbox run <code>` | Execute code in a sandboxed container |
|
|
141
|
+
| `gipity sandbox run <code> --lang <js\|py\|bash>` | Execute code in a sandboxed container (language is required) |
|
|
142
142
|
| `gipity project` | List, create, switch, or delete projects |
|
|
143
143
|
| `gipity agent` | List, create, switch, or configure agents |
|
|
144
144
|
| `gipity approval` | List, create, answer, or cancel pending approvals |
|
|
@@ -208,7 +208,7 @@ gipity memory write design_notes "use dark theme" --project
|
|
|
208
208
|
Run code in a sandboxed Docker container with no network access. JavaScript, Python, and Bash.
|
|
209
209
|
|
|
210
210
|
```bash
|
|
211
|
-
gipity sandbox run "console.log('Hello')"
|
|
211
|
+
gipity sandbox run "console.log('Hello')" --lang js
|
|
212
212
|
gipity sandbox run "import pandas; print(pandas.__version__)" --lang py
|
|
213
213
|
gipity sandbox run "echo hello" --lang bash
|
|
214
214
|
```
|
package/dist/adopt-cwd.js
CHANGED
|
@@ -175,16 +175,19 @@ export function formatCwdLabel(cwd) {
|
|
|
175
175
|
return norm;
|
|
176
176
|
return parts.slice(-2).join('/');
|
|
177
177
|
}
|
|
178
|
-
/** Format byte count handling KB/MB/GB. utils.formatSize tops out at MB,
|
|
179
|
-
* which prints "1024.0 MB" for the 1 GB refuse threshold.
|
|
178
|
+
/** Format byte count handling KB/MB/GB/TB. utils.formatSize tops out at MB,
|
|
179
|
+
* which prints "1024.0 MB" for the 1 GB refuse threshold. TB matters for
|
|
180
|
+
* storage quotas, where the top plan is a flat 1 TB. */
|
|
180
181
|
export function formatBytes(n) {
|
|
181
182
|
if (n < 1024)
|
|
182
183
|
return `${n} B`;
|
|
183
|
-
if (n < 1024
|
|
184
|
+
if (n < 1024 ** 2)
|
|
184
185
|
return `${(n / 1024).toFixed(1)} KB`;
|
|
185
|
-
if (n < 1024
|
|
186
|
-
return `${(n /
|
|
187
|
-
|
|
186
|
+
if (n < 1024 ** 3)
|
|
187
|
+
return `${(n / 1024 ** 2).toFixed(1)} MB`;
|
|
188
|
+
if (n < 1024 ** 4)
|
|
189
|
+
return `${(n / 1024 ** 3).toFixed(2)} GB`;
|
|
190
|
+
return `${(n / 1024 ** 4).toFixed(2)} TB`;
|
|
188
191
|
}
|
|
189
192
|
/** Adopt `cwd` as a Gipity project. Mirrors `gipity init`'s flow:
|
|
190
193
|
* 1. Slug = slugify(basename(cwd)) - provided by caller (so caller can
|
package/dist/commands/claude.js
CHANGED
|
@@ -148,6 +148,17 @@ function formatElapsed(ms) {
|
|
|
148
148
|
* truncate the file. Best-effort: a malformed or unreadable config is left
|
|
149
149
|
* untouched and never blocks the launch. Headless `-p` runs skip the dialog
|
|
150
150
|
* already, so this is only needed for interactive launches. */
|
|
151
|
+
/** True when the Claude Code argv asks for stream-json output (both the
|
|
152
|
+
* `--output-format stream-json` and `--output-format=stream-json` forms).
|
|
153
|
+
* Combined with an inherited GIPITY_CONVERSATION_GUID this identifies a
|
|
154
|
+
* relay-daemon spawn, where the daemon owns capture and lifecycle-hook
|
|
155
|
+
* capture must stand down. Exported for tests. */
|
|
156
|
+
export function hasStreamJsonFlag(args) {
|
|
157
|
+
const idx = args.indexOf('--output-format');
|
|
158
|
+
if (idx !== -1 && args[idx + 1] === 'stream-json')
|
|
159
|
+
return true;
|
|
160
|
+
return args.includes('--output-format=stream-json');
|
|
161
|
+
}
|
|
151
162
|
export function markFolderTrusted(dir) {
|
|
152
163
|
try {
|
|
153
164
|
const file = join(homedir(), '.claude.json');
|
|
@@ -675,12 +686,15 @@ export const claudeCommand = new Command('claude')
|
|
|
675
686
|
// we can't satisfy the claude_code ownership rule, so hooks stay
|
|
676
687
|
// offline for this run.
|
|
677
688
|
//
|
|
678
|
-
// Note: when
|
|
679
|
-
//
|
|
680
|
-
//
|
|
681
|
-
//
|
|
682
|
-
//
|
|
683
|
-
//
|
|
689
|
+
// Note: when this process was spawned BY the relay daemon (inherited
|
|
690
|
+
// GIPITY_CONVERSATION_GUID) with `--output-format stream-json`, the
|
|
691
|
+
// daemon is capturing Claude's stdout directly and hook-based capture
|
|
692
|
+
// would double-post every event. In that case we set GIPITY_CAPTURE=off
|
|
693
|
+
// on the Claude child (see childEnv assignment below) so the hook
|
|
694
|
+
// runner stands down. A USER-supplied stream-json run has no daemon
|
|
695
|
+
// behind it - hooks stay armed there, or the conversation would sit
|
|
696
|
+
// empty forever (the qwen-flight zero-message bug, 2026-07-07).
|
|
697
|
+
const inheritedConvGuid = Boolean(process.env.GIPITY_CONVERSATION_GUID);
|
|
684
698
|
let convGuidForHooks = process.env.GIPITY_CONVERSATION_GUID ?? null;
|
|
685
699
|
if (!convGuidForHooks) {
|
|
686
700
|
const device = relayState.getDevice();
|
|
@@ -819,14 +833,16 @@ export const claudeCommand = new Command('claude')
|
|
|
819
833
|
// throws EINVAL on Node >=18.20.2/20.12.2 - the CVE-2024-27980 fix).
|
|
820
834
|
const claudeCmd = resolveCommand('claude');
|
|
821
835
|
const childEnv = { ...process.env };
|
|
822
|
-
// Gate hook-based capture: when the
|
|
823
|
-
// --output-format stream-json
|
|
824
|
-
// post would be a dupe.
|
|
825
|
-
//
|
|
826
|
-
|
|
827
|
-
|
|
836
|
+
// Gate hook-based capture: only when the DAEMON is streaming via
|
|
837
|
+
// --output-format stream-json (inherited guid = daemon spawn) does it
|
|
838
|
+
// own the capture; any hook post would then be a dupe. GIPITY_CAPTURE=off
|
|
839
|
+
// stands the hook runner down explicitly - merely unsetting the guid is
|
|
840
|
+
// no longer enough now that the runner self-arms from .gipity.json.
|
|
841
|
+
// A user-supplied stream-json run (no inherited guid) keeps hooks armed.
|
|
842
|
+
const daemonCapturing = inheritedConvGuid && hasStreamJsonFlag(allArgs);
|
|
828
843
|
if (daemonCapturing) {
|
|
829
844
|
delete childEnv.GIPITY_CONVERSATION_GUID;
|
|
845
|
+
childEnv.GIPITY_CAPTURE = 'off';
|
|
830
846
|
}
|
|
831
847
|
else if (convGuidForHooks) {
|
|
832
848
|
childEnv.GIPITY_CONVERSATION_GUID = convGuidForHooks;
|
|
@@ -274,11 +274,61 @@ Examples:
|
|
|
274
274
|
process.exit(1);
|
|
275
275
|
}
|
|
276
276
|
});
|
|
277
|
+
// ── SOUND ──────────────────────────────────────────────────────────────
|
|
278
|
+
const soundCommand = new Command('sound')
|
|
279
|
+
.description(`Generate a sound effect from a text description using AI.
|
|
280
|
+
|
|
281
|
+
Always billed to you (the caller) - the app's service billing_mode does not
|
|
282
|
+
apply to direct generation, so there is never a reason to flip a service to
|
|
283
|
+
owner_pays just to create assets.
|
|
284
|
+
|
|
285
|
+
Tips:
|
|
286
|
+
- Describe the sound, not a scene (e.g. "cartoon boing with a springy wobble")
|
|
287
|
+
- Omit --duration to let the model pick a natural length (billing is per second)
|
|
288
|
+
- --influence closer to 1 follows the text more literally
|
|
289
|
+
|
|
290
|
+
Examples:
|
|
291
|
+
gipity generate sound "cartoon character saying oof"
|
|
292
|
+
gipity generate sound "thunder rolling in the distance" --duration 5
|
|
293
|
+
gipity generate sound "sci-fi laser blast" -o src/assets/sounds/laser.mp3`)
|
|
294
|
+
.argument('<text>', 'Text description of the sound effect (max 1000 characters)')
|
|
295
|
+
.option('--duration <seconds>', 'Clip length in seconds (0.5-30; default: automatic)', (v) => parseFloat(v))
|
|
296
|
+
.option('--influence <n>', 'How closely to follow the prompt, 0.0-1.0 (higher = more literal)', (v) => parseFloat(v))
|
|
297
|
+
.option('-o, --output <file>', 'Output path (default ./sound.mp3). For audio your app ships, write it into the source tree so it deploys, e.g. -o src/assets/sounds/boing.mp3; the cwd default is fine for one-off generation.')
|
|
298
|
+
.option('--json', 'Output as JSON')
|
|
299
|
+
.action(async (text, opts) => {
|
|
300
|
+
try {
|
|
301
|
+
const { config } = await resolveProjectContext();
|
|
302
|
+
const doGenerate = () => post(`/projects/${config.projectGuid}/generate/sound`, {
|
|
303
|
+
text,
|
|
304
|
+
duration_seconds: opts.duration,
|
|
305
|
+
prompt_influence: opts.influence,
|
|
306
|
+
});
|
|
307
|
+
const result = opts.json
|
|
308
|
+
? await doGenerate()
|
|
309
|
+
: await withSpinner('Generating sound effect…', doGenerate, { done: null });
|
|
310
|
+
const filename = opts.output || 'sound.mp3';
|
|
311
|
+
const savedPath = await downloadFile(result.url, filename);
|
|
312
|
+
if (opts.json) {
|
|
313
|
+
console.log(JSON.stringify({ ...result, saved: savedPath }));
|
|
314
|
+
}
|
|
315
|
+
else {
|
|
316
|
+
const sizeKb = Math.round(result.size_bytes / 1024);
|
|
317
|
+
console.log(`${muted(`Generated sound effect (${sizeKb}KB)`)}`);
|
|
318
|
+
console.log(success(`Saved to ${savedPath}`));
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
catch (err) {
|
|
322
|
+
printCommandError('Sound generation', err);
|
|
323
|
+
process.exit(1);
|
|
324
|
+
}
|
|
325
|
+
});
|
|
277
326
|
// ── PARENT COMMAND ─────────────────────────────────────────────────────
|
|
278
327
|
export const generateCommand = new Command('generate')
|
|
279
|
-
.description('Generate images, video, speech, or music')
|
|
328
|
+
.description('Generate images, video, speech, sound effects, or music')
|
|
280
329
|
.addCommand(imageCommand)
|
|
281
330
|
.addCommand(videoCommand)
|
|
282
331
|
.addCommand(speechCommand)
|
|
332
|
+
.addCommand(soundCommand)
|
|
283
333
|
.addCommand(musicCommand);
|
|
284
334
|
//# sourceMappingURL=generate.js.map
|
package/dist/commands/init.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { Command } from 'commander';
|
|
2
2
|
import { basename, resolve, dirname } from 'path';
|
|
3
|
-
import { existsSync } from 'fs';
|
|
3
|
+
import { existsSync, readFileSync } from 'fs';
|
|
4
4
|
import { getAccountSlug } from '../api.js';
|
|
5
5
|
import { getConfig, getConfigPath, saveConfigAt } from '../config.js';
|
|
6
6
|
import { getAuth } from '../auth.js';
|
|
@@ -26,6 +26,7 @@ export const initCommand = new Command('init')
|
|
|
26
26
|
.addHelpText('after', '\nWrites CLAUDE.md/AGENTS.md primer files so your AI coding tool understands Gipity.')
|
|
27
27
|
.argument('[name]', 'Project name/slug (defaults to current directory name)')
|
|
28
28
|
.option('--agent <guid>', 'Agent GUID to use')
|
|
29
|
+
.option('--no-capture', 'Don\'t record Claude Code sessions in this directory to your Gipity project (sets captureHooks: false in .gipity.json)')
|
|
29
30
|
.option('--for <tools>', `Which AI tool primer files to write (comma-separated). Default: all except aider (opt-in - it also writes .aider.conf.yml). Choices: ${TOOL_KEYS.join(', ')}, all`)
|
|
30
31
|
.addHelpText('after', `
|
|
31
32
|
Examples:
|
|
@@ -86,14 +87,28 @@ Working with an existing Gipity project:
|
|
|
86
87
|
// artifact introduced by a newer CLI (e.g. aider's .aider.conf.yml)
|
|
87
88
|
// would sync up as project content. Union in the current defaults.
|
|
88
89
|
if (existing) {
|
|
90
|
+
let changed = false;
|
|
89
91
|
const cur = existing.ignore ?? (existing.ignore = []);
|
|
90
92
|
const missing = DEFAULT_SYNC_IGNORE.filter(e => !cur.includes(e));
|
|
91
93
|
if (missing.length) {
|
|
92
94
|
cur.push(...missing);
|
|
93
|
-
|
|
95
|
+
changed = true;
|
|
96
|
+
}
|
|
97
|
+
// `--no-capture` on a re-init is the documented way to opt an
|
|
98
|
+
// existing project out of session recording. One-way from flags:
|
|
99
|
+
// a bare re-run never silently reverses an explicit opt-out
|
|
100
|
+
// (delete the key or set it true in .gipity.json to re-enable).
|
|
101
|
+
if (opts.capture === false && existing.captureHooks !== false) {
|
|
102
|
+
existing.captureHooks = false;
|
|
103
|
+
changed = true;
|
|
94
104
|
}
|
|
105
|
+
if (changed)
|
|
106
|
+
saveConfigAt(cwd, existing);
|
|
95
107
|
}
|
|
96
108
|
console.log(success(`Refreshed primer files: ${primerSummary}.`));
|
|
109
|
+
if (opts.capture === false) {
|
|
110
|
+
console.log(success('Session recording disabled for this project (captureHooks: false in .gipity.json).'));
|
|
111
|
+
}
|
|
97
112
|
return;
|
|
98
113
|
}
|
|
99
114
|
// Refuse $HOME / system roots - adopting one as a project root would
|
|
@@ -156,9 +171,27 @@ Working with an existing Gipity project:
|
|
|
156
171
|
}
|
|
157
172
|
if (adopted.applied > 0)
|
|
158
173
|
console.log(`Synced ${adopted.applied} change${adopted.applied > 1 ? 's' : ''} with Gipity.`);
|
|
174
|
+
// Session recording opt-out. Written after adopt so it lands in the
|
|
175
|
+
// freshly created .gipity.json regardless of how the link happened.
|
|
176
|
+
if (opts.capture === false) {
|
|
177
|
+
try {
|
|
178
|
+
const cfg = JSON.parse(readFileSync(resolve(cwd, '.gipity.json'), 'utf-8'));
|
|
179
|
+
cfg.captureHooks = false;
|
|
180
|
+
saveConfigAt(cwd, cfg);
|
|
181
|
+
}
|
|
182
|
+
catch { /* config missing/unreadable - nothing to opt out of */ }
|
|
183
|
+
}
|
|
159
184
|
console.log(success(`Wrote primer files: ${primerSummary}.`));
|
|
160
185
|
if (wantsClaude) {
|
|
161
186
|
console.log(success('Ready! Run `gipity claude` for Claude Code, or open this directory in your other AI coding tool.'));
|
|
187
|
+
// Recording happens by default (however Claude Code is launched), so
|
|
188
|
+
// say so up front - consent should be explicit, not discovered later.
|
|
189
|
+
if (opts.capture === false) {
|
|
190
|
+
console.log(muted('Session recording is off for this project (captureHooks: false in .gipity.json).'));
|
|
191
|
+
}
|
|
192
|
+
else {
|
|
193
|
+
console.log(muted('Claude Code sessions here are recorded to your Gipity project (view at prompt.gipity.ai). Opt out: gipity init --no-capture.'));
|
|
194
|
+
}
|
|
162
195
|
}
|
|
163
196
|
else {
|
|
164
197
|
console.log(success('Ready! Open this directory in your AI coding tool.'));
|
|
@@ -4,6 +4,7 @@ import { formatSize } from '../utils.js';
|
|
|
4
4
|
import { brand, bold, error as clrError, warning, muted, info } from '../colors.js';
|
|
5
5
|
import { run } from '../helpers/index.js';
|
|
6
6
|
import { capWaitMs } from './page-eval.js';
|
|
7
|
+
import { TOUCH_DEVICES as INSPECT_DEVICES, resolveTouchDevice } from './page-screenshot.js';
|
|
7
8
|
/** A console line is an error-level entry (page error or console.error). */
|
|
8
9
|
const isErrorLine = (line) => /^error:/i.test(line);
|
|
9
10
|
/** A message-less, cross-origin "Script error." The throwing <script> lacks
|
|
@@ -39,6 +40,7 @@ export const pageInspectCommand = new Command('inspect')
|
|
|
39
40
|
.option('--no-truncate', 'Show full URLs instead of truncating long ones with middle-ellipsis')
|
|
40
41
|
.option('--all', 'Include render-blocking, large resources, oversized images, overflow culprits, and LCP detail')
|
|
41
42
|
.option('--fake-media', 'Grant a synthetic microphone + camera and auto-accept the getUserMedia prompt, so voice/camera apps run headlessly (audio is a built-in tone, not real speech)')
|
|
43
|
+
.option('--device <name>', `Inspect as a real touch device: ${INSPECT_DEVICES.join(', ')}. Emulates touch events, mobile user-agent and DPR, so a touch-gated mobile layout is what gets inspected.`)
|
|
42
44
|
.option('--auth', 'Load the page signed in as you (your Gipity account), so pages behind a Sign-in-with-Gipity login are reachable. Only works for apps using Sign in with Gipity, hosted on *.gipity.ai.')
|
|
43
45
|
// Hidden redirect: agents reach for `page inspect --screenshot`. We don't take
|
|
44
46
|
// an image here (`page screenshot` is the single path for that) — just point there.
|
|
@@ -60,6 +62,7 @@ export const pageInspectCommand = new Command('inspect')
|
|
|
60
62
|
waitForSelector: opts.waitFor || undefined,
|
|
61
63
|
waitForTimeoutMs: opts.waitFor ? waitForTimeoutMs : undefined,
|
|
62
64
|
fakeMedia: opts.fakeMedia || undefined,
|
|
65
|
+
device: opts.device ? resolveTouchDevice(opts.device) : undefined,
|
|
63
66
|
auth: opts.auth || undefined,
|
|
64
67
|
};
|
|
65
68
|
const res = await post(`/tools/browser/inspect`, inspectBody);
|
|
@@ -7,13 +7,20 @@ import { brand, bold, muted, success } from '../colors.js';
|
|
|
7
7
|
import { formatSize } from '../utils.js';
|
|
8
8
|
import { run } from '../helpers/index.js';
|
|
9
9
|
import { withSpinner } from '../progress.js';
|
|
10
|
+
/** `mobile` and `tablet` name a real handset/tablet rather than just a window size.
|
|
11
|
+
* The server emulates it end to end - mobile user-agent, DPR, and crucially TOUCH,
|
|
12
|
+
* so a page that gates its mobile UI on `'ontouchstart' in window` or
|
|
13
|
+
* `navigator.maxTouchPoints` renders that UI instead of its desktop layout. The
|
|
14
|
+
* dimensions here mirror the device's own metrics (reported in meta.json).
|
|
15
|
+
* `device` values must be ones the server's BROWSER_DEVICES accepts. */
|
|
10
16
|
const DEVICE_PRESETS = {
|
|
11
17
|
default: { width: 1280, height: 720 },
|
|
12
18
|
desktop: { width: 1920, height: 1080 },
|
|
13
19
|
laptop: { width: 1366, height: 768 },
|
|
14
|
-
tablet: { width:
|
|
15
|
-
mobile: { width:
|
|
20
|
+
tablet: { width: 820, height: 1180, deviceScaleFactor: 2, device: 'iPad' },
|
|
21
|
+
mobile: { width: 393, height: 852, deviceScaleFactor: 3, device: 'iPhone 15' },
|
|
16
22
|
};
|
|
23
|
+
const TOUCH_DEVICE_ALIASES = { mobile: 'iPhone 15', tablet: 'iPad' };
|
|
17
24
|
const LABEL_WIDTH = 18; // "Screenshot dims:" + trailing space
|
|
18
25
|
function label(text) {
|
|
19
26
|
return muted((text + ':').padEnd(LABEL_WIDTH));
|
|
@@ -77,14 +84,43 @@ function parseViewportString(s) {
|
|
|
77
84
|
}
|
|
78
85
|
return dpr ? { width, height, deviceScaleFactor: dpr } : { width, height };
|
|
79
86
|
}
|
|
87
|
+
/** Device metrics for the exact server device names, so `--device "Pixel 9"` works
|
|
88
|
+
* alongside the friendly `mobile`/`tablet` presets. Mirrors agent-browser's own
|
|
89
|
+
* descriptors; the server re-applies them, these are for filenames + meta. */
|
|
90
|
+
const EXACT_DEVICE_VIEWPORTS = {
|
|
91
|
+
'iPhone 15': { width: 393, height: 852, deviceScaleFactor: 3, device: 'iPhone 15' },
|
|
92
|
+
'iPhone 16': { width: 393, height: 852, deviceScaleFactor: 3, device: 'iPhone 16' },
|
|
93
|
+
'iPhone 16 Pro': { width: 402, height: 874, deviceScaleFactor: 3, device: 'iPhone 16 Pro' },
|
|
94
|
+
'iPhone 17': { width: 402, height: 874, deviceScaleFactor: 3, device: 'iPhone 17' },
|
|
95
|
+
'iPad': { width: 820, height: 1180, deviceScaleFactor: 2, device: 'iPad' },
|
|
96
|
+
'iPad Pro': { width: 1024, height: 1366, deviceScaleFactor: 2, device: 'iPad Pro' },
|
|
97
|
+
'Pixel 9': { width: 412, height: 923, deviceScaleFactor: 2.625, device: 'Pixel 9' },
|
|
98
|
+
'Galaxy S25': { width: 360, height: 800, deviceScaleFactor: 3, device: 'Galaxy S25' },
|
|
99
|
+
};
|
|
100
|
+
/** Emulatable devices the server accepts (its BROWSER_DEVICES). Shared with
|
|
101
|
+
* `page inspect` so both commands take the same names. */
|
|
102
|
+
export const TOUCH_DEVICES = Object.keys(EXACT_DEVICE_VIEWPORTS);
|
|
103
|
+
/** Case-insensitively resolve a device name or `mobile`/`tablet` alias to a server
|
|
104
|
+
* device name. Used by `page inspect`, which takes one device rather than viewports. */
|
|
105
|
+
export function resolveTouchDevice(name) {
|
|
106
|
+
const key = name.trim().toLowerCase();
|
|
107
|
+
if (TOUCH_DEVICE_ALIASES[key])
|
|
108
|
+
return TOUCH_DEVICE_ALIASES[key];
|
|
109
|
+
const exact = TOUCH_DEVICES.find((d) => d.toLowerCase() === key);
|
|
110
|
+
if (exact)
|
|
111
|
+
return exact;
|
|
112
|
+
throw new Error(`Unknown --device: "${name}" (known: mobile, tablet, ${TOUCH_DEVICES.join(', ')})`);
|
|
113
|
+
}
|
|
80
114
|
function resolveDevice(name) {
|
|
81
115
|
const key = name.trim().toLowerCase();
|
|
82
116
|
const preset = DEVICE_PRESETS[key];
|
|
83
|
-
if (
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
117
|
+
if (preset)
|
|
118
|
+
return preset;
|
|
119
|
+
const exact = TOUCH_DEVICES.find((d) => d.toLowerCase() === key);
|
|
120
|
+
if (exact)
|
|
121
|
+
return EXACT_DEVICE_VIEWPORTS[exact];
|
|
122
|
+
const available = [...Object.keys(DEVICE_PRESETS), ...TOUCH_DEVICES].join(', ');
|
|
123
|
+
throw new Error(`Unknown --device preset: "${name}" (known: ${available})`);
|
|
88
124
|
}
|
|
89
125
|
function splitCsv(values) {
|
|
90
126
|
if (!values || values.length === 0)
|
|
@@ -104,7 +140,7 @@ export const pageScreenshotCommand = new Command('screenshot')
|
|
|
104
140
|
.option('--action <js>', 'Run JS in the page before capturing — e.g. click a button to enter a state ("document.getElementById(\'play\').click()"). Runs after the post-load delay, then settles again before the shot.')
|
|
105
141
|
.option('--full', 'Capture the full scrollable page (default: viewport only). Scrolls the page through first so scroll-reveal/fade-in-on-scroll (IntersectionObserver) sections render into the shot instead of capturing blank.')
|
|
106
142
|
.option('-o, --output <file>', 'Output path (single viewport only; default .gipity/screenshots/ss-<host>-<timestamp>.png)')
|
|
107
|
-
.option('--device <names>', `
|
|
143
|
+
.option('--device <names>', `Device preset(s): ${Object.keys(DEVICE_PRESETS).join(', ')} (comma-separated or repeat flag). mobile/tablet emulate a real touch device — touch events, mobile user-agent, DPR — so touch-gated mobile UI actually renders.`, appendOption, [])
|
|
108
144
|
.option('--viewport <dims>', 'Raw viewport(s): WxH or WxH@dpr (comma-separated or repeat flag)', appendOption, [])
|
|
109
145
|
.option('--no-reload-between', 'Skip reload between viewports (faster, lower fidelity - only safe for static pages)')
|
|
110
146
|
.option('--fake-media', 'Grant a synthetic microphone + camera and auto-accept the getUserMedia prompt, so voice/camera apps render headlessly (audio is a built-in tone, not real speech)')
|
|
@@ -194,6 +230,8 @@ export const pageScreenshotCommand = new Command('screenshot')
|
|
|
194
230
|
width: s.viewport.width,
|
|
195
231
|
height: s.viewport.height,
|
|
196
232
|
device_scale_factor: s.viewport.deviceScaleFactor,
|
|
233
|
+
...(s.viewport.device ? { device: s.viewport.device } : {}),
|
|
234
|
+
touch: !!s.viewport.touch,
|
|
197
235
|
},
|
|
198
236
|
width: s.width,
|
|
199
237
|
height: s.height,
|
|
@@ -215,6 +253,8 @@ export const pageScreenshotCommand = new Command('screenshot')
|
|
|
215
253
|
console.log(`${label('Web page status')} ${meta.status}`);
|
|
216
254
|
if (meta.performance)
|
|
217
255
|
console.log(`${label('Web page perf')} ${fmtPerformance(meta.performance)}`);
|
|
256
|
+
if (s.viewport.device)
|
|
257
|
+
console.log(`${label('Emulated device')} ${s.viewport.device} ${muted('(touch events, mobile user-agent)')}`);
|
|
218
258
|
const sizePart = formatSize(s.screenshotSizeBytes) + (meta.full ? ' (full page)' : '');
|
|
219
259
|
console.log(`${label('Screenshot size')} ${sizePart}`);
|
|
220
260
|
if (s.width && s.height)
|
|
@@ -234,7 +274,8 @@ export const pageScreenshotCommand = new Command('screenshot')
|
|
|
234
274
|
for (let i = 0; i < meta.screenshots.length; i++) {
|
|
235
275
|
const s = meta.screenshots[i];
|
|
236
276
|
const dims = `${s.viewport.width}×${s.viewport.height}${s.viewport.deviceScaleFactor > 1 ? ` @${s.viewport.deviceScaleFactor}x` : ''}`;
|
|
237
|
-
|
|
277
|
+
const deviceTag = s.viewport.device ? muted(` — ${s.viewport.device}, touch`) : '';
|
|
278
|
+
console.log(`\n${brand('@ ' + dims)}${deviceTag}`);
|
|
238
279
|
const sizePart = formatSize(s.screenshotSizeBytes) + (meta.full ? ' (full page)' : '');
|
|
239
280
|
console.log(`${label('Screenshot size')} ${sizePart}`);
|
|
240
281
|
if (s.width && s.height)
|
package/dist/commands/sandbox.js
CHANGED
|
@@ -28,6 +28,81 @@ const INTERPRETERS = {
|
|
|
28
28
|
bash: 'bash',
|
|
29
29
|
sh: 'bash',
|
|
30
30
|
};
|
|
31
|
+
/** The three ways to pin a language, shown whenever none was pinned. */
|
|
32
|
+
const PIN_LANGUAGE_HELP = [
|
|
33
|
+
' gipity sandbox run bash "<code>" # interpreter token (bash | python | node)',
|
|
34
|
+
' gipity sandbox run --language bash "<code>" # explicit flag (js | py | bash)',
|
|
35
|
+
' gipity sandbox run --file script.sh # inferred from the file extension',
|
|
36
|
+
].join('\n');
|
|
37
|
+
/**
|
|
38
|
+
* Resolve the language from an explicit signal, or exit.
|
|
39
|
+
*
|
|
40
|
+
* Precedence: interpreter token > --language > --file extension.
|
|
41
|
+
*
|
|
42
|
+
* There is no default. js/python/bash are mutually exclusive, and plenty of
|
|
43
|
+
* snippets parse as more than one of them (`x = 1`, `a[0]`, `foo()`), so any
|
|
44
|
+
* default silently runs some fraction of input in the wrong interpreter. The old
|
|
45
|
+
* behavior defaulted to JavaScript, which meant a shell one-liner ran as JS and
|
|
46
|
+
* died with a Node `SyntaxError` at `/work/_run.js` - after paying for a project
|
|
47
|
+
* sync and a server round trip. Failing here instead costs nothing and says what
|
|
48
|
+
* to type. (`docs/skills/sandbox-tools.md` used to carry a hand-written "always
|
|
49
|
+
* pin the language" warning to work around this; the CLI enforces it now.)
|
|
50
|
+
*/
|
|
51
|
+
export function resolveLanguage(opts) {
|
|
52
|
+
const explicit = opts.langFromInterp
|
|
53
|
+
?? (opts.langOpt ? LANG_MAP[opts.langOpt.toLowerCase()] ?? opts.langOpt : undefined);
|
|
54
|
+
if (explicit && !['javascript', 'python', 'bash'].includes(explicit)) {
|
|
55
|
+
console.error(clrError(`Invalid language: ${opts.langOpt}. Use: js, py, or bash`));
|
|
56
|
+
process.exit(1);
|
|
57
|
+
}
|
|
58
|
+
if (explicit)
|
|
59
|
+
return explicit;
|
|
60
|
+
const fromExt = opts.filePath
|
|
61
|
+
? LANG_MAP[extname(opts.filePath).slice(1).toLowerCase()]
|
|
62
|
+
: undefined;
|
|
63
|
+
if (fromExt)
|
|
64
|
+
return fromExt;
|
|
65
|
+
if (opts.filePath) {
|
|
66
|
+
console.error(clrError(`Cannot infer the language of ${opts.filePath} from its extension (expected .js, .py, or .sh).`));
|
|
67
|
+
console.error(dim(`Pass --language explicitly:\n gipity sandbox run --language py --file ${opts.filePath}`));
|
|
68
|
+
process.exit(1);
|
|
69
|
+
}
|
|
70
|
+
console.error(clrError('No language specified for inline code.'));
|
|
71
|
+
console.error(dim(`Pin it one of three ways:\n${PIN_LANGUAGE_HELP}`));
|
|
72
|
+
process.exit(1);
|
|
73
|
+
}
|
|
74
|
+
/** Truncate one arg for echoing back, so a 2 KB fragment doesn't flood the terminal. */
|
|
75
|
+
function preview(arg) {
|
|
76
|
+
const flat = arg.replace(/\s+/g, ' ').trim();
|
|
77
|
+
return flat.length > 60 ? `${flat.slice(0, 57)}...` : flat;
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Inline code arrived as several positional args, which means the caller's shell
|
|
81
|
+
* split it before the CLI ever saw it. The old message ("Unrecognized
|
|
82
|
+
* invocation") just restated the rules and left the caller to guess which one it
|
|
83
|
+
* broke - an agent that hits this typically retries the same mangled quoting.
|
|
84
|
+
*
|
|
85
|
+
* So: show the fragments we actually received (the split point is the evidence),
|
|
86
|
+
* then name the usual cause. On PowerShell a double-quoted string interpolates
|
|
87
|
+
* `$(...)` and backslash is NOT an escape character, so a POSIX-style
|
|
88
|
+
* `"... $(cmd) ... \"$f\" ..."` one-liner both runs `cmd` locally and terminates
|
|
89
|
+
* the string early at `\"`. That is exactly how one quoted arg becomes many.
|
|
90
|
+
*/
|
|
91
|
+
function explainSplitArgs(args) {
|
|
92
|
+
const looksInterpolated = args.some(a => a.includes('$(') || a.includes('`'));
|
|
93
|
+
const lines = [
|
|
94
|
+
clrError(`Inline code must be a single argument, but ${args.length} were received:`),
|
|
95
|
+
...args.slice(0, 4).map((a, i) => dim(` ${i + 1}: ${preview(a)}`)),
|
|
96
|
+
...(args.length > 4 ? [dim(` ... and ${args.length - 4} more`)] : []),
|
|
97
|
+
'',
|
|
98
|
+
'Your shell split the code before the CLI saw it.',
|
|
99
|
+
];
|
|
100
|
+
if (looksInterpolated) {
|
|
101
|
+
lines.push(dim('In PowerShell a double-quoted string interpolates $(...), and backslash does not'), dim('escape a quote - so a POSIX one-liner runs its subshell locally and ends early.'));
|
|
102
|
+
}
|
|
103
|
+
lines.push('', 'Fix, in order of preference:', dim(' 1. Put the code in a file (best for anything with quotes, $(...), or newlines):'), ' gipity sandbox run --file script.sh', dim(' 2. Use the interpreter shorthand on a file:'), ' gipity sandbox run bash script.sh', dim(' 3. Keep it inline, but as ONE argument your shell will not split:'), " gipity sandbox run --language bash 'echo hi'");
|
|
104
|
+
return lines.join('\n');
|
|
105
|
+
}
|
|
31
106
|
/** Project-relative path from the process cwd, or undefined when there's
|
|
32
107
|
* no local config (one-off mode) or the cwd is at/above the project root. */
|
|
33
108
|
function resolveRelativeCwd() {
|
|
@@ -45,7 +120,11 @@ export const sandboxCommand = new Command('sandbox')
|
|
|
45
120
|
sandboxCommand
|
|
46
121
|
.command('run [args...]')
|
|
47
122
|
.description('Run code')
|
|
48
|
-
|
|
123
|
+
// No default. js/python/bash are mutually exclusive and the same snippet can
|
|
124
|
+
// parse as more than one of them, so an implicit default silently runs code in
|
|
125
|
+
// the wrong interpreter. Every invocation must pin the language via this flag,
|
|
126
|
+
// an interpreter token, or a --file extension - see resolveLanguage().
|
|
127
|
+
.option('--language <language>', 'Language: js, py, or bash (required unless pinned by an interpreter token or --file extension)')
|
|
49
128
|
.option('--file <path>', 'Read the code body from a file instead of the inline <code> arg; --language is inferred from the extension when not given')
|
|
50
129
|
.option('--timeout <seconds>', 'Execution timeout in seconds', '30')
|
|
51
130
|
.option('--input <path>', 'Narrow to specific project files instead of auto-mirroring the whole tree (repeatable). Use this only for >1 GB projects or when you want surgical control.', (v, prev) => [...(prev ?? []), v])
|
|
@@ -89,8 +168,12 @@ Pre-installed: Python (pandas, numpy, matplotlib, Pillow, scipy, bs4),
|
|
|
89
168
|
CLI tools (ImageMagick, FFmpeg, webp/cwebp, optipng, jq, pandoc, exiftool,
|
|
90
169
|
GCC/Rust).
|
|
91
170
|
`)
|
|
92
|
-
.action((args = [], opts
|
|
93
|
-
|
|
171
|
+
.action((args = [], opts) => run('Sandbox', async () => {
|
|
172
|
+
// Everything below this point is pure argument validation - it reads the local
|
|
173
|
+
// filesystem and nothing else. It runs BEFORE resolveProjectContext() so a
|
|
174
|
+
// malformed invocation fails on the spot instead of first paying a project
|
|
175
|
+
// lookup (and printing a misleading "→ (project: …)" banner) only to reject
|
|
176
|
+
// the args a moment later.
|
|
94
177
|
// Resolve the positional args into either inline code or a script-file path.
|
|
95
178
|
// `run <interpreter> <file>` (e.g. `run python build_report.py`) is the natural
|
|
96
179
|
// mental model, so accept it: a leading interpreter token + a path becomes
|
|
@@ -112,7 +195,7 @@ GCC/Rust).
|
|
|
112
195
|
inlineCode = args[0];
|
|
113
196
|
}
|
|
114
197
|
else if (args.length > 1) {
|
|
115
|
-
console.error(
|
|
198
|
+
console.error(explainSplitArgs(args));
|
|
116
199
|
process.exit(1);
|
|
117
200
|
}
|
|
118
201
|
if (inlineCode !== undefined && filePath) {
|
|
@@ -133,22 +216,13 @@ GCC/Rust).
|
|
|
133
216
|
process.exit(1);
|
|
134
217
|
}
|
|
135
218
|
}
|
|
136
|
-
// Language precedence: interpreter token > file extension
|
|
137
|
-
//
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
// command shape pinned a language. Used to explain the execution mode if a
|
|
144
|
-
// shell/Python snippet gets parsed as JavaScript and blows up (see hint below).
|
|
145
|
-
const usedDefaultJs = !langFromInterp
|
|
146
|
-
&& command.getOptionValueSource('language') === 'default'
|
|
147
|
-
&& language === 'javascript';
|
|
148
|
-
if (!['javascript', 'python', 'bash'].includes(language)) {
|
|
149
|
-
console.error(clrError(`Invalid language: ${opts.language}. Use: js, py, or bash`));
|
|
150
|
-
process.exit(1);
|
|
151
|
-
}
|
|
219
|
+
// Language precedence: interpreter token > --language > file extension.
|
|
220
|
+
// There is deliberately no fallback: resolveLanguage() exits when nothing
|
|
221
|
+
// pinned one, rather than guessing. This runs BEFORE the project sync and the
|
|
222
|
+
// server round trip below, so a missing language costs nothing but the message.
|
|
223
|
+
const language = resolveLanguage({ langFromInterp, langOpt: opts.language, filePath });
|
|
224
|
+
// Args are good - now it's worth resolving (and announcing) the project.
|
|
225
|
+
const { config } = await resolveProjectContext();
|
|
152
226
|
const timeout = parseInt(opts.timeout, 10);
|
|
153
227
|
const cwd = resolveRelativeCwd();
|
|
154
228
|
// Push local working-tree changes up before executing. The sandbox mirrors
|
|
@@ -207,12 +281,8 @@ GCC/Rust).
|
|
|
207
281
|
console.log(`${f}`);
|
|
208
282
|
}
|
|
209
283
|
if (res.data.exitCode !== 0) {
|
|
210
|
-
//
|
|
211
|
-
// always
|
|
212
|
-
// The raw Node stack trace never says which mode ran, so name it.
|
|
213
|
-
if (usedDefaultJs && /SyntaxError|cjs\/loader|wrapSafe/.test(res.data.stderr || '')) {
|
|
214
|
-
console.error(dim('Hint: ran as JavaScript (the default). For a shell command pass `--language bash` (or `gipity sandbox run bash "<cmd>"`); for Python pass `--language py`.'));
|
|
215
|
-
}
|
|
284
|
+
// No "did you mean another language?" hint is needed: the language is now
|
|
285
|
+
// always something the caller pinned, never a silent default we chose.
|
|
216
286
|
process.exit(res.data.exitCode);
|
|
217
287
|
}
|
|
218
288
|
}
|
package/dist/commands/service.js
CHANGED
|
@@ -13,7 +13,7 @@ const SERVICES = [
|
|
|
13
13
|
{ name: 'image/models', method: 'GET', desc: 'List image providers/models' },
|
|
14
14
|
{ name: 'tts', method: 'POST', desc: 'Text-to-speech ({ text, voice?, ... })' },
|
|
15
15
|
{ name: 'tts/voices', method: 'GET', desc: 'List TTS voices' },
|
|
16
|
-
{ name: 'sound', method: 'POST', desc: 'Sound effect ({
|
|
16
|
+
{ name: 'sound', method: 'POST', desc: 'Sound effect ({ text, duration_seconds? })' },
|
|
17
17
|
{ name: 'music', method: 'POST', desc: 'Music generation ({ prompt, duration_seconds? })' },
|
|
18
18
|
{ name: 'video', method: 'POST', desc: 'Video generation ({ prompt, ... })' },
|
|
19
19
|
{ name: 'location/ip', method: 'POST', desc: 'IP geolocation ({ ip? })' },
|
package/dist/commands/storage.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { Command } from 'commander';
|
|
2
2
|
import { get, patch } from '../api.js';
|
|
3
|
-
import {
|
|
3
|
+
import { formatBytes } from '../adopt-cwd.js';
|
|
4
|
+
import { bold, brand, muted, warning } from '../colors.js';
|
|
4
5
|
import { run } from '../helpers/index.js';
|
|
5
6
|
/** Parse a CLI flag value as a positive whole number, or throw a friendly error.
|
|
6
7
|
* The server is the source of truth for the plan-cap bound; this just rejects
|
|
@@ -22,8 +23,49 @@ function printRetention(d, updated) {
|
|
|
22
23
|
console.log(` ${muted(`days: ${daysNote}, copies: ${countNote}`)}`);
|
|
23
24
|
console.log(muted(`Plan allows up to ${d.maxDays} days / ${d.maxCount} copies.`));
|
|
24
25
|
}
|
|
26
|
+
function row(label, value, note = '') {
|
|
27
|
+
console.log(` ${label.padEnd(16)} ${value.padStart(10)}${note ? ` ${muted(note)}` : ''}`);
|
|
28
|
+
}
|
|
29
|
+
function printUsage(d) {
|
|
30
|
+
const over = d.usedBytes > d.quotaBytes;
|
|
31
|
+
const pct = d.quotaBytes > 0 ? Math.round((d.usedBytes / d.quotaBytes) * 100) : 0;
|
|
32
|
+
const headline = `${formatBytes(d.usedBytes)} of ${formatBytes(d.quotaBytes)} used (${pct}%)`;
|
|
33
|
+
console.log(`Storage: ${over ? warning(headline) : brand(headline)}`);
|
|
34
|
+
console.log('');
|
|
35
|
+
const s = d.storage;
|
|
36
|
+
row('Live files', formatBytes(s.liveBytes), `${s.liveFiles.toLocaleString()} files`);
|
|
37
|
+
row('All versions', formatBytes(s.versionedBytes), `${s.versionCount.toLocaleString()} versions`);
|
|
38
|
+
row('Dedup saved', formatBytes(s.dedupSavedBytes), `${s.dedupedObjects.toLocaleString()} shared`);
|
|
39
|
+
row('Billed', formatBytes(s.physicalBytes), 'versions kept, dedup applied');
|
|
40
|
+
if (d.projects.length > 0) {
|
|
41
|
+
console.log('');
|
|
42
|
+
console.log(`${bold('By project')} ${muted('(live files only)')}`);
|
|
43
|
+
for (const p of d.projects) {
|
|
44
|
+
const name = p.projectName ?? '(no project)';
|
|
45
|
+
row(name.length > 16 ? `${name.slice(0, 15)}…` : name, formatBytes(p.liveBytes), `${p.liveFiles.toLocaleString()} files`);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
const r = d.versionRetention;
|
|
49
|
+
console.log('');
|
|
50
|
+
console.log(muted(`Version retention: ${r.days} days / ${r.count} copies (plan allows up to ${r.maxDays} / ${r.maxCount}).`));
|
|
51
|
+
if (over) {
|
|
52
|
+
console.log(warning('Over quota. Delete files you no longer need, or keep less history with `gipity storage retention`.'));
|
|
53
|
+
}
|
|
54
|
+
}
|
|
25
55
|
export const storageCommand = new Command('storage')
|
|
26
56
|
.description('Manage file storage settings');
|
|
57
|
+
storageCommand
|
|
58
|
+
.command('usage')
|
|
59
|
+
.description('Show what is using your storage, per project and live vs. version history')
|
|
60
|
+
.option('--json', 'Output as JSON')
|
|
61
|
+
.action((opts) => run('Usage', async () => {
|
|
62
|
+
const res = await get('/users/me/storage');
|
|
63
|
+
if (opts.json) {
|
|
64
|
+
console.log(JSON.stringify(res.data));
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
printUsage(res.data);
|
|
68
|
+
}));
|
|
27
69
|
storageCommand
|
|
28
70
|
.command('retention')
|
|
29
71
|
.description('View or adjust your file version-retention policy')
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* Internal hook runner - invoked by Claude Code's lifecycle hooks
|
|
4
4
|
* (SessionStart, Stop, SubagentStop, SessionEnd, and a throttled
|
|
5
|
-
* PostToolUse for mid-run flushing) to mirror a terminal
|
|
5
|
+
* PostToolUse for mid-run flushing) to mirror a terminal Claude Code
|
|
6
6
|
* session into the Gipity server so the web CLI can display it read-only.
|
|
7
7
|
*
|
|
8
8
|
* Not a user-facing `gipity` subcommand by design: users never invoke
|
|
@@ -16,9 +16,22 @@
|
|
|
16
16
|
* source: 'claude-code' (today) | future: 'codex', …
|
|
17
17
|
* event: 'session-start' | 'stop' | 'subagent-stop' | 'session-end' | 'post-tool-use' | 'pre-compact'
|
|
18
18
|
*
|
|
19
|
+
* Conversation binding, in order:
|
|
20
|
+
* 1. GIPITY_CONVERSATION_GUID env var - set by `gipity claude`, which
|
|
21
|
+
* created/reused the conversation before spawning Claude Code.
|
|
22
|
+
* 2. A session_id → conv mapping persisted in the capture-state dir by
|
|
23
|
+
* an earlier event of this session.
|
|
24
|
+
* 3. Self-arm: the session was launched WITHOUT `gipity claude` (bare
|
|
25
|
+
* `claude` in a linked project dir). Resolve the project from
|
|
26
|
+
* .gipity.json and ask the server to bind this session_id to a
|
|
27
|
+
* conversation (POST /remote-sessions/resolve), then persist the
|
|
28
|
+
* mapping. This is what makes bare `claude` sessions record.
|
|
29
|
+
*
|
|
19
30
|
* Graceful no-ops (exit 0 silently):
|
|
20
|
-
* -
|
|
21
|
-
*
|
|
31
|
+
* - GIPITY_CAPTURE=off - the relay daemon owns capture for this run
|
|
32
|
+
* (it parses stream-json from stdout), or the caller opted out.
|
|
33
|
+
* - No binding resolvable: not a Gipity project, `captureHooks: false`
|
|
34
|
+
* in .gipity.json, or the resolve call failed.
|
|
22
35
|
* - The machine isn't paired - no device token available.
|
|
23
36
|
* - Anything unexpected (parse error, network error, etc.). We must
|
|
24
37
|
* not break the user's interactive session.
|
|
@@ -28,6 +41,7 @@ import { homedir } from 'os';
|
|
|
28
41
|
import { join } from 'path';
|
|
29
42
|
import { getDevice } from '../relay/state.js';
|
|
30
43
|
import { deviceFetch } from '../relay/device-http.js';
|
|
44
|
+
import { getConfig } from '../config.js';
|
|
31
45
|
import { parseTranscript, } from '../capture/sources/claude-code.js';
|
|
32
46
|
const CAPTURE_DIR = join(homedir(), '.gipity', 'capture-state');
|
|
33
47
|
const INGEST_BATCH_MAX = 100; // server caps at 200; stay comfortably under
|
|
@@ -159,6 +173,117 @@ export function acquireLock(convGuid) {
|
|
|
159
173
|
}
|
|
160
174
|
return null;
|
|
161
175
|
}
|
|
176
|
+
// ─── conversation binding ────────────────────────────────────────────
|
|
177
|
+
// Sessions launched via `gipity claude` carry GIPITY_CONVERSATION_GUID.
|
|
178
|
+
// Bare `claude` in a linked project has no env binding, so the first
|
|
179
|
+
// event resolves one from the server (keyed by the agent session_id) and
|
|
180
|
+
// persists it here for every later event of the session.
|
|
181
|
+
/** session_id is Claude Code's session UUID - filesystem-safe by
|
|
182
|
+
* construction; the character guard keeps a hostile hook payload from
|
|
183
|
+
* escaping the capture-state dir (used for both the mapping file and
|
|
184
|
+
* the resolve lock). */
|
|
185
|
+
function safeSessionKey(sessionId) {
|
|
186
|
+
return `sid-${sessionId.replace(/[^A-Za-z0-9._-]/g, '_')}`;
|
|
187
|
+
}
|
|
188
|
+
function sessionMapPath(sessionId) {
|
|
189
|
+
return join(CAPTURE_DIR, `${safeSessionKey(sessionId)}.json`);
|
|
190
|
+
}
|
|
191
|
+
function readSessionMap(sessionId) {
|
|
192
|
+
try {
|
|
193
|
+
const parsed = JSON.parse(readFileSync(sessionMapPath(sessionId), 'utf-8'));
|
|
194
|
+
return typeof parsed.conv_guid === 'string' ? parsed.conv_guid : null;
|
|
195
|
+
}
|
|
196
|
+
catch {
|
|
197
|
+
return null;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
function writeSessionMap(sessionId, convGuid) {
|
|
201
|
+
mkdirSync(CAPTURE_DIR, { recursive: true });
|
|
202
|
+
writeFileSync(sessionMapPath(sessionId), JSON.stringify({ conv_guid: convGuid }) + '\n');
|
|
203
|
+
}
|
|
204
|
+
function deleteSessionMap(sessionId) {
|
|
205
|
+
try {
|
|
206
|
+
unlinkSync(sessionMapPath(sessionId));
|
|
207
|
+
}
|
|
208
|
+
catch { /* already gone */ }
|
|
209
|
+
}
|
|
210
|
+
/** Ask the server which conversation this session belongs to: the conv
|
|
211
|
+
* already bound to this session_id (resume), the project's still-empty
|
|
212
|
+
* placeholder, or a fresh one. Returns null on any failure - capture is
|
|
213
|
+
* best-effort and must never break the session. */
|
|
214
|
+
async function resolveFromServer(projectGuid, hook) {
|
|
215
|
+
let res;
|
|
216
|
+
try {
|
|
217
|
+
res = await deviceFetch('POST', '/remote-sessions/resolve', {
|
|
218
|
+
project_guid: projectGuid,
|
|
219
|
+
session_id: hook.session_id,
|
|
220
|
+
cwd: hook.cwd,
|
|
221
|
+
}, 15_000);
|
|
222
|
+
}
|
|
223
|
+
catch {
|
|
224
|
+
return null;
|
|
225
|
+
}
|
|
226
|
+
if (!res.ok)
|
|
227
|
+
return null;
|
|
228
|
+
try {
|
|
229
|
+
const body = await res.json();
|
|
230
|
+
return body.data?.conversation_guid ?? null;
|
|
231
|
+
}
|
|
232
|
+
catch {
|
|
233
|
+
return null;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
/** Resolve the conversation this event writes to. Env binding first (set
|
|
237
|
+
* by `gipity claude`), then the session's persisted mapping, then the
|
|
238
|
+
* self-arm server resolve. The resolve is serialized per-session with
|
|
239
|
+
* the same crash-safe lock the flushers use, so concurrent hooks (Stop +
|
|
240
|
+
* SubagentStop) can't each mint a conversation: the loser polls for the
|
|
241
|
+
* winner's mapping instead of racing the server. */
|
|
242
|
+
export async function resolveConvGuid(hook, sleep = (ms) => new Promise(r => setTimeout(r, ms))) {
|
|
243
|
+
const fromEnv = process.env.GIPITY_CONVERSATION_GUID;
|
|
244
|
+
if (fromEnv)
|
|
245
|
+
return fromEnv;
|
|
246
|
+
const sessionId = hook.session_id;
|
|
247
|
+
if (!sessionId)
|
|
248
|
+
return null;
|
|
249
|
+
const mapped = readSessionMap(sessionId);
|
|
250
|
+
if (mapped)
|
|
251
|
+
return mapped;
|
|
252
|
+
// Self-arm gates: linked project, capture not opted out, machine paired
|
|
253
|
+
// (main() already checked the device, but resolveConvGuid is also the
|
|
254
|
+
// unit-tested entry - keep it self-sufficient).
|
|
255
|
+
const config = getConfig();
|
|
256
|
+
if (!config?.projectGuid || config.captureHooks === false)
|
|
257
|
+
return null;
|
|
258
|
+
if (!getDevice())
|
|
259
|
+
return null;
|
|
260
|
+
const release = acquireLock(safeSessionKey(sessionId));
|
|
261
|
+
if (!release) {
|
|
262
|
+
// Another hook instance is resolving this session right now. Give it
|
|
263
|
+
// a moment and use its result; bail silently if it never lands (the
|
|
264
|
+
// next event retries the whole resolution).
|
|
265
|
+
for (let i = 0; i < 10; i++) {
|
|
266
|
+
await sleep(300);
|
|
267
|
+
const conv = readSessionMap(sessionId);
|
|
268
|
+
if (conv)
|
|
269
|
+
return conv;
|
|
270
|
+
}
|
|
271
|
+
return null;
|
|
272
|
+
}
|
|
273
|
+
try {
|
|
274
|
+
// Re-check under the lock - the previous holder may have just finished.
|
|
275
|
+
const raced = readSessionMap(sessionId);
|
|
276
|
+
if (raced)
|
|
277
|
+
return raced;
|
|
278
|
+
const conv = await resolveFromServer(config.projectGuid, hook);
|
|
279
|
+
if (conv)
|
|
280
|
+
writeSessionMap(sessionId, conv);
|
|
281
|
+
return conv;
|
|
282
|
+
}
|
|
283
|
+
finally {
|
|
284
|
+
release();
|
|
285
|
+
}
|
|
286
|
+
}
|
|
162
287
|
async function readStdin() {
|
|
163
288
|
if (process.stdin.isTTY)
|
|
164
289
|
return '';
|
|
@@ -262,6 +387,8 @@ async function handleSessionEnd(convGuid, hook, source) {
|
|
|
262
387
|
}];
|
|
263
388
|
await postEntries(convGuid, entries);
|
|
264
389
|
deleteState(convGuid);
|
|
390
|
+
if (hook.session_id)
|
|
391
|
+
deleteSessionMap(hook.session_id);
|
|
265
392
|
}
|
|
266
393
|
function displayName(source) {
|
|
267
394
|
if (source === 'claude-code')
|
|
@@ -269,9 +396,12 @@ function displayName(source) {
|
|
|
269
396
|
return source;
|
|
270
397
|
}
|
|
271
398
|
async function main() {
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
399
|
+
// Explicit capture opt-out. Set by the relay daemon's dispatch spawns
|
|
400
|
+
// (the daemon parses stream-json from stdout - hook capture would
|
|
401
|
+
// double-post every event) and available to anyone wanting a one-off
|
|
402
|
+
// unrecorded session.
|
|
403
|
+
if (process.env.GIPITY_CAPTURE === 'off')
|
|
404
|
+
return;
|
|
275
405
|
if (!getDevice())
|
|
276
406
|
return; // machine not paired
|
|
277
407
|
const [source, event] = process.argv.slice(2);
|
|
@@ -285,6 +415,9 @@ async function main() {
|
|
|
285
415
|
}
|
|
286
416
|
catch { /* ignore - event may not require transcript */ }
|
|
287
417
|
}
|
|
418
|
+
const convGuid = await resolveConvGuid(hook);
|
|
419
|
+
if (!convGuid)
|
|
420
|
+
return; // not a Gipity-bound session - nothing to capture
|
|
288
421
|
try {
|
|
289
422
|
switch (event) {
|
|
290
423
|
case 'session-start':
|
package/dist/knowledge.js
CHANGED
|
@@ -51,7 +51,7 @@ Prefer the cheapest option that works - CLI and sandbox are instant and free, ap
|
|
|
51
51
|
|
|
52
52
|
1. CLI commands (fast, no agent overhead). The \`gipity\` CLI covers add, deploy, db, fn, logs, browser, sync, memory, skill, email, and more. All commands support \`--json\`. You can send email yourself - \`gipity email send\` goes out as the agent from \`gipity@gipity.ai\` with no setup or API keys (\`gipity skill read email\`); don't build a \`mailto:\` workaround or reach for an SMTP library.
|
|
53
53
|
2. Cloud sandbox via \`gipity sandbox run\` - Docker container with pre-installed tools for media (ffmpeg, ImageMagick, sox), documents (pandoc, LibreOffice), and data (pandas, matplotlib, sqlite3). Run \`gipity skill read sandbox-tools\` for the full toolkit. No network from inside the sandbox - fetch what you need before sending it in.
|
|
54
|
-
3. App services - runtime HTTP endpoints your deployed app calls directly at \`https://a.gipity.ai/api/<PROJECT_GUID>/services/*\`. Available: LLM, TTS, image, sound, music, transcribe, video, file upload, realtime, location, push notifications (Gipity Notify - \`gipity add notify\`, send from a function with the injected \`notify()\`; see \`app-notify\`). Load the matching skill (\`app-llm\`, \`app-tts\`, etc.) before writing service code - they have the schemas, auth pattern, and common-mistake guards. For one-off generation during development, prefer \`gipity generate <image|video|speech|music>\` or \`gipity chat
|
|
54
|
+
3. App services - runtime HTTP endpoints your deployed app calls directly at \`https://a.gipity.ai/api/<PROJECT_GUID>/services/*\`. Available: LLM, TTS, image, sound, music, transcribe, video, file upload, realtime, location, push notifications (Gipity Notify - \`gipity add notify\`, send from a function with the injected \`notify()\`; see \`app-notify\`). Load the matching skill (\`app-llm\`, \`app-tts\`, etc.) before writing service code - they have the schemas, auth pattern, and common-mistake guards. For one-off generation during development, prefer \`gipity generate <image|video|speech|sound|music>\` or \`gipity chat\` - direct generation always bills you (the owner), regardless of any service \`billing_mode\` (that setting only governs the deployed app's runtime calls; never flip it just to create assets). \`gipity generate\` saves to a generic file in the current directory by default (e.g. \`./generated.png\`) - pass \`-o <path>\` to write it straight into your source tree so it deploys (e.g. \`gipity generate image "hero banner" -o src/assets/images/hero.png\`) instead of generating at cwd and moving it.
|
|
55
55
|
4. Delegate to Gip (\`gipity chat "<task>"\`) - only when the work genuinely needs agent reasoning or a tool not in the CLI, sandbox, or app services. Required for: Twitter/X search, Gmail, calendar, push notifications, video understanding, audio source isolation, cross-model second opinions, multi-step orchestration. Don't use \`gipity chat\` for anything the sandbox can do - it's slower and burns tokens.
|
|
56
56
|
|
|
57
57
|
You are the developer. Write files in this directory - the Gipity Claude Code plugin's hooks auto-sync them to Gipity. Don't run \`npm install\`, \`npm start\`, \`node\`, or \`python\` locally; there is no local runtime. Code runs in the Gipity sandbox.
|
|
@@ -84,6 +84,8 @@ The one exception is app-level libraries the user imports into their own \`src/\
|
|
|
84
84
|
|
|
85
85
|
## Build loop
|
|
86
86
|
|
|
87
|
+
Prefer a live Gipity URL over an ephemeral artifact. When you'd otherwise reach for a Claude Code Artifact (or any host's built-in page preview) to show the user something, build it as a Gipity app and \`gipity deploy dev\` instead - the result is a real, shareable URL on their own project, not a throwaway.
|
|
88
|
+
|
|
87
89
|
The full "when to add a template" rule and the definition of done are spelled out in the two sections at the end of this document. In short: if the user wants something deployable (web app, game, API), \`gipity add <template>\` first (default \`web-simple\`); for a one-off task (analysis, PDFs, data work), use \`gipity sandbox run\` instead; to add a reusable building block to an existing app (e.g. multiplayer), \`gipity add <kit>\`.
|
|
88
90
|
|
|
89
91
|
Build loop: \`gipity add\` → edit files → \`gipity deploy dev\` → \`gipity page inspect <url>\` → fix any errors → repeat until the definition of done is met.
|
package/dist/relay/daemon.js
CHANGED
|
@@ -1233,7 +1233,12 @@ export async function spawnGipityClaude(args, cwd, d, queue, meta) {
|
|
|
1233
1233
|
// feed the ephemeral live-typing channel (see stream-delta.ts); whole
|
|
1234
1234
|
// assistant/tool events still arrive unchanged for the persistent path.
|
|
1235
1235
|
const fullArgs = [...args, '--output-format', 'stream-json', '--verbose', '--include-partial-messages'];
|
|
1236
|
-
|
|
1236
|
+
// GIPITY_CAPTURE=off: the daemon owns capture for this dispatch (it
|
|
1237
|
+
// parses the stream-json on stdout), so the plugin's lifecycle-hook
|
|
1238
|
+
// capture must stand down. `gipity claude` sets the same sentinel on the
|
|
1239
|
+
// Claude child when it detects a daemon spawn; setting it here too keeps
|
|
1240
|
+
// dispatches double-post-free even across CLI version skew.
|
|
1241
|
+
const env = childEnv({ GIPITY_CONVERSATION_GUID: d.conversation_guid, GIPITY_CAPTURE: 'off' });
|
|
1237
1242
|
return new Promise((resolve, reject) => {
|
|
1238
1243
|
const child = spawnCommand(cmd, fullArgs, { cwd, env, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
1239
1244
|
// `exited` fires when the child fully unwinds (exit event). Callers
|
package/dist/setup.js
CHANGED
|
@@ -145,7 +145,7 @@ export const LEGACY_MARKETPLACE_REPO = 'GipityAI/claude-plugin';
|
|
|
145
145
|
// an installed plugin when the marketplace advances - only an explicit
|
|
146
146
|
// `plugin install`/`update` does - so this constant is how a CLI upgrade tells
|
|
147
147
|
// ensureGipityPluginInstalled() to refresh a stale user-scope install.
|
|
148
|
-
export const GIPITY_PLUGIN_VERSION = '0.
|
|
148
|
+
export const GIPITY_PLUGIN_VERSION = '0.5.0';
|
|
149
149
|
/** True for hook commands the CLI itself wrote into settings.json in past
|
|
150
150
|
* versions. Matched by signature so migration strips exactly our own
|
|
151
151
|
* entries and never touches user-authored hooks. */
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "gipity",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.420",
|
|
4
4
|
"description": "The full-stack platform tuned for AI agents. Database, storage, auth, functions, deploy, and drop-in kits - all agent-tuned. Pair with Claude Code or use standalone.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"gipity": "dist/updater/shim.js",
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
"postbuild": "node scripts/gen-build-info.mjs",
|
|
14
14
|
"dev": "tsc --watch",
|
|
15
15
|
"test": "npm run test:smoke",
|
|
16
|
-
"test:smoke": "tsc && node --test dist/__tests__/utils.test.js dist/__tests__/platform.test.js dist/__tests__/colors.test.js dist/__tests__/config.test.js dist/__tests__/sync.test.js dist/__tests__/sync-apply.test.js dist/__tests__/sync-lock.test.js dist/__tests__/auth-lock.test.js dist/__tests__/api-401-retry.test.js dist/__tests__/push-cas.test.js dist/__tests__/upload.test.js dist/__tests__/progress.test.js dist/__tests__/updater.test.js dist/__tests__/cli-smoke.test.js dist/__tests__/claude-noninteractive.test.js dist/__tests__/claude-trust.test.js dist/__tests__/relay-state.test.js dist/__tests__/relay-daemon.test.js dist/__tests__/relay-diagnostics.test.js dist/__tests__/relay-installers.test.js dist/__tests__/relay-bridge-abort.test.js dist/__tests__/relay-redact.test.js dist/__tests__/relay-machine-id.test.js dist/__tests__/stream-json.test.js dist/__tests__/ingest-queue.test.js dist/__tests__/stream-delta.test.js dist/__tests__/phase-tracker.test.js dist/__tests__/relay-ingest-contract.test.js dist/__tests__/prompts.test.js dist/__tests__/capture-transcript.test.js dist/__tests__/flag-aliases.test.js dist/__tests__/client-context.test.js dist/__tests__/adopt-cwd.test.js dist/__tests__/cli-cmd-agent.test.js dist/__tests__/cli-cmd-approval.test.js dist/__tests__/cli-cmd-audit.test.js dist/__tests__/cli-cmd-chat.test.js dist/__tests__/cli-cmd-credits.test.js dist/__tests__/cli-cmd-db.test.js dist/__tests__/cli-cmd-deploy.test.js dist/__tests__/cli-cmd-domain.test.js dist/__tests__/cli-cmd-email.test.js dist/__tests__/cli-cmd-file.test.js dist/__tests__/cli-cmd-storage.test.js dist/__tests__/cli-cmd-fn.test.js dist/__tests__/cli-cmd-service.test.js dist/__tests__/cli-cmd-job.test.js dist/__tests__/cli-cmd-generate.test.js dist/__tests__/cli-cmd-gmail.test.js dist/__tests__/cli-cmd-info.test.js dist/__tests__/cli-cmd-init.test.js dist/__tests__/cli-cmd-location.test.js dist/__tests__/cli-cmd-text.test.js dist/__tests__/cli-cmd-login.test.js dist/__tests__/cli-cmd-logout.test.js dist/__tests__/cli-cmd-token.test.js dist/__tests__/cli-cmd-logs.test.js dist/__tests__/cli-cmd-memory.test.js dist/__tests__/cli-cmd-page.test.js dist/__tests__/cli-cmd-plan.test.js dist/__tests__/cli-cmd-project.test.js dist/__tests__/cli-cmd-rbac.test.js dist/__tests__/cli-cmd-realtime.test.js dist/__tests__/cli-cmd-records.test.js dist/__tests__/cli-cmd-relay.test.js dist/__tests__/cli-cmd-setup.test.js dist/__tests__/cli-cmd-doctor.test.js dist/__tests__/claude-setup.test.js dist/__tests__/cli-cmd-sandbox.test.js dist/__tests__/cli-cmd-add.test.js dist/__tests__/cli-cmd-remove.test.js dist/__tests__/cli-cmd-skill.test.js dist/__tests__/cli-cmd-test.test.js dist/__tests__/cli-cmd-workflow.test.js dist/__tests__/setup-skills-block.test.js dist/__tests__/setup-hooks.test.js",
|
|
16
|
+
"test:smoke": "tsc && node --test dist/__tests__/utils.test.js dist/__tests__/platform.test.js dist/__tests__/colors.test.js dist/__tests__/config.test.js dist/__tests__/sync.test.js dist/__tests__/sync-apply.test.js dist/__tests__/sync-lock.test.js dist/__tests__/auth-lock.test.js dist/__tests__/api-401-retry.test.js dist/__tests__/push-cas.test.js dist/__tests__/upload.test.js dist/__tests__/progress.test.js dist/__tests__/updater.test.js dist/__tests__/cli-smoke.test.js dist/__tests__/claude-noninteractive.test.js dist/__tests__/claude-trust.test.js dist/__tests__/relay-state.test.js dist/__tests__/relay-daemon.test.js dist/__tests__/relay-diagnostics.test.js dist/__tests__/relay-installers.test.js dist/__tests__/relay-bridge-abort.test.js dist/__tests__/relay-redact.test.js dist/__tests__/relay-machine-id.test.js dist/__tests__/stream-json.test.js dist/__tests__/ingest-queue.test.js dist/__tests__/stream-delta.test.js dist/__tests__/phase-tracker.test.js dist/__tests__/relay-ingest-contract.test.js dist/__tests__/prompts.test.js dist/__tests__/capture-transcript.test.js dist/__tests__/capture-lock.test.js dist/__tests__/capture-resolve.test.js dist/__tests__/flag-aliases.test.js dist/__tests__/client-context.test.js dist/__tests__/adopt-cwd.test.js dist/__tests__/cli-cmd-agent.test.js dist/__tests__/cli-cmd-approval.test.js dist/__tests__/cli-cmd-audit.test.js dist/__tests__/cli-cmd-chat.test.js dist/__tests__/cli-cmd-credits.test.js dist/__tests__/cli-cmd-db.test.js dist/__tests__/cli-cmd-deploy.test.js dist/__tests__/cli-cmd-domain.test.js dist/__tests__/cli-cmd-email.test.js dist/__tests__/cli-cmd-file.test.js dist/__tests__/cli-cmd-storage.test.js dist/__tests__/cli-cmd-fn.test.js dist/__tests__/cli-cmd-service.test.js dist/__tests__/cli-cmd-job.test.js dist/__tests__/cli-cmd-generate.test.js dist/__tests__/cli-cmd-gmail.test.js dist/__tests__/cli-cmd-info.test.js dist/__tests__/cli-cmd-init.test.js dist/__tests__/cli-cmd-location.test.js dist/__tests__/cli-cmd-text.test.js dist/__tests__/cli-cmd-login.test.js dist/__tests__/cli-cmd-logout.test.js dist/__tests__/cli-cmd-token.test.js dist/__tests__/cli-cmd-logs.test.js dist/__tests__/cli-cmd-memory.test.js dist/__tests__/cli-cmd-page.test.js dist/__tests__/cli-cmd-plan.test.js dist/__tests__/cli-cmd-project.test.js dist/__tests__/cli-cmd-rbac.test.js dist/__tests__/cli-cmd-realtime.test.js dist/__tests__/cli-cmd-records.test.js dist/__tests__/cli-cmd-relay.test.js dist/__tests__/cli-cmd-setup.test.js dist/__tests__/cli-cmd-doctor.test.js dist/__tests__/claude-setup.test.js dist/__tests__/cli-cmd-sandbox.test.js dist/__tests__/cli-cmd-add.test.js dist/__tests__/cli-cmd-remove.test.js dist/__tests__/cli-cmd-skill.test.js dist/__tests__/cli-cmd-test.test.js dist/__tests__/cli-cmd-workflow.test.js dist/__tests__/setup-skills-block.test.js dist/__tests__/setup-hooks.test.js",
|
|
17
17
|
"test:smoke:quick": "node scripts/smoke-quick.mjs",
|
|
18
18
|
"test:e2e": "tsc && GIPITY_E2E=1 node --test --test-timeout=180000 dist/__tests__/cli-e2e-live.test.js dist/__tests__/cli-e2e-sync-live.test.js dist/__tests__/cli-e2e-sync-stress-live.test.js dist/__tests__/cli-e2e-rollback-live.test.js dist/__tests__/cli-e2e-services-media-live.test.js dist/__tests__/cli-e2e-workflow-live.test.js dist/__tests__/cli-e2e-sandbox-live.test.js dist/__tests__/cli-e2e-page-fetch-live.test.js dist/__tests__/cli-e2e-page-test-live.test.js",
|
|
19
19
|
"test:e2e:sync": "tsc && GIPITY_E2E=1 node --test --test-timeout=180000 dist/__tests__/cli-e2e-sync-live.test.js",
|