gipity 1.0.391 → 1.0.394

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/dist/api.js CHANGED
@@ -16,13 +16,23 @@ export class ApiError extends Error {
16
16
  this.name = 'ApiError';
17
17
  }
18
18
  }
19
- async function getHeaders() {
19
+ /** Resolve the Bearer token value. A GIPITY_TOKEN env var (a long-lived agent
20
+ * API token) takes precedence over the saved session — the persistent,
21
+ * login-free path for headless agents and CI. Falls back to the refreshed
22
+ * session access token. */
23
+ async function bearerToken() {
24
+ const envToken = process.env.GIPITY_TOKEN?.trim();
25
+ if (envToken)
26
+ return envToken;
20
27
  await refreshTokenIfNeeded();
21
28
  const auth = getAuth();
22
29
  if (!auth)
23
30
  throw new Error('Not authenticated. Run: gipity login');
31
+ return auth.accessToken;
32
+ }
33
+ async function getHeaders() {
24
34
  return {
25
- 'Authorization': `Bearer ${auth.accessToken}`,
35
+ 'Authorization': `Bearer ${await bearerToken()}`,
26
36
  'Content-Type': 'application/json',
27
37
  };
28
38
  }
@@ -125,13 +135,9 @@ export async function sendMessage(message) {
125
135
  }
126
136
  /** Download a file as raw bytes (no JSON parsing) */
127
137
  export async function download(path) {
128
- await refreshTokenIfNeeded();
129
- const auth = getAuth();
130
- if (!auth)
131
- throw new Error('Not authenticated. Run: gipity login');
132
138
  const url = `${baseUrl()}${path}`;
133
139
  const res = await fetch(url, {
134
- headers: { 'Authorization': `Bearer ${auth.accessToken}` },
140
+ headers: { 'Authorization': `Bearer ${await bearerToken()}` },
135
141
  });
136
142
  if (!res.ok) {
137
143
  throw new ApiError(res.status, 'DOWNLOAD_ERROR', `Download failed: ${res.statusText}`);
@@ -141,13 +147,9 @@ export async function download(path) {
141
147
  /** Download a response as a Node.js Readable stream */
142
148
  export async function downloadStream(path) {
143
149
  const { Readable } = await import('stream');
144
- await refreshTokenIfNeeded();
145
- const auth = getAuth();
146
- if (!auth)
147
- throw new Error('Not authenticated. Run: gipity login');
148
150
  const url = `${baseUrl()}${path}`;
149
151
  const res = await fetch(url, {
150
- headers: { 'Authorization': `Bearer ${auth.accessToken}` },
152
+ headers: { 'Authorization': `Bearer ${await bearerToken()}` },
151
153
  });
152
154
  if (!res.ok) {
153
155
  throw new ApiError(res.status, 'DOWNLOAD_ERROR', `Download failed: ${res.statusText}`);
package/dist/auth.js CHANGED
@@ -2,7 +2,11 @@ import { readFileSync, writeFileSync, mkdirSync, existsSync, unlinkSync, chmodSy
2
2
  import { join } from 'path';
3
3
  import { homedir } from 'os';
4
4
  import { decodeJwtExp } from './utils.js';
5
- const AUTH_DIR = join(homedir(), '.gipity');
5
+ // GIPITY_DIR lets a caller keep a SEPARATE auth context (its own auth.json) from
6
+ // the default ~/.gipity — e.g. GipRunner logging into a local dev server without
7
+ // clobbering your real (prod) login. Only the auth dir moves; HOME is untouched,
8
+ // so the `claude` subprocess and git/npm still use the real home.
9
+ const AUTH_DIR = process.env.GIPITY_DIR || join(homedir(), '.gipity');
6
10
  const AUTH_FILE = join(AUTH_DIR, 'auth.json');
7
11
  let cached = null;
8
12
  export function getAuth() {
@@ -52,18 +56,10 @@ export function clearAuth() {
52
56
  catch { /* already gone */ }
53
57
  cached = null;
54
58
  }
55
- export function isExpired() {
56
- const auth = getAuth();
57
- if (!auth)
58
- return true;
59
- const expiresAt = new Date(auth.expiresAt).getTime();
60
- const buffer = 5 * 60 * 1000; // 5 minute buffer
61
- return Date.now() > expiresAt - buffer;
62
- }
63
59
  /** True only when re-login is genuinely required: the refresh token itself
64
- * has expired. Access-token expiry (`expiresAt` / isExpired) is invisible
65
- * to users — every API call renews it via refreshTokenIfNeeded() — so it
66
- * must never be surfaced as a session warning. */
60
+ * has expired. Access-token expiry (`expiresAt`) is invisible to users —
61
+ * every API call renews it via refreshTokenIfNeeded() — so it must never be
62
+ * surfaced as a session warning. */
67
63
  export function sessionExpired() {
68
64
  const auth = getAuth();
69
65
  if (!auth)
@@ -73,40 +69,78 @@ export function sessionExpired() {
73
69
  return false; // undecodable - let the refresh path decide
74
70
  return Date.now() > exp * 1000;
75
71
  }
72
+ const delay = (ms) => new Promise(r => setTimeout(r, ms));
73
+ /** Renew the access token (5-min buffer) before an authenticated call, surviving
74
+ * the case that broke overnight fix-mode runs: MANY concurrent `gipity` processes
75
+ * (relay daemon, file-sync hook, parallel commands) sharing one ~/.gipity/auth.json.
76
+ * Refresh tokens are SINGLE-USE — the server rotates them, so when several siblings
77
+ * race to refresh the same token, the first wins and the rest get a 401. The old
78
+ * code trusted a stale in-process cache and, on that race, let the 401 reach the
79
+ * caller, whose handler called clearAuth() and DELETED the shared file — locking
80
+ * every sibling out mid-run ("Not logged in"). Fix: always read the file fresh, and
81
+ * retry the race/transient failures, re-reading each attempt so we ADOPT whatever
82
+ * token a sibling just rotated in rather than resubmitting the rotated-away one.
83
+ * Stays void / never throws / never clears auth: a genuine dead token still flows to
84
+ * the caller's existing 401 path (which messages "run: gipity login"). */
76
85
  export async function refreshTokenIfNeeded() {
77
- if (!isExpired())
78
- return;
79
- const auth = getAuth();
86
+ const auth = readAuthFresh(); // never the cache — a sibling may have rotated
80
87
  if (!auth)
81
- return; // not logged in, caller will handle
82
- try {
83
- const config = await import('./config.js');
84
- const apiBase = config.resolveApiBase();
85
- const res = await fetch(`${apiBase}/auth/refresh`, {
86
- method: 'POST',
87
- headers: { 'Content-Type': 'application/json' },
88
- body: JSON.stringify({ refreshToken: auth.refreshToken }),
89
- });
90
- if (!res.ok) {
91
- cached = null; // force re-login
88
+ return; // not logged in - caller throws the clean error
89
+ cached = auth;
90
+ const buffer = 5 * 60 * 1000; // refresh 5 min before the access token lapses
91
+ const fresh = (a) => Date.now() <= new Date(a.expiresAt).getTime() - buffer;
92
+ if (fresh(auth))
93
+ return;
94
+ // If the refresh token itself has expired, re-login is genuinely required; leave the
95
+ // expired auth in place so the caller's existing 401 path prompts `gipity login`.
96
+ const refreshExp = decodeJwtExp(auth.refreshToken);
97
+ if (refreshExp && Date.now() > refreshExp * 1000)
98
+ return;
99
+ const { resolveApiBase } = await import('./config.js');
100
+ const apiBase = resolveApiBase();
101
+ for (let attempt = 1; attempt <= 3; attempt++) {
102
+ const cur = readAuthFresh(); // a sibling may have just refreshed for us
103
+ if (cur && fresh(cur)) {
104
+ cached = cur;
92
105
  return;
93
106
  }
94
- const json = await res.json();
95
- const exp = decodeJwtExp(json.accessToken);
96
- if (!exp) {
97
- cached = null;
107
+ const refreshToken = cur?.refreshToken ?? auth.refreshToken;
108
+ let res;
109
+ try {
110
+ res = await fetch(`${apiBase}/auth/refresh`, {
111
+ method: 'POST',
112
+ headers: { 'Content-Type': 'application/json' },
113
+ body: JSON.stringify({ refreshToken }),
114
+ });
115
+ }
116
+ catch {
117
+ await delay(attempt * 300);
118
+ continue; // network blip - retry
119
+ }
120
+ if (res.ok) {
121
+ const json = await res.json().catch(() => null);
122
+ const exp = json && decodeJwtExp(json.accessToken);
123
+ if (!json || !exp) {
124
+ await delay(attempt * 300);
125
+ continue;
126
+ }
127
+ saveAuth({ accessToken: json.accessToken, refreshToken: json.refreshToken, email: auth.email, expiresAt: new Date(exp * 1000).toISOString() });
98
128
  return;
99
129
  }
100
- const expiresAt = new Date(exp * 1000).toISOString();
101
- saveAuth({
102
- accessToken: json.accessToken,
103
- refreshToken: json.refreshToken,
104
- email: auth.email,
105
- expiresAt,
106
- });
107
- }
108
- catch {
109
- // Refresh failed - caller will see expired auth
130
+ // 401/403 the refresh token was rejected outright (it was rotated away by a
131
+ // sibling, or genuinely expired). Re-read once more in case a sibling's fresh
132
+ // token just landed; otherwise stop and let the caller's 401 path re-login.
133
+ if (res.status === 401 || res.status === 403) {
134
+ const after = readAuthFresh();
135
+ if (after && fresh(after)) {
136
+ cached = after;
137
+ return;
138
+ }
139
+ return;
140
+ }
141
+ await delay(attempt * 300); // 5xx / unexpected → transient, retry
110
142
  }
143
+ // Retries exhausted: leave the existing token. The caller's request will 401 and the
144
+ // existing handler messages the user — we never delete the shared auth.json here.
111
145
  }
112
146
  //# sourceMappingURL=auth.js.map
@@ -68,12 +68,103 @@ agentCommand
68
68
  else if (field === 'temp' || field === 'temperature')
69
69
  body.temperature = parseFloat(value);
70
70
  else {
71
- console.error(clrError(`Unknown field: ${field}. Use: model, temp`));
71
+ console.error(clrError(`Unknown field: ${field}. Use: model, temp (for soul/goal use \`gipity agent soul|goal\`)`));
72
72
  process.exit(1);
73
73
  }
74
74
  await put(`/agents/${config.agentGuid}`, body);
75
75
  printResult(`Set ${field} = ${value}`, opts, { success: true, field, value });
76
76
  }));
77
+ /** The active agent's guid, or a clear error - the brain commands all need one. */
78
+ function requireAgentGuid() {
79
+ const config = requireConfig();
80
+ if (!config.agentGuid) {
81
+ console.error(clrError('No active agent. Switch to one with: gipity agent <name>'));
82
+ process.exit(1);
83
+ }
84
+ return config.agentGuid;
85
+ }
86
+ // --- Brain: soul / goal / rules / learn ---
87
+ // These hit the account-scoped /account/agents surface (the same dual-auth,
88
+ // app-callable routes a deployed app uses), so the CLI, the web terminal, and an
89
+ // app all drive the agent's brain through one set of endpoints. No more
90
+ // hand-rolled `curl -X PUT a.gipity.ai/agents/:guid/soul` with a scraped token.
91
+ agentCommand
92
+ .command('soul [text...]')
93
+ .description("Show the current agent's soul, or set it (its voice/personality)")
94
+ .option('--json', 'Output as JSON')
95
+ .action((text, opts) => run('Soul', async () => {
96
+ const guid = requireAgentGuid();
97
+ if (text && text.length) {
98
+ const content = text.join(' ');
99
+ const res = await put(`/account/agents/${guid}/soul`, { content });
100
+ printResult('Soul updated.', opts, res.data);
101
+ }
102
+ else {
103
+ const res = await get(`/account/agents/${guid}/soul`);
104
+ printResult(res.data.content || '(no soul set)', opts, res.data);
105
+ }
106
+ }));
107
+ agentCommand
108
+ .command('goal [text...]')
109
+ .description("Show the current agent's goal, or set it")
110
+ .option('--clear', 'Clear the goal (back to a plain assistant)')
111
+ .option('--json', 'Output as JSON')
112
+ .action((text, opts) => run('Goal', async () => {
113
+ const guid = requireAgentGuid();
114
+ if (opts.clear) {
115
+ const res = await put(`/account/agents/${guid}/goal`, { goal: null });
116
+ printResult('Goal cleared.', opts, res.data);
117
+ }
118
+ else if (text && text.length) {
119
+ const goal = text.join(' ');
120
+ const res = await put(`/account/agents/${guid}/goal`, { goal });
121
+ printResult('Goal updated.', opts, res.data);
122
+ }
123
+ else {
124
+ const res = await get(`/account/agents/${guid}/goal`);
125
+ printResult(res.data.goal || '(no goal set)', opts, res.data);
126
+ }
127
+ }));
128
+ const rulesCommand = agentCommand
129
+ .command('rules')
130
+ .description("Show the agent's rules playbook (manual + learned)")
131
+ .option('--json', 'Output as JSON')
132
+ .action((opts) => run('Rules', async () => {
133
+ const guid = requireAgentGuid();
134
+ const res = await get(`/account/agents/${guid}/rules`);
135
+ printList(res.data, opts, 'No rules yet.', r => `[${r.source}] ${r.short_guid} ${r.text}`);
136
+ }));
137
+ rulesCommand
138
+ .command('add <text...>')
139
+ .description('Add a manual rule')
140
+ .option('--json', 'Output as JSON')
141
+ .action((text, opts) => run('Add', async () => {
142
+ const guid = requireAgentGuid();
143
+ const res = await post(`/account/agents/${guid}/rules`, { text: text.join(' ') });
144
+ printResult(`Added rule ${res.data[0].short_guid}.`, opts, res.data[0]);
145
+ }));
146
+ rulesCommand
147
+ .command('rm <rule-guid>')
148
+ .alias('delete')
149
+ .description('Deactivate a rule by its guid')
150
+ .option('--json', 'Output as JSON')
151
+ .action((ruleGuid, opts) => run('Remove', async () => {
152
+ const guid = requireAgentGuid();
153
+ await del(`/account/agents/${guid}/rules/${ruleGuid}`);
154
+ printResult(`Removed rule ${ruleGuid}.`, opts, { removed: ruleGuid });
155
+ }));
156
+ agentCommand
157
+ .command('learn')
158
+ .description("Teach the agent from one correction (distills a durable learned rule)")
159
+ .requiredOption('--original <text>', 'What the agent originally produced')
160
+ .requiredOption('--comment <text>', "Your correction / why it was wrong")
161
+ .option('--json', 'Output as JSON')
162
+ .action((opts) => run('Learn', async () => {
163
+ const guid = requireAgentGuid();
164
+ const res = await post(`/account/agents/${guid}/learn`, { original: opts.original, comment: opts.comment });
165
+ const d = res.data;
166
+ printResult(d.saved ? `Learned: ${d.rule.text}` : `No rule saved (${d.reason || 'too idiosyncratic to generalize'}).`, opts, d);
167
+ }));
77
168
  agentCommand
78
169
  .command('rename <new-name>')
79
170
  .description('Rename the current agent')
@@ -2,7 +2,7 @@ import { Command } from 'commander';
2
2
  import { get, post, sendMessage } from '../api.js';
3
3
  import { requireConfig } from '../config.js';
4
4
  import { error as clrError, success } from '../colors.js';
5
- import { run, printList } from '../helpers/index.js';
5
+ import { run, printList, emitField } from '../helpers/index.js';
6
6
  import { confirm } from '../utils.js';
7
7
  export const dbCommand = new Command('db')
8
8
  .description('Manage databases');
@@ -10,6 +10,7 @@ dbCommand
10
10
  .command('query <sql>')
11
11
  .description('Run SQL')
12
12
  .option('--database <name>', 'Database name')
13
+ .option('--field <path>', 'Print only this field of the result (dot path, e.g. rows.0.status)')
13
14
  .option('--json', 'Output as JSON')
14
15
  .action((sql, opts) => run('Query', async () => {
15
16
  const config = requireConfig();
@@ -24,7 +25,10 @@ dbCommand
24
25
  dbName = listRes.data[0].friendlyName;
25
26
  }
26
27
  const res = await post(`/projects/${config.projectGuid}/db/query`, { sql, database: dbName });
27
- if (opts.json) {
28
+ if (opts.field) {
29
+ emitField(res.data, opts.field);
30
+ }
31
+ else if (opts.json) {
28
32
  console.log(JSON.stringify(res.data));
29
33
  }
30
34
  else {
@@ -2,7 +2,7 @@ import { Command } from 'commander';
2
2
  import { get, post, del } from '../api.js';
3
3
  import { requireConfig } from '../config.js';
4
4
  import { error as clrError, bold, muted, success } from '../colors.js';
5
- import { run, printList } from '../helpers/index.js';
5
+ import { run, printList, emitField } from '../helpers/index.js';
6
6
  import { confirm } from '../utils.js';
7
7
  export const fnCommand = new Command('fn')
8
8
  .description('Manage functions');
@@ -38,12 +38,17 @@ fnCommand
38
38
  .command('call <name> [body]')
39
39
  .description('Call a function')
40
40
  .option('--data <json>', 'JSON request body')
41
+ .option('--field <path>', 'Print only this field of the result (dot path, e.g. items.0.short_guid)')
41
42
  .option('--json', 'Output as JSON')
42
43
  .action((name, bodyArg, opts) => run('Call', async () => {
43
44
  const config = requireConfig();
44
45
  const raw = bodyArg || opts.data || '{}';
45
46
  const body = JSON.parse(raw);
46
47
  const res = await post(`/api/${config.projectGuid}/fn/${encodeURIComponent(name)}`, body);
48
+ if (opts.field) {
49
+ emitField(res.data, opts.field);
50
+ return;
51
+ }
47
52
  console.log(opts.json ? JSON.stringify(res.data) : JSON.stringify(res.data, null, 2));
48
53
  }));
49
54
  fnCommand
@@ -1,19 +1,48 @@
1
1
  import { Command } from 'commander';
2
2
  import { post } from '../api.js';
3
- import { resolveProjectContext } from '../config.js';
3
+ import { resolveProjectContext, getConfigPath } from '../config.js';
4
+ import { pushFile } from '../sync.js';
4
5
  import { writeFileSync } from 'fs';
5
- import { resolve as resolvePath } from 'path';
6
+ import { resolve as resolvePath, dirname, relative, isAbsolute } from 'path';
6
7
  import { error as clrError, success, muted, info } from '../colors.js';
7
8
  import { IMAGE_MODELS_DOC, IMAGE_GEMINI_ASPECT_RATIOS, IMAGE_GEMINI_SIZES, VIDEO_MODELS_DOC, TTS_PROVIDER_DESCRIPTIONS } from '../provider-docs.js';
8
- /** Download a URL and save to a local file. Returns the absolute path written,
9
- * so callers can report exactly where the file landed. */
9
+ /** Download a URL and save to a local file, then push it up to the project so
10
+ * the cloud (and anything that mirrors it) immediately matches local disk.
11
+ * Returns the absolute path written, so callers can report where it landed.
12
+ *
13
+ * The push matters because generated media is written straight to disk with
14
+ * writeFileSync, which does NOT trip the editor's file-sync hook the way an
15
+ * agent's own file write does. Without it the file is local-only until the
16
+ * next `gipity sync`, so `gipity sandbox run` - which mirrors the *server* -
17
+ * can't see a just-generated image to convert/optimize it. Pushing here closes
18
+ * that gap so the "sandbox auto-mirrors the project" contract holds right after
19
+ * generation. Best-effort: the local save already succeeded, so a push failure
20
+ * only warns and points at `gipity sync`. */
10
21
  async function downloadFile(url, filename) {
11
22
  const res = await fetch(url);
12
23
  if (!res.ok)
13
24
  throw new Error(`Download failed: ${res.status}`);
14
25
  const buffer = Buffer.from(await res.arrayBuffer());
15
26
  writeFileSync(filename, buffer);
16
- return resolvePath(filename);
27
+ const savedPath = resolvePath(filename);
28
+ await pushGenerated(savedPath);
29
+ return savedPath;
30
+ }
31
+ /** Sync a freshly generated file up to the linked project (no-op when there's
32
+ * no local project, or the file was written outside the project tree). */
33
+ async function pushGenerated(savedPath) {
34
+ const configPath = getConfigPath();
35
+ if (!configPath)
36
+ return; // not linked to a project - nothing to sync into
37
+ const rel = relative(dirname(configPath), savedPath);
38
+ if (rel.startsWith('..') || isAbsolute(rel))
39
+ return; // outside the project tree
40
+ try {
41
+ await pushFile(savedPath);
42
+ }
43
+ catch (err) {
44
+ console.error(muted(`Note: couldn't sync to the cloud automatically (${err.message}). Run \`gipity sync\` before referencing this file in \`gipity sandbox run\`.`));
45
+ }
17
46
  }
18
47
  // ── IMAGE ──────────────────────────────────────────────────────────────
19
48
  const imageCommand = new Command('image')
@@ -1,5 +1,5 @@
1
1
  import { readFileSync } from 'node:fs';
2
- import { Command } from 'commander';
2
+ import { Command, Option } from 'commander';
3
3
  import { post, get, ApiError } from '../api.js';
4
4
  import { brand, bold, muted, warning } from '../colors.js';
5
5
  import { run } from '../helpers/index.js';
@@ -122,6 +122,13 @@ export function evalExecTimeoutMessage(result) {
122
122
  `and cannot extend it. Split a long interactive check into several shorter 'page eval' calls (e.g. ` +
123
123
  `one per state to verify), keeping each body's in-page waits well under ${EVAL_EXEC_BUDGET_MS / 1000}s.`);
124
124
  }
125
+ // Agents instinctively reach for a flag to pass the script (`--js`, `--script`,
126
+ // `--code`, …); the JS is actually the positional <expr> (or --file for a saved
127
+ // script). Without these, commander answers `--js` with "did you mean --json?" —
128
+ // a trap, since --json is a real flag that changes output but still leaves the
129
+ // script unset, sending the agent in a loop. Capture the common guesses as
130
+ // hidden decoy options so the action can redirect to the positional arg exactly.
131
+ const JS_DECOY_FLAGS = ['--js', '--javascript', '--script', '--code', '--expr', '--eval', '--exec'];
125
132
  // The long-tail escape hatch alongside `page inspect`'s fixed bundle: when the
126
133
  // curated metrics don't cover what you need (computed styles, element rects,
127
134
  // visibility, z-index stacks), eval an expression in page context and get the
@@ -143,6 +150,13 @@ export const pageEvalCommand = new Command('eval')
143
150
  .option('--wait-timeout <ms>', 'Max ms to wait for --wait-for before giving up', '5000')
144
151
  .option('--json', 'Output as JSON')
145
152
  .action((url, exprArg, opts) => run('Page eval', async () => {
153
+ // A JS-intent flag guess (captured as a hidden decoy below): redirect to the
154
+ // positional <expr> precisely, before the inline/--file shape checks fire.
155
+ const decoy = JS_DECOY_FLAGS.find((f) => opts[f.slice(2)] !== undefined);
156
+ if (decoy) {
157
+ pageEvalCommand.error(`error: ${decoy} is not a flag — pass the JavaScript as the positional <expr> argument ` +
158
+ `(or --file <path> for a saved script), e.g. gipity page eval "<url>" 'document.title'`);
159
+ }
146
160
  // Arg-shape errors go through commander's error() so the enableHelpAfterError
147
161
  // hook renders this command's help inline with the one-line error LAST
148
162
  // (survives `| tail`), same as commander-detected errors like a missing url.
@@ -225,6 +239,11 @@ export const pageEvalCommand = new Command('eval')
225
239
  }
226
240
  }
227
241
  }));
242
+ // Register the JS-intent flag guesses as hidden decoys (take a value so they
243
+ // swallow the script the agent passed) — the action turns any of them into the
244
+ // precise "JS is the positional arg" redirect above.
245
+ for (const f of JS_DECOY_FLAGS)
246
+ pageEvalCommand.addOption(new Option(`${f} <value>`).hideHelp());
228
247
  // Each `page eval` call runs to completion before the next starts, so two evals
229
248
  // fired back-to-back never coexist in time - they CANNOT test whether two live
230
249
  // clients see each other (presence, shared state). For that, use the genuinely-
@@ -6,6 +6,13 @@ import { run } from '../helpers/index.js';
6
6
  import { capWaitMs } from './page-eval.js';
7
7
  /** A console line is an error-level entry (page error or console.error). */
8
8
  const isErrorLine = (line) => /^error:/i.test(line);
9
+ /** A message-less, cross-origin "Script error." The throwing <script> lacks
10
+ * CORS, so the browser strips its message/stack and the source is unknowable
11
+ * from the console alone — there is no own-code stack to chase. These can't be
12
+ * attributed to app code, so we surface them apart from real console errors
13
+ * rather than letting an unactionable (and sometimes growing) count read as a
14
+ * regression in the app the agent just wrote. */
15
+ const isMessagelessCrossOrigin = (line) => isErrorLine(line) && /message-less|cross-origin|Script error\.?/i.test(line);
9
16
  function shortUrl(url, truncate = true, maxLen = 100) {
10
17
  let result;
11
18
  try {
@@ -55,13 +62,55 @@ export const pageInspectCommand = new Command('inspect')
55
62
  };
56
63
  const res = await post(`/tools/browser/inspect`, inspectBody);
57
64
  const b = res.data;
58
- // Self-verify console errors before flagging them. A freshly-deployed page's
59
- // first hit can throw a one-time, non-reproducible error typically a
60
- // cross-origin "Script error." with no message/stack from a CDN asset still
61
- // propagating and reporting it as a real defect sends agents chasing a
62
- // phantom. So when the first probe reports error-level console lines, re-probe
63
- // once (the sticky session is now warm) and keep only the errors that recur;
64
- // errors seen on a single probe are surfaced separately as transient noise.
65
+ // ── Strip the platform's own instrumentation noise first ──
66
+ // Every deployed page loads Gipity's injected analytics SDK, which POSTs to
67
+ // Gipity's traffic/error log endpoints (`/api/<guid>/log/traffic|error`).
68
+ // Those are platform infrastructure, not the app's resources, so when one
69
+ // fails it surfaces as a failed resource on the Gipity host PLUS a generic,
70
+ // URL-less "Failed to load resource" console error identical noise on
71
+ // essentially every deployed app. Drop both so an agent inspecting the app
72
+ // it just built sees only its own code's resources, not the platform's.
73
+ const isPlatformLog = (entry) => {
74
+ const urlPart = entry.replace(/\s*\([^)]*\)\s*$/, '');
75
+ try {
76
+ const u = new URL(urlPart);
77
+ return /(^|\.)gipity\.ai$/.test(u.hostname) && /\/log\/(traffic|error)$/.test(u.pathname);
78
+ }
79
+ catch {
80
+ return false;
81
+ }
82
+ };
83
+ const platformFailures = (b.failedResources || []).filter(isPlatformLog);
84
+ b.failedResources = (b.failedResources || []).filter((r) => !isPlatformLog(r));
85
+ // Each failed platform POST also emits exactly one generic, URL-less
86
+ // "Failed to load resource" console error. Drop one per platform failure —
87
+ // the text is identical, so removing by count is exact and any genuine app
88
+ // 404 keeps its own (indistinguishable) line.
89
+ let platformConsoleToDrop = platformFailures.length;
90
+ if (platformConsoleToDrop > 0) {
91
+ b.console = (b.console || []).filter((l) => {
92
+ if (platformConsoleToDrop > 0 && /^error:\s*Failed to load resource:/i.test(l)) {
93
+ platformConsoleToDrop--;
94
+ return false;
95
+ }
96
+ return true;
97
+ });
98
+ }
99
+ // Pull message-less cross-origin "Script error." lines out first. They carry
100
+ // no source/stack, so they're never actionable as app-code defects, and on a
101
+ // Gipity-deployed page the platform's own injected SDK is itself a
102
+ // cross-origin script — so these are reported separately (not as app console
103
+ // errors, and not folded into the re-probe count) instead of misleading the
104
+ // agent into chasing its own code.
105
+ const crossOriginErrors = (b.console || []).filter(isMessagelessCrossOrigin);
106
+ b.console = (b.console || []).filter((l) => !isMessagelessCrossOrigin(l));
107
+ // Self-verify the remaining console errors before flagging them. A
108
+ // freshly-deployed page's first hit can throw a one-time, non-reproducible
109
+ // error from an asset still propagating — and reporting it as a real defect
110
+ // sends agents chasing a phantom. So when the first probe reports error-level
111
+ // console lines, re-probe once (the sticky session is now warm) and keep only
112
+ // the errors that recur; errors seen on a single probe are surfaced
113
+ // separately as transient noise.
65
114
  let transientErrors = [];
66
115
  if ((b.console || []).some(isErrorLine)) {
67
116
  try {
@@ -76,7 +125,11 @@ export const pageInspectCommand = new Command('inspect')
76
125
  }
77
126
  }
78
127
  if (opts.json) {
79
- console.log(JSON.stringify(transientErrors.length ? { ...b, transientConsole: transientErrors } : b));
128
+ console.log(JSON.stringify({
129
+ ...b,
130
+ ...(transientErrors.length ? { transientConsole: transientErrors } : {}),
131
+ ...(crossOriginErrors.length ? { crossOriginConsole: crossOriginErrors } : {}),
132
+ }));
80
133
  return;
81
134
  }
82
135
  const timing = b.timing || { ttfb: 0, domReady: 0, load: 0 };
@@ -112,7 +165,12 @@ export const pageInspectCommand = new Command('inspect')
112
165
  for (const line of transientErrors) {
113
166
  console.log(muted(line));
114
167
  }
115
- console.log(muted('One-time cold-load artifact (first hit of freshly-deployed assets, or a cross-origin script) — not reproducible, not in your app code. Ignore unless it recurs.'));
168
+ console.log(muted('One-time cold-load artifact (first hit of freshly-deployed assets) — not reproducible, not in your app code. Ignore unless it recurs.'));
169
+ }
170
+ // ── Cross-origin console errors (message-less; source hidden by the browser) ──
171
+ if (crossOriginErrors.length > 0) {
172
+ console.log(`\n${bold('Cross-origin console errors')} ${muted(`(${crossOriginErrors.length}, source hidden by the browser)`)}:`);
173
+ console.log(muted("Message-less — the throwing <script> lacks CORS, so the browser hides its source and there's no own-code stack to chase. Gipity's injected SDK is itself cross-origin, so if your app loads no third-party CDN scripts these are platform noise — ignore them. If your app DOES load a third-party <script>, add crossorigin=\"anonymous\" to that tag to surface the real error."));
116
174
  }
117
175
  // ── Failed Resources ──
118
176
  // Browsers auto-request /favicon.ico at the site root for every page, so a
@@ -100,6 +100,7 @@ export const pageScreenshotCommand = new Command('screenshot')
100
100
  // so the `?? opts.wait` merge below would never see the --wait alias. Default
101
101
  // is applied in the merge instead.
102
102
  .option('--post-load-delay <ms>', 'Delay after DOMContentLoaded before capture, in ms (default: 1000)')
103
+ .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.')
103
104
  .option('--full', 'Capture the full scrollable page (default: viewport only)')
104
105
  .option('-o, --output <file>', 'Output path (single viewport only; default .gipity/screenshots/ss-<host>-<timestamp>.png)')
105
106
  .option('--device <names>', `Viewport preset(s): ${Object.keys(DEVICE_PRESETS).join(', ')} (comma-separated or repeat flag)`, appendOption, [])
@@ -140,6 +141,7 @@ export const pageScreenshotCommand = new Command('screenshot')
140
141
  reloadBetween: opts.reloadBetween !== false,
141
142
  ...(userSpecifiedViewports ? { viewports: customViewports } : {}),
142
143
  ...(opts.fakeMedia ? { fakeMedia: true } : {}),
144
+ ...(opts.action ? { action: opts.action } : {}),
143
145
  };
144
146
  const entries = await postForTarEntries('/tools/browser/screenshot', body);
145
147
  const metaEntry = entries.find((e) => e.name === 'meta.json');
@@ -231,4 +233,28 @@ export const pageScreenshotCommand = new Command('screenshot')
231
233
  console.log(`${label('Screenshot file')} ${success(savedFiles[i])}`);
232
234
  }
233
235
  }));
236
+ // `screenshot` captures the page AFTER load + settle (+ optional --action). It does
237
+ // NOT scroll or wait for a selector before capture (agents reach for --scroll/
238
+ // --selector and get an unknown-option detour). State the supported levers right
239
+ // here, so the help (rendered on any bad flag, and this 'after' block survives
240
+ // `| tail`/`| grep`) ends the hunt in one shot. --action covers "click, then shoot";
241
+ // --full + crop covers off-screen regions; `page eval` reads data without a picture.
242
+ pageScreenshotCommand.addHelpText('after', `
243
+ Examples:
244
+ gipity page screenshot "https://dev.gipity.ai/me/app/"
245
+ gipity page screenshot "https://dev.gipity.ai/me/app/" --full # whole scrollable page
246
+ gipity page screenshot "https://dev.gipity.ai/me/app/" --device mobile,desktop
247
+ gipity page screenshot "https://dev.gipity.ai/me/app/" \\
248
+ --action "document.getElementById('play').click()" # capture an in-game frame
249
+
250
+ Capturing a state that needs an interaction (start a game, open a menu, dismiss a modal)?
251
+ Use --action to run JS in the page before the shot — it fires after the post-load
252
+ delay, then settles again so the result has painted. Do NOT hand-roll a 'page eval'
253
+ that returns a base64 image: the eval result is capped (~16KB) and truncates the PNG.
254
+
255
+ Capturing an off-screen region or reading element data?
256
+ • --full captures the ENTIRE scrollable page (then crop to the region).
257
+ • 'gipity page eval <url> "<expr>"' reads any (even off-screen) element's
258
+ data/state/rect without a picture — e.g. read the chart's bar values
259
+ directly instead of screenshotting the slide.`);
234
260
  //# sourceMappingURL=page-screenshot.js.map
@@ -150,6 +150,17 @@ GCC/Rust).
150
150
  }
151
151
  const timeout = parseInt(opts.timeout, 10);
152
152
  const cwd = resolveRelativeCwd();
153
+ // Push local working-tree changes up before executing. The sandbox mirrors
154
+ // the *server* (VFS), not the local cwd, so any input staged outside Claude's
155
+ // Write/Edit auto-push hook - a Bash `cp`/`ffmpeg`/redirect, or any external
156
+ // process - would otherwise be invisible to the run and the first invocation
157
+ // would silently miss its inputs. Syncing first makes the auto-mirror reflect
158
+ // local state regardless of how files got there ("no manual copy needed").
159
+ // Bidirectional + CAS, so it's a cheap manifest check when nothing changed.
160
+ // Symmetric with the post-run pull below. Skip in one-off mode (no project).
161
+ if (getConfigPath()) {
162
+ await sync({ interactive: false });
163
+ }
153
164
  const res = await post(`/projects/${config.projectGuid}/sandbox/execute`, {
154
165
  code: source,
155
166
  language,
@@ -0,0 +1,65 @@
1
+ import { Command } from 'commander';
2
+ import { get, post, del } from '../api.js';
3
+ import { bold, muted, success, warning } from '../colors.js';
4
+ import { run, printList } from '../helpers/index.js';
5
+ export const tokenCommand = new Command('token')
6
+ .description('Manage agent API tokens (gip_at_*) for headless agents and CI');
7
+ const fmtDate = (d) => (d ? new Date(d).toLocaleDateString() : 'never');
8
+ tokenCommand
9
+ .command('create')
10
+ .description('Mint a long-lived agent API token (shown once)')
11
+ .option('--name <name>', 'Label for the token, e.g. "Hermes on my VPS"')
12
+ .option('--expires <days>', 'Days until the token expires (default: never)')
13
+ .option('--json', 'Output as JSON')
14
+ .action((opts) => run('Create', async () => {
15
+ const body = {};
16
+ if (opts.name)
17
+ body.name = opts.name;
18
+ if (opts.expires !== undefined) {
19
+ const days = parseInt(opts.expires, 10);
20
+ if (!Number.isFinite(days) || days <= 0)
21
+ throw new Error('--expires must be a positive number of days');
22
+ body.expiresInDays = days;
23
+ }
24
+ const res = await post('/auth/agent-tokens', body);
25
+ const { token, shortGuid, expiresAt } = res.data;
26
+ if (opts.json) {
27
+ console.log(JSON.stringify(res.data));
28
+ return;
29
+ }
30
+ const expNote = expiresAt ? ` (expires ${fmtDate(expiresAt)})` : ' (never expires)';
31
+ console.log(success(`Created token ${bold(shortGuid)}${muted(expNote)}.`));
32
+ console.log('');
33
+ console.log(token);
34
+ console.log('');
35
+ console.log(muted('Use it from an agent, script, or CI — no login needed:'));
36
+ console.log(` export GIPITY_TOKEN=${token}`);
37
+ console.log('');
38
+ console.log(warning('Copy it now — it will not be shown again.'));
39
+ }));
40
+ tokenCommand
41
+ .command('list')
42
+ .alias('ls')
43
+ .description('List your active agent API tokens')
44
+ .option('--json', 'Output as JSON')
45
+ .action((opts) => run('List', async () => {
46
+ const res = await get('/auth/agent-tokens');
47
+ printList(res.data, opts, 'No agent API tokens.', (t) => {
48
+ const label = t.name ? ` ${t.name}` : '';
49
+ return `${bold(t.short_guid)}${label} ${muted(`created ${fmtDate(t.created_at)}`)} ${muted(`expires ${fmtDate(t.expires_at)}`)} ${muted(`last used ${fmtDate(t.last_used_at)}`)}`;
50
+ });
51
+ }));
52
+ tokenCommand
53
+ .command('revoke <short_guid>')
54
+ .alias('rm')
55
+ .description('Revoke an agent API token (instant, irreversible)')
56
+ .option('--json', 'Output as JSON')
57
+ .action((shortGuid, opts) => run('Revoke', async () => {
58
+ await del(`/auth/agent-tokens/${encodeURIComponent(shortGuid)}`);
59
+ if (opts.json) {
60
+ console.log(JSON.stringify({ shortGuid, revoked: true }));
61
+ return;
62
+ }
63
+ console.log(success(`Revoked token ${bold(shortGuid)}.`));
64
+ }));
65
+ //# sourceMappingURL=token.js.map
package/dist/config.js CHANGED
@@ -45,6 +45,12 @@ export function resolveApiBase() {
45
45
  const override = getApiBaseOverride();
46
46
  if (override)
47
47
  return override;
48
+ // GIPITY_API_BASE env is a trusted override (any host, like --api-base) so a
49
+ // caller can point the CLI at a local dev server without passing the flag on
50
+ // every command — e.g. GipRunner running builds against http://localhost:7201.
51
+ const fromEnv = process.env.GIPITY_API_BASE;
52
+ if (fromEnv)
53
+ return fromEnv;
48
54
  const fromConfig = getConfig()?.apiBase;
49
55
  if (fromConfig) {
50
56
  if (isAllowedApiHost(fromConfig))
@@ -2,6 +2,6 @@
2
2
  * CLI helpers - shared patterns across all commands.
3
3
  */
4
4
  export { run } from './command.js';
5
- export { printOutput, printList, printResult } from './output.js';
5
+ export { printOutput, printList, printResult, pluckField, emitField } from './output.js';
6
6
  export { syncBeforeAction } from './sync.js';
7
7
  //# sourceMappingURL=index.js.map
@@ -63,6 +63,29 @@ export function printResult(text, opts, jsonData) {
63
63
  }
64
64
  console.log(text);
65
65
  }
66
+ /**
67
+ * Pluck a nested value out of a result by dot-path. Numeric segments index into
68
+ * arrays, so `items.0.short_guid` reaches the first item's guid. Returns
69
+ * `undefined` if any segment along the way is missing. This is what lets
70
+ * `--field` replace the `... | node -e "JSON.parse(...)"` extraction dance.
71
+ */
72
+ export function pluckField(data, path) {
73
+ return path.split('.').reduce((acc, key) => (acc == null ? undefined : acc[key]), data);
74
+ }
75
+ /**
76
+ * Emit a single plucked field for `--field`. Scalars print raw (no quotes) so
77
+ * the output drops straight into `$(...)` / pipes; objects/arrays print as
78
+ * compact JSON. A missing path is an error (exit 1) so scripts fail loudly
79
+ * instead of silently consuming an empty string.
80
+ */
81
+ export function emitField(data, path) {
82
+ const value = pluckField(data, path);
83
+ if (value === undefined) {
84
+ console.error(`Field not found: ${path}`);
85
+ process.exit(1);
86
+ }
87
+ console.log(typeof value === 'object' && value !== null ? JSON.stringify(value) : String(value));
88
+ }
66
89
  /**
67
90
  * Print a list with JSON mode, empty state, and per-item formatting.
68
91
  * Replaces the most common output pattern across all commands.
package/dist/index.js CHANGED
@@ -10,6 +10,7 @@ import { GIPITY_TAGLINE } from './knowledge.js';
10
10
  import { getAuth, sessionExpired } from './auth.js';
11
11
  import { loginCommand } from './commands/login.js';
12
12
  import { logoutCommand } from './commands/logout.js';
13
+ import { tokenCommand } from './commands/token.js';
13
14
  import { initCommand } from './commands/init.js';
14
15
  import { statusCommand } from './commands/status.js';
15
16
  import { syncCommand } from './commands/sync.js';
@@ -108,7 +109,7 @@ const filesGroup = [fileCommand, syncCommand, pushCommand, uploadCommand];
108
109
  const appBuildingGroup = [testCommand, fnCommand, serviceCommand, jobCommand, dbCommand, logsCommand, workflowCommand, realtimeCommand, rbacCommand, auditCommand, recordsCommand];
109
110
  const utilitiesGroup = [pageCommand, sandboxCommand, generateCommand, emailCommand, gmailCommand, locationCommand, textCommand];
110
111
  const agentGroup = [chatCommand, memoryCommand, agentCommand, approvalCommand];
111
- const setupGroup = [loginCommand, logoutCommand, creditsCommand, planCommand, doctorCommand, updateCommand, uninstallCommand];
112
+ const setupGroup = [loginCommand, logoutCommand, tokenCommand, creditsCommand, planCommand, doctorCommand, updateCommand, uninstallCommand];
112
113
  const HELP_SECTIONS = [
113
114
  { title: 'Common', cmds: commonGroup },
114
115
  { title: 'Connect', cmds: connectGroup },
@@ -125,11 +126,15 @@ program
125
126
  .version(pkg.version, '-v, --version')
126
127
  .addOption(new Option('--api-base <url>', 'API base URL').hideHelp())
127
128
  .option('-y, --yes', 'Skip confirmation prompts');
128
- program.hook('preAction', () => {
129
+ program.hook('preAction', (_thisCommand, actionCommand) => {
129
130
  const globalOpts = program.opts();
130
131
  if (globalOpts.apiBase)
131
132
  setApiBaseOverride(globalOpts.apiBase);
132
- if (globalOpts.yes)
133
+ // Honor `-y`/`--yes` whether it came before the subcommand (the global flag)
134
+ // or after it (the per-command flag registered by enableYesEverywhere below),
135
+ // so both `gipity -y records delete ...` and `gipity records delete ... --yes`
136
+ // skip confirmation identically.
137
+ if (globalOpts.yes || actionCommand.opts().yes)
133
138
  setAutoConfirm(true);
134
139
  });
135
140
  // Bracket non-JSON command output with leading/trailing blank lines centrally,
@@ -216,6 +221,25 @@ function enableHelpAfterError(cmd) {
216
221
  enableHelpAfterError(sub);
217
222
  }
218
223
  enableHelpAfterError(program);
224
+ // ── `-y`/`--yes` accepted AFTER any subcommand, not only before it ──────
225
+ // The global `-y` lives on `program`, so Commander parses it only when it
226
+ // precedes the subcommand (`gipity -y records delete ...`). Agents and humans
227
+ // instinctively append it instead (`gipity records delete ... --yes`), which
228
+ // Commander would reject as an unknown option and dump help for. Register the
229
+ // flag on every leaf command so both positions work identically; the preAction
230
+ // hook honors whichever one was set. Skip commands that already declare their
231
+ // own `--yes` (e.g. `fn delete`, `db drop`, `remove`) to avoid a duplicate.
232
+ function enableYesEverywhere(cmd) {
233
+ if (cmd.commands.length > 0) {
234
+ for (const sub of cmd.commands)
235
+ enableYesEverywhere(sub);
236
+ return;
237
+ }
238
+ const hasYes = cmd.options.some(o => o.long === '--yes' || o.short === '-y');
239
+ if (!hasYes)
240
+ cmd.addOption(new Option('-y, --yes', 'Skip confirmation prompts').hideHelp());
241
+ }
242
+ enableYesEverywhere(program);
219
243
  // Auto-fetch related skill docs when --help is run on a doc-bearing TOP-LEVEL
220
244
  // command (e.g. `gipity fn --help`, `gipity db --help`). It must NOT fire for a
221
245
  // subcommand's help: `gipity db query --help` should render commander's own
package/dist/knowledge.js CHANGED
@@ -20,6 +20,7 @@ Templates:
20
20
  - \`3d-world\` - Multiplayer world, 3D sandbox, shooter, exploration, virtual showroom (Three.js + Rapier + Colyseus)
21
21
  - \`api\` - Backend service, webhook, data pipeline, chatbot, cron job - no frontend
22
22
  - \`karaoke-captions\` - Forced-alignment app - karaoke captions, subtitle timing, language learning, dubbing alignment
23
+ - \`outreach-agent\` - AI outreach / drip-email funnel - reach a list of people with personalized, human-approved emails that auto-send on a schedule and a self-improving agent that learns from your edits
23
24
  When unsure, default to \`web-simple\`. After adding the template, edit the generated files, then \`gipity deploy dev\`.
24
25
  Only skip this on a build request if the user explicitly says not to.
25
26
 
@@ -36,7 +37,8 @@ Kits are reusable building blocks added to an existing app, not whole templates
36
37
  - \`gipity add i18n\` - Multi-language for web apps - language picker, locale persistence, RTL, plural/translation lookup. Scaffolds src/js/strings.js and wires it up; move your copy there and read it with t('key'). Web only.
37
38
  - \`gipity add records\` - Registry-driven records: declare objects/fields as data, get generic CRUD functions with validation, full-text search, soft delete, ACTOR provenance, and an audit event spine - every write is transactional (row + event). Field types include relations ({id,label}), currency, emails/phones/links composites. Ships backend functions + migrations. Needs a database (web-fullstack/api template).
38
39
  - \`gipity add views\` - Generic UI over records-kit objects: sortable/filterable table with full-text search, create/edit/delete forms with type-appropriate widgets, kanban board with drag-to-update. Renders entirely from the field registry - zero per-object UI code. Requires the records kit.
39
- - \`gipity add agent-api\` - Make your app agent-operable: named API keys (kit_api_keys) let agents and scripts write through the records kit's single write path with AGENT/API actor attribution - machine writes land on the same audit spine as human edits. Requires the records kit.`;
40
+ - \`gipity add agent-api\` - Make your app agent-operable: named API keys (kit_api_keys) let agents and scripts write through the records kit's single write path with AGENT/API actor attribution - machine writes land on the same audit spine as human edits. Requires the records kit.
41
+ - \`gipity add contacts\` - Source-agnostic contact data layer for lead-gen/CRM apps: import people from LinkedIn CSV + Gmail + pasted lists, resolve duplicates into one person while keeping EVERY value from every source with provenance (multi-valued attributes, never overwrites). Exact email/URL auto-merge; fuzzy name+company goes to a human merge-review queue (reversible). Re-imports detect job changes and emit signals. User-definable tags, full-text search, and a transactional event spine. Ships backend functions + migrations. Needs a database (web-fullstack/api template).`;
40
42
  export const SKILLS_CONTENT = `# Gipity Integration
41
43
 
42
44
  Gipity is the cloud platform your project runs on - hosting, databases, deployment, file storage, code execution, workflows, and monitoring. Gip is the cloud agent that runs on Gipity.
@@ -45,7 +47,7 @@ Prefer the cheapest option that works - CLI and sandbox are instant and free, ap
45
47
 
46
48
  1. CLI commands (fast, no agent overhead). The \`gipity\` CLI covers add, deploy, db, fn, logs, browser, sync, memory, skill, and more. All commands support \`--json\`.
47
49
  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.
48
- 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. 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\`. \`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.
50
+ 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. 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\`. \`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.
49
51
  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.
50
52
 
51
53
  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.
@@ -78,21 +80,32 @@ The full "when to add a template" rule and the definition of done are spelled ou
78
80
 
79
81
  Build loop: \`gipity add\` → edit files → \`gipity deploy dev\` → \`gipity page inspect <url>\` → fix any errors → repeat until the definition of done is met.
80
82
 
81
- \`add\` writes real files to disk Read a scaffolded file before your first Write/Edit to it, or the call fails \`"File has not been read yet"\`. Don't rewrite from memory of the template.
83
+ \`add\` writes real files to disk - Read a scaffolded file before your first Write/Edit to it, or the call fails \`"File has not been read yet"\`. Don't rewrite from memory of the template.
82
84
 
83
85
  Make your file changes and verify they landed, then run \`gipity deploy dev\` once. \`0 uploaded, N unchanged\` means nothing changed on disk - fix the files, don't re-run deploy or probe the environment.
84
86
 
85
87
  Before telling the user the app is online, verify the source tree is consistent: no files named like \`* (conflict from *)*\`, and every package directory has its expected canonical entry file. If a conflict artifact exists, resolve it (keep one copy), re-deploy, and re-inspect before reporting done.
86
88
 
89
+ ## Work on an existing project that isn't local yet
90
+
91
+ If you're pointed at a project that already exists on Gipity but has no local copy - e.g. the user gives a live URL \`https://dev.gipity.ai/<account>/<slug>/\` (or \`app.gipity.ai\` for prod) and you need its files to edit them - the last path segment is the project **slug**. Pull it down by adopting it into a directory named for the slug:
92
+
93
+ \`\`\`
94
+ mkdir -p ~/GipityProjects/<slug> && cd ~/GipityProjects/<slug> && gipity init <slug>
95
+ \`\`\`
96
+
97
+ \`init\` matches the existing remote project by slug, links this directory to it, and syncs its files down (you'll see \`Found existing project ...\` and \`Synced N changes\`). There's no separate \`clone\`/\`pull\` - \`init\` against a matching slug *is* the pull. After it finishes, the files are in cwd; edit and \`gipity deploy dev\` as usual. (Already linked to a different project in this dir? Switch and pull instead: \`gipity project <slug>\` then \`gipity sync\`. List your projects with \`gipity project --json\`.)
98
+
87
99
  ## CLI quick reference
88
100
 
89
101
  Key commands: \`gipity add <template|kit>\`, \`gipity deploy dev\`, \`gipity sandbox run\`, \`gipity page inspect <url>\`, \`gipity page screenshot <url>\`, \`gipity db query "SQL"\`, \`gipity fn call <name>\`, \`gipity logs fn <name>\`, \`gipity skill read <name>\`.
102
+ Pull an existing remote project local (given its URL/slug): \`mkdir -p ~/GipityProjects/<slug> && cd ~/GipityProjects/<slug> && gipity init <slug>\` (adopts the matching project and syncs files down - this is the "clone").
90
103
  For deterministic text questions (letter/word counts, substring occurrences, nth word/char, anagrams), use \`gipity text analyze "<text>"\` - local and instant, no sandbox or LLM needed.
91
104
  Run \`gipity --help\` for the full list. Use \`--help\` on any command for details.
92
105
 
93
- Function return shape: \`gipity fn call\`, the in-test \`ctx.fn.call\`/\`callAs\`, and the client \`Gipity.fn\` all return your function's value **unwrapped** read/assert \`result.field\`. Only raw HTTP/\`curl\` wraps it as \`{ data: ... }\`; never write \`result.data.field\` in a test.
106
+ Function return shape: \`gipity fn call\`, the in-test \`ctx.fn.call\`/\`callAs\`, and the client \`Gipity.fn\` all return your function's value **unwrapped** - read/assert \`result.field\`. Only raw HTTP/\`curl\` wraps it as \`{ data: ... }\`; never write \`result.data.field\` in a test.
94
107
 
95
- Tests write to your real DB: \`gipity test\` runs the test code sandboxed, but \`ctx.fn.call\`/\`callAs\` hit your actual deployed functions, which write to the same project database the app reads from rows a test creates persist and surface on the live page. Register \`ctx.cleanup(fn)\` in any write-test to delete what it made; the harness runs every cleanup after the suite (even on failure).
108
+ Tests write to your real DB: \`gipity test\` runs the test code sandboxed, but \`ctx.fn.call\`/\`callAs\` hit your actual deployed functions, which write to the same project database the app reads from - rows a test creates persist and surface on the live page. Register \`ctx.cleanup(fn)\` in any write-test to delete what it made; the harness runs every cleanup after the suite (even on failure).
96
109
 
97
110
  ## Tool output is complete and synchronous
98
111
 
@@ -122,6 +135,7 @@ App services skills (load before calling \`/services/*\` endpoints):
122
135
  - \`app-video\` - Gipity Video: models, aspect, resolution
123
136
 
124
137
  App development skills:
138
+ - \`agent-deploy\` - headless auth via agent API tokens (GIPITY_TOKEN) for unattended deploys
125
139
  - \`app-debugging\` - debug a deployed app: page inspect/eval, screenshots, function logs
126
140
  - \`app-development\` - functions, database, and API
127
141
  - \`deploy\` - the deploy pipeline & gipity.yaml manifest
@@ -718,7 +718,13 @@ async function handleDispatch(d) {
718
718
  // Future cleanup: see docs/feature-backlog/future-generate-to-vfs.md
719
719
  // - server-side /generate/* should write directly to VFS and make
720
720
  // this sync redundant for that case.
721
- if (!spawnErr) {
721
+ //
722
+ // Skip on `killed`: a kill-on-new-message replacement is already starting for
723
+ // this conv, and a bidirectional reconcile over the half-finished tree of the
724
+ // cancelled run is exactly the WS-00172 stale-state trap - it pushes/pulls a
725
+ // partial state that the resuming run then fights. The replacement dispatch
726
+ // runs its own sync; let it own the tree.
727
+ if (!spawnErr && !killed) {
722
728
  try {
723
729
  await spawnSync(cwd, PROJECT_SYNC_TIMEOUT_MS);
724
730
  }
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Local state for `gipity relay`.
3
3
  *
4
- * One file, `~/.gipity/relay.json`, mode 0600:
4
+ * One file, `$GIPITY_DIR/relay.json` (default `~/.gipity/relay.json`), mode 0600:
5
5
  * {
6
6
  * device: { guid, name, platform, token, paired_at },
7
7
  * // (no allowlist - daemon materializes any of the user's projects on demand)
@@ -16,7 +16,13 @@
16
16
  import { readFileSync, writeFileSync, mkdirSync, existsSync, chmodSync, unlinkSync } from 'fs';
17
17
  import { join } from 'path';
18
18
  import { homedir } from 'os';
19
- const RELAY_DIR = join(homedir(), '.gipity');
19
+ // GIPITY_DIR scopes the relay/device state the same way it scopes auth.json (see
20
+ // auth.ts). Without this, a separate auth context (e.g. GIPITY_DIR=~/.giprunner-prod
21
+ // logged in as ec-giprunner@914-6.com) would still read the DEFAULT ~/.gipity device —
22
+ // which is paired to a DIFFERENT account — and project/chat creation fails with
23
+ // "deviceGuid does not match a paired device". Scoping it lets each auth context pair
24
+ // and own its own device. Unset GIPITY_DIR → ~/.gipity, unchanged for normal users.
25
+ const RELAY_DIR = process.env.GIPITY_DIR || join(homedir(), '.gipity');
20
26
  const RELAY_FILE = join(RELAY_DIR, 'relay.json');
21
27
  const FILE_MODE = 0o600;
22
28
  function emptyState() {
package/dist/sync.js CHANGED
@@ -1030,9 +1030,18 @@ export async function pushFile(filePath) {
1030
1030
  const rel = relative(root, filePath).replace(/\\/g, '/');
1031
1031
  if (shouldIgnore(rel, effectiveIgnore(root, config.ignore)))
1032
1032
  return;
1033
- const baseline = readBaseline(config.projectGuid);
1034
- const baseEntry = baseline.files[rel];
1033
+ // Serialize against `gipity sync` and other concurrent pushes by holding the
1034
+ // same per-project lock `sync()` uses. Both paths read-modify-write the shared
1035
+ // baseline; without a common lock, a burst of PostToolUse pushes (each a
1036
+ // detached `gipity push`) racing the UserPromptSubmit/post-dispatch reconciles
1037
+ // drops baseline updates, and the 3-way merge then misreads our own just-pushed
1038
+ // edits as `modified×modified` conflicts (or pulls stale bytes over a live
1039
+ // edit). Read the baseline AFTER acquiring the lock so earlier pushes' writes
1040
+ // are visible. (WS-00172)
1041
+ const releaseLock = await acquireLock();
1035
1042
  try {
1043
+ const baseline = readBaseline(config.projectGuid);
1044
+ const baseEntry = baseline.files[rel];
1036
1045
  const result = await uploadOneFile(config.projectGuid, filePath, rel, {
1037
1046
  expectedServerVersion: baseEntry ? baseEntry.serverVersion : null,
1038
1047
  });
@@ -1051,5 +1060,8 @@ export async function pushFile(filePath) {
1051
1060
  }
1052
1061
  throw err;
1053
1062
  }
1063
+ finally {
1064
+ releaseLock();
1065
+ }
1054
1066
  }
1055
1067
  //# sourceMappingURL=sync.js.map
package/dist/utils.js CHANGED
@@ -47,6 +47,15 @@ export async function promptBoxed() {
47
47
  let _autoConfirm = false;
48
48
  export function setAutoConfirm(val) { _autoConfirm = val; }
49
49
  export function getAutoConfirm() { return _autoConfirm; }
50
+ /** Reconstruct the current invocation with `--yes` appended, for self-correcting
51
+ * non-interactive confirmation hints. Shell-quotes args that need it. */
52
+ function rerunWithYes() {
53
+ const args = process.argv.slice(2);
54
+ if (!args.some(a => a === '--yes' || a === '-y'))
55
+ args.push('--yes');
56
+ const quote = (a) => (/[^\w@%+=:,./-]/.test(a) ? `'${a.replace(/'/g, `'\\''`)}'` : a);
57
+ return `gipity ${args.map(quote).join(' ')}`;
58
+ }
50
59
  /** Ask for Y/n confirmation. Single-keypress - no Enter required.
51
60
  *
52
61
  * - `opts.default` controls which answer Enter / unknown-key selects. Defaults to `'no'`.
@@ -59,7 +68,10 @@ export async function confirm(question, opts = {}) {
59
68
  if (opts.skip ?? _autoConfirm)
60
69
  return true;
61
70
  if (!process.stdin.isTTY) {
62
- console.error('Confirmation required. Use --yes to skip prompts.');
71
+ // Headless/agent context: no one can answer the prompt. Don't just say
72
+ // "use --yes" - echo the exact command to re-run so the fix is copy-paste,
73
+ // not a second guessing trip.
74
+ console.error(`Confirmation required (non-interactive). Re-run with --yes:\n ${rerunWithYes()}`);
63
75
  return false;
64
76
  }
65
77
  const hint = defaultYes ? dim('[Y/n]') : dim('[y/N]');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gipity",
3
- "version": "1.0.391",
3
+ "version": "1.0.394",
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",
@@ -12,7 +12,7 @@
12
12
  "build": "tsc && chmod +x dist/index.js dist/gipcc.js dist/gipccd.js dist/updater/shim.js dist/updater/check.js",
13
13
  "dev": "tsc --watch",
14
14
  "test": "npm run test:smoke",
15
- "test:smoke": "tsc && node --test dist/__tests__/utils.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__/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-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__/relay-ingest-contract.test.js dist/__tests__/prompts.test.js dist/__tests__/capture-transcript.test.js dist/__tests__/flag-aliases.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-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-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-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",
15
+ "test:smoke": "tsc && node --test dist/__tests__/utils.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__/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-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__/relay-ingest-contract.test.js dist/__tests__/prompts.test.js dist/__tests__/capture-transcript.test.js dist/__tests__/flag-aliases.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-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-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
16
  "test:e2e": "tsc && GIPITY_E2E=1 node --test --test-timeout=180000 dist/__tests__/cli-e2e-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",
17
17
  "test:e2e:sandbox": "tsc && GIPITY_E2E=1 node --test --test-timeout=180000 dist/__tests__/cli-e2e-sandbox-live.test.js"
18
18
  },