@notis_ai/cli 0.2.0-beta.157.1 → 0.2.0-beta.159.1

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 (42) hide show
  1. package/dist/agent-hooks/notis-agent-hook.mjs +8296 -7912
  2. package/dist/base-skills/notis-apps/SKILL.md +34 -513
  3. package/dist/base-skills/notis-apps/references/architecture.md +164 -0
  4. package/dist/base-skills/notis-apps/references/design.md +165 -0
  5. package/dist/base-skills/notis-apps/references/release.md +99 -0
  6. package/dist/base-skills/notis-apps/references/sdk.md +61 -0
  7. package/dist/base-skills/notis-apps/references/troubleshooting.md +23 -0
  8. package/dist/base-skills/notis-cli/SKILL.md +19 -200
  9. package/dist/base-skills/notis-cli/references/app-delivery.md +18 -0
  10. package/dist/base-skills/notis-cli/references/native-databases.md +20 -0
  11. package/dist/base-skills/notis-cli/references/tool-examples.md +56 -0
  12. package/dist/base-skills/notis-cli/references/troubleshooting.md +39 -0
  13. package/dist/base-skills/notis-query/SKILL.md +13 -651
  14. package/dist/base-skills/notis-query/references/database-discovery.md +59 -0
  15. package/dist/base-skills/notis-query/references/documents.md +50 -0
  16. package/dist/base-skills/notis-query/references/query.md +543 -0
  17. package/dist/skill-sync/index.js +24 -7
  18. package/dist/skill-sync/index.js.map +4 -4
  19. package/dist/skill-sync-worker.mjs +2989 -0
  20. package/package.json +1 -1
  21. package/src/cli.js +4 -0
  22. package/src/command-specs/diagnostics.js +37 -0
  23. package/src/command-specs/skills.js +23 -5
  24. package/src/runtime/profiles.js +5 -2
  25. package/src/runtime/skill-sync/cloud-client.ts +2 -1
  26. package/src/runtime/skill-sync/index.ts +24 -6
  27. package/src/runtime/skill-sync/types.ts +2 -0
  28. package/src/runtime/skill-sync-service.js +109 -0
  29. package/src/skill-sync-worker-entry.js +2 -0
  30. package/src/skill-sync-worker.js +50 -0
  31. package/template/packages/sdk/src/components/MultiSelectActionBar.tsx +36 -7
  32. package/template/packages/sdk/src/components/MultiSelectCheckbox.tsx +3 -1
  33. package/template/packages/sdk/src/hooks/useCollectionInteractions.ts +138 -28
  34. package/template/packages/sdk/src/hooks/useDocuments.ts +4 -1
  35. package/template/packages/sdk/src/hooks/useLongPressSelection.ts +79 -0
  36. package/template/packages/sdk/src/hooks/useMultiSelect.ts +2 -8
  37. package/template/packages/sdk/src/index.ts +3 -0
  38. package/template/packages/sdk/src/interactions/actions.ts +14 -1
  39. package/template/packages/sdk/src/interactions/shortcuts.tsx +79 -19
  40. package/template/packages/sdk/src/interactions/visibility.ts +13 -0
  41. package/template/packages/sdk/src/interactions.ts +3 -0
  42. package/template/packages/sdk/src/queryCache.ts +10 -2
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@notis_ai/cli",
3
- "version": "0.2.0-beta.157.1",
3
+ "version": "0.2.0-beta.159.1",
4
4
  "description": "Agent-first Notis CLI for apps and generic tool execution",
5
5
  "type": "module",
