gipity 1.0.380 → 1.0.384

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.
@@ -8,20 +8,25 @@ import { sync } from '../sync.js';
8
8
  import { success, muted, bold } from '../colors.js';
9
9
  import { run } from '../helpers/index.js';
10
10
  const STARTERS = [
11
- { key: 'web-fullstack', hint: 'backend API + database (weather-by-zip demo)' },
12
11
  { key: 'web-vision-cam', hint: 'fullscreen camera app with on-device vision (MediaPipe)' },
12
+ { key: 'object-spotter', hint: 'camera app that boxes, labels, and counts objects (YOLOX on-device)' },
13
13
  { key: '2d-game', hint: '2D games with Phaser 3 - platformer, arcade, puzzle' },
14
14
  { key: '3d-world', hint: 'playable 3D multiplayer rocket-launcher demo' },
15
- { key: 'api', hint: 'pure API backend, no frontend' },
15
+ { key: 'karaoke-captions', hint: 'audio + lyrics -> word-synced karaoke captions (GPU job)' },
16
16
  ];
17
17
  const BLANK = [
18
18
  { key: 'web-simple', hint: 'static frontend-only site - pages, dashboards, simple games' },
19
+ { key: 'web-fullstack', hint: 'backend API + database wiring - frontend, functions, migrations; deploys green' },
20
+ { key: 'api', hint: 'pure API backend, no frontend - one example function + test' },
19
21
  { key: '3d-engine', hint: '3D multiplayer wiring - Three.js + Rapier + Gipity Realtime' },
20
22
  ];
21
23
  const HIDDEN = [{ key: 'app-itsm', hint: 'IT service management / helpdesk / ticketing' }];
22
24
  const KITS = [
23
25
  { key: 'realtime', hint: 'multiplayer / presence / shared state' },
24
26
  { key: 'web-vision-mediapipe', hint: 'browser camera vision - gesture, pose, object detection' },
27
+ { key: 'web-vision-detect', hint: 'browser object detection - YOLOX, WebGPU/WASM, custom models' },
28
+ { key: 'chatbot', hint: 'drop-in chatbot - persona, guardrails, streaming responses' },
29
+ { key: 'audio-align', hint: 'audio + lyrics -> word-level timing JSON (GPU job)' },
25
30
  { key: 'i18n', hint: 'multi-language web apps - language picker, RTL, translations' },
26
31
  ];
27
32
  // The catalog block, rendered once and reused by the full help output
@@ -142,7 +147,7 @@ export const addCommand = new Command('add')
142
147
  .argument('[name]', 'Template/kit key, OR a local directory path (./, ~/, or /abs). Omit for help; use --list for just the catalog.')
143
148
  .option('--title <title>', 'App title - templates only (defaults to project name)')
144
149
  .option('--description <desc>', 'App description for meta tags - templates only')
145
- .option('--force', 'Templates only: overwrite any colliding files')
150
+ .option('--force', 'Templates only: install into a non-empty project (same-named files get a new version; other files are kept)')
146
151
  .option('--list', 'List the template/kit catalog and exit')
147
152
  .option('--json', 'Output as JSON')
148
153
  .addHelpText('after', () => catalogText() + '\n\n'
@@ -676,8 +676,8 @@ export const claudeCommand = new Command('claude')
676
676
  }
677
677
  if (!nonInteractive) {
678
678
  console.log(` ${bold('Launching Claude Code, powered by Gipity.')}`);
679
- console.log(` ${muted("Just tell Claude what you'd like to build or do - everything Claude could do")}`);
680
- console.log(` ${muted('before, and now, on Gipity, so much more.')}`);
679
+ console.log(` ${muted("Just tell Claude what you'd like to build or do - everything Claude can do,")}`);
680
+ console.log(` ${muted('plus hosting, databases, and live deploys on Gipity.')}`);
681
681
  console.log('');
682
682
  }
683
683
  // In non-interactive (-p) mode, prepend a Gipity preamble to the
@@ -1,48 +1,40 @@
1
1
  import { Command } from 'commander';
2
2
  import { existsSync, readFileSync } from 'fs';
3
3
  import { join, resolve } from 'path';
4
+ import { homedir } from 'os';
4
5
  import { getAuth, getTimeRemaining } from '../auth.js';
5
6
  import { getConfig, liveUrl } from '../config.js';
6
7
  import { brand, success, warning, muted, error as clrError } from '../colors.js';
