gipity 1.1.4 → 1.1.6

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.
Files changed (52) hide show
  1. package/dist/adopt-cwd.js +1 -1
  2. package/dist/agents/agy.js +52 -0
  3. package/dist/agents/index.js +2 -0
  4. package/dist/api.js +19 -3
  5. package/dist/banner.js +3 -5
  6. package/dist/capture/sources/agy.js +88 -0
  7. package/dist/client-context.js +9 -0
  8. package/dist/commands/add.js +1 -1
  9. package/dist/commands/brand.js +125 -0
  10. package/dist/commands/build.js +13 -12
  11. package/dist/commands/chat.js +1 -1
  12. package/dist/commands/deploy.js +1 -1
  13. package/dist/commands/fn.js +2 -8
  14. package/dist/commands/generate.js +5 -5
  15. package/dist/commands/gmail.js +1 -1
  16. package/dist/commands/init.js +2 -2
  17. package/dist/commands/job.js +10 -5
  18. package/dist/commands/key.js +91 -0
  19. package/dist/commands/load.js +2 -2
  20. package/dist/commands/login.js +7 -2
  21. package/dist/commands/page-eval.js +47 -8
  22. package/dist/commands/page-fetch.js +14 -10
  23. package/dist/commands/page-inspect.js +2 -2
  24. package/dist/commands/page-screenshot.js +5 -5
  25. package/dist/commands/page-test.js +1 -1
  26. package/dist/commands/records.js +57 -19
  27. package/dist/commands/remove.js +1 -1
  28. package/dist/commands/sandbox.js +1 -1
  29. package/dist/commands/save.js +1 -1
  30. package/dist/commands/secrets.js +1 -1
  31. package/dist/commands/service.js +1 -1
  32. package/dist/commands/setup.js +4 -6
  33. package/dist/commands/status.js +41 -12
  34. package/dist/commands/storage.js +2 -2
  35. package/dist/commands/sync.js +15 -0
  36. package/dist/commands/test.js +1 -1
  37. package/dist/commands/token.js +6 -1
  38. package/dist/commands/uninstall.js +14 -1
  39. package/dist/commands/upload.js +1 -1
  40. package/dist/commands/workflow.js +1 -1
  41. package/dist/hooks/capture-runner.js +20 -8
  42. package/dist/index.js +1046 -518
  43. package/dist/knowledge.js +4 -1
  44. package/dist/login-flow.js +44 -2
  45. package/dist/project-setup.js +2 -0
  46. package/dist/relay/diagnostics.js +4 -2
  47. package/dist/relay/onboarding.js +26 -44
  48. package/dist/setup.js +198 -16
  49. package/dist/sync.js +6 -6
  50. package/dist/template-vars.js +74 -0
  51. package/dist/utils.js +60 -3
  52. package/package.json +2 -2
package/dist/knowledge.js CHANGED
@@ -106,7 +106,7 @@ mkdir -p ~/GipityProjects/<slug> && cd ~/GipityProjects/<slug> && gipity init <s
106
106
 
107
107
  ## CLI quick reference
108
108
 
