gigarag-cursor 0.3.0 → 0.3.2

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.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "gigarag",
3
3
  "description": "Your GigaRAG knowledge base inside Cursor: index a codebase, save decisions, recall them later.",
4
- "version": "0.3.0",
4
+ "version": "0.3.2",
5
5
  "author": { "name": "GigaRAG", "url": "https://gigarag.com" },
6
6
  "homepage": "https://gigarag.com/connect/cursor",
7
7
  "license": "UNLICENSED",
package/cli/cli.js CHANGED
@@ -1,4 +1,8 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { join } from 'node:path';
1
3
  import { version } from './constants.js';
4
+ import { stateDir } from './paths.js';
5
+ import { tryOpenState } from './state.js';
2
6
  import { err, out, UsageError } from './ui.js';
3
7
  const HELP = `gigarag ${version()}
4
8
 
@@ -41,10 +45,47 @@ const COMMANDS = {
41
45
  'auth-header': async () => (await import('./commands/authHeader.js')).authHeader,
42
46
  'index-sync': async () => (await import('./commands/indexSync.js')).indexSync,
43
47
  };
48
+ /**
49
+ * Commands a person reads, never a script: the ones whose stdout `gigarag hook`, `mcp`, `index-sync`,
50
+ * `record` and `scan` are read by something else stays clear of. `status` also stays clear of it: it
51
+ * already puts the same notice on its own stdout, and printing it again on stderr would just repeat
52
+ * it. Each of the rest gets one line on stderr, after it runs, when a newer version is available.
53
+ */
54
+ const INTERACTIVE_COMMANDS = new Set(['login', 'logout', 'connect', 'workspaces', 'use', 'trust']);
55
+ /**
56
+ * Prints the one-line update notice, or nothing at all: not signed in, never checked, already
57
+ * current, or stderr isn't a terminal a person is reading (a pipe, a redirect, another program
58
+ * capturing it). `update.js` and the state it reads are loaded here, on demand, so a hook or the
59
+ * MCP bridge, which never call this, never pay to load them.
60
+ */
61
+ export async function notifyIfOutdated() {
62
+ if (!process.stderr.isTTY)
63
+ return;
64
+ try {
65
+ if (!existsSync(join(stateDir(), 'state.db')))
66
+ return;
67
+ const state = tryOpenState();
68
+ if (!state)
69
+ return;
70
+ try {
71
+ const { terminalNotice } = await import('./update.js');
72
+ const notice = terminalNotice(state);
73
+ if (notice)
74
+ err(notice);
75
+ }
76
+ finally {
77
+ state.close();
78
+ }
79
+ }
80
+ catch {
81
+ /* a command's own result is never held up by this */
82
+ }
83
+ }
44
84
  export async function main(argv) {
45
85
  const [command, ...rest] = argv;
46
86
  if (!command || command === 'help' || command === '--help' || command === '-h') {
47
87
  out(HELP);
88
+ await notifyIfOutdated();
48
89
  return 0;
49
90
  }
50
91
  if (command === '--version' || command === '-v' || command === 'version') {
@@ -60,7 +101,12 @@ export async function main(argv) {
60
101
  return 2;
61
102
  }
62
103
  try {
63
- return await (await load())(rest);
104
+ const code = await (await load())(rest);
105
+ // Only after the command actually succeeded: a command that threw never gets this line, since
106
+ // whatever it failed to do is the thing that needs attention, not a version bump.
107
+ if (INTERACTIVE_COMMANDS.has(command))
108
+ await notifyIfOutdated();
109
+ return code;
64
110
  }
65
111
  catch (e) {
66
112
  if (e instanceof UsageError) {
@@ -1,6 +1,7 @@
1
1
  import { parseArgs } from 'node:util';
2
2
  import { GigaRag } from '../sdk.js';
3
3
  import { State } from '../state.js';
4
+ import { checkForUpdate } from '../update.js';
4
5
  const MAX_NODES = 5000;
5
6
  /** A worker that started less than this long ago owns the sync. */
6
7
  const LOCK_MS = 2 * 60_000;
@@ -23,7 +24,7 @@ async function pendingImports(rag) {
23
24
  * so it is what makes memos saved from other clients, a chat app or another
24
25
  * machine, show up in the next session. Runs detached from a hook, never in one.
25
26
  */
26
- export async function indexSync(argv = []) {
27
+ export async function indexSync(argv = [], opts = {}) {
27
28
  const { values } = parseArgs({ args: argv, options: { workspace: { type: 'string' } } });
28
29
  const workspace = values.workspace;
29
30
  const state = new State();
@@ -33,6 +34,15 @@ export async function indexSync(argv = []) {
33
34
  held = state.tryAcquire('sync_lock', LOCK_MS);
34
35
  if (!held)
35
36
  return 0;
37
+ // Never lets an update check turn a real sync failure into something else, or the reverse:
38
+ // a throwing or hanging registry never keeps the memo sync below from running or from
39
+ // recording that it finished.
40
+ try {
41
+ await checkForUpdate(state, { fetch: opts.fetch });
42
+ }
43
+ catch {
44
+ /* an update check is not worth failing a sync over */
45
+ }
36
46
  const rag = new GigaRag(workspace ? { workspaceId: workspace } : {});
37
47
  const rows = [];
38
48
  for await (const n of rag.iterateNodes({ max: MAX_NODES })) {
@@ -8,6 +8,7 @@ import { createClient, resolveUrl } from '../mcp/session.js';
8
8
  import { McpHttpError } from '../mcp/client.js';
9
9
  import { tildify } from '../paths.js';
10
10
  import { tryOpenState } from '../state.js';
11
+ import { terminalNotice } from '../update.js';
11
12
  import { out, table } from '../ui.js';
12
13
  import { existsSync } from 'node:fs';
13
14
  import { join } from 'node:path';
@@ -74,6 +75,7 @@ export async function status(argv) {
74
75
  try {
75
76
  const last = state.getMeta('last_sync');
76
77
  report.index = { memos: state.nodeCount(), ...(last ? { lastSync: new Date(Number(last)).toISOString() } : {}) };
78
+ report.update = terminalNotice(state);
77
79
  for (const r of state.listRepos()) {
78
80
  report.repos.push({
79
81
  slug: r.slug,
@@ -124,6 +126,8 @@ function print(r, missing) {
124
126
  if (r.index) {
125
127
  out(`\nMemo index: ${r.index.memos} memos${r.index.lastSync ? `, synced ${r.index.lastSync}` : ', not synced yet'}`);
126
128
  }
129
+ if (r.update)
130
+ out(`\nUpdate available: ${r.update}`);
127
131
  if (r.repos.length > 0) {
128
132
  out('\nIndexed repositories:');
129
133
  for (const line of table(r.repos.map(p => [` ${p.slug}`, p.indexedAt ? `indexed ${p.indexedAt}` : 'never indexed', p.dirty ? `${p.dirty} changed since` : '']))) {
package/cli/hooks.js CHANGED
@@ -11,6 +11,7 @@ import { findRepoRoot, repoInfo } from './scan/repo.js';
11
11
  import { tryOpenState } from './state.js';
12
12
  import { maxRewriteFiles, readNote, renderNote } from './sync/notes.js';
13
13
  import { syncSlug } from './sync/slug.js';
14
+ import { sessionStartNotice } from './update.js';
14
15
  /** Rows the SessionStart index carries at most. */
15
16
  export const INDEX_ROWS = 40;
16
17
  /** A sync worker is started at the end of a session only if the last one is older than this. */
@@ -73,6 +74,8 @@ export function renderIndex(rows, total, dirty, repoSlug, extras = {}) {
73
74
  ? '1 imported item is waiting to be filed. /gigainbox files it.'
74
75
  : `${pending} imported items are waiting to be filed. /gigainbox files them.`);
75
76
  }
77
+ if (extras.update)
78
+ lines.push(extras.update);
76
79
  return lines.join('\n');
77
80
  }
78
81
  export async function runHook(rawEvent, raw, dir = stateDir()) {
@@ -134,6 +137,7 @@ function sessionStart(state, input, cursor) {
134
137
  const text = renderIndex(state.recentNodes(INDEX_ROWS, ws), state.nodeCount(ws), state.hasRepo(slug) ? state.dirtyCount(slug) : 0, slug, {
135
138
  note: noteLine,
136
139
  importsPending,
140
+ update: sessionStartNotice(state),
137
141
  });
138
142
  if (!process.env['GIGARAG_SYNC_WORKER'])
139
143
  maybeSpawnSync(state, ws);
package/cli/package.json CHANGED
@@ -1,5 +1,6 @@
1
1
  {
2
2
  "name": "gigarag",
3
- "version": "0.3.0",
4
- "type": "module"
3
+ "version": "0.3.2",
4
+ "type": "module",
5
+ "gigaragHost": "cursor"
5
6
  }
package/cli/update.js ADDED
@@ -0,0 +1,197 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { basename, dirname, join } from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { isSea } from 'node:sea';
5
+ import { version } from './constants.js';
6
+ /** Where the CLI's own npm package answers with the newest published version. */
7
+ export const REGISTRY_URL = 'https://registry.npmjs.org/gigarag/latest';
8
+ /** Where the Claude Code plugin's npm package answers with the newest published version. */
9
+ export const PLUGIN_REGISTRY_URL = 'https://registry.npmjs.org/gigarag-claude-code/latest';
10
+ /** How long a checked timestamp holds off the next attempt, success or failure alike. */
11
+ export const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000;
12
+ /** How long the registry request is given before it counts as a failure. */
13
+ export const CHECK_TIMEOUT_MS = 3000;
14
+ /** How long a SessionStart line, once shown for a given version, stays quiet before it can show again. */
15
+ export const NOTICE_INTERVAL_MS = 24 * 60 * 60 * 1000;
16
+ const CHECKED_AT_KEY = 'update_checked_at';
17
+ const LATEST_KEY = 'latest_version';
18
+ const PLUGIN_LATEST_KEY = 'latest_plugin_version';
19
+ /** What a version is allowed to look like before it is trusted: no prerelease, no build metadata, three parts, each capped so a hostile answer can't grow unbounded. */
20
+ const PLAIN_VERSION = /^\d{1,6}\.\d{1,6}\.\d{1,6}$/;
21
+ /**
22
+ * True when the check is turned off for this run: an opt-out, or a CI environment nobody is
23
+ * watching. `CI=false`, which some runners set deliberately to mean "this is not CI", never
24
+ * disables it: only a truthy value that isn't the literal string "false" does.
25
+ */
26
+ export function updateCheckDisabled(env = process.env) {
27
+ return Boolean(env['GIGARAG_NO_UPDATE_CHECK']) || Boolean(env['CI'] && env['CI'] !== 'false');
28
+ }
29
+ async function fetchVersion(doFetch, url, timeoutMs) {
30
+ try {
31
+ const res = await doFetch(url, { signal: AbortSignal.timeout(timeoutMs) });
32
+ if (!res.ok)
33
+ return undefined;
34
+ const body = (await res.json());
35
+ const v = typeof body.version === 'string' ? body.version : '';
36
+ return PLAIN_VERSION.test(v) ? v : undefined;
37
+ }
38
+ catch {
39
+ return undefined;
40
+ }
41
+ }
42
+ /**
43
+ * Asks npm for the newest published version of both `gigarag` and `gigarag-claude-code`, at most
44
+ * once a day, and remembers what it heard, each under its own key. Runs inside the detached
45
+ * `index-sync` worker, never in a hook, so a slow or blocked network never touches a session's
46
+ * budget. The checked timestamp is written before either request, so a machine that cannot reach
47
+ * the registry is not retried on every worker run; each version is written only on a clean,
48
+ * well-shaped answer for that package, independently of the other. A bad status, a body that is
49
+ * not a plain x.y.z, a timeout, a thrown error stores nothing beyond the attempt for that package
50
+ * and is otherwise silent.
51
+ */
52
+ export async function checkForUpdate(state, opts = {}) {
53
+ if (updateCheckDisabled(opts.env ?? process.env))
54
+ return;
55
+ const now = opts.now ?? Date.now();
56
+ const last = Number(state.getMeta(CHECKED_AT_KEY) ?? 0);
57
+ // A clock that jumped backward (a stored timestamp now in the future) never holds off a check:
58
+ // only a `last` that is genuinely in the past and inside the interval does.
59
+ if (last <= now && now - last < CHECK_INTERVAL_MS)
60
+ return;
61
+ state.setMeta(CHECKED_AT_KEY, String(now));
62
+ const doFetch = opts.fetch ?? fetch;
63
+ const timeoutMs = opts.timeoutMs ?? CHECK_TIMEOUT_MS;
64
+ const [cliVersion, pluginVersion] = await Promise.all([
65
+ fetchVersion(doFetch, opts.url ?? REGISTRY_URL, timeoutMs),
66
+ fetchVersion(doFetch, opts.pluginUrl ?? PLUGIN_REGISTRY_URL, timeoutMs),
67
+ ]);
68
+ if (cliVersion)
69
+ state.setMeta(LATEST_KEY, cliVersion);
70
+ if (pluginVersion)
71
+ state.setMeta(PLUGIN_LATEST_KEY, pluginVersion);
72
+ }
73
+ /** Numeric major.minor.patch compare. A prerelease or anything else that is not a plain x.y.z never prompts, on either side. */
74
+ export function isNewerVersion(current, latest) {
75
+ if (!PLAIN_VERSION.test(current) || !PLAIN_VERSION.test(latest))
76
+ return false;
77
+ const a = current.split('.').map(Number);
78
+ const b = latest.split('.').map(Number);
79
+ for (let i = 0; i < 3; i++) {
80
+ if (b[i] > a[i])
81
+ return true;
82
+ if (b[i] < a[i])
83
+ return false;
84
+ }
85
+ return false;
86
+ }
87
+ /**
88
+ * The CLI version the worker last stored against `gigarag`, when it is validly shaped and strictly
89
+ * newer than this build. Undefined whenever the check itself is disabled, so the opt-out also
90
+ * silences a notice a still-running worker stored before the variable was set.
91
+ */
92
+ export function pendingUpdate(state, env = process.env) {
93
+ if (updateCheckDisabled(env))
94
+ return undefined;
95
+ const latest = state.getMeta(LATEST_KEY);
96
+ if (!latest || !PLAIN_VERSION.test(latest))
97
+ return undefined;
98
+ return isNewerVersion(version(), latest) ? latest : undefined;
99
+ }
100
+ /** The plugin version the worker last stored against `gigarag-claude-code`, under the same rules as {@link pendingUpdate}. */
101
+ export function pendingPluginUpdate(state, env = process.env) {
102
+ if (updateCheckDisabled(env))
103
+ return undefined;
104
+ const latest = state.getMeta(PLUGIN_LATEST_KEY);
105
+ if (!latest || !PLAIN_VERSION.test(latest))
106
+ return undefined;
107
+ return isNewerVersion(version(), latest) ? latest : undefined;
108
+ }
109
+ /**
110
+ * How this copy of the CLI reached the machine, so a notice names the right next step.
111
+ * `isSea()` is the standalone binary. The plugin's vendored copy is the one path
112
+ * `scripts/vendor-cli.mjs` always writes to, a `cli/` folder holding `bin.js` directly;
113
+ * anything else is the npm package, global, local, or run once through `npx`.
114
+ */
115
+ export function detectInstallKind(binPath = fileURLToPath(new URL('./bin.js', import.meta.url)), sea = isSea()) {
116
+ if (sea)
117
+ return 'binary';
118
+ return basename(dirname(binPath)) === 'cli' ? 'plugin' : 'npm';
119
+ }
120
+ /** True when this run's own path runs through npm's `_npx` cache, so the npm suggestion should name `npx` rather than a global install. */
121
+ export function isNpxInstall(binPath = fileURLToPath(new URL('./bin.js', import.meta.url))) {
122
+ return /[\\/]_npx[\\/]/.test(binPath);
123
+ }
124
+ /**
125
+ * Which plugin vendored this copy, read from the `gigaragHost` field `scripts/vendor-cli.mjs`
126
+ * writes into the vendored `package.json` next to `bin.js`. Undefined for anything without that
127
+ * field: an npm install, a standalone binary, or a copy without it is treated as not Claude Code.
128
+ */
129
+ function pluginHost(binPath) {
130
+ try {
131
+ const pkg = JSON.parse(readFileSync(join(dirname(binPath), 'package.json'), 'utf8'));
132
+ return typeof pkg.gigaragHost === 'string' ? pkg.gigaragHost : undefined;
133
+ }
134
+ catch {
135
+ return undefined;
136
+ }
137
+ }
138
+ /**
139
+ * Whether this copy is the Claude Code plugin's own vendored copy, deciding the SessionStart
140
+ * wording. `gigaragHost` decides it, and only it: `scripts/vendor-cli.mjs` always writes the field,
141
+ * so there is no real vendored copy without one to fall back for, and a path-shape guess would only
142
+ * add a second, weaker way to answer the same question. Its absence, an npm install or a standalone
143
+ * binary, is "not Claude Code".
144
+ */
145
+ function isClaudeCodeHost(binPath) {
146
+ return pluginHost(binPath) === 'claude-code';
147
+ }
148
+ /** The next action for the install kind this copy is running from. */
149
+ export function updateCommand(kind, npx = false) {
150
+ switch (kind) {
151
+ case 'binary':
152
+ return ('Run: curl -fsSL https://gigarag.com/install | GIGARAG_NO_SETUP=1 sh ' +
153
+ '(Windows PowerShell: $env:GIGARAG_NO_SETUP=1; irm https://gigarag.com/install.ps1 | iex)');
154
+ case 'plugin':
155
+ return 'Run: claude plugin marketplace update gigarag, then claude plugin update gigarag@gigarag, and restart Claude Code';
156
+ default:
157
+ return npx ? 'Run: npx gigarag@latest' : 'Run: npm install -g gigarag@latest';
158
+ }
159
+ }
160
+ /** One line for an interactive command's stderr, or undefined when this copy is current. */
161
+ export function terminalNotice(state, env = process.env) {
162
+ const latest = pendingUpdate(state, env);
163
+ if (!latest)
164
+ return undefined;
165
+ const binPath = fileURLToPath(new URL('./bin.js', import.meta.url));
166
+ return `gigarag ${version()} is out of date; ${latest} is available. ${updateCommand(detectInstallKind(binPath), isNpxInstall(binPath))}`;
167
+ }
168
+ /**
169
+ * The one line SessionStart adds for the agent, at most once every 24 hours for a given target
170
+ * version, so a session that starts every few minutes doesn't repeat itself. The hook only ever
171
+ * runs from a plugin's own vendored copy, so the next action is always the plugin's, not a guess
172
+ * at how this machine installed the CLI proper; a Cursor or Copilot vendored copy, or anything else
173
+ * that isn't the Claude Code plugin's own copy (see {@link isClaudeCodeHost}), never gets the
174
+ * Claude Code wording.
175
+ */
176
+ export function sessionStartNotice(state, now = Date.now(), binPath = fileURLToPath(new URL('./bin.js', import.meta.url)), env = process.env) {
177
+ if (!isClaudeCodeHost(binPath))
178
+ return undefined;
179
+ const latest = pendingPluginUpdate(state, env);
180
+ if (!latest)
181
+ return undefined;
182
+ const shownKey = `update_notified:${latest}`;
183
+ const last = Number(state.getMeta(shownKey) ?? 0);
184
+ if (last <= now && now - last < NOTICE_INTERVAL_MS)
185
+ return undefined;
186
+ // A write that can't happen right now (SQLITE_BUSY, a concurrent writer) must never cost the
187
+ // agent the rest of the SessionStart index over one throttling timestamp; worst case, the line
188
+ // repeats a little sooner than every 24 hours next time.
189
+ try {
190
+ state.setMeta(shownKey, String(now));
191
+ }
192
+ catch {
193
+ /* the memo index this is appended to matters far more than this one timestamp */
194
+ }
195
+ return (`GigaRAG plugin ${version()} is installed and ${latest} is available. Ask the user whether to update: run ` +
196
+ '`claude plugin marketplace update gigarag` then `claude plugin update gigarag@gigarag`, and restart Claude Code.');
197
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gigarag-cursor",
3
- "version": "0.3.0",
3
+ "version": "0.3.2",
4
4
  "description": "The GigaRAG plugin for Cursor. Skills and an MCP server that carry the gigarag CLI inside them.",
5
5
  "license": "UNLICENSED",
6
6
  "files": [