6
6
  "bin": {
package/src/cli.js CHANGED
@@ -10,6 +10,7 @@ import { reportCliCommand } from './runtime/telemetry.js';
10
10
  import { reconcileBaseSkillsBestEffort } from './runtime/base-skills.js';
11
11
  import { detectedAgentIds } from './runtime/agent-setup.js';
12
12
  import { withSkillSyncLock } from './runtime/sync-skills.js';
13
+ import { maybeInstallSkillSyncService } from './runtime/skill-sync-service.js';
13
14
  import {
14
15
  CHANNEL_SWITCH_ENV,
15
16
  resolveChannelSwitch,
@@ -188,6 +189,9 @@ function attachSpec(program, parentMap, spec, specs, launchContext = {}) {
188
189
  ...launchContext,
189
190
  });
190
191
  process.exitCode = typeof exitCode === 'number' ? exitCode : 0;
192
+ if (process.exitCode === 0 && !['logout', 'agent-context', 'agent-capture'].includes(spec.command_path[0])) {
193
+ await maybeInstallSkillSyncService(runtime).catch(() => undefined);
194
+ }
191
195
  await reportCliCommand({
192
196
  spec,
193
197
  runtime,
@@ -47,6 +47,8 @@ async function discoverSupabaseSqlTool(runtime) {
47
47
  const match = names.find((name) => (
48
48
  name.toUpperCase().includes('SUPABASE')
49
49
  && name.toUpperCase().includes('EXECUTE_SQL')
50
+ && name.toUpperCase().includes('NOTIS_APP')
51
+ && !name.toUpperCase().includes('WEBSITE')
50
52
  ));
51
53
  if (!match) {
52
54
  throw usageError('No connected Supabase execute-SQL capability was discovered. Run `notis tools link mcp_supabase_notis_app`.');
@@ -606,7 +608,42 @@ async function debugWorkerIdentityHandler(ctx) {
606
608
  });
607
609
  }
608
610
 
611
+ export function buildProcessDiagnosticsSql(reference) {
612
+ if (typeof reference !== 'string' || !reference.trim() || reference.length > 256) {
613
+ throw usageError('An interaction id of 1–256 characters is required.');
614
+ }
615
+ return `BEGIN READ ONLY; SET LOCAL request.jwt.claim.role='service_role'; SELECT public.notis_process_diagnostics_v1(${sqlLiteral(reference)}) AS diagnostic; COMMIT;`;
616
+ }
617
+
618
+ async function debugProcessHandler(ctx) {
619
+ const execution = await executeReadOnlySql(ctx, buildProcessDiagnosticsSql(ctx.args.interactionId), 'process');
620
+ const raw = execution.rows[0]?.diagnostic;
621
+ const diagnostic = typeof raw === 'string' ? JSON.parse(raw) : raw;
622
+ if (!diagnostic || diagnostic.definition_version !== 1 || diagnostic.status) {
623
+ throw usageError(`Process diagnostics unavailable: ${diagnostic?.status || 'invalid response'}.`);
624
+ }
625
+ if (diagnostic.interaction_id !== ctx.args.interactionId) throw usageError('Crossed diagnostic identity.');
626
+ return ctx.output.emitSuccess({
627
+ command: ctx.spec.command_path.join(' '), data: diagnostic, requestId: execution.requestId,
628
+ humanSummary: `${diagnostic.user_outcome}; ${diagnostic.handling}; ${diagnostic.reason_code || 'no incident'}.`,
629
+ meta: { mutating: false, sql_tool: execution.toolName },
630
+ renderHuman: () => JSON.stringify(diagnostic, null, 2),
631
+ });
632
+ }
633
+
609
634
  export const diagnosticCommandSpecs = [
635
+ {
636
+ command_path: ['debug', 'process'],
637
+ summary: 'Read receipt-backed process diagnostics for one interaction.',
638
+ when_to_use: 'Correlate an interaction with Temporal runs, private failure references, delivery and settlement.',
639
+ args_schema: { arguments: [{ token: '<interaction-id>', key: 'interactionId', description: 'Exact interaction id.' }], options: [] },
640
+ examples: ['notis debug process interaction_123 --json'],
641
+ output_schema: 'Versioned, content-free logical outcome, handling, workflow/run and evidence references.',
642
+ mutates: false, idempotent: true,
643
+ related_commands: ['notis debug worker-identity', 'notis debug trace-cost <trace-or-interaction>'],
644
+ backend_call: { type: 'tool-discovery', name: 'Supabase execute SQL capability' },
645
+ handler: debugProcessHandler,
646
+ },
610
647
  {
611
648
  command_path: ['debug', 'user-context'],
612
649
  summary: 'Resolve a user’s effective redacted runtime and billing context.',
@@ -1,25 +1,43 @@
1
1
  import { getJwtSubject } from '../runtime/profiles.js';
2
2
  import { reconcileAllSkills } from '../runtime/sync-skills.js';
3
+ import { ensureFreshOAuthCredential } from '../runtime/oauth.js';
4
+ import { installSkillSyncService } from '../runtime/skill-sync-service.js';
3
5
 
4
6
  async function loadSkillSyncEngine() {
5
7
  return import('../../dist/skill-sync/index.js');
6
8
  }
7
9
 
8
- async function syncSkillsHandler(ctx) {
10
+ export async function syncSkillsHandler(ctx, {
11
+ refresh = ensureFreshOAuthCredential, loadEngine = loadSkillSyncEngine,
12
+ reconcile = reconcileAllSkills, install = installSkillSyncService,
13
+ } = {}) {
14
+ await refresh(ctx.runtime);
9
15
  const userId = ctx.runtime.oauthUserId || getJwtSubject(ctx.runtime.jwt);
10
- const { runSkillSync } = await loadSkillSyncEngine();
11
- const result = await reconcileAllSkills({
16
+ const { runSkillSync, fetchSyncSettings } = await loadEngine();
17
+ const settings = await fetchSyncSettings(ctx.runtime.apiBase, ctx.runtime.jwt);
18
+ const result = await reconcile({
12
19
  serverUrl: ctx.runtime.apiBase,
13
20
  jwt: ctx.runtime.jwt,
14
- userId,
21
+ userId: settings.user_id || userId,
15
22
  honorSyncEnabled: Boolean(ctx.options.electronRepeat),
16
- runAccountSync: runSkillSync,
23
+ runAccountSync: (serverUrl, jwt, dependencies, options) => runSkillSync(
24
+ serverUrl, jwt, { ...dependencies, fetchSyncSettings: async () => settings }, options,
25
+ ),
17
26
  });
27
+ if (settings.sync_enabled && !ctx.options.electronRepeat) {
28
+ try {
29
+ result.automaticSync = await install(ctx.runtime);
30
+ } catch (error) {
31
+ result.automaticSync = { status: 'error', message: error.message };
32
+ }
33
+ }
18
34
 
19
35
  const failures = [...(result.failedPushes || []), ...(result.failedLinks || [])];
20
36
  return ctx.output.emitSuccess({
21
37
  command: 'skills sync',
22
38
  data: result,
39
+ warnings: result.automaticSync?.status === 'error'
40
+ ? [`Skills synced, but automatic refresh could not start: ${result.automaticSync.message}`] : [],
23
41
  humanSummary: failures.length ? `Skill sync completed with ${failures.length} reported failures; inspect failedPushes and failedLinks.` : result.syncEnabled
24
42
  ? `Synced account skills and kept ${result.baseSkills.length} base skills current.`
25
43
  : `Automatic Desktop sync is off; kept ${result.baseSkills.length} base skills current.`,
@@ -228,8 +228,11 @@ function processIsAlive(pid) {
228
228
  try {
229
229
  process.kill(pid, 0);
230
230
  return true;
231
- } catch {
232
- return false;
231
+ } catch (error) {
232
+ // Sandboxed children may be allowed to observe the worktree lease but not
233
+ // signal its dev.sh supervisor. POSIX EPERM proves that the PID exists;
234
+ // ESRCH (and every other probe failure) does not.
235
+ return error?.code === 'EPERM';
233
236
  }
234
237
  }
235
238
 
@@ -9,6 +9,7 @@ async function requestJson<T>(
9
9
  options: { method?: string; body?: JsonBody } = {},
10
10
  ): Promise<T> {
11
11
  const response = await fetch(url, {
12
+ signal: AbortSignal.timeout(90_000),
12
13
  method: options.method || 'POST',
13
14
  headers: {
14
15
  'Content-Type': 'application/json',
@@ -59,7 +60,7 @@ export async function pushChangedSkills(
59
60
  }
60
61
 
61
62
  export async function downloadSkillBundle(bundleUrl: string): Promise<Buffer> {
62
- const response = await fetch(bundleUrl);
63
+ const response = await fetch(bundleUrl, { signal: AbortSignal.timeout(90_000) });
63
64
  if (!response.ok) {
64
65
  const text = await response.text();
65
66
  throw new Error(`GET ${bundleUrl} → ${response.status}: ${text}`);
@@ -1,3 +1,4 @@
1
+ import { createHash } from 'node:crypto';
1
2
  import type {
2
3
  AgentTargets,
3
4
  LocalSkill,
@@ -33,6 +34,7 @@ import {
33
34
  } from "./symlink-manager";
34
35
  import { getPushCandidates } from "./sync-plan";
35
36
  import { writeCloudSkillWithBundleFallback } from "./write-cloud-skill";
37
+ export { fetchSyncSettings } from './cloud-client';
36
38
 
37
39
  export interface RunSkillSyncResult {
38
40
  syncEnabled: boolean;
@@ -147,7 +149,17 @@ function decodeJwtSubject(jwt: string): string | null {
147
149
  }
148
150
  }
149
151
 
150
- function shouldWriteCloudSkill(
152
+ function cloudContentHash(skill: SyncPullResponse['skills'][number]): string {
153
+ return createHash('sha256').update(JSON.stringify({
154
+ md: skill.skill_md,
155
+ hash: skill.skill_folder_hash,
156
+ source: skill.skill_source_url,
157
+ files: skill.bundle_files?.slice().sort((a, b) => a.path.localeCompare(b.path)),
158
+ hydrationFailed: skill.bundle_hydration_failed === true,
159
+ })).digest('hex');
160
+ }
161
+
162
+ export function shouldWriteCloudSkill(
151
163
  cloudSkill: SyncPullResponse["skills"][number],
152
164
  localSkills: Map<string, LocalSkill>,
153
165
  previousState: NotisSyncState,
@@ -159,17 +171,20 @@ function shouldWriteCloudSkill(
159
171
  return true;
160
172
  }
161
173
 
174
+ const previous = previousState.skills[skillName];
175
+ if (previous?.folderHash === localSkill.folderHash
176
+ && previous.cloudContentHash === cloudContentHash(cloudSkill)) return false;
177
+
162
178
  if (cloudSkill.source === "curated") {
163
179
  return cloudHash ? cloudHash !== localSkill.folderHash : true;
164
180
  }
165
181
 
166
- const previous = previousState.skills[skillName];
167
182
  const localChangedSinceLastSync =
168
183
  !previous || previous.folderHash !== localSkill.folderHash;
169
184
  return (
170
185
  !localChangedSinceLastSync &&
171
- Boolean(cloudHash) &&
172
- cloudHash !== localSkill.folderHash
186
+ ((Boolean(cloudHash) && cloudHash !== localSkill.folderHash)
187
+ || Boolean(previous?.cloudContentHash && previous.cloudContentHash !== cloudContentHash(cloudSkill)))
173
188
  );
174
189
  }
175
190
 
@@ -178,6 +193,7 @@ function buildSyncState(
178
193
  localSkills: LocalSkill[],
179
194
  lastSyncedAt: string | null,
180
195
  verifiedAgentLinks: Record<string, Partial<AgentTargets>> = {},
196
+ failedContentNames: ReadonlySet<string> = new Set(),
181
197
  ): NotisSyncState {
182
198
  const localSkillMap = toSkillMap(localSkills);
183
199
  const skills = Object.fromEntries(
@@ -191,6 +207,8 @@ function buildSyncState(
191
207
  agentTargets: normalizeAgentTargets(skill.agent_targets),
192
208
  verifiedAgentLinks: skill.status === "active" ? verifiedAgentLinks[skill.name] ?? {} : {},
193
209
  cloudUpdatedAt: skill.updated_at,
210
+ ...(!failedContentNames.has(skill.name) && !skill.skill_source_url
211
+ ? { cloudContentHash: cloudContentHash(skill) } : {}),
194
212
  syncedAt: lastSyncedAt || new Date().toISOString(),
195
213
  },
196
214
  ];
@@ -392,7 +410,7 @@ export async function materializeCloudSkillsForLocalShell(
392
410
  for (const failure of failedDownloads) delete verifiedLinks[failure.name];
393
411
 
394
412
  await deps.writeSyncState(
395
- buildSyncState(pullResponse, finalLocalSkills, lastSyncedAt, verifiedLinks),
413
+ buildSyncState(pullResponse, finalLocalSkills, lastSyncedAt, verifiedLinks, new Set(failedDownloads.map(item => item.name))),
396
414
  syncPaths,
397
415
  );
398
416
 
@@ -629,7 +647,7 @@ export async function runSkillSync(
629
647
  const lastSyncedAt = pullResponse.last_synced_at || new Date().toISOString();
630
648
 
631
649
  await deps.writeSyncState(
632
- buildSyncState(pullResponse, finalLocalSkills, lastSyncedAt, verifiedLinks),
650
+ buildSyncState(pullResponse, finalLocalSkills, lastSyncedAt, verifiedLinks, new Set(failedDownloads.map(item => item.name))),
633
651
  syncPaths,
634
652
  );
635
653
 
@@ -12,6 +12,8 @@ export interface SyncedSkill {
12
12
  /** Actual readable managed links, never inferred from desired targets. */
13
13
  verifiedAgentLinks?: Partial<AgentTargets>;
14
14
  cloudUpdatedAt?: string;
15
+ /** Content accepted on disk; independent of server-specific folder hash formats. */
16
+ cloudContentHash?: string;
15
17
  syncedAt: string;
16
18
  }
17
19
 
@@ -0,0 +1,109 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import { createHash } from 'node:crypto';
3
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
4
+ import { homedir } from 'node:os';
5
+ import { dirname, join } from 'node:path';
6
+ import { fileURLToPath } from 'node:url';
7
+ import { isValidProfileName, loadConfig } from './profiles.js';
8
+ import { withSkillSyncLock } from './sync-skills.js';
9
+
10
+ export const SKILL_SYNC_SERVICE_LABEL = 'ai.notis.skills-sync';
11
+ const bundlePath = fileURLToPath(new URL('../../dist/skill-sync-worker.mjs', import.meta.url));
12
+
13
+ function atomicWrite(target, value, mode = 0o600) {
14
+ mkdirSync(dirname(target), { recursive: true, mode: 0o700 });
15
+ const temporary = `${target}.${process.pid}.tmp`;
16
+ writeFileSync(temporary, value, { mode });
17
+ renameSync(temporary, target);
18
+ }
19
+
20
+ function xml(value) {
21
+ return String(value).replaceAll('&', '&amp;').replaceAll('<', '&lt;')
22
+ .replaceAll('>', '&gt;').replaceAll('"', '&quot;').replaceAll("'", '&apos;');
23
+ }
24
+
25
+ export async function maybeInstallSkillSyncService(runtime, {
26
+ config = loadConfig(), install = installSkillSyncService,
27
+ fetchSettings, refresh,
28
+ } = {}) {
29
+ if (process.platform !== 'darwin' || runtime.credentialKind !== 'oauth'
30
+ || config.current_profile !== runtime.profileName
31
+ || !['https://api.notis.ai', 'https://api-beta.notis.ai'].includes(runtime.apiBase)) return;
32
+ // Ordinary commands must not steal another explicitly bound account's job.
33
+ const plist = join(homedir(), 'Library', 'LaunchAgents', `${SKILL_SYNC_SERVICE_LABEL}.plist`);
34
+ if (existsSync(plist)) {
35
+ const saved = readFileSync(plist, 'utf8');
36
+ if (runtime.oauthUserId && [runtime.profileName, runtime.apiBase, runtime.oauthUserId]
37
+ .every(value => saved.includes(`<string>${xml(value)}</string>`))) return install(runtime);
38
+ return;
39
+ }
40
+ const refreshCredential = refresh || (await import('./oauth.js')).ensureFreshOAuthCredential;
41
+ await refreshCredential(runtime);
42
+ const getSettings = fetchSettings || (await import('../../dist/skill-sync/index.js')).fetchSyncSettings;
43
+ const settings = await getSettings(runtime.apiBase, runtime.jwt);
44
+ if (settings.sync_enabled && settings.user_id === runtime.oauthUserId) return install(runtime);
45
+ }
46
+
47
+ export async function installSkillSyncService(runtime, options = {}) {
48
+ if ((options.platform || process.platform) !== 'darwin') return { status: 'unsupported_platform' };
49
+ if (runtime.credentialKind !== 'oauth' || !runtime.oauthUserId
50
+ || !['https://api.notis.ai', 'https://api-beta.notis.ai'].includes(runtime.apiBase)) {
51
+ return { status: 'skipped_non_personal_profile' };
52
+ }
53
+ // Registration upgrades must not kill a worker holding the filesystem lock,
54
+ // nor race another CLI registering the same LaunchAgent.
55
+ return withSkillSyncLock(() => installSkillSyncServiceLocked(runtime, options),
56
+ options.home ? { home: options.home } : {});
57
+ }
58
+
59
+ async function installSkillSyncServiceLocked(runtime, {
60
+ home = homedir(), platform = process.platform, nodePath = process.execPath,
61
+ source = bundlePath, run = spawnSync, uid = process.getuid?.(),
62
+ } = {}) {
63
+ // A single explicitly selected personal account owns global agent folders.
64
+ // Development and hosted credentials must never enroll a machine-wide job.
65
+ if (platform !== 'darwin') return { status: 'unsupported_platform' };
66
+ if (runtime.credentialKind !== 'oauth' || runtime.envCredentialOverride
67
+ || !isValidProfileName(runtime.profileName)
68
+ || !['https://api.notis.ai', 'https://api-beta.notis.ai'].includes(runtime.apiBase)) {
69
+ return { status: 'skipped_non_personal_profile' };
70
+ }
71
+ const root = join(home, '.notis', 'skills', 'service');
72
+ const bundle = readFileSync(source);
73
+ const digest = createHash('sha256').update(bundle).digest('hex');
74
+ const installedBundle = join(root, 'runtime', digest, 'worker.mjs');
75
+ if (!existsSync(installedBundle)
76
+ || !readFileSync(installedBundle).equals(bundle)) atomicWrite(installedBundle, bundle, 0o500);
77
+ const args = [nodePath, installedBundle, runtime.profileName, runtime.apiBase, runtime.oauthUserId];
78
+ if (!runtime.oauthUserId) return { status: 'skipped_missing_account' };
79
+ const plist = `<?xml version="1.0" encoding="UTF-8"?>
80
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
81
+ <plist version="1.0"><dict>
82
+ <key>Label</key><string>${SKILL_SYNC_SERVICE_LABEL}</string>
83
+ <key>ProgramArguments</key><array>${args.map(value => `<string>${xml(value)}</string>`).join('')}</array>
84
+ <key>WorkingDirectory</key><string>${xml(root)}</string>
85
+ <key>StartInterval</key><integer>60</integer>
86
+ <key>ProcessType</key><string>Background</string>
87
+ </dict></plist>
88
+ `;
89
+ const plistPath = join(home, 'Library', 'LaunchAgents', `${SKILL_SYNC_SERVICE_LABEL}.plist`);
90
+ const domain = `gui/${uid}`;
91
+ const target = `${domain}/${SKILL_SYNC_SERVICE_LABEL}`;
92
+ const unchanged = existsSync(plistPath) && readFileSync(plistPath, 'utf8') === plist;
93
+ const loaded = run('/bin/launchctl', ['print', target], { encoding: 'utf8', timeout: 5000 });
94
+ if (unchanged && loaded.status === 0) return { status: 'installed', intervalSeconds: 60, profile: runtime.profileName };
95
+ if (loaded.status === 0) {
96
+ const stopped = run('/bin/launchctl', ['bootout', target], { encoding: 'utf8', timeout: 5000 });
97
+ if (stopped.status !== 0) throw new Error('Could not update the existing automatic skill sync job');
98
+ }
99
+ atomicWrite(plistPath, plist);
100
+ let started;
101
+ // launchd may finish tearing down an old registration after bootout returns.
102
+ for (let attempt = 0; attempt < 10; attempt++) {
103
+ started = run('/bin/launchctl', ['bootstrap', domain, plistPath], { encoding: 'utf8', timeout: 5000 });
104
+ if (started.status === 0) break;
105
+ await new Promise(resolve => setTimeout(resolve, 100));
106
+ }
107
+ if (started?.status !== 0) throw new Error('Could not register automatic skill sync with macOS');
108
+ return { status: 'installed', intervalSeconds: 60, profile: runtime.profileName };
109
+ }
@@ -0,0 +1,2 @@
1
+ import { main } from './skill-sync-worker.js';
2
+ await main();
@@ -0,0 +1,50 @@
1
+ import { mkdirSync, renameSync, writeFileSync } from 'node:fs';
2
+ import { homedir } from 'node:os';
3
+ import { join } from 'node:path';
4
+ import { resolveRuntimeProfile } from './runtime/profiles.js';
5
+ import { ensureFreshOAuthCredential } from './runtime/oauth.js';
6
+ import { runSkillSync, fetchSyncSettings } from '../dist/skill-sync/index.js';
7
+ import { withSkillSyncLock } from './runtime/sync-skills.js';
8
+
9
+ export async function runAutomaticSkillSync({ profile, apiBase, userId }, {
10
+ resolveRuntime = resolveRuntimeProfile, refresh = ensureFreshOAuthCredential,
11
+ settings = fetchSyncSettings, sync = runSkillSync, lock = withSkillSyncLock,
12
+ } = {}) {
13
+ const runtime = resolveRuntime({ profile }, { requireAuth: true });
14
+ if (runtime.credentialKind !== 'oauth' || runtime.apiBase !== apiBase
15
+ || runtime.oauthUserId !== userId) throw new Error('Automatic skill sync account changed; run notis skills sync to rebind');
16
+ await refresh(runtime);
17
+ const saved = await settings(runtime.apiBase, runtime.jwt);
18
+ if (saved.user_id !== userId) throw new Error('Automatic skill sync identity mismatch');
19
+ // Do not gather, unlink foreign accounts or write anything when opted out.
20
+ if (!saved.sync_enabled) return { status: 'disabled' };
21
+ const result = await lock(() => sync(runtime.apiBase, runtime.jwt, {
22
+ fetchSyncSettings: async () => saved,
23
+ }, { honorSyncEnabled: true }));
24
+ return { status: (result.failedLinks?.length || result.failedPushes?.length) ? 'partial' : 'synced', ...result };
25
+ }
26
+
27
+ export async function main(args = process.argv.slice(2)) {
28
+ const [profile, apiBase, userId] = args;
29
+ const root = join(homedir(), '.notis', 'skills', 'service');
30
+ const record = (value) => {
31
+ mkdirSync(root, { recursive: true, mode: 0o700 });
32
+ const target = join(root, 'status.json');
33
+ const temporary = `${target}.${process.pid}.tmp`;
34
+ writeFileSync(temporary, JSON.stringify({ ...value, profile, at: new Date().toISOString() }, null, 2), { mode: 0o600 });
35
+ renameSync(temporary, target);
36
+ };
37
+ const deadline = setTimeout(() => {
38
+ record({ status: 'error', code: 'sync_timeout' });
39
+ process.exit(1);
40
+ }, 240_000);
41
+ try {
42
+ record(await runAutomaticSkillSync({ profile, apiBase, userId }));
43
+ } catch (error) {
44
+ // Persist only a classified error, never a bearer, signed URL or response body.
45
+ record({ status: 'error', code: error.code || 'sync_failed' });
46
+ process.exitCode = 1;
47
+ } finally {
48
+ clearTimeout(deadline);
49
+ }
50
+ }
@@ -9,7 +9,8 @@ import React, {
9
9
  type ReactElement,
10
10
  } from 'react';
11
11
  import type { ResolvedCollectionAction } from '../interactions/actions';
12
- import { shortcutDisplay, useShortcuts, type ShortcutDefinition } from '../interactions/shortcuts';
12
+ import { activateShortcutCollection, shortcutDisplay, useShortcuts, type ShortcutDefinition } from '../interactions/shortcuts';
13
+ import { isInteractionElementVisible } from '../interactions/visibility';
13
14
  import type { ShortcutScope } from '../interactions/shortcuts';
14
15
 
15
16
  export type MultiSelectAction = ResolvedCollectionAction;
@@ -34,6 +35,9 @@ export interface MultiSelectActionBarProps {
34
35
  shortcutsEnabled?: boolean;
35
36
  /** Override the default collection shortcut scope. */
36
37
  shortcutScope?: ShortcutScope;
38
+ collectionOwnerId?: string;
39
+ isAvailable?: () => boolean;
40
+ onClearSelection?: () => void;
37
41
  }
38
42
 
39
43
  const containerBaseStyle: CSSProperties = {
@@ -114,7 +118,7 @@ const iconSlotStyle: CSSProperties = {
114
118
  display: 'inline-flex',
115
119
  alignItems: 'center',
116
120
  justifyContent: 'center',
117
- opacity: 0.8,
121
+ color: 'inherit',
118
122
  };
119
123
 
120
124
  export function MultiSelectActionBar({
@@ -125,6 +129,9 @@ export function MultiSelectActionBar({
125
129
  style,
126
130
  shortcutsEnabled = true,
127
131
  shortcutScope = 'collection',
132
+ collectionOwnerId,
133
+ isAvailable,
134
+ onClearSelection,
128
135
  }: MultiSelectActionBarProps): ReactElement | null {
129
136
  const barRef = useRef<HTMLDivElement>(null);
130
137
  const visible = selectedCount > 0;
@@ -142,24 +149,35 @@ export function MultiSelectActionBar({
142
149
  }));
143
150
  }
144
151
  };
145
- const update = () => report(true);
152
+ const update = () => report(isAvailable?.() !== false && isInteractionElementVisible(bar));
146
153
  update();
147
154
  const observer = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(update);
148
155
  observer?.observe(bar);
156
+ const VisibilityObserver = bar.ownerDocument.defaultView?.MutationObserver;
157
+ const visibilityObserver = VisibilityObserver ? new VisibilityObserver(update) : null;
158
+ let element: Element | null = bar;
159
+ while (element) {
160
+ visibilityObserver?.observe(element, { attributes: true, attributeFilter: element === bar.ownerDocument.documentElement ? ['hidden', 'inert', 'aria-hidden', 'class'] : ['hidden', 'inert', 'aria-hidden', 'style', 'class'] });
161
+ const root: Node = element.getRootNode();
162
+ element = element.parentElement ?? ('host' in root ? (root as ShadowRoot).host : null);
163
+ }
149
164
  return () => {
150
165
  observer?.disconnect();
166
+ visibilityObserver?.disconnect();
151
167
  report(false);
152
168
  };
153
- }, [visible]);
169
+ }, [isAvailable, visible]);
154
170
 
155
171
  const actionShortcuts = useMemo<ShortcutDefinition[]>(() => {
156
172
  return actions.flatMap((action): ShortcutDefinition[] => {
157
- if (!action.shortcut || action.disabled || action.pending) return [];
173
+ if (!action.shortcut) return [];
158
174
  return [{
159
175
  id: `collection.action.${action.id}`,
160
176
  keys: action.shortcut,
161
177
  label: action.label,
162
- onTrigger: () => action.onRun(),
178
+ allowRepeat: Boolean(action.disabled || action.pending),
179
+ // Keep advertised keys owned while disabled/pending; never fall through to another action.
180
+ onTrigger: () => { if (!action.disabled && !action.pending) action.onRun(); },
163
181
  }];
164
182
  });
165
183
  }, [actions]);
@@ -167,6 +185,8 @@ export function MultiSelectActionBar({
167
185
  enabled: shortcutsEnabled && selectedCount > 0,
168
186
  scope: shortcutScope,
169
187
  priority: 25,
188
+ collectionOwnerId,
189
+ isAvailable: () => isAvailable?.() !== false && isInteractionElementVisible(barRef.current),
170
190
  });
171
191
 
172
192
  if (selectedCount === 0) return null;
@@ -186,6 +206,14 @@ export function MultiSelectActionBar({
186
206
  <div
187
207
  ref={barRef}
188
208
  data-notis-bulk-actions
209
+ onFocusCapture={() => { if (collectionOwnerId) activateShortcutCollection(collectionOwnerId); }}
210
+ onMouseDownCapture={() => { if (collectionOwnerId) activateShortcutCollection(collectionOwnerId); }}
211
+ onKeyDown={(event) => {
212
+ if (event.key !== 'Escape' || !shortcutsEnabled || !onClearSelection || isAvailable?.() === false) return;
213
+ event.preventDefault();
214
+ event.stopPropagation();
215
+ onClearSelection();
216
+ }}
189
217
  role="toolbar"
190
218
  aria-label={`Bulk actions for ${selectedCount} selected ${countWord}`}
191
219
  className={className}
@@ -199,10 +227,11 @@ export function MultiSelectActionBar({
199
227
  [data-notis-bulk-divider] { display: none; }
200
228
  [data-notis-bulk-action-list] { width: 100%; }
201
229
  [data-notis-bulk-actions] button { min-height: 48px; font-size: 16px !important; }
230
+ }
231
+ @media (hover: none) and (pointer: coarse) {
202
232
  [data-notis-bulk-actions] kbd { display: none !important; }
203
233
  [data-notis-bulk-action-icon][data-has-shortcut] { display: inline-flex !important; }
204
234
  }
205
- @media (hover: none) { [data-notis-bulk-actions] kbd { display: none !important; } }
206
235
  `}</style>
207
236
  <span data-notis-bulk-count style={countStyle}>{countLabel}</span>
208
237
  {actions.length > 0 ? <span data-notis-bulk-divider aria-hidden style={dividerStyle} /> : null}
@@ -23,7 +23,9 @@ const baseStyle: CSSProperties = {
23
23
  width: '16px',
24
24
  height: '16px',
25
25
  borderRadius: '4px',
26
- border: '1px solid hsl(var(--border))',
26
+ borderWidth: '1px',
27
+ borderStyle: 'solid',
28
+ borderColor: 'hsl(var(--border))',
27
29
  background: 'transparent',
28
30
  color: 'hsl(var(--primary-foreground))',
29
31
  cursor: 'pointer',