109
- 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 secrets set <NAME> <value> [--account]\` (store an API key/token encrypted; read in functions via \`secrets.get('NAME')\` — never hardcode keys), \`gipity email send --to <addr> --subject <s> --body <b>\` (sends as \`gipity@gipity.ai\`; omit \`--to\` to self-send), \`gipity skill read <name>\`.
109
+ Key commands: \`gipity add <template|kit>\`, \`gipity brand set --emoji <e>|--color <hex>\` (regenerate the app's icons + social share card), \`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 secrets set <NAME> <value> [--account]\` (store an API key/token encrypted; read in functions via \`secrets.get('NAME')\` — never hardcode keys), \`gipity email send --to <addr> --subject <s> --body <b>\` (sends as \`gipity@gipity.ai\`; omit \`--to\` to self-send), \`gipity skill read <name>\`.
110
110
  Rename for findability: \`gipity project rename <name>\` renames the current project's display name (the slug and deployed URLs never change); \`gipity chat rename <title>\` renames the current chat's tab title. Both are the display label users scan to switch between tabs — retitle a chat when the conversation clearly shifts to a new topic (sparingly, not every turn), and keep every project/chat title SHORT: 2-4 words, ≤40 characters, no trailing punctuation (e.g. "Stripe checkout", "Tetris game").
111
111
  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").
112
112
  Move whole apps in/out: \`gipity save\` (export this project as a portable \`.gip\` bundle), \`gipity load <file.gip | github:owner/repo>\` (import as a NEW project; \`--inspect\` to preview), \`gipity github connect\` (1-2 click GitHub access for imports). Porting a Vercel/Replit/Lovable app? Load the \`app-import\` skill first.
@@ -148,6 +148,8 @@ Write files locally - Gipity's editor hooks (installed into Claude Code, Grok, a
148
148
 
149
149
  To keep local-only material (research clones, scratch data, vendored references) in the project directory without syncing or deploying it, list it in a \`.gipityignore\` at the project root - gitignore-style, one pattern per line, \`#\` comments. Ignored paths are invisible to sync in both directions; anything that already synced before being ignored stays on the server until you delete it.
150
150
 
151
+ **WSL trap: user-dropped files may be in a Windows-side twin.** On WSL, a same-named folder often exists at \`C:\\Users\\<name>\\GipityProjects\\<project>\` (visible as \`/mnt/c/Users/<name>/GipityProjects/<project>\`) - from Windows Explorer it looks like "the project folder", so users drop new files (audio, images) there. Nothing syncs from it. If the user says files are "in the project" but you can't find them, check that path before searching wider; \`gipity sync\` prints a notice when such a twin exists.
152
+
151
153
  ### Where files go: deploy only ships \`src/\`
152
154
 
153
155
  Deploy is opt-in, not opt-out: the \`files\` phase uploads **only** what's under \`src/\` (plus \`functions/\` and \`migrations/\` as backend, not CDN files). Anything else at the project root is kept but never deployed. Put each kind of file in the right bucket so scratch and reference material can't bloat a deploy:
@@ -184,6 +186,7 @@ App development skills:
184
186
  - \`app-debugging\` - debug a deployed app: page inspect/eval, screenshots, function logs
185
187
  - \`app-development\` - functions, database, and API
186
188
  - \`app-import\` - import apps from GitHub/.gip bundles (incl. Vercel/Replit/Lovable porting) and export any project as a portable .gip - app_import tool, gipity save/load
189
+ - \`app-records\` - Gipity Records: declared tables → validated CRUD, provenance, workflows, auto UI
187
190
  - \`app-testing\` - testing deployed app functions (ctx.fn.call/callAs, the isolated test DB)
188
191
  - \`deploy\` - the deploy pipeline & gipity.yaml manifest
189
192
  - \`jobs\` - long-running CPU + GPU compute jobs (Python / Node / bash)
@@ -10,9 +10,48 @@
10
10
  */
11
11
  import { publicPost } from './api.js';
12
12
  import { getAuth, saveAuth } from './auth.js';
13
+ import { getConfig } from './config.js';
13
14
  import { prompt, decodeJwtExp } from './utils.js';
14
- import { success, error as clrError, muted } from './colors.js';
15
+ import { success, error as clrError, warning, muted } from './colors.js';
15
16
  import { flushBugQueue } from './bug-queue.js';
17
+ /** True when a fresh-account result would be a SURPRISE worth warning about:
18
+ * either this directory is linked to an existing project, or this machine
19
+ * already held a session for this exact email. A genuine first-time signup
20
+ * (nothing linked, no prior session) must stay silent. */
21
+ function newAccountWouldBeUnexpected(email, priorAuth) {
22
+ return !!getConfig() || priorAuth?.email.toLowerCase() === email.toLowerCase().trim();
23
+ }
24
+ /** Called right after `/auth/login` (send-code), before the code is entered -
25
+ * the server already knows at this point whether the email matches an
26
+ * existing account (bug cli#S2: logging into the wrong/new account was
27
+ * previously silent until a later "not found" error). */
28
+ export function warnBeforeCodeIfUnexpectedNewAccount(isNewUser, email, indent = '') {
29
+ if (isNewUser !== true)
30
+ return;
31
+ const priorAuth = getAuth();
32
+ if (!newAccountWouldBeUnexpected(email, priorAuth))
33
+ return;
34
+ const config = getConfig();
35
+ console.log(`${indent}${warning(`No existing Gipity account for ${email} — entering the code will CREATE a new one.`)}`);
36
+ if (config) {
37
+ console.log(`${indent}${muted(`This directory is linked to project ${config.projectSlug} (account ${config.accountSlug}). If you meant to log into that account, stop and re-check the email before entering the code.`)}`);
38
+ }
39
+ }
40
+ /** Called right after `/auth/verify` succeeds. `priorAuth` must be captured
41
+ * BEFORE `saveAuth()` overwrites the cache - it's the only way to tell "this
42
+ * machine already had a session for this email" from "first login ever". */
43
+ export function warnIfUnexpectedNewAccount(isNewUser, email, priorAuth, indent = '') {
44
+ if (isNewUser !== true)
45
+ return;
46
+ if (!newAccountWouldBeUnexpected(email, priorAuth))
47
+ return;
48
+ const config = getConfig();
49
+ console.log(`${indent}${warning(`Logged into a NEW, empty account for ${email} — no prior account existed for this email.`)}`);
50
+ if (config) {
51
+ console.log(`${indent}${muted(`This directory is linked to project ${config.projectSlug} (account ${config.accountSlug}), which the new account does not own — project / skill / sync commands will fail with "not found".`)}`);
52
+ }
53
+ console.log(`${indent}${muted('If you expected an existing account, run: gipity status — then gipity login again with the correct email.')}`);
54
+ }
16
55
  /** Prompt for email + 6-digit code, persist the tokens, and return the new
17
56
  * auth. Exits the process on empty input or a bad token (the caller can't
18
57
  * proceed without a session). */
@@ -22,13 +61,15 @@ export async function interactiveLogin() {
22
61
  console.error(`\n ${clrError('Email required.')}`);
23
62
  process.exit(1);
24
63
  }
25
- await publicPost('/auth/login', { email });
64
+ const sendRes = await publicPost('/auth/login', { email });
26
65
  console.log(' Check your email for a 6-digit code.\n');
66
+ warnBeforeCodeIfUnexpectedNewAccount(sendRes.isNewUser, email, ' ');
27
67
  const code = await prompt(' Code: ');
28
68
  if (!code) {
29
69
  console.error(`\n ${clrError('Code required.')}`);
30
70
  process.exit(1);
31
71
  }
72
+ const priorAuth = getAuth();
32
73
  const res = await publicPost('/auth/verify', { email, code });
33
74
  const exp = decodeJwtExp(res.accessToken);
34
75
  if (!exp) {
@@ -38,6 +79,7 @@ export async function interactiveLogin() {
38
79
  const expiresAt = new Date(exp * 1000).toISOString();
39
80
  saveAuth({ accessToken: res.accessToken, refreshToken: res.refreshToken, email, expiresAt });
40
81
  console.log(` ${success(`Logged in (${email}).`)}`);
82
+ warnIfUnexpectedNewAccount(res.isNewUser, email, priorAuth, ' ');
41
83
  const delivered = await flushBugQueue().catch(() => 0);
42
84
  if (delivered > 0) {
43
85
  console.log(` ${muted(`Delivered ${delivered} queued bug report${delivered === 1 ? '' : 's'}.`)}`);
@@ -36,6 +36,8 @@ export async function finalizeLocalProject(opts) {
36
36
  const sub = await substituteDir(opts.dir, {
37
37
  projectGuid: opts.projectGuid,
38
38
  projectName: opts.projectName,
39
+ accountSlug: opts.accountSlug,
40
+ projectSlug: opts.projectSlug,
39
41
  });
40
42
  if (sub.changed.length) {
41
43
  console.log(muted(`Resolved template vars in ${sub.changed.length} file${sub.changed.length > 1 ? 's' : ''}.`));
@@ -137,8 +137,8 @@ export async function collectDiagnostics() {
137
137
  } })();
138
138
  // Run the subprocess probes concurrently (each already bounded + best-effort)
139
139
  // so the whole snapshot costs one timeout, not the sum of four.
140
- const [claude, codex, grok, cursor, gpu] = await Promise.all([
141
- probeVersion('claude'), probeVersion('codex'), probeVersion('grok'), probeVersion('cursor'), detectGpu(),
140
+ const [claude, codex, grok, agy, cursor, gpu] = await Promise.all([
141
+ probeVersion('claude'), probeVersion('codex'), probeVersion('grok'), probeVersion('agy'), probeVersion('cursor'), detectGpu(),
142
142
  ]);
143
143
  const agents = {};
144
144
  if (claude)
@@ -147,6 +147,8 @@ export async function collectDiagnostics() {
147
147
  agents.codex = codex;
148
148
  if (grok)
149
149
  agents.grok = grok;
150
+ if (agy)
151
+ agents.agy = agy;
150
152
  if (cursor)
151
153
  agents.cursor = cursor;
152
154
  return {
@@ -12,8 +12,7 @@
12
12
  * All the non-interactive primitives live in `setup.ts` (`pairDevice`,
13
13
  * `startDaemon`, `installAutostart`); this module is only the prompts + copy.
14
14
  */
15
- import { readFileSync } from 'node:fs';
16
- import { prompt, confirm } from '../utils.js';
15
+ import { prompt, confirm, isWsl } from '../utils.js';
17
16
  import { bold, brand, dim, success, error as clrError, muted } from '../colors.js';
18
17
  import * as state from './state.js';
19
18
  import { UnsupportedPlatformError } from './installers.js';
@@ -27,21 +26,6 @@ export const ensureDaemonRunning = startDaemon;
27
26
  * ended up enabled (or was already), `false` if the user declined or a step
28
27
  * failed. Non-interactive flows (e.g. `gipity claude -p`) must not call this.
29
28
  */
30
- /** True inside Windows Subsystem for Linux (either env marker or kernel
31
- * string). Used to explain autostart failures accurately - WSL ships without
32
- * a systemd user session unless the user opts in via /etc/wsl.conf. */
33
- function isWsl() {
34
- if (process.platform !== 'linux')
35
- return false;
36
- if (process.env.WSL_DISTRO_NAME || process.env.WSL_INTEROP)
37
- return true;
38
- try {
39
- return /microsoft/i.test(readFileSync('/proc/version', 'utf-8'));
40
- }
41
- catch {
42
- return false;
43
- }
44
- }
45
29
  export async function runRelaySetup(opts) {
46
30
  const alreadyAnswered = state.getRelayEnabled() !== undefined;
47
31
  // `gipity claude` first-run: honor the user's prior choice and don't re-ask.
@@ -66,13 +50,13 @@ export async function runRelaySetup(opts) {
66
50
  console.log('');
67
51
  console.log(` ${success('Registered as')} ${bold(existingDevice.name)} ${muted(`(${existingDevice.guid})`)}`);
68
52
  if (state.isPaused()) {
69
- console.log(` ${dim('The relay is paused resume it with')} ${brand('gipity relay resume')}${dim('.')}`);
53
+ console.log(` ${dim('The relay is paused - resume it with')} ${brand('gipity relay resume')}${dim('.')}`);
70
54
  }
71
55
  else {
72
56
  console.log(` ${dim('The relay is running. Open')} ${brand('gipity.ai')}${dim(' and type `/claude` to drive Claude Code here.')}`);
73
57
  }
74
58
  console.log('');
75
- console.log(` ${dim('To register this computer again for example under a different name')}`);
59
+ console.log(` ${dim('To register this computer again (for example under a different name),')}`);
76
60
  console.log(` ${dim('unregister it first, then re-run setup:')}`);
77
61
  console.log(` ${brand('gipity relay revoke')} ${dim('# unpairs this computer and removes the login service')}`);
78
62
  console.log(` ${brand('gipity connect')} ${dim('# register it again (asks for a new name)')}`);
@@ -82,34 +66,36 @@ export async function runRelaySetup(opts) {
82
66
  }
83
67
  // Header. `gipity setup` frames it as the deliberate action it is; the
84
68
  // `gipity claude` first-run frames it as an optional add-on it's offering.
69
+ // Lines are pre-wrapped short so they never rewrap in a normal terminal.
85
70
  if (opts.mode === 'run-now') {
86
71
  console.log(` ${bold('Set up this computer as a relay')}`);
87
- console.log(` ${dim('A relay runs your coding agent (Claude Code, Codex, or Grok) here so you can drive it from the web (')}${brand('gipity.ai')}${dim(') on any browser.')}`);
88
- console.log(` ${dim('It uses your Claude, Codex, or Grok subscription — the cheapest way to pay for tokens.')}`);
72
+ console.log(` ${dim('Gipity drives the coding agents on this computer from the web (')}${brand('gipity.ai')}${dim(')')}`);
73
+ console.log(` ${dim('on any browser, desktop or phone. Uses your Claude, Codex, or Grok')}`);
74
+ console.log(` ${dim('subscription - the cheapest way to pay for tokens.')}`);
89
75
  }
90
76
  else {
91
- console.log(` ${bold('Remote control of your coding agent')}`);
92
- console.log(` ${dim('Drive your coding agent on this computer from the web (')}${brand('gipity.ai')}${dim(') on any browser (desktop or phone).')}`);
93
- console.log('');
94
- console.log(` ${dim('Enable now (takes 2 seconds) or turn on later with')} ${brand('gipity connect')}`);
77
+ console.log(` ${bold('Optional:')} ${dim('Gipity can drive the coding agents on this computer from the')}`);
78
+ console.log(` ${dim('web (')}${brand('gipity.ai')}${dim(') on any browser, desktop or phone. Like Claude Code remote')}`);
79
+ console.log(` ${dim('control, but one pane for all your coding agents.')}`);
95
80
  }
96
81
  console.log('');
97
82
  const promptText = opts.mode === 'run-now'
98
- ? ' Set up remote control on this computer?'
99
- : ' Enable remote control?';
83
+ ? ' Set up remote control on this computer'
84
+ : ' Enable remote control';
100
85
  const enable = await confirm(promptText, { default: 'yes' });
101
86
  if (!enable) {
102
87
  state.setRelayEnabled(false);
103
- console.log(` ${muted('Skipped.')}`);
88
+ console.log(` ${muted('Skipped. Turn on later with')} ${brand('gipity connect')}${muted('.')}`);
104
89
  console.log('');
105
90
  return false;
106
91
  }
92
+ console.log('');
107
93
  // Device name - show a friendly default (owner + device kind); Enter accepts.
108
94
  const defaultName = friendlyDeviceName();
109
95
  const rawName = await prompt(` Device name [${bold(defaultName)}]: `);
110
96
  const name = (rawName || defaultName).trim();
111
97
  if (!name || name.length > 100) {
112
- console.error(` ${clrError('Device name must be 1100 non-whitespace characters. Skipping.')}`);
98
+ console.error(` ${clrError('Device name must be 1-100 non-whitespace characters. Skipping.')}`);
113
99
  state.setRelayEnabled(false);
114
100
  return false;
115
101
  }
@@ -128,13 +114,12 @@ export async function runRelaySetup(opts) {
128
114
  // run instead of silently opting the user out forever.
129
115
  return false;
130
116
  }
131
- // Start the daemon for this session.
132
- const startNow = await confirm(' Start the relay now (and on future `gipity build` runs)?', { default: 'yes' });
133
- if (startNow) {
134
- startDaemon();
135
- }
117
+ // The user said yes to remote control - start the daemon now, no extra
118
+ // question. `gipity build` keeps it running on future runs.
119
+ startDaemon();
120
+ console.log('');
136
121
  // Offer OS-level autostart (launchd / systemd --user / Task Scheduler).
137
- const autostartOs = await confirm(' Also start at OS login (auto-start with Windows / macOS / Linux)?', { default: 'yes' });
122
+ const autostartOs = await confirm(' Also start at OS login', { default: 'yes' });
138
123
  if (autostartOs) {
139
124
  try {
140
125
  const res = installAutostart();
@@ -152,9 +137,7 @@ export async function runRelaySetup(opts) {
152
137
  console.log(` ${muted('Autostart install returned non-zero - you can run')} ${brand('gipity relay install')} ${muted('later.')}`);
153
138
  }
154
139
  }
155
- else {
156
- console.log(` ${success('Auto-start installed.')} ${dim(res.summary)}`);
157
- }
140
+ // Success is silent - the Done line below covers it.
158
141
  }
159
142
  catch (err) {
160
143
  if (err instanceof UnsupportedPlatformError) {
@@ -166,16 +149,15 @@ export async function runRelaySetup(opts) {
166
149
  }
167
150
  }
168
151
  // Diagnostics consent - default on, clearly opt-out. Only ask once.
152
+ // (No personal data or file paths are ever sent; `gipity relay diagnostics
153
+ // on|off` flips it later.)
169
154
  if (state.getDiagnosticsConsent() === undefined) {
170
- const diag = await confirm(' Share anonymous diagnostics (CPU/GPU/memory/disk/versions) to improve reliability?', { default: 'yes' });
155
+ console.log('');
156
+ const diag = await confirm(' Share anonymous diagnostics info', { default: 'yes' });
171
157
  state.setDiagnosticsConsent(diag);
172
- console.log(` ${dim(diag
173
- ? 'Thanks - no personal data or file paths are ever sent. Turn off anytime with `gipity relay diagnostics off`.'
174
- : 'Diagnostics off. Turn on anytime with `gipity relay diagnostics on`.')}`);
175
158
  }
