gipity 1.0.419 → 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 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 * 1024)
184
+ if (n < 1024 ** 2)
184
185
  return `${(n / 1024).toFixed(1)} KB`;
185
- if (n < 1024 * 1024 * 1024)
186
- return `${(n / (1024 * 1024)).toFixed(1)} MB`;
187
- return `${(n / (1024 * 1024 * 1024)).toFixed(2)} GB`;
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
@@ -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: 768, height: 1024, deviceScaleFactor: 2 },
15
- mobile: { width: 390, height: 844, deviceScaleFactor: 3 },
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 (!preset) {
84
- const available = Object.keys(DEVICE_PRESETS).join(', ');
85
- throw new Error(`Unknown --device preset: "${name}" (known: ${available})`);
86
- }
87
- return preset;
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>', `Viewport preset(s): ${Object.keys(DEVICE_PRESETS).join(', ')} (comma-separated or repeat flag)`, appendOption, [])
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
- console.log(`\n${brand('@ ' + dims)}`);
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)
@@ -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
- .option('--language <language>', 'Language: js, py, or bash', 'js')
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, command) => run('Sandbox', async () => {
93
- const { config } = await resolveProjectContext();
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(clrError('Unrecognized invocation. Pass inline code as a single quoted arg, a script with --file <path>, or use the `run <python|node|bash> <file>` shorthand.'));
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 (unless --language
137
- // was passed explicitly) > the --language value (default js).
138
- const fromExt = filePath && !langFromInterp && command.getOptionValueSource('language') === 'default'
139
- ? LANG_MAP[extname(filePath).slice(1).toLowerCase()]
140
- : undefined;
141
- const language = langFromInterp || fromExt || LANG_MAP[opts.language] || opts.language;
142
- // True when the run fell back to the implicit JS default - nothing in the
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
- // A SyntaxError / CJS-loader trace under the implicit JS default almost
211
- // always means the input was shell or Python that got run as JavaScript.
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
  }
@@ -1,6 +1,7 @@
1
1
  import { Command } from 'commander';
2
2
  import { get, patch } from '../api.js';
3
- import { brand, muted } from '../colors.js';
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')
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gipity",
3
- "version": "1.0.419",
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",