7
- import { HOOKS_SETTINGS, setupClaudeHooks } from '../setup.js';
8
- /** Inspect `.claude/settings.json` against the current `HOOKS_SETTINGS`.
9
- * Returns the set of hook-event names that are missing or mismatched. */
10
- function checkCaptureHooks(cwd) {
11
- const path = join(cwd, '.claude', 'settings.json');
12
- if (!existsSync(path))
13
- return { missing: Object.keys(HOOKS_SETTINGS.hooks), ok: false };
14
- let settings;
15
- try {
16
- settings = JSON.parse(readFileSync(path, 'utf-8'));
17
- }
18
- catch {
19
- return { missing: Object.keys(HOOKS_SETTINGS.hooks), ok: false };
20
- }
21
- const actualHooks = settings?.hooks ?? {};
22
- const missing = [];
23
- for (const [event, expectedGroups] of Object.entries(HOOKS_SETTINGS.hooks)) {
24
- const actualGroups = Array.isArray(actualHooks[event]) ? actualHooks[event] : [];
25
- const expectedCmds = new Set(expectedGroups.flatMap(g => (g.hooks ?? []).map((h) => h.command)));
26
- const actualCmds = new Set(actualGroups.flatMap(g => (g.hooks ?? []).map((h) => h.command)));
27
- // Every expected command must be present. Users can add their own.
28
- for (const cmd of expectedCmds) {
29
- if (!actualCmds.has(cmd)) {
30
- missing.push(event);
31
- break;
32
- }
8
+ import { GIPITY_PLUGIN_ID, GIPITY_MARKETPLACE_NAME, setupClaudeHooks, ensureGipityPlugin } from '../setup.js';
9
+ /** Hooks ship in the Gipity Claude Code plugin now - "installed" means the
10
+ * user-scope settings register the marketplace and enable the plugin.
11
+ * Claude Code fetches/updates the plugin itself at launch. */
12
+ function checkGipityPlugin() {
13
+ const path = join(homedir(), '.claude', 'settings.json');
14
+ let settings = {};
15
+ if (existsSync(path)) {
16
+ try {
17
+ settings = JSON.parse(readFileSync(path, 'utf-8'));
33
18
  }
19
+ catch { /* treat as empty */ }
34
20
  }
21
+ const missing = [];
22
+ if (!settings?.extraKnownMarketplaces?.[GIPITY_MARKETPLACE_NAME])
23
+ missing.push('marketplace');
24
+ if (settings?.enabledPlugins?.[GIPITY_PLUGIN_ID] !== true)
25
+ missing.push('plugin');
35
26
  return { missing, ok: missing.length === 0 };
36
27
  }
37
28
  export const statusCommand = new Command('status')
38
29
  .description('Show project and login status')
39
30
  .option('--json', 'Output as JSON')
40
- .option('--repair-hooks', 'Reinstall the capture hooks in .claude/settings.json if missing')
31
+ .option('--repair-hooks', 'Re-enable the Gipity Claude Code plugin (hooks) if missing or disabled')
41
32
  .action(async (opts) => {
42
33
  const config = getConfig();
43
34
  const auth = getAuth();
44
35
  const cwd = resolve(process.cwd());
45
- const hookCheck = config ? checkCaptureHooks(cwd) : null;
36
+ void cwd;
37
+ const hookCheck = config ? checkGipityPlugin() : null;
46
38
  if (opts.json) {
47
39
  console.log(JSON.stringify({
48
40
  project: config ? {
@@ -57,7 +49,7 @@ export const statusCommand = new Command('status')
57
49
  expiresAt: auth.expiresAt,
58
50
  valid: new Date(auth.expiresAt).getTime() > Date.now(),
59
51
  } : null,
60
- capture_hooks: hookCheck,
52
+ plugin: hookCheck,
61
53
  }, null, 2));
62
54
  return;
63
55
  }
@@ -80,16 +72,18 @@ export const statusCommand = new Command('status')
80
72
  }
81
73
  if (hookCheck) {
82
74
  if (hookCheck.ok) {
83
- console.log(`${muted('Hooks:')} ${success('capture hooks installed')}`);
75
+ console.log(`${muted('Hooks:')} ${success(`Gipity plugin enabled (${GIPITY_PLUGIN_ID})`)}`);
84
76
  }
85
77
  else if (opts.repairHooks) {
78
+ // force: an explicit repair request overrides a previous disable.
79
+ ensureGipityPlugin(true);
86
80
  setupClaudeHooks();
87
- console.log(`${muted('Hooks:')} ${success('repaired - re-installed capture hooks')}`);
81
+ console.log(`${muted('Hooks:')} ${success('repaired - Gipity plugin re-enabled')}`);
88
82
  }
89
83
  else {
90
- console.log(`${muted('Hooks:')} ${warning(`missing/modified: ${hookCheck.missing.join(', ')}`)}`);
91
- console.log(muted('Run `gipity status --repair-hooks` to re-install.'));
92
- console.log(muted('Without these, web CLI dispatches can\'t show Claude Code output.'));
84
+ console.log(`${muted('Hooks:')} ${warning(`Gipity plugin not enabled (missing: ${hookCheck.missing.join(', ')})`)}`);
85
+ console.log(muted('Run `gipity status --repair-hooks` to re-enable.'));
86
+ console.log(muted('Without it, files don\'t auto-sync and web CLI dispatches can\'t show Claude Code output.'));
93
87
  }
94
88
  }
95
89
  });
@@ -3,7 +3,7 @@ import { sync } from '../sync.js';
3
3
  import { createProgressReporter } from '../progress.js';
4
4
  import { error as clrError } from '../colors.js';
5
5
  export const syncCommand = new Command('sync')
6
- .description('Sync files')
6
+ .description('Sync files (a .gipityignore at the project root excludes paths, gitignore-style)')
7
7
  .option('--plan', 'Print the plan without applying any changes')
8
8
  .option('--force', 'Bypass the bulk-deletion guard')
9
9
  .option('--json', 'Output as JSON')
@@ -12,6 +12,8 @@ function statusIcon(status) {
12
12
  return muted('→');
13
13
  return muted('?');
14
14
  }
15
+ // Long-run hint for non-TTY runs: after this, print a one-time "not hung" + faster-path hint.
16
+ const LONG_RUN_MS = 60000;
15
17
  async function pollTestStatus(projectGuid, runGuid, opts) {
16
18
  // Adaptive polling: fast at first (tests often finish quickly with warm pool),
17
19
  // then back off for long-running suites.
@@ -24,7 +26,21 @@ async function pollTestStatus(projectGuid, runGuid, opts) {
24
26
  return 1000; // next 15s: poll every 1s
25
27
  return 3000; // after 20s: poll every 3s
26
28
  };
29
+ // Heartbeat cadence for non-TTY runs (background/piped output, e.g. a watching
30
+ // agent): every line is re-read by the watcher, and on long LLM-backed suites
31
+ // nothing changes second-to-second — so back off like getPollInterval does.
32
+ const getHeartbeatInterval = () => {
33
+ const elapsed = Date.now() - startTime;
34
+ if (elapsed < 120000)
35
+ return 10000; // first 2 min: every 10s
36
+ if (elapsed < 300000)
37
+ return 30000; // to 5 min: every 30s
38
+ return 60000; // after 5 min: every 60s
39
+ };
27
40
  let lastResultCount = 0;
41
+ const isTTY = !!process.stdout.isTTY;
42
+ let lastHeartbeat = 0; // 0 => emit the first heartbeat immediately (non-TTY)
43
+ let longRunHintShown = false;
28
44
  while (true) {
29
45
  const res = await get(`/projects/${projectGuid}/test/status/${runGuid}`);
30
46
  const data = res.data;
@@ -61,14 +77,38 @@ async function pollTestStatus(projectGuid, runGuid, opts) {
61
77
  if (data.status !== 'running') {
62
78
  return data;
63
79
  }
64
- // Show progress indicator (overwrite in-place) - only in real terminals
65
- if (!opts.json && process.stdout.isTTY) {
66
- if (data.totalFiles === 0) {
67
- process.stdout.write(`\r ${muted('Starting...')} `);
80
+ if (!opts.json) {
81
+ if (isTTY) {
82
+ // Real terminal: overwrite a single in-place progress line.
83
+ if (data.totalFiles === 0) {
84
+ process.stdout.write(`\r ${muted('Starting...')} `);
85
+ }
86
+ else {
87
+ const pct = Math.round((data.completedFiles / data.totalFiles) * 100);
88
+ process.stdout.write(`\r ${muted(`${data.completedFiles}/${data.totalFiles} files (${pct}%)`)}`);
89
+ }
68
90
  }
69
91
  else {
70
- const pct = Math.round((data.completedFiles / data.totalFiles) * 100);
71
- process.stdout.write(`\r ${muted(`${data.completedFiles}/${data.totalFiles} files (${pct}%)`)}`);
92
+ // Non-TTY (background/piped, e.g. a watching agent): a \r line never
93
+ // shows up in a captured file, so emit periodic newline heartbeats.
94
+ // Time-based so the file always grows — lets a watcher tell a slow run
95
+ // from a hung one, and gives elapsed time to budget against.
96
+ const now = Date.now();
97
+ if (now - lastHeartbeat >= getHeartbeatInterval()) {
98
+ lastHeartbeat = now;
99
+ const elapsed = Math.round((now - startTime) / 1000);
100
+ const progress = data.totalFiles === 0
101
+ ? 'starting up'
102
+ : `${data.completedFiles}/${data.totalFiles} files`;
103
+ const tally = data.passed + data.failed > 0
104
+ ? ` (${data.passed} passed${data.failed > 0 ? `, ${data.failed} failed` : ''})`
105
+ : '';
106
+ console.log(muted(` … still running — ${progress}${tally}, ${elapsed}s elapsed`));
107
+ if (now - startTime >= LONG_RUN_MS && !longRunHintShown) {
108
+ longRunHintShown = true;
109
+ console.log(muted(' Note: progressing, not hung. LLM-backed tests can take minutes. To verify one function fast, use `gipity fn call <name>`; narrow this suite with `gipity test <path>`.'));
110
+ }
111
+ }
72
112
  }
73
113
  }
74
114
  await new Promise(resolve => setTimeout(resolve, getPollInterval()));
@@ -78,13 +118,15 @@ async function pollTestStatus(projectGuid, runGuid, opts) {
78
118
  export const testCommand = new Command('test')
79
119
  .description('Run tests')
80
120
  .argument('[path]', 'Test path filter (e.g. "api", "e2e/portal")')
121
+ .option('--filter <path>', 'Alias for the positional test path filter')
81
122
  .option('--timeout <ms>', 'Per-test timeout in ms', '30000')
82
123
  .option('--retry <n>', 'Retry failed tests N times', '0')
83
124
  .option('--no-sync', 'Skip sync-up before tests')
84
125
  .option('--json', 'Output as JSON')
85
- .action((filterPath, opts) => run('Test', async () => {
126
+ .action((pathFilter, opts) => run('Test', async () => {
86
127
  const config = requireConfig();
87
128
  await syncBeforeAction(opts);
129
+ const filterPath = opts.filter || pathFilter;
88
130
  if (!opts.json) {
89
131
  console.log(bold(`Running tests: ${filterPath || 'all'}`));
90
132
  console.log('');
@@ -113,6 +155,10 @@ export const testCommand = new Command('test')
113
155
  // Print any remaining results not yet shown (edge case: final batch)
114
156
  // (pollTestStatus already printed results incrementally)
115
157
  console.log('');
158
+ if (filterPath && data.total === 0 && data.results.length === 0) {
159
+ console.log(clrError(`No tests matched filter: ${filterPath}`));
160
+ process.exit(1);
161
+ }
116
162
  // Summary
117
163
  const parts = [];
118
164
  if (data.passed > 0)
@@ -1,14 +1,15 @@
1
1
  /**
2
2
  * `gipity uninstall` - true reset. Stops the relay daemon, removes the
3
3
  * platform autostart service, revokes the device on the server (best-effort),
4
- * and wipes ~/.gipity/. Never touches ~/GipityProjects/ - your local
5
- * project trees are yours to keep or remove yourself.
4
+ * removes the Gipity Claude Code plugin enablement (which removes all Gipity
5
+ * hooks at once), and wipes ~/.gipity/. Never touches ~/GipityProjects/ -
6
+ * your local project trees are yours to keep or remove yourself.
6
7
  *
7
8
  * Does not touch the npm-installed shim - the user removes that separately
8
9
  * via `npm uninstall -g gipity`.
9
10
  */
10
11
  import { Command } from 'commander';
11
- import { existsSync, rmSync, unlinkSync } from 'fs';
12
+ import { existsSync, rmSync, unlinkSync, readFileSync, writeFileSync } from 'fs';
12
13
  import { homedir } from 'os';
13
14
  import { join, resolve } from 'path';
14
15
  import { spawnSync } from 'child_process';
@@ -18,6 +19,39 @@ import { confirm, getAutoConfirm } from '../utils.js';
18
19
  import { bold, brand, dim, success, error as clrError, muted } from '../colors.js';
19
20
  import * as relayState from '../relay/state.js';
20
21
  import { planFor, UnsupportedPlatformError } from '../relay/installers.js';
22
+ import { GIPITY_PLUGIN_ID, GIPITY_MARKETPLACE_NAME, stripGipityHooks } from '../setup.js';
23
+ /** Remove Gipity's entries from the user-scope Claude Code settings: the
24
+ * plugin enablement, the marketplace registration, and any legacy hook
25
+ * blocks older CLI versions wrote there. Surgical - everything else in the
26
+ * file (the user's own permissions, hooks, other plugins) is untouched. */
27
+ function removeGipityPluginConfig() {
28
+ const settingsPath = join(homedir(), '.claude', 'settings.json');
29
+ if (!existsSync(settingsPath))
30
+ return false;
31
+ let settings;
32
+ try {
33
+ settings = JSON.parse(readFileSync(settingsPath, 'utf-8'));
34
+ }
35
+ catch {
36
+ return false;
37
+ }
38
+ let changed = stripGipityHooks(settings);
39
+ if (settings.enabledPlugins && GIPITY_PLUGIN_ID in settings.enabledPlugins) {
40
+ delete settings.enabledPlugins[GIPITY_PLUGIN_ID];
41
+ if (Object.keys(settings.enabledPlugins).length === 0)
42
+ delete settings.enabledPlugins;
43
+ changed = true;
44
+ }
45
+ if (settings.extraKnownMarketplaces?.[GIPITY_MARKETPLACE_NAME]) {
46
+ delete settings.extraKnownMarketplaces[GIPITY_MARKETPLACE_NAME];
47
+ if (Object.keys(settings.extraKnownMarketplaces).length === 0)
48
+ delete settings.extraKnownMarketplaces;
49
+ changed = true;
50
+ }
51
+ if (changed)
52
+ writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n');
53
+ return changed;
54
+ }
21
55
  function resolveCliPath() {
22
56
  return resolve(process.argv[1] ?? 'gipity');
23
57
  }
@@ -99,6 +133,7 @@ export const uninstallCommand = new Command('uninstall')
99
133
  console.log(`• Stop the running relay daemon (if any)`);
100
134
  console.log(`• Remove the OS autostart service (launchd / systemd / Task Scheduler)`);
101
135
  console.log(`• Revoke this device on the server (best-effort)`);
136
+ console.log(`• Remove the Gipity Claude Code plugin enablement (all Gipity hooks)`);
102
137
  console.log(`• Delete ${gipityDir}/`);
103
138
  console.log('');
104
139
  console.log(`${dim('It will NOT remove the `gipity` binary. Run `npm uninstall -g gipity` afterward if you want that too.')}`);
@@ -124,7 +159,14 @@ export const uninstallCommand = new Command('uninstall')
124
159
  // 3. Revoke device on server.
125
160
  await revokeDeviceBestEffort();
126
161
  console.log(`${success('Device revoked on server (or was already revoked).')}`);
127
- // 4. Wipe ~/.gipity/.
162
+ // 4. Remove the Claude Code plugin enablement + any legacy hook blocks.
163
+ if (removeGipityPluginConfig()) {
164
+ console.log(`${success('Gipity Claude Code plugin disabled (hooks removed).')}`);
165
+ }
166
+ else {
167
+ console.log(`${muted('No Gipity entries in Claude Code settings.')}`);
168
+ }
169
+ // 5. Wipe ~/.gipity/.
128
170
  if (existsSync(gipityDir)) {
129
171
  try {
130
172
  rmSync(gipityDir, { recursive: true, force: true });
@@ -6,8 +6,10 @@
6
6
  * session into the Gipity server so the web CLI can display it read-only.
7
7
  *
8
8
  * Not a user-facing `gipity` subcommand by design: users never invoke
9
- * this directly. `setupClaudeHooks` wires up hook entries that call
10
- * `node <absolute-path>/capture-runner.js <source> <event>`.
9
+ * this directly. The Gipity Claude Code plugin's capture hook script
10
+ * (claude-plugin hooks/scripts/capture.cjs) resolves this file inside the
11
+ * installed CLI at fire time and runs it - so the capture logic versions
12
+ * with the CLI, not the plugin.
11
13
  *
12
14
  * Usage:
13
15
  * node capture-runner.js <source> <event>
package/dist/knowledge.js CHANGED
@@ -14,6 +14,7 @@ Templates:
14
14
  - \`web-simple\` - Landing page, dashboard, calculator, canvas demo, visualization, animation, single-page tool
15
15
  - \`web-fullstack\` - Web app with login, database, or API - CRM, invoice tracker, booking system, admin panel
16
16
  - \`web-vision-cam\` - Live camera app with gesture, pose, or object detection - hand-tracking input, fitness/pose feedback, object-aware UI; on-device, no upload
17
+ - \`object-spotter\` - Object-detection app - count or label things from the camera or a photo, inventory/shelf counting, custom-model detection; on-device, no upload
17
18
  - \`2d-game\` - Platformer, arcade, puzzle, endless runner, physics toy (Phaser 3)
18
19
  - \`3d-engine\` - Minimal 3D multiplayer base - Three.js + Rapier + Colyseus, no gameplay; build your own scene and mechanics on top
19
20
  - \`3d-world\` - Multiplayer world, 3D sandbox, shooter, exploration, virtual showroom (Three.js + Rapier + Colyseus)
@@ -29,9 +30,13 @@ Hidden types (do NOT suggest unsolicited - use only when the user explicitly ask
29
30
  Kits are reusable building blocks added to an existing app, not whole templates - their files land in \`src/packages/<name>/\`:
30
31
  - \`gipity add realtime\` - Multiplayer / presence / shared state - channels, host election, server-persisted sync. Engine-agnostic; works in any app.
31
32
  - \`gipity add web-vision-mediapipe\` - On-device camera vision - gesture recognition, body pose, object detection. Runs fully client-side via MediaPipe Tasks; no server, no upload. Web only.
33
+ - \`gipity add web-vision-detect\` - High-accuracy object detection in the browser - YOLOX (Apache-2.0) on ONNX Runtime Web, WebGPU with WASM fallback. 80 COCO classes, three speed/accuracy presets, or bring your own custom-trained YOLO/YOLOX ONNX model. Client-side; no server, no upload. Web only.
32
34
  - \`gipity add chatbot\` - Drop-in chatbot - configurable persona, scope guardrails, static knowledge (20k budget), streaming responses. Headless engine + bubble widget; bring your own UI if you want. Works in any app.
33
35
  - \`gipity add audio-align\` - Audio + lyrics -> word-level timing JSON. Demucs vocal isolation + MMS_FA forced alignment, runs as a Modal L4 GPU job (~$0.01 per 3-min song). For karaoke captions, subtitling, language learning, dubbing alignment.
34
- - \`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.`;
36
+ - \`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
+ - \`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
+ - \`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.`;
35
40
  export const SKILLS_CONTENT = `# Gipity Integration
36
41
 
37
42
  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.
@@ -40,15 +45,19 @@ Prefer the cheapest option that works - CLI and sandbox are instant and free, ap
40
45
 
41
46
  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\`.
42
47
  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.
43
- 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\`.
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.
44
49
  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.
45
50
 
46
- You are the developer. Write files in this directory - they auto-sync to Gipity via hooks. Don't run \`npm install\`, \`npm start\`, \`node\`, or \`python\` locally; there is no local runtime. Code runs in the Gipity sandbox.
51
+ 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.
47
52
 
48
53
  ## Use first-party services before reaching outside
49
54
 
50
55
  Gipity ships first-party services for what apps usually pull from third parties - auth, location/geocoding, LLM, image/audio/video generation, transcription, file uploads, realtime. Before calling an external API or adding an npm package for one of these, check \`gipity skill list\` for a match. First-party services need no API keys, cost less, and keep data in-house. Reach outside only when the catalog has no equivalent - and say so when you do.
51
56
 
57
+ ## Don't guess Gipity facts - look them up
58
+
59
+ When a user asks about Gipity itself - how to install it, what it costs, what's shipped, how a command or flag works - answer from an authoritative source, not memory, and don't hedge with "I don't want to guess." Check in this order: (1) \`gipity skill read <name>\` / \`gipity skill list\` - install and account basics live in \`getting-started\`; (2) \`gipity <command> --help\` for command syntax; (3) if it's genuinely not in the skills or CLI help, fetch the live site at \`https://gipity.ai\` (or web search) and cite what you found. A wrong or vague answer about the product is worse than spending one tool call to get the current, correct one.
60
+
52
61
  ## Gipity is opinionated - build on its stack
53
62
 
54
63
  Gipity is an opinionated platform with its own best-practice stack, and that stack is the one you use - whatever tools the user names. The platform layer is fixed:
@@ -84,7 +93,9 @@ Every tool call returns its full output with that call. There is no output buffe
84
93
 
85
94
  ## Files and sync
86
95
 
87
- Write files locally - hooks auto-push to Gipity on every save. Remote-generated files (images, audio from \`gipity chat\`) auto-pull. Use \`gipity sync\` if things get out of sync. Deletes are safe - use \`rollback\` with a datetime to undo, or \`file_version_restore\` for individual files.
96
+ Write files locally - the Gipity Claude Code plugin's hooks auto-push every save to Gipity and auto-pull remote changes (images, audio from \`gipity chat\`) before each turn. Use \`gipity sync\` if things get out of sync (or if the plugin isn't installed - \`gipity status --repair-hooks\` re-enables it). Deletes are safe - use \`rollback\` with a datetime to undo, or \`file_version_restore\` for individual files.
97
+
98
+ 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.
88
99
 
89
100
  ## Skills (detailed documentation)
90
101
 
package/dist/prompts.js CHANGED
@@ -162,13 +162,6 @@ export function buildFreshWrap(contextBlock, userMsg) {
162
162
  ].join('\n');
163
163
  }
164
164
  // ---------------------------------------------------------------------------
165
- // PreToolUse soft-warning hook (advisory message printed to stderr)
166
- // ---------------------------------------------------------------------------
167
- /** Plain ASCII, no apostrophes or backslashes - embedded inside a node -e shell command. */
168
- export const SCAFFOLD_HOOK_WARNING = `[gipity] Heads up: this project has no app yet. If you are building an app/game/API to deploy, ` +
169
- `stop and run: gipity add <${TEMPLATE_KEY_PATTERN}> (default: web-simple). ` +
170
- `If this is a one-off task (analysis, data, PDFs, scratch work), proceed.`;
171
- // ---------------------------------------------------------------------------
172
165
  // Per-project CLAUDE.md / AGENTS.md body.
173
166
  //
174
167
  // The content (`SKILLS_CONTENT`) is sourced from
@@ -21,7 +21,7 @@
21
21
  * See docs/feature-backlog/gipity-relay-phases.md (Phase A Step 7).
22
22
  */
23
23
  import { spawn } from 'child_process';
24
- import { appendFileSync, mkdirSync, existsSync, readFileSync, writeFileSync, chmodSync, closeSync, openSync } from 'fs';
24
+ import { appendFileSync, mkdirSync, existsSync, readFileSync, writeFileSync, chmodSync, closeSync, openSync, unlinkSync } from 'fs';
25
25
  import { stat, readFile } from 'fs/promises';
26
26
  import { createInterface } from 'readline';
27
27
  import { homedir, hostname, platform as osPlatform } from 'os';
@@ -29,7 +29,6 @@ import { join } from 'path';
29
29
  import { getApiBaseOverride, getConfig } from '../config.js';
30
30
  import { getProjectsRoot } from './paths.js';
31
31
  import { setupClaudeHooks, setupClaudeMd, setupAgentsMd, setupGitignore, DEFAULT_SYNC_IGNORE } from '../setup.js';
32
- import { sync } from '../sync.js';
33
32
  import { getAuth, readAuthFresh } from '../auth.js';
34
33
  import { post } from '../api.js';
35
34
  import * as state from './state.js';
@@ -52,6 +51,9 @@ const BACKOFF_BASE_MS = parseInt(process.env.GIPITY_RELAY_BACKOFF_BASE_MS || '10
52
51
  const BACKOFF_MAX_MS = parseInt(process.env.GIPITY_RELAY_BACKOFF_MAX_MS || '30000', 10);
53
52
  const CANCEL_POLL_INTERVAL_MS = parseInt(process.env.GIPITY_RELAY_CANCEL_POLL_MS || '3000', 10);
54
53
  const MAX_CONCURRENT_DISPATCHES = Math.max(1, parseInt(process.env.GIPITY_RELAY_MAX_CONCURRENT || '6', 10));
54
+ // Cap how long the pre-Claude project sync (and the post-dispatch push-back) may
55
+ // run before we kill it - a stalled sync must never hang a dispatch forever.
56
+ const PROJECT_SYNC_TIMEOUT_MS = parseInt(process.env.GIPITY_RELAY_SYNC_TIMEOUT_MS || '120000', 10);
55
57
  // ─── HTTP helpers ──────────────────────────────────────────────────────
56
58
  // Device-auth fetch lives in ./device-http.ts - shared with the capture
57
59
  // hook runner so both POST to /remote-sessions/:convGuid/ingest with the
@@ -214,13 +216,13 @@ export async function run(opts = {}) {
214
216
  process.on('SIGTERM', () => shutdown('SIGTERM'));
215
217
  if (opts.maxRunMs)
216
218
  setTimeout(() => shutdown('maxRunMs'), opts.maxRunMs).unref();
217
- // Take the PID lock. If another daemon already holds it, exit clean -
218
- // the caller (usually `gipity claude`'s auto-start) is racing us.
219
- try {
220
- state.writeDaemonPid(process.pid);
221
- }
222
- catch (err) {
223
- log('info', 'another daemon is already running - exiting', { err: err?.message });
219
+ // Take the PID lock. Only a *live* daemon should block us: a leftover pid file
220
+ // from an unclean exit (container SIGKILL'd, or `--restart` brought us back on
221
+ // the same filesystem) must not trap us in a permanent restart loop.
222
+ // isDaemonRunning() validates the recorded PID is actually alive and clears
223
+ // the file if it's stale, so writeDaemonPid below can then take the lock.
224
+ if (state.isDaemonRunning()) {
225
+ log('info', 'another daemon is already running - exiting');
224
226
  if (opts.verbose) {
225
227
  process.stderr.write('Another relay daemon is already running (likely the autostarted one).\n' +
226
228
  'Stop it first, then retry: gipity relay autostart uninstall (or stop the service),\n' +
@@ -228,6 +230,14 @@ export async function run(opts = {}) {
228
230
  }
229
231
  return 0;
230
232
  }
233
+ try {
234
+ state.writeDaemonPid(process.pid);
235
+ }
236
+ catch (err) {
237
+ // Lost a genuine race with another daemon starting at the same instant.
238
+ log('info', 'lost pid-lock race with another daemon - exiting', { err: err?.message });
239
+ return 0;
240
+ }
231
241
  const releasePid = () => state.clearDaemonPid();
232
242
  process.on('exit', releasePid);
233
243
  // Also release on our shutdown signals (exit handler sometimes doesn't fire).
@@ -582,15 +592,35 @@ async function handleDispatch(d) {
582
592
  // serialization point that prevents that.
583
593
  await killRunningForConv(d.conversation_guid);
584
594
  let cwd;
595
+ let bootstrapped;
585
596
  try {
586
- cwd = await resolveCwdForProject(d);
587
- log('debug', 'resolved project cwd', { id: d.short_guid, project: d.project_slug, cwd });
597
+ ({ cwd, bootstrapped } = await resolveCwdForProject(d));
598
+ log('debug', 'resolved project cwd', { id: d.short_guid, project: d.project_slug, cwd, bootstrapped });
588
599
  }
589
600
  catch (err) {
590
601
  log('error', 'could not resolve project cwd', { id: d.short_guid, err: err?.message });
591
602
  await ack(d.short_guid, 'error', `Could not materialize project locally: ${err?.message || err}`);
592
603
  return;
593
604
  }
605
+ // Explicit, user-visible, timeout-bounded project sync BEFORE starting Claude -
606
+ // only on a freshly bootstrapped dir (the files aren't there yet). Pull the
607
+ // project's files first so Claude works against the real tree; a hung/slow sync
608
+ // is killed at PROJECT_SYNC_TIMEOUT_MS and reported instead of silently stalling
609
+ // the dispatch (the old in-process bootstrap sync could hang forever).
610
+ if (bootstrapped) {
611
+ await postIngest(d.conversation_guid, [{ kind: 'system', content: 'Syncing project files…' }]);
612
+ try {
613
+ await spawnSync(cwd, PROJECT_SYNC_TIMEOUT_MS);
614
+ await postIngest(d.conversation_guid, [{ kind: 'system', content: 'Project files synced.' }]);
615
+ }
616
+ catch (err) {
617
+ const msg = `project sync ${err?.message || 'failed'}`;
618
+ log('error', 'project sync failed - aborting dispatch', { id: d.short_guid, err: err?.message });
619
+ await postIngest(d.conversation_guid, [{ kind: 'system', content: `Claude Code not started - ${msg}` }]);
620
+ await ack(d.short_guid, 'error', msg);
621
+ return;
622
+ }
623
+ }
594
624
  // Build argv for `gipity claude -p …` (or with --resume). No shell - argv
595
625
  // as array so the message string can't be interpreted as shell syntax.
596
626
  //
@@ -690,7 +720,7 @@ async function handleDispatch(d) {
690
720
  // this sync redundant for that case.
691
721
  if (!spawnErr) {
692
722
  try {
693
- await spawnSync(cwd);
723
+ await spawnSync(cwd, PROJECT_SYNC_TIMEOUT_MS);
694
724
  }
695
725
  catch (err) {
696
726
  log('warn', 'sync after dispatch failed', { id: d.short_guid, err: err?.message });
@@ -742,11 +772,11 @@ async function resolveCwdForProject(d) {
742
772
  try {
743
773
  const cfg = JSON.parse(readFileSync(configPath, 'utf-8'));
744
774
  if (cfg.projectGuid === d.project_guid)
745
- return path;
775
+ return { cwd: path, bootstrapped: false };
746
776
  log('warn', 'project dir exists but guid mismatch - using it anyway', {
747
777
  path, expected: d.project_guid, found: cfg.projectGuid,
748
778
  });
749
- return path;
779
+ return { cwd: path, bootstrapped: false };
750
780
  }
751
781
  catch { /* fall through to re-bootstrap */ }
752
782
  }
@@ -771,17 +801,14 @@ async function resolveCwdForProject(d) {
771
801
  setupClaudeMd();
772
802
  setupAgentsMd();
773
803
  setupGitignore();
774
- try {
775
- await sync({ interactive: false });
776
- }
777
- catch (err) {
778
- log('warn', 'initial sync failed; project dir created but empty', { err: err?.message });
779
- }
780
804
  }
781
805
  finally {
782
806
  process.chdir(origCwd);
783
807
  }
784
- return path;
808
+ // Sync is NOT done here: the dispatch handler runs it as an explicit, visible,
809
+ // timeout-bounded step (a blocking in-process sync here used to hang the whole
810
+ // dispatch with no timeout and no user feedback).
811
+ return { cwd: path, bootstrapped: true };
785
812
  }
786
813
  const running = new Map();
787
814
  export function getRunningDispatchGuids() {
@@ -821,7 +848,7 @@ export async function killRunningForConv(convGuid) {
821
848
  * back to VFS. Runs as a child so we inherit sync's cwd-walk for config
822
849
  * resolution (the daemon itself doesn't chdir into projects).
823
850
  * Non-blocking on failure - caller catches and logs. */
824
- async function spawnSync(cwd) {
851
+ async function spawnSync(cwd, timeoutMs) {
825
852
  const cmd = process.env.GIPITY_RELAY_CLAUDE_CMD || 'gipity';
826
853
  return new Promise((resolve, reject) => {
827
854
  const child = spawn(cmd, ['sync', '--json'], {
@@ -832,18 +859,39 @@ async function spawnSync(cwd) {
832
859
  // Drain pipes so the child doesn't stall on a full buffer.
833
860
  let stdoutLen = 0;
834
861
  let stderrBuf = '';
862
+ let settled = false;
863
+ let timer = null;
864
+ const finish = (fn) => { if (settled)
865
+ return; settled = true; if (timer)
866
+ clearTimeout(timer); fn(); };
867
+ // A sync that never returns must not hang the dispatch forever. Kill the child
868
+ // and clear its sync.lock (a SIGKILL'd `gipity sync` leaves the lock behind,
869
+ // which would make the next sync wait 30s on a dead holder).
870
+ if (timeoutMs) {
871
+ timer = setTimeout(() => {
872
+ try {
873
+ child.kill('SIGKILL');
874
+ }
875
+ catch { /* gone */ }
876
+ try {
877
+ unlinkSync(join(cwd, '.gipity', 'sync.lock'));
878
+ }
879
+ catch { /* not there */ }
880
+ finish(() => reject(new Error(`timed out after ${Math.round(timeoutMs / 1000)}s`)));
881
+ }, timeoutMs);
882
+ }
835
883
  child.stdout?.on('data', (b) => { stdoutLen += b.length; });
836
884
  child.stderr?.on('data', (b) => { stderrBuf += b.toString('utf-8'); });
837
- child.on('error', (err) => reject(err));
838
- child.on('exit', (code) => {
885
+ child.on('error', (err) => finish(() => reject(err)));
886
+ child.on('exit', (code) => finish(() => {
839
887
  if (code === 0) {
840
- log('info', 'sync after dispatch', { cwd, stdoutLen });
888
+ log('info', 'sync done', { cwd, stdoutLen });
841
889
  resolve();
842
890
  }
843
891
  else {
844
892
  reject(new Error(`gipity sync exited ${code}${stderrBuf ? `: ${stderrBuf.trim().slice(0, 300)}` : ''}`));
845
893
  }
846
- });
894
+ }));
847
895
  });
848
896
  }
849
897
  export async function spawnGipityClaude(args, cwd, d) {
@@ -30,15 +30,23 @@ const MIN_SECRET_LEN = 12;
30
30
  * appearing in a relay transcript is effectively always a credential, so
31
31
  * over-redaction risk is negligible. */
32
32
  const JWT_RE = /eyJ[A-Za-z0-9_-]+\.eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/g;
33
- /** Replace every occurrence of each known secret - and any JWT-shaped
34
- * substring - in `text` with the marker. */
33
+ /** Anthropic credential tokens: API keys (`sk-ant-api…`) and Claude Code OAuth
34
+ * tokens (`sk-ant-oat…`). These are NOT JWT-shaped, so the JWT backstop misses
35
+ * them. On a bring-your-own-key relay the user's own API key lives in the
36
+ * container env, and a `bypassPermissions` session could otherwise echo it
37
+ * (`env`, `cat`) into the transcript — so pattern-redact it regardless of the
38
+ * literal-secret list. An `sk-ant-` token in a relay transcript is always a
39
+ * credential, so over-redaction risk is negligible. */
40
+ const ANTHROPIC_KEY_RE = /sk-ant-[A-Za-z0-9_-]{20,}/g;
41
+ /** Replace every occurrence of each known secret - and any JWT- or
42
+ * Anthropic-key-shaped substring - in `text` with the marker. */
35
43
  function redactString(text, secrets) {
36
44
  let out = text;
37
45
  for (const secret of secrets) {
38
46
  if (out.includes(secret))
39
47
  out = out.split(secret).join(REDACTION_MARKER);
40
48
  }
41
- return out.replace(JWT_RE, REDACTION_MARKER);
49
+ return out.replace(JWT_RE, REDACTION_MARKER).replace(ANTHROPIC_KEY_RE, REDACTION_MARKER);
42
50
  }
43
51
  /** Deep-walk any JSON-ish value, redacting every string. Returns a new
44
52
  * value; objects/arrays are cloned, primitives passed through. */
@@ -116,8 +116,26 @@ export function isDaemonRunning() {
116
116
  try {
117
117
  const raw = readFileSync(RELAY_PID_FILE, 'utf-8').trim();
118
118
  const pid = parseInt(raw, 10);
119
- if (!pid || isNaN(pid))
119
+ // A corrupt/empty pid file is stale - clear it so it can't trap a restart.
120
+ if (!pid || isNaN(pid)) {
121
+ try {
122
+ unlinkSync(RELAY_PID_FILE);
123
+ }
124
+ catch { /* ignore */ }
120
125
  return false;
126
+ }
127
+ // Our OWN pid in the file = stale from a previous incarnation, NOT a live peer.
128
+ // In a container the daemon is always pid 1, so `--restart` brings us back as
129
+ // pid 1 with the dead run's relay.pid (also 1) left behind; process.kill(1,0)
130
+ // would say "alive" (it's us) and trap us in a permanent restart loop. We write
131
+ // our pid only AFTER this check, so finding it here means the file predates us.
132
+ if (pid === process.pid) {
133
+ try {
134
+ unlinkSync(RELAY_PID_FILE);
135
+ }
136
+ catch { /* ignore */ }
137
+ return false;
138
+ }
121
139
  // `kill 0` sends no signal but checks if the PID is addressable.
122
140
  process.kill(pid, 0);
123
141
  return true;
package/dist/setup.js CHANGED
@@ -2,11 +2,9 @@
2
2
  * Shared project setup helpers used by both `init` and `claude`.
3
3
  */
4
4
  import { resolve, join, dirname } from 'path';
5
- import { fileURLToPath } from 'url';
5
+ import { homedir } from 'os';
6
6
  import { existsSync, mkdirSync, writeFileSync, readFileSync } from 'fs';
7
- import { SCAFFOLD_HOOK_WARNING } from './prompts.js';
8
7
  import { SKILLS_CONTENT, BUILD_VS_NON_BUILD_RULE, DEFINITION_OF_DONE } from './knowledge.js';
9
- import { getConfig } from './config.js';
10
8
  export { SKILLS_CONTENT };
11
9
  /** Canonical list of workstation artifacts that are NOT part of the project.
12
10
  * Used as the single source of truth for three separate decisions:
@@ -91,116 +89,122 @@ export const PERMISSIONS_SETTINGS = {
91
89
  ],
92
90
  },
93
91
  };
94
- /** Absolute path to the bundled capture-runner script. Resolved once at
95
- * install time so hook commands embed a stable `node <path>` command
96
- * rather than `gipity …` (which would require re-running `gipity` every
97
- * hook fire). Works whether the CLI is installed globally, linked, or
98
- * run from a build output - `import.meta.url` always points at this
99
- * file under `dist/`, and the runner sits at `dist/hooks/`. */
100
- export function resolveCaptureRunnerPath() {
101
- const here = dirname(fileURLToPath(import.meta.url));
102
- return resolve(here, 'hooks', 'capture-runner.js');
92
+ // Hooks now ship in the Gipity Claude Code plugin (GipityAI/claude-plugin,
93
+ // which doubles as its own marketplace): file sync (push on edit, pull on
94
+ // prompt) and `gipity claude` session capture, every script guarded to no-op
95
+ // outside Gipity projects. Past CLI versions wrote these hook blocks directly
96
+ // into each project's .claude/settings.json with absolute paths baked in -
97
+ // that left orphaned entries behind on uninstall (the CLI keeps no inventory
98
+ // of projects it touched) and could even land in the user-global settings
99
+ // when a gipity command ran from $HOME. The plugin replaces all of it with
100
+ // one declarative enablement entry: Claude Code resolves script paths via
101
+ // ${CLAUDE_PLUGIN_ROOT}, fetches the marketplace non-interactively at launch
102
+ // (headless included), and uninstall/disable removes every hook at once.
103
+ export const GIPITY_PLUGIN_ID = 'gipity@gipity';
104
+ export const GIPITY_MARKETPLACE_NAME = 'gipity';
105
+ export const GIPITY_MARKETPLACE_REPO = 'GipityAI/claude-plugin';
106
+ /** True for hook commands the CLI itself wrote into settings.json in past
107
+ * versions. Matched by signature so migration strips exactly our own
108
+ * entries and never touches user-authored hooks. */
109
+ export function isGipityManagedHookCommand(command) {
110
+ return (
111
+ // Capture hooks: bare absolute runner path or the fire-time launcher.
112
+ command.includes('capture-runner.js') ||
113
+ // File-sync push one-liner (spawn('gipity',['push',p,'--quiet'],...)).
114
+ command.includes("'gipity',['push'") ||
115
+ // Pull-on-prompt one-liner, current and older variants.
116
+ command.includes('gipity sync --json') ||
117
+ command.includes('gipity sync down --json') ||
118
+ // Scaffold nudge (retired entirely - CLAUDE.md carries the rule).
119
+ command.includes("['gipity.yaml','src','functions','package.json']"));
103
120
  }
104
- // Cross-platform hooks using node -e (no bash/jq dependency).
105
- //
106
- // Two categories:
107
- // 1. File-sync hooks (PreToolUse / PostToolUse / UserPromptSubmit):
108
- // installed unconditionally. Scaffold reminder + push/pull. Not
109
- // related to conversation capture.
110
- // 2. Capture hooks (SessionStart / Stop / SubagentStop / SessionEnd, plus
111
- // a throttled PostToolUse for mid-run flushing): mirror a terminal
112
- // Claude Code session into the Gipity DB so the web CLI can display it
113
- // read-only. The PostToolUse capture entry is merged alongside the
114
- // file-sync one (see setupClaudeHooks). Toggled by the `captureHooks`
115
- // field in `.gipity.json` - default on.
116
- export const HOOKS_SETTINGS = {
117
- hooks: {
118
- PreToolUse: [
119
- {
120
- // Soft scaffold reminder. If this is a Gipity project (has
121
- // .gipity.json) AND has no scaffold markers (gipity.yaml, src/,
122
- // functions/, package.json), nudge the agent to scaffold first
123
- // when building an app. Non-blocking - exit 0 always; stderr is
124
- // visible to Claude as an advisory. Auto-quiet once any scaffold
125
- // marker appears, so it doesn't spam during normal editing.
126
- matcher: 'Write|Edit',
127
- hooks: [{
128
- type: 'command',
129
- // Embed warning as a single-quoted JS string (safe: shell double
130
- // quotes survive, and SCAFFOLD_HOOK_WARNING is plain ASCII without
131
- // single quotes or backslashes).
132
- command: `node -e "const fs=require('fs');if(!fs.existsSync('.gipity.json'))process.exit(0);const m=['gipity.yaml','src','functions','package.json'].some(p=>fs.existsSync(p));if(m)process.exit(0);process.stderr.write('${SCAFFOLD_HOOK_WARNING.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}\\n');process.exit(0)"`,
133
- }],
134
- },
135
- ],
136
- PostToolUse: [
137
- {
138
- // File sync for Write/Edit - push any edited file back to the
139
- // cloud workspace so web previews see the change.
140
- matcher: 'Write|Edit',
141
- hooks: [{
142
- type: 'command',
143
- command: `node -e "let d='';process.stdin.on('data',c=>d+=c);process.stdin.on('end',()=>{try{const p=JSON.parse(d).tool_input?.file_path;if(!p||!require('fs').existsSync('.gipity.json'))process.exit(0);require('child_process').spawn('gipity',['push',p,'--quiet'],{stdio:'ignore',detached:true,shell:true}).unref()}catch{}})"`,
144
- }],
145
- },
146
- ],
147
- UserPromptSubmit: [
148
- {
149
- // Pull down any cloud-side file changes before the next turn so
150
- // the agent sees current state.
151
- matcher: '',
152
- hooks: [{
153
- type: 'command',
154
- command: `node -e "if(!require('fs').existsSync('.gipity.json'))process.exit(0);require('child_process').exec('gipity sync --json',(e,o)=>{if(e)process.exit(0);try{const r=JSON.parse(o);if(r.applied>0)console.log(JSON.stringify({systemMessage:'Gipity sync: '+(r.summary||'Files changed.')}))}catch{}})"`,
155
- }],
156
- },
157
- ],
158
- },
159
- };
160
- /** Build the four lifecycle-hook entries that invoke the bundled capture
161
- * runner. Kept as a factory rather than a constant so the absolute
162
- * runner path is resolved at install time, reflecting the CLI's current
163
- * install location. */
164
- function buildCaptureHookEntries(source) {
165
- const runner = resolveCaptureRunnerPath();
166
- const cmd = (event) => `node ${JSON.stringify(runner)} ${source} ${event}`;
167
- return {
168
- SessionStart: [{ hooks: [{ type: 'command', command: cmd('session-start') }] }],
169
- Stop: [{ hooks: [{ type: 'command', command: cmd('stop') }] }],
170
- SubagentStop: [{ hooks: [{ type: 'command', command: cmd('subagent-stop') }] }],
171
- SessionEnd: [{ hooks: [{ type: 'command', command: cmd('session-end') }] }],
172
- // Mid-run incremental flush (matcher '' = every tool). Stop/SessionEnd only
173
- // fire on a CLEAN exit, so without this a session that's killed/crashes mid-run
174
- // (e.g. a long headless build that times out) loses its whole transcript. The
175
- // runner throttles this so it's cheap. Merged alongside the file-sync PostToolUse.
176
- PostToolUse: [{ matcher: '', hooks: [{ type: 'command', command: cmd('post-tool-use') }] }],
177
- };
121
+ /** Remove Gipity-managed hook entries from a parsed settings object,
122
+ * preserving user-authored hooks untouched. Returns true if anything
123
+ * was removed. Exported for tests and uninstall. */
124
+ export function stripGipityHooks(settings) {
125
+ const hooks = settings.hooks;
126
+ if (!hooks || typeof hooks !== 'object')
127
+ return false;
128
+ let changed = false;
129
+ for (const [event, groups] of Object.entries(hooks)) {
130
+ if (!Array.isArray(groups))
131
+ continue;
132
+ const kept = groups
133
+ .map((group) => {
134
+ if (!Array.isArray(group?.hooks))
135
+ return group;
136
+ const remaining = group.hooks.filter((h) => !(typeof h?.command === 'string' && isGipityManagedHookCommand(h.command)));
137
+ if (remaining.length !== group.hooks.length)
138
+ changed = true;
139
+ return { ...group, hooks: remaining };
140
+ })
141
+ .filter((group) => !Array.isArray(group?.hooks) || group.hooks.length > 0);
142
+ if (kept.length === 0)
143
+ delete hooks[event];
144
+ else
145
+ hooks[event] = kept;
146
+ }
147
+ if (Object.keys(hooks).length === 0)
148
+ delete settings.hooks;
149
+ return changed;
178
150
  }
179
- export function setupClaudeHooks() {
180
- const claudeDir = resolve(process.cwd(), '.claude');
181
- mkdirSync(claudeDir, { recursive: true });
151
+ function readSettingsFile(path) {
152
+ if (!existsSync(path))
153
+ return {};
154
+ try {
155
+ return JSON.parse(readFileSync(path, 'utf-8'));
156
+ }
157
+ catch {
158
+ return {}; // corrupted - start fresh rather than crash setup
159
+ }
160
+ }
161
+ /** Ensure the Gipity plugin is enabled at user scope (~/.claude/settings.json)
162
+ * via the documented declarative keys: register the marketplace under
163
+ * `extraKnownMarketplaces` and enable the plugin under `enabledPlugins`.
164
+ * Claude Code fetches both non-interactively at next launch. An explicit
165
+ * user disable (`"gipity@gipity": false`) is respected unless `force` -
166
+ * the user said no, and `gipity status --repair-hooks` is the deliberate
167
+ * way to say yes again. Also strips legacy Gipity hook blocks that older
168
+ * CLI versions left in the user-global settings. */
169
+ export function ensureGipityPlugin(force = false) {
170
+ const claudeDir = join(homedir(), '.claude');
182
171
  const settingsPath = join(claudeDir, 'settings.json');
183
- let settings = {};
184
- if (existsSync(settingsPath)) {
185
- try {
186
- settings = JSON.parse(readFileSync(settingsPath, 'utf-8'));
187
- }
188
- catch {
189
- // corrupted - overwrite
190
- }
172
+ const settings = readSettingsFile(settingsPath);
173
+ let changed = stripGipityHooks(settings);
174
+ const marketplaces = settings.extraKnownMarketplaces ?? (settings.extraKnownMarketplaces = {});
175
+ if (!marketplaces[GIPITY_MARKETPLACE_NAME]) {
176
+ marketplaces[GIPITY_MARKETPLACE_NAME] = {
177
+ source: { source: 'github', repo: GIPITY_MARKETPLACE_REPO },
178
+ };
179
+ changed = true;
191
180
  }
192
- // Merge capture hooks in only when the project opts in (default true).
193
- // `captureHooks === false` in `.gipity.json` disables the mirror-to-web
194
- // feature for this project without affecting the file-sync hooks.
195
- const captureEnabled = getConfig()?.captureHooks !== false;
196
- const captureEntries = captureEnabled ? buildCaptureHookEntries('claude-code') : {};
197
- // Merge per-event arrays rather than spread-overwriting: capture and file-sync
198
- // both register PostToolUse, and a plain spread would drop one of them.
199
- const mergedHooks = { ...HOOKS_SETTINGS.hooks };
200
- for (const [event, entries] of Object.entries(captureEntries)) {
201
- mergedHooks[event] = [...(mergedHooks[event] ?? []), ...entries];
181
+ const enabled = settings.enabledPlugins ?? (settings.enabledPlugins = {});
182
+ if (enabled[GIPITY_PLUGIN_ID] !== true && (force || !(GIPITY_PLUGIN_ID in enabled))) {
183
+ enabled[GIPITY_PLUGIN_ID] = true;
184
+ changed = true;
202
185
  }
203
- settings.hooks = mergedHooks;
186
+ if (!changed)
187
+ return;
188
+ mkdirSync(claudeDir, { recursive: true });
189
+ writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n');
190
+ }
191
+ export function setupClaudeHooks() {
192
+ // All hooks ship in the plugin - enable it at user scope (and clean up any
193
+ // legacy hook blocks in the user-global settings while we're there).
194
+ ensureGipityPlugin();
195
+ // Never treat the home directory as a project. A gipity command run from
196
+ // $HOME used to write "project" hooks straight into the user-global
197
+ // ~/.claude/settings.json; permissions are project-scoped, so at $HOME
198
+ // there is nothing project-level left to do.
199
+ const cwd = resolve(process.cwd());
200
+ if (cwd === resolve(homedir()))
201
+ return;
202
+ const claudeDir = join(cwd, '.claude');
203
+ mkdirSync(claudeDir, { recursive: true });
204
+ const settingsPath = join(claudeDir, 'settings.json');
205
+ const settings = readSettingsFile(settingsPath);
206
+ // Migration: remove the hook blocks older CLI versions wrote here.
207
+ stripGipityHooks(settings);
204
208
  // Merge permissions (additive - preserve user's existing allows)
205
209
  const perms = settings.permissions || {};
206
210
  if (!perms.allow)
package/dist/sync.js CHANGED
@@ -455,17 +455,35 @@ async function applyUpload(projectGuid, root, a, onConflict) {
455
455
  throw err;
456
456
  }
457
457
  }
458
+ /** Name of the optional per-project ignore file (gitignore-style: one pattern
459
+ * per line, blank lines and `#` comments skipped). Patterns use the same
460
+ * matcher as the config `ignore` list (see shouldIgnore) and let research
461
+ * artifacts, scratch data, or vendored references live inside the project
462
+ * directory without being synced (and therefore without being deployed). */
463
+ export const GIPITY_IGNORE_FILE = '.gipityignore';
464
+ export function readGipityIgnore(root) {
465
+ const path = join(root, GIPITY_IGNORE_FILE);
466
+ if (!existsSync(path))
467
+ return [];
468
+ return readFileSync(path, 'utf8')
469
+ .split('\n')
470
+ .map(line => line.trim())
471
+ .filter(line => line && !line.startsWith('#'))
472
+ .map(line => line.replace(/^\.\//, '').replace(/^\//, ''));
473
+ }
474
+ /** The ignore list a sync/push actually runs with: the project config's
475
+ * `ignore` (falling back to DEFAULT_SYNC_IGNORE when empty, so an empty list
476
+ * never means "sync everything - node_modules, .git and all"), plus any
477
+ * `.gipityignore` patterns. The ignore file itself never syncs. */
478
+ export function effectiveIgnore(root, configIgnore) {
479
+ const base = configIgnore && configIgnore.length ? configIgnore : DEFAULT_SYNC_IGNORE;
480
+ return [...base, GIPITY_IGNORE_FILE, ...readGipityIgnore(root)];
481
+ }
458
482
  export async function sync(opts = {}) {
459
483
  const config = requireConfig();
460
484
  const root = projectDir();
461
485
  const interactive = opts.interactive ?? process.stdout.isTTY ?? false;
462
- // A config written with an empty `ignore` (older projects, or one produced
463
- // by the one-off Home-fallback path) would make sync walk and hash the
464
- // entire tree - node_modules, .git, caches and all. Fall back to the
465
- // standard ignore set so an empty list never means "sync everything".
466
- const ignore = config.ignore && config.ignore.length
467
- ? config.ignore
468
- : [...DEFAULT_SYNC_IGNORE];
486
+ const ignore = effectiveIgnore(root, config.ignore);
469
487
  const releaseLock = await acquireLock();
470
488
  try {
471
489
  return await syncInner(config.projectGuid, root, ignore, opts, interactive);
@@ -484,6 +502,15 @@ async function syncInner(projectGuid, root, ignore, opts, interactive) {
484
502
  const local = walkLocal(root, ignore, baseline.files);
485
503
  p?.phase('Checking Gipity for changes…');
486
504
  const remote = await fetchRemote(projectGuid);
505
+ // Ignored paths are invisible on BOTH sides: filtering only the local walk
506
+ // would classify a remote copy as "added" (pull it), then next pass as a
507
+ // local deletion (delete it remotely) - a churn loop. Filtering remote too
508
+ // means a path that synced before it was ignored just stays put remotely
509
+ // (delete it explicitly if it shouldn't be deployed).
510
+ for (const path of [...remote.keys()]) {
511
+ if (shouldIgnore(path, ignore))
512
+ remote.delete(path);
513
+ }
487
514
  // Hash everything we might classify ambiguously. Any local path also on
488
515
  // remote (and the remote has a hash) needs a local hash so size-match-but-
489
516
  // content-differs isn't misclassified. Anything in baseline that's still
@@ -792,7 +819,7 @@ export async function pushFile(filePath) {
792
819
  const config = requireConfig();
793
820
  const root = projectDir();
794
821
  const rel = relative(root, filePath).replace(/\\/g, '/');
795
- if (shouldIgnore(rel, config.ignore))
822
+ if (shouldIgnore(rel, effectiveIgnore(root, config.ignore)))
796
823
  return;
797
824
  const baseline = readBaseline(config.projectGuid);
798
825
  const baseEntry = baseline.files[rel];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gipity",
3
- "version": "1.0.380",
3
+ "version": "1.0.384",
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",