176
159
  console.log('');
177
- console.log(` ${success(`Registered as ${bold(device.name)} (${device.guid}).`)}`);
178
- console.log(` ${dim('In the Gipity web CLI, type `/claude` to dispatch messages to this PC.')}`);
160
+ console.log(` ${success(`Done! ${bold(device.name)} is set up. New chats from gipity.ai can now execute here.`)}`);
179
161
  console.log('');
180
162
  return true;
181
163
  }
package/dist/setup.js CHANGED
@@ -32,6 +32,7 @@ export const PRIMER_FILES = {
32
32
  claude: 'CLAUDE.md',
33
33
  codex: 'AGENTS.md',
34
34
  grok: 'AGENTS.md', // Grok Build reads the AGENTS.md family (and CLAUDE.md) natively
35
+ agy: 'AGENTS.md', // Antigravity reads the same AGENTS.md/GEMINI.md rules family natively
35
36
  aider: 'AGENTS.md', // shares the Codex primer; aider is pointed at it via .aider.conf.yml
36
37
  gemini: 'GEMINI.md',
37
38
  copilot: '.github/copilot-instructions.md',
@@ -58,7 +59,7 @@ export const AIDER_CONF_FILE = '.aider.conf.yml';
58
59
  * `ignore` package in config.ts. */
59
60
  export const SCRATCH_IGNORE = ['tmp/', '.tmp/', '*_tmp/', '.gipityscratch/'];
60
61
  export const DEFAULT_SYNC_IGNORE = [
61
- 'node_modules', '.git', '.gipity.json', '.gipity/', '.claude/', '.codex/', '.gitignore', AIDER_CONF_FILE,
62
+ 'node_modules', '.git', '.gipity.json', '.gipity/', '.claude/', '.codex/', '.agents/', '.gitignore', AIDER_CONF_FILE,
62
63
  // Home-directory junk: a project created inside a real home dir (or one that
63
64
  // shells out) sweeps in a cache dir + shell dotfiles that are never app
64
65
  // files. `.cache/` alone can be gigabytes (it was 2.4 GB on one project),
@@ -398,9 +399,16 @@ export const AGENT_HOOKS_DIR = join(homedir(), '.gipity', 'agent-hooks');
398
399
  * version and the exact skill names copied, so upgrades replace and uninstall
399
400
  * removes precisely those. */
400
401
  export const AGENT_SKILLS_MANIFEST = join(homedir(), '.gipity', 'agent-skills.json');
401
- export function agentSkillsState() {
402
+ // Antigravity (agy) reads skills from its own global customization root,
403
+ // ~/.gemini/config/skills/ (confirmed against agy's own customization-system
404
+ // docs) - NOT the cross-agent ~/.agents/skills Codex/OpenClaw-family tools
405
+ // share. Same manifest pattern, separate directory + file so upgrades and
406
+ // uninstall touch exactly the skills each tool actually reads.
407
+ export const AGY_SKILLS_DIR = join(homedir(), '.gemini', 'config', 'skills');
408
+ export const AGY_SKILLS_MANIFEST = join(homedir(), '.gipity', 'agy-skills.json');
409
+ function skillsManifestState(manifestPath) {
402
410
  try {
403
- const m = JSON.parse(readFileSync(AGENT_SKILLS_MANIFEST, 'utf-8'));
411
+ const m = JSON.parse(readFileSync(manifestPath, 'utf-8'));
404
412
  return {
405
413
  current: typeof m?.version === 'string' && versionGte(m.version, GIPITY_PLUGIN_VERSION),
406
414
  skills: Array.isArray(m?.skills) ? m.skills : [],
@@ -410,15 +418,21 @@ export function agentSkillsState() {
410
418
  return { current: false, skills: [] };
411
419
  }
412
420
  }
413
- /** Materialize the Gipity skills into ~/.agents/skills (the cross-agent skills
414
- * dir Codex reads) and the plugin's hook scripts into ~/.gipity/agent-hooks.
415
- * Source of truth is the same GipityAI/skills repo the Claude/Grok plugin
416
- * installs clone - fetched with a shallow git clone into a temp dir.
417
- * Best-effort: no git, no network, or a failed clone all leave things as they
418
- * were; the next init retries. */
419
- export function ensureAgentSkillsInstalled() {
420
- if (agentSkillsState().current)
421
- return;
421
+ export function agentSkillsState() {
422
+ return skillsManifestState(AGENT_SKILLS_MANIFEST);
423
+ }
424
+ export function agySkillsState() {
425
+ return skillsManifestState(AGY_SKILLS_MANIFEST);
426
+ }
427
+ /** Shared core: clone GipityAI/skills, copy every skill dir with a SKILL.md
428
+ * into `skillsDir`, stage the plugin's hook scripts into ~/.gipity/agent-hooks
429
+ * (shared infra - launch.sh/capture.cjs/sync-push.cjs are agent-agnostic, so
430
+ * every caller re-stages them harmlessly), and record what was installed in
431
+ * `manifestPath`. Source of truth is the same GipityAI/skills repo the
432
+ * Claude/Grok plugin installs clone - fetched with a shallow git clone into a
433
+ * temp dir. Best-effort: no git, no network, or a failed clone all leave
434
+ * things as they were; the next init retries. */
435
+ function installSkillsAndHooks(skillsDir, manifestPath, harnessLabel) {
422
436
  if (!binaryOnPath('git'))
423
437
  return;
424
438
  const tmp = mkdtempSync(join(tmpdir(), 'gipity-skills-'));
@@ -441,8 +455,8 @@ export function ensureAgentSkillsInstalled() {
441
455
  continue;
442
456
  if (!existsSync(join(skillsSrc, entry.name, 'SKILL.md')))
443
457
  continue;
444
- mkdirSync(AGENTS_SKILLS_DIR, { recursive: true });
445
- cpSync(join(skillsSrc, entry.name), join(AGENTS_SKILLS_DIR, entry.name), {
458
+ mkdirSync(skillsDir, { recursive: true });
459
+ cpSync(join(skillsSrc, entry.name), join(skillsDir, entry.name), {
446
460
  recursive: true,
447
461
  force: true,
448
462
  });
@@ -452,14 +466,27 @@ export function ensureAgentSkillsInstalled() {
452
466
  for (const script of readdirSync(join(repo, 'hooks', 'scripts'))) {
453
467
  cpSync(join(repo, 'hooks', 'scripts', script), join(AGENT_HOOKS_DIR, script), { force: true });
454
468
  }
455
- writeFileSync(AGENT_SKILLS_MANIFEST, JSON.stringify({ version, skills: names }, null, 2) + '\n');
456
- console.log(`Installed ${names.length} Gipity skills for Codex (~/.agents/skills).`);
469
+ writeFileSync(manifestPath, JSON.stringify({ version, skills: names }, null, 2) + '\n');
470
+ console.log(`Installed ${names.length} Gipity skills for ${harnessLabel} (${skillsDir}).`);
457
471
  }
458
472
  catch { /* best-effort - never break setup */ }
459
473
  finally {
460
474
  rmSync(tmp, { recursive: true, force: true });
461
475
  }
462
476
  }
477
+ export function ensureAgentSkillsInstalled() {
478
+ if (agentSkillsState().current)
479
+ return;
480
+ installSkillsAndHooks(AGENTS_SKILLS_DIR, AGENT_SKILLS_MANIFEST, 'Codex');
481
+ }
482
+ /** Materialize the Gipity skills into Antigravity's global skill root. Mirrors
483
+ * ensureAgentSkillsInstalled() but targets agy's own directory - see the
484
+ * AGY_SKILLS_DIR comment above for why it differs from Codex's. */
485
+ export function ensureAgySkillsInstalled() {
486
+ if (agySkillsState().current)
487
+ return;
488
+ installSkillsAndHooks(AGY_SKILLS_DIR, AGY_SKILLS_MANIFEST, 'Antigravity');
489
+ }
463
490
  /** Pure core of the .codex/hooks.json merge: given the file's current content
464
491
  * (`null` when absent), return the new content, or `null` when no change is
465
492
  * needed. Adds the Gipity sync hook groups (push on file edits, pull before
@@ -535,6 +562,160 @@ export function setupCodexIntegration() {
535
562
  ensureAgentSkillsInstalled();
536
563
  setupCodexHooks();
537
564
  }
565
+ // --- Google Antigravity (agy) --------------------------------------------
566
+ // agy's hook system is a real departure from the Claude-format hooks Codex
567
+ // and Grok reuse: project hooks live in `.agents/hooks.json` as a NAMED-block
568
+ // object (not a flat `hooks` key), tool-scoped events (PreToolUse/PostToolUse)
569
+ // require a `matcher` regex, and every hook command must print a JSON object
570
+ // on stdout.
571
+ //
572
+ // Deliberately NOT wired: PreToolUse. Gipity only needs to OBSERVE tool calls
573
+ // (capture) and react after a write lands (sync-push) - it has no reason to
574
+ // gate them. A PreToolUse hook that answers `{"decision":"allow"}` genuinely
575
+ // overrides agy's own approval prompt (confirmed against agy's own hooks
576
+ // contract: `"allow"` means "automatically allow the tool execution"), so
577
+ // registering one would auto-approve every tool agy runs - including shell
578
+ // commands - in every Gipity-linked project, in BOTH headless and interactive
579
+ // sessions. Confirmed live that headless `-p` writes succeed with no
580
+ // PreToolUse hook at all, with or without `--dangerously-skip-permissions` -
581
+ // so there is no capture/sync reason to have one, and Claude/Codex don't
582
+ // silently touch agent approval either. PostToolUse/Stop route through a
583
+ // small wrapper script (AGY_HOOKS_SCRIPT below) that does the real work
584
+ // (sync-push + session capture) and then unconditionally prints `{}` - not
585
+ // decision-gated, so degrading silently on a node-resolution failure is the
586
+ // same acceptable risk every other harness's hooks already carry.
587
+ /** Written verbatim into ~/.gipity/agent-hooks/agy-hooks.cjs by setupAgyHooks().
588
+ * Unlike Codex's hook scripts (cloned from the GipityAI/skills repo), this
589
+ * file is authored by the CLI itself - agy is not part of that repo's plugin
590
+ * ecosystem, just a consumer of the same shared sync-push.cjs/capture.cjs.
591
+ *
592
+ * post-tool-use: reads agy's PostToolUse payload (which - confirmed live -
593
+ * carries `toolCall` directly, unlike agy's own docs suggest) and, for a
594
+ * file-write tool, synthesizes a Claude-Code-shaped `{tool_input:{file_path}}`
595
+ * payload for the UNMODIFIED sync-push.cjs (which only knows Claude/Grok's
596
+ * field names) - then always forwards the raw payload to capture.cjs for
597
+ * session mirroring (its normalizeHookInput already reads agy's camelCase
598
+ * conversationId/transcriptPath fields).
599
+ * stop: forwards the raw payload to capture.cjs for a final flush.
600
+ * Either way, stdout is always `{}` - agy isn't gating on this response. */
601
+ const AGY_HOOKS_SCRIPT = `#!/usr/bin/env node
602
+ 'use strict';
603
+ const { spawnSync } = require('child_process');
604
+ const { join } = require('path');
605
+
606
+ const WRITE_TOOLS = new Set(['write_to_file', 'replace_file_content']);
607
+
608
+ function readStdin() {
609
+ return new Promise((res) => {
610
+ let data = '';
611
+ process.stdin.setEncoding('utf-8');
612
+ process.stdin.on('data', (c) => { data += c; });
613
+ process.stdin.on('end', () => res(data));
614
+ process.stdin.on('error', () => res(data));
615
+ });
616
+ }
617
+
618
+ async function main() {
619
+ const event = process.argv[2];
620
+ const raw = await readStdin();
621
+ let payload = {};
622
+ try { payload = JSON.parse(raw); } catch { /* keep {} */ }
623
+
624
+ try {
625
+ if (event === 'post-tool-use') {
626
+ const toolCall = payload.toolCall;
627
+ const filePath = toolCall && toolCall.args && toolCall.args.TargetFile;
628
+ if (toolCall && WRITE_TOOLS.has(toolCall.name) && typeof filePath === 'string' && filePath) {
629
+ spawnSync(process.execPath, [join(__dirname, 'sync-push.cjs')], {
630
+ input: JSON.stringify({ tool_input: { file_path: filePath } }),
631
+ stdio: ['pipe', 'ignore', 'ignore'],
632
+ windowsHide: true,
633
+ });
634
+ }
635
+ spawnSync(process.execPath, [join(__dirname, 'capture.cjs'), 'agy', 'post-tool-use'], {
636
+ input: raw,
637
+ stdio: ['pipe', 'ignore', 'ignore'],
638
+ windowsHide: true,
639
+ });
640
+ } else if (event === 'stop') {
641
+ spawnSync(process.execPath, [join(__dirname, 'capture.cjs'), 'agy', 'stop'], {
642
+ input: raw,
643
+ stdio: ['pipe', 'ignore', 'ignore'],
644
+ windowsHide: true,
645
+ });
646
+ }
647
+ } catch { /* never let a side effect break the response below */ }
648
+
649
+ process.stdout.write('{}');
650
+ }
651
+
652
+ main();
653
+ `;
654
+ /** Pure core of the .agents/hooks.json merge for Antigravity (agy). Given the
655
+ * file's current content (`null` when absent), return the new content, or
656
+ * `null` when no change is needed. Our whole contribution lives under one
657
+ * named key ('gipity'), so a re-run replaces it wholesale (simpler than
658
+ * Codex's per-entry merge, and fine here since nothing else writes into this
659
+ * key) while any other named hook block - the user's own, or another tool's -
660
+ * is preserved untouched. Exported for unit testing. */
661
+ export function applyAgyHooks(existing) {
662
+ const wrapper = join(AGENT_HOOKS_DIR, 'agy-hooks.cjs');
663
+ const launcher = join(AGENT_HOOKS_DIR, 'launch.sh');
664
+ const wrapCmd = (event) => `sh "${launcher}" "${wrapper}" ${event}`;
665
+ const block = {
666
+ PostToolUse: [
667
+ { matcher: '.*', hooks: [{ type: 'command', command: wrapCmd('post-tool-use'), timeout: 30 }] },
668
+ ],
669
+ Stop: [
670
+ { hooks: [{ type: 'command', command: wrapCmd('stop'), timeout: 60 }] },
671
+ ],
672
+ };
673
+ let settings = {};
674
+ if (existing !== null) {
675
+ try {
676
+ settings = JSON.parse(existing);
677
+ }
678
+ catch {
679
+ return null; // user file we can't parse - leave it alone
680
+ }
681
+ }
682
+ if (JSON.stringify(settings.gipity ?? null) === JSON.stringify(block))
683
+ return null; // already current
684
+ settings.gipity = block;
685
+ return JSON.stringify(settings, null, 2) + '\n';
686
+ }
687
+ /** Write the project-level Antigravity hooks (.agents/hooks.json) and stage
688
+ * the wrapper script it invokes. POSIX only, same constraint as Codex's
689
+ * hooks (the commands run through a POSIX sh launcher). */
690
+ export function setupAgyHooks() {
691
+ if (process.platform === 'win32')
692
+ return;
693
+ const cwd = resolve(process.cwd());
694
+ if (cwd === resolve(homedir()))
695
+ return; // never treat $HOME as a project
696
+ mkdirSync(AGENT_HOOKS_DIR, { recursive: true });
697
+ writeFileSync(join(AGENT_HOOKS_DIR, 'agy-hooks.cjs'), AGY_HOOKS_SCRIPT);
698
+ const path = join(cwd, '.agents', 'hooks.json');
699
+ const existing = existsSync(path) ? readFileSync(path, 'utf-8') : null;
700
+ const next = applyAgyHooks(existing);
701
+ if (next === null)
702
+ return;
703
+ mkdirSync(dirname(path), { recursive: true });
704
+ writeFileSync(path, next);
705
+ console.log(existing === null
706
+ ? 'Wrote Antigravity sync + session-capture hooks (.agents/hooks.json).'
707
+ : 'Updated Antigravity hooks (.agents/hooks.json).');
708
+ }
709
+ /** Full Antigravity integration: skills at its own global root + project sync
710
+ * hooks. Gated on the agy binary so machines without it get only the
711
+ * AGENTS.md primer. Unlike Codex, agy needs no one-time hook-approval nudge -
712
+ * confirmed live that project hooks fire without any manual trust step. */
713
+ export function setupAgyIntegration() {
714
+ if (!binaryOnPath('agy'))
715
+ return;
716
+ ensureAgySkillsInstalled();
717
+ setupAgyHooks();
718
+ }
538
719
  export function setupClaudeHooks() {
539
720
  // All hooks ship in the plugin - enable it at user scope (and clean up any
540
721
  // legacy hook blocks in the user-global settings while we're there).
@@ -746,6 +927,7 @@ export const SUPPORTED_TOOLS = [
746
927
  { key: 'claude', label: 'Claude Code (CLAUDE.md + Gipity plugin)', setup: setupClaudeMd, integrate: setupClaudeHooks },
747
928
  { key: 'codex', label: 'OpenAI Codex (AGENTS.md + skills + sync hooks)', setup: setupAgentsMd, integrate: setupCodexIntegration },
748
929
  { key: 'grok', label: 'Grok Build (AGENTS.md + Gipity plugin)', setup: setupAgentsMd, integrate: ensureGrokPluginInstalled },
930
+ { key: 'agy', label: 'Antigravity (AGENTS.md + skills + sync hooks)', setup: setupAgentsMd, integrate: setupAgyIntegration },
749
931
  { key: 'aider', label: 'Aider (AGENTS.md + .aider.conf.yml)', setup: setupAiderMd, optIn: true },
750
932
  { key: 'gemini', label: 'Gemini CLI (GEMINI.md)', setup: setupGeminiMd },
751
933
  { key: 'copilot', label: 'GitHub Copilot (.github/copilot-instructions.md)', setup: setupCopilotMd },
package/dist/sync.js CHANGED
@@ -154,7 +154,7 @@ export async function acquireLock(progress) {
154
154
  // First time we're forced to wait: open the spinner so the wait is visible
155
155
  // (with an elapsed timer) rather than reading as a frozen process.
156
156
  if (!waitSpinner) {
157
- waitSpinner = progress?.spinner('Waiting for another sync to finish') ?? null;
157
+ waitSpinner = progress?.spinner('Waiting for another sync to finish...') ?? null;
158
158
  }
159
159
  await new Promise(r => setTimeout(r, LOCK_POLL_MS));
160
160
  }
@@ -735,9 +735,9 @@ async function syncInner(projectGuid, root, ignore, opts, interactive) {
735
735
  const config = { projectGuid, ignore };
736
736
  const p = opts.progress;
737
737
  const baseline = readBaseline(projectGuid);
738
- p?.phase('Scanning local files');
738
+ p?.phase('Scanning local files...');
739
739
  const local = walkLocal(root, ignore, baseline.files);
740
- p?.phase('Checking Gipity for changes');
740
+ p?.phase('Checking Gipity for changes...');
741
741
  const remote = await fetchRemote(projectGuid);
742
742
  // Ignored paths are invisible on BOTH sides: filtering only the local walk
743
743
  // would classify a remote copy as "added" (pull it), then next pass as a
@@ -761,7 +761,7 @@ async function syncInner(projectGuid, root, ignore, opts, interactive) {
761
761
  needHash.push(path);
762
762
  }
763
763
  if (needHash.length)
764
- p?.phase(`Hashing ${needHash.length} file${needHash.length === 1 ? '' : 's'}…`);
764
+ p?.phase(`Hashing ${needHash.length} file${needHash.length === 1 ? '' : 's'}...`);
765
765
  await ensureLocalHashes(root, local, needHash);
766
766
  const planned = plan(local, remote, baseline.files);
767
767
  if (opts.plan) {
@@ -890,7 +890,7 @@ async function syncInner(projectGuid, root, ignore, opts, interactive) {
890
890
  // Either way we fall through to the single-file recovery below rather than
891
891
  // proceeding on a half-empty tree - a partial download must never be
892
892
  // mistaken for a complete one.
893
- errors.push(`Bulk download incomplete (${err.message}); recovering files individually…`);
893
+ errors.push(`Bulk download incomplete (${err.message}); recovering files individually...`);
894
894
  }
895
895
  finally {
896
896
  // Settle the bar even if the extracted-byte tally fell short of the
@@ -903,7 +903,7 @@ async function syncInner(projectGuid, root, ignore, opts, interactive) {
903
903
  // an earlier truncated pull.
904
904
  const missing = wantedDownloads.filter(a => !downloadedBytes.has(a.path));
905
905
  if (missing.length) {
906
- p?.phase(`Recovering ${missing.length} file${missing.length === 1 ? '' : 's'} the bulk download dropped…`);
906
+ p?.phase(`Recovering ${missing.length} file${missing.length === 1 ? '' : 's'} the bulk download dropped...`);
907
907
  for (const a of missing) {
908
908
  // Verify against the remote's content hash: the downloads pass records
909
909
  // this sha into the baseline, so bytes that don't match it must be