@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.
- package/dist/agent-hooks/notis-agent-hook.mjs +8296 -7912
- package/dist/base-skills/notis-apps/SKILL.md +34 -513
- package/dist/base-skills/notis-apps/references/architecture.md +164 -0
- package/dist/base-skills/notis-apps/references/design.md +165 -0
- package/dist/base-skills/notis-apps/references/release.md +99 -0
- package/dist/base-skills/notis-apps/references/sdk.md +61 -0
- package/dist/base-skills/notis-apps/references/troubleshooting.md +23 -0
- package/dist/base-skills/notis-cli/SKILL.md +19 -200
- package/dist/base-skills/notis-cli/references/app-delivery.md +18 -0
- package/dist/base-skills/notis-cli/references/native-databases.md +20 -0
- package/dist/base-skills/notis-cli/references/tool-examples.md +56 -0
- package/dist/base-skills/notis-cli/references/troubleshooting.md +39 -0
- package/dist/base-skills/notis-query/SKILL.md +13 -651
- package/dist/base-skills/notis-query/references/database-discovery.md +59 -0
- package/dist/base-skills/notis-query/references/documents.md +50 -0
- package/dist/base-skills/notis-query/references/query.md +543 -0
- package/dist/skill-sync/index.js +24 -7
- package/dist/skill-sync/index.js.map +4 -4
- package/dist/skill-sync-worker.mjs +2989 -0
- package/package.json +1 -1
- package/src/cli.js +4 -0
- package/src/command-specs/diagnostics.js +37 -0
- package/src/command-specs/skills.js +23 -5
- package/src/runtime/profiles.js +5 -2
- package/src/runtime/skill-sync/cloud-client.ts +2 -1
- package/src/runtime/skill-sync/index.ts +24 -6
- package/src/runtime/skill-sync/types.ts +2 -0
- package/src/runtime/skill-sync-service.js +109 -0
- package/src/skill-sync-worker-entry.js +2 -0
- package/src/skill-sync-worker.js +50 -0
- package/template/packages/sdk/src/components/MultiSelectActionBar.tsx +36 -7
- package/template/packages/sdk/src/components/MultiSelectCheckbox.tsx +3 -1
- package/template/packages/sdk/src/hooks/useCollectionInteractions.ts +138 -28
- package/template/packages/sdk/src/hooks/useDocuments.ts +4 -1
- package/template/packages/sdk/src/hooks/useLongPressSelection.ts +79 -0
- package/template/packages/sdk/src/hooks/useMultiSelect.ts +2 -8
- package/template/packages/sdk/src/index.ts +3 -0
- package/template/packages/sdk/src/interactions/actions.ts +14 -1
- package/template/packages/sdk/src/interactions/shortcuts.tsx +79 -19
- package/template/packages/sdk/src/interactions/visibility.ts +13 -0
- package/template/packages/sdk/src/interactions.ts +3 -0
- package/template/packages/sdk/src/queryCache.ts +10 -2
package/package.json
CHANGED
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
|
|
11
|
-
const
|
|
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.`,
|
package/src/runtime/profiles.js
CHANGED
|
@@ -228,8 +228,11 @@ function processIsAlive(pid) {
|
|
|
228
228
|
try {
|
|
229
229
|
process.kill(pid, 0);
|
|
230
230
|
return true;
|
|
231
|
-
} catch {
|
|
232
|
-
|
|
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
|
|
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
|
-
|
|
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('&', '&').replaceAll('<', '<')
|
|
22
|
+
.replaceAll('>', '>').replaceAll('"', '"').replaceAll("'", ''');
|
|
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,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
|
-
|
|
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(
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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',
|