@notis_ai/cli 0.2.0-beta.154.1 → 0.2.0-beta.156.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 (30) hide show
  1. package/dist/agent-hooks/notis-agent-hook.mjs +209 -110
  2. package/dist/base-skills/notis-apps/SKILL.md +33 -30
  3. package/dist/skill-sync/index.js +134 -72
  4. package/dist/skill-sync/index.js.map +2 -2
  5. package/package.json +1 -1
  6. package/src/command-specs/apps.js +4 -2
  7. package/src/command-specs/skills.js +3 -2
  8. package/src/command-specs/tools.js +5 -0
  9. package/src/runtime/app-dev-build-supervisor.js +47 -0
  10. package/src/runtime/app-dev-build.js +41 -0
  11. package/src/runtime/app-dev-server.js +2 -6
  12. package/src/runtime/skill-sync/cloud-client.ts +5 -3
  13. package/src/runtime/skill-sync/index.ts +73 -65
  14. package/src/runtime/skill-sync/symlink-manager.ts +66 -16
  15. package/src/runtime/skill-sync/types.ts +5 -0
  16. package/template/app/page.tsx +8 -7
  17. package/template/packages/sdk/src/components/MultiSelectActionBar.tsx +49 -8
  18. package/template/packages/sdk/src/components/Skeleton.tsx +24 -0
  19. package/template/packages/sdk/src/hooks/useCloudComputer.ts +15 -48
  20. package/template/packages/sdk/src/hooks/useDatabaseSchema.ts +17 -53
  21. package/template/packages/sdk/src/hooks/useDatabaseSubscription.ts +2 -2
  22. package/template/packages/sdk/src/hooks/useDocument.ts +12 -47
  23. package/template/packages/sdk/src/hooks/useDocuments.ts +18 -58
  24. package/template/packages/sdk/src/hooks/useQuery.ts +71 -0
  25. package/template/packages/sdk/src/hooks/useToolQuery.ts +12 -0
  26. package/template/packages/sdk/src/hooks/useTopBarSearch.ts +15 -7
  27. package/template/packages/sdk/src/index.ts +8 -0
  28. package/template/packages/sdk/src/interactions/shortcuts.tsx +1 -1
  29. package/template/packages/sdk/src/queryCache.ts +162 -0
  30. package/template/packages/sdk/src/runtime.ts +5 -0
@@ -104,7 +104,7 @@ App code never accesses the runtime directly -- it uses SDK hooks (`useTool`, `u
104
104
 
105
105
  1. **React + Vite only** -- No Next.js, no custom server
106
106
  2. **ES module bundle** -- Vite builds a library-mode bundle with React externalized
107
- 3. **Component rendering** -- Apps render as React components directly in the portal. No iframes.
107
+ 3. **Component rendering** -- Apps render as React components directly in the portal. Do not create your own iframe; the host chooses the trusted shadow-root or isolated Store rendering path.
108
108
  The portal owns the `ShadowRoot`, theme tokens, and runtime provider.
109
109
  4. **HTTP bridge** -- Runtime calls use fetch to `/portal_views/runtime_query`
110
110
  5. **Declarative tools** -- Tool access is declared in `notis.config.ts` by the final names returned by tool discovery and enforced server-side. Views can call native Notis, connected integrations, PostForMe, and MCP tools directly; metered calls use the same credit-cap and usage-billing path as the CLI.
@@ -293,40 +293,43 @@ For arbitrary app-owned resources that are not Notis collection rows, set `resou
293
293
  Standard React pages in `app/`. Use generic SDK tool hooks for data and build on top of the scaffolded shadcn components and portal shell classes (`notis-app-shell`, `notis-app-surface`):
294
294
 
295
295
  ```tsx
296
- 'use client';
297
- import { useEffect, useState } from 'react';
298
- import { useTool } from '@notis/sdk';
296
+ import { useDocuments, ViewSkeleton } from '@notis/sdk';
299
297
  import { Card } from '@/components/ui/card';
300
298
 
301
- type QueryTasksArgs = { database_id?: string; database_slug?: string; query: { page_size?: number } };
302
- type TaskDoc = { document_id?: string; id?: string; title?: string; properties?: Record<string, unknown> };
303
- type QueryTasksResult = { documents?: TaskDoc[] };
304
-
305
299
  export default function TasksPage() {
306
- const queryTasks = useTool<QueryTasksArgs, QueryTasksResult>('LOCAL_NOTIS_DATABASE_QUERY');
307
- const [documents, setDocuments] = useState<TaskDoc[]>([]);
308
-
309
- useEffect(() => {
310
- void queryTasks
311
- .call({ database_id: 'tasks-db-id', query: { page_size: 25 } })
312
- .then((result) => setDocuments(result.documents || []));
313
- }, [queryTasks.call]);
314
-
315
- if (queryTasks.loading) return <div>Loading...</div>;
316
-
317
- return (
318
- <div className="p-6 space-y-4">
319
- {documents.map((doc) => (
320
- <Card key={doc.id || doc.document_id} className="p-4">
321
- <h3>{doc.title || 'Untitled'}</h3>
322
- <p className="text-muted-foreground">{String(doc.properties?.status || '')}</p>
323
- </Card>
324
- ))}
325
- </div>
326
- );
300
+ const tasks = useDocuments('tasks', { pageSize: 25 });
301
+ return <section className="space-y-4 p-6">
302
+ <h1 className="text-xl font-semibold">Tasks</h1>
303
+ {tasks.error && <p role="alert">{tasks.error.message} <button onClick={tasks.refetch}>Retry</button></p>}
304
+ {tasks.loading ? <ViewSkeleton variant="table" rows={5} /> : tasks.hasData ? (
305
+ tasks.documents.length ? tasks.documents.map((task) => (
306
+ <Card key={task.id} className="p-4"><h2>{task.title || 'Untitled'}</h2></Card>
307
+ )) : <p>No tasks yet.</p>
308
+ ) : null}
309
+ </section>;
327
310
  }
328
311
  ```
329
312
 
313
+ ### Instant-view loading contract (required)
314
+
315
+ Build a client-side, multi-route app with one persistent `app/layout.tsx` shell. Navigate with `useNotisNavigation`; never use a document reload for an internal route. “SPA” means preserving that shell and reusing reads, **not** mounting every page or fetching every database at startup.
316
+
317
+ | State | Required UI |
318
+ | --- | --- |
319
+ | First read, no successful data | Keep headings/navigation/layout visible; use content-shaped skeletons only in missing regions. No page spinner or whole-page `Loading...`. |
320
+ | Cached view / successful empty result | Render synchronously from the shared SDK cache. Empty results are real cached results. |
321
+ | Background refresh | Keep current content and selection. Never replace populated content with a skeleton; do not drive the top-bar spinner from mount/refetch state. |
322
+ | Explicit Save / Upload / submitted search | Progress belongs in that button or affected section. Disable only the conflicting action. |
323
+ | Failed read | Show a scoped error and Retry; keep usable cached content. Never show an empty-state message before `hasData` is true. |
324
+
325
+ Use `useDocuments`, `useDocument`, `useDatabaseSchema`, and `useDatabaseSubscription` for native reads. `loading` means no first successful response; `isFetching` includes silent refresh. Do not copy their data into mount-only state, clear rows on error, or gate the entire app on `isFetching`.
326
+
327
+ For another **explicitly identified idempotent read**, use `useToolQuery<Result>(toolName, exactArguments, { readOnly: true })`, or `useQuery(keyArray, readCallback, { readOnly: true })`. Include every filter, selected resource, pagination option, and other input in the key. Call tools within a custom read with `{ readOnly: true, dedupe: true }`; the same SQL/shell tool can also perform writes, so never mark a whole toolkit read-only. Leave mutations as ordinary `useTool` actions.
328
+
329
+ `useQueryClient().prefetch(keyArray, readCallback, { readOnly: true })` prepares small known reads after the current view has rendered or on hover/focus. It shares the host's two-request speculative budget. Match the exact foreground query key. Never prefetch a mutation, login/polling action, provider sweep, `fetchAll` query, or an aggregate that fans out into more requests. Do not invent tool names to prepare a view. Older hosts safely fall back to uncached hook-local reads and skip prefetch.
330
+
331
+ Caches belong to the host's in-memory account/environment/app/version/effective-permission scope. Do not add module-global or `localStorage` caches of user data. Writes and realtime events invalidate reads; logout, access loss, and updates retire scopes. Preserve the last successful snapshot on an ordinary network failure.
332
+
330
333
  ### Discovering database schema
331
334
 
332
335
  Before writing app code, inspect the database schema to know what properties exist:
@@ -367,7 +370,7 @@ Do NOT pass Notion-style wrappers (`{select: {name: "Todo"}}`) when upserting.
367
370
  - If a screen looks like a standalone microsite instead of a portal tool, it is too custom.
368
371
  - For Notes-style apps, the folder tree belongs to the portal sidebar when configured via `collection.sidebar`. The page content should complement that chrome, not duplicate or replace it.
369
372
  - Never indicate selected items with a heavy left-border bar (e.g. `border-l-2 border-l-foreground` paired with a muted background). It looks dated and clashes with the portal chrome. Use a single subtle background change (`bg-muted` for selected, `hover:bg-muted/50` for hover) and let typography or an icon carry the rest of the state.
370
- - Do not render any search input inside the app (in-page search rails, "Ask Notis…" pills, command-palette-style bars, etc.). The portal already owns the top-bar search field. Wire your view to it with `useTopBarSearch({ value, onChange, placeholder, onSubmit })` from `@notis/sdk` and let the page filter or refetch on the values it receives. The hook also exposes `setLoading` so the standard top-bar spinner reflects in-flight queries.
373
+ - Do not render any search input inside the app (in-page search rails, "Ask Notis…" pills, command-palette-style bars, etc.). The portal already owns the top-bar search field. Wire your view to it with `useTopBarSearch({ value, onChange, placeholder, onSubmit })` from `@notis/sdk` and let the page filter or refetch on the values it receives. Use its `setLoading` only for an explicit submitted search, never initial view loading or background refresh.
371
374
 
372
375
  ### Sidebar invariants
373
376
 
@@ -798,12 +798,13 @@ async function downloadSkillBundle(bundleUrl) {
798
798
  }
799
799
  return Buffer.from(await response.arrayBuffer());
800
800
  }
801
- async function updateAgentTargets(serverUrl, jwt, skillId, targets) {
801
+ async function updateAgentTargets(serverUrl, jwt, skillId, targets, expectedUpdatedAt) {
802
802
  return requestJson(`${serverUrl}/portal_skills/agent-targets`, jwt, {
803
803
  method: "PATCH",
804
804
  body: {
805
805
  skill_id: skillId,
806
- agent_targets: targets
806
+ agent_targets: targets,
807
+ ...expectedUpdatedAt ? { expected_updated_at: expectedUpdatedAt } : {}
807
808
  }
808
809
  });
809
810
  }
@@ -819,6 +820,18 @@ var EXTERNAL_AGENT_SKILL_DIRS = {
819
820
  codex: path2.join(HOME_DIR2, ".codex", "skills")
820
821
  };
821
822
  var EXTERNAL_AGENTS = Object.keys(EXTERNAL_AGENT_SKILL_DIRS);
823
+ var AGENT_FAILURE_LABELS = {
824
+ claude_code: "Claude Code",
825
+ cursor: "Cursor",
826
+ codex: "Codex",
827
+ legacy: "legacy ~/.agents/skills"
828
+ };
829
+ function agentFailureLabel(agent) {
830
+ return AGENT_FAILURE_LABELS[agent] ?? agent;
831
+ }
832
+ function agentFolderFailureName(agent) {
833
+ return `${agentFailureLabel(agent)} skills folder`;
834
+ }
822
835
  async function removeForeignAccountSymlinks(skillsDir, options = {}) {
823
836
  const agentSkillDirs = { ...defaultAgentSkillDirs(skillsDir), ...options.agentSkillDirs };
824
837
  const currentRoot = path2.resolve(skillsDir);
@@ -879,8 +892,9 @@ async function isManagedSymlink(linkPath, managedRoots) {
879
892
  const target = await fs2.readlink(linkPath);
880
893
  const resolvedTarget = path2.resolve(path2.dirname(linkPath), target);
881
894
  return managedRoots.some((root) => resolvedTarget === root || resolvedTarget.startsWith(`${root}${path2.sep}`));
882
- } catch {
883
- return false;
895
+ } catch (error) {
896
+ if (error?.code === "ENOENT") return false;
897
+ throw error;
884
898
  }
885
899
  }
886
900
  async function ensureCorrectSymlink(linkPath, targetPath) {
@@ -896,7 +910,8 @@ async function ensureCorrectSymlink(linkPath, targetPath) {
896
910
  } else {
897
911
  return "blocked";
898
912
  }
899
- } catch {
913
+ } catch (error) {
914
+ if (error?.code !== "ENOENT") throw error;
900
915
  }
901
916
  const relativePath = path2.relative(path2.dirname(linkPath), targetPath);
902
917
  await fs2.symlink(relativePath, linkPath);
@@ -908,19 +923,26 @@ function defaultAgentSkillDirs(skillsDir) {
908
923
  ...EXTERNAL_AGENT_SKILL_DIRS
909
924
  };
910
925
  }
911
- async function removeUndesiredManagedSymlinks(agentDir, desiredSkills, managedRoots) {
926
+ async function removeUndesiredManagedSymlinks(agentDir, desiredSkills, managedRoots, failures, agent) {
912
927
  let removed = 0;
913
928
  try {
914
929
  await fs2.mkdir(agentDir, { recursive: true });
915
930
  const existingEntries = await fs2.readdir(agentDir, { withFileTypes: true });
916
931
  for (const entry of existingEntries) {
917
932
  const entryPath = path2.join(agentDir, entry.name);
918
- if (!desiredSkills.has(entry.name) && await isManagedSymlink(entryPath, managedRoots)) {
919
- await fs2.unlink(entryPath);
920
- removed += 1;
933
+ try {
934
+ if (!desiredSkills.has(entry.name) && await isManagedSymlink(entryPath, managedRoots)) {
935
+ await fs2.unlink(entryPath);
936
+ removed += 1;
937
+ }
938
+ } catch (error) {
939
+ if (error?.code !== "ENOENT") {
940
+ failures.push({ name: entry.name, error: `${agentFailureLabel(agent)}: could not remove skill link (${error.message})` });
941
+ }
921
942
  }
922
943
  }
923
- } catch {
944
+ } catch (error) {
945
+ failures.push({ name: agentFolderFailureName(agent), error: `Could not read agent skills directory (${error.message})` });
924
946
  }
925
947
  return removed;
926
948
  }
@@ -965,7 +987,7 @@ async function detectDeletedAgentSymlinks(cloudSkills, previousState, skillsDir
965
987
  continue;
966
988
  }
967
989
  const previous = previousState.skills[skill.name];
968
- if (!previous) {
990
+ if (!previous || previous.cloudId !== skill.id || previous.verifiedAgentLinks?.[agent] !== true || !skill.updated_at || previous.cloudUpdatedAt !== skill.updated_at) {
969
991
  continue;
970
992
  }
971
993
  const cloudTargets = normalizeAgentTargets(skill.agent_targets);
@@ -996,7 +1018,9 @@ async function syncSymlinks(skills, skillsDir = LEGACY_AGENTS_SKILLS_DIR, option
996
1018
  const result = {
997
1019
  linked: 0,
998
1020
  removed: 0,
999
- skipped: 0
1021
+ skipped: 0,
1022
+ verifiedAgentLinks: {},
1023
+ failures: []
1000
1024
  };
1001
1025
  const agentSkillDirs = { ...defaultAgentSkillDirs(skillsDir), ...options.agentSkillDirs };
1002
1026
  const legacyGlobalSkillsDir = options.legacyGlobalSkillsDir || LEGACY_AGENTS_SKILLS_DIR;
@@ -1031,21 +1055,34 @@ async function syncSymlinks(skills, skillsDir = LEGACY_AGENTS_SKILLS_DIR, option
1031
1055
  result.skipped += desiredByAgent[agent].size;
1032
1056
  continue;
1033
1057
  }
1034
- await fs2.mkdir(agentDir, { recursive: true });
1058
+ try {
1059
+ await fs2.mkdir(agentDir, { recursive: true });
1060
+ } catch (error) {
1061
+ result.failures.push({ name: agentFolderFailureName(agent), error: `Could not create agent skills directory (${error.message})` });
1062
+ continue;
1063
+ }
1035
1064
  const desiredSkills = desiredByAgent[agent];
1036
1065
  if (options.removeUndesired !== false) {
1037
- result.removed += await removeUndesiredManagedSymlinks(agentDir, desiredSkills, managedRoots);
1066
+ result.removed += await removeUndesiredManagedSymlinks(agentDir, desiredSkills, managedRoots, result.failures, agent);
1038
1067
  }
1039
1068
  for (const skillName of desiredSkills) {
1040
1069
  const targetPath = path2.join(skillsDir, skillName);
1041
1070
  const linkPath = path2.join(agentDir, safeName(skillName, agentDir));
1042
1071
  try {
1043
- await fs2.access(targetPath);
1072
+ if (!(await fs2.stat(path2.join(targetPath, "SKILL.md"))).isFile()) throw new Error("Missing SKILL.md");
1044
1073
  } catch {
1045
1074
  result.skipped += 1;
1075
+ result.failures.push({ name: skillName, error: `${agentFailureLabel(agent)}: SKILL.md is missing or unreadable` });
1076
+ continue;
1077
+ }
1078
+ let syncOutcome;
1079
+ try {
1080
+ syncOutcome = await ensureCorrectSymlink(linkPath, targetPath);
1081
+ } catch (error) {
1082
+ result.skipped += 1;
1083
+ result.failures.push({ name: skillName, error: `${agentFailureLabel(agent)}: could not create skill link (${error.message})` });
1046
1084
  continue;
1047
1085
  }
1048
- const syncOutcome = await ensureCorrectSymlink(linkPath, targetPath);
1049
1086
  if (syncOutcome === "linked") {
1050
1087
  result.linked += 1;
1051
1088
  } else if (syncOutcome === "blocked") {
@@ -1053,9 +1090,13 @@ async function syncSymlinks(skills, skillsDir = LEGACY_AGENTS_SKILLS_DIR, option
1053
1090
  `[skill-sync] Could not link "${skillName}" for ${agent}: non-symlink entry blocks ${linkPath}`
1054
1091
  );
1055
1092
  result.skipped += 1;
1093
+ result.failures.push({ name: skillName, error: `${agentFailureLabel(agent)}: an existing file or folder blocks the skill link` });
1056
1094
  } else {
1057
1095
  result.skipped += 1;
1058
1096
  }
1097
+ if (syncOutcome !== "blocked") {
1098
+ result.verifiedAgentLinks[skillName] = { ...result.verifiedAgentLinks[skillName], [agent]: true };
1099
+ }
1059
1100
  }
1060
1101
  }
1061
1102
  if (options.removeUndesired !== false && !Object.values(agentSkillDirs).some(
@@ -1064,7 +1105,9 @@ async function syncSymlinks(skills, skillsDir = LEGACY_AGENTS_SKILLS_DIR, option
1064
1105
  result.removed += await removeUndesiredManagedSymlinks(
1065
1106
  legacyGlobalSkillsDir,
1066
1107
  /* @__PURE__ */ new Set(),
1067
- managedRoots
1108
+ managedRoots,
1109
+ result.failures,
1110
+ "legacy"
1068
1111
  );
1069
1112
  }
1070
1113
  return result;
@@ -1188,7 +1231,7 @@ function shouldWriteCloudSkill(cloudSkill, localSkills, previousState) {
1188
1231
  const localChangedSinceLastSync = !previous || previous.folderHash !== localSkill.folderHash;
1189
1232
  return !localChangedSinceLastSync && Boolean(cloudHash) && cloudHash !== localSkill.folderHash;
1190
1233
  }
1191
- function buildSyncState(pullResponse, localSkills, lastSyncedAt) {
1234
+ function buildSyncState(pullResponse, localSkills, lastSyncedAt, verifiedAgentLinks = {}) {
1192
1235
  const localSkillMap = toSkillMap(localSkills);
1193
1236
  const skills = Object.fromEntries(
1194
1237
  pullResponse.skills.map((skill) => {
@@ -1199,6 +1242,8 @@ function buildSyncState(pullResponse, localSkills, lastSyncedAt) {
1199
1242
  cloudId: skill.id,
1200
1243
  folderHash: localSkill?.folderHash || skill.skill_folder_hash || "",
1201
1244
  agentTargets: normalizeAgentTargets(skill.agent_targets),
1245
+ verifiedAgentLinks: skill.status === "active" ? verifiedAgentLinks[skill.name] ?? {} : {},
1246
+ cloudUpdatedAt: skill.updated_at,
1202
1247
  syncedAt: lastSyncedAt || (/* @__PURE__ */ new Date()).toISOString()
1203
1248
  }
1204
1249
  ];
@@ -1254,7 +1299,7 @@ function applyLegacyFirstRunState(localSkills, scopedState, legacyState) {
1254
1299
  skills: migratedSkills
1255
1300
  };
1256
1301
  }
1257
- async function writePulledSkillsToScopedMirror(pullResponse, localSkills, previousState, syncPaths, deps) {
1302
+ async function writePulledSkillsToScopedMirror(pullResponse, localSkills, previousState, syncPaths, deps, failures = []) {
1258
1303
  const localSkillMap = toSkillMap(localSkills);
1259
1304
  const warnSkillSync = (message, error) => {
1260
1305
  console.warn(`[Notis] ${message}`, error);
@@ -1270,6 +1315,8 @@ async function writePulledSkillsToScopedMirror(pullResponse, localSkills, previo
1270
1315
  onWarning: warnSkillSync
1271
1316
  })) {
1272
1317
  downloaded += 1;
1318
+ } else {
1319
+ failures.push({ name: cloudSkill.name, error: "Skill content could not be downloaded or written; sync will retry" });
1273
1320
  }
1274
1321
  }
1275
1322
  return downloaded;
@@ -1297,25 +1344,42 @@ async function materializeCloudSkillsForLocalShell(serverUrl, jwt, dependencies
1297
1344
  assertSkillsPullAuthorized(pullResponse);
1298
1345
  const previousState = await deps.readSyncState(syncPaths);
1299
1346
  const localSkills = await deps.scanLocalSkills(syncPaths);
1347
+ const failedDownloads = [];
1300
1348
  const downloaded = await writePulledSkillsToScopedMirror(
1301
1349
  pullResponse,
1302
1350
  localSkills,
1303
1351
  previousState,
1304
1352
  syncPaths,
1305
- deps
1353
+ deps,
1354
+ failedDownloads
1306
1355
  );
1307
1356
  const finalLocalSkills = await deps.scanLocalSkills(syncPaths);
1308
1357
  const lastSyncedAt = pullResponse.last_synced_at || (/* @__PURE__ */ new Date()).toISOString();
1309
1358
  const relinkSkillNames = new Set(options.relinkSkillNames || []);
1359
+ const failures = [...failedDownloads];
1360
+ const verifiedLinks = {};
1361
+ for (const skill of pullResponse.skills) {
1362
+ const previous = previousState.skills[skill.name];
1363
+ if (skill.updated_at && previous?.cloudId === skill.id && previous.cloudUpdatedAt === skill.updated_at) {
1364
+ verifiedLinks[skill.name] = { ...previous.verifiedAgentLinks };
1365
+ }
1366
+ }
1310
1367
  if (relinkSkillNames.size > 0) {
1311
- await deps.syncSymlinks(
1368
+ const relinked = await deps.syncSymlinks(
1312
1369
  pullResponse.skills.filter((skill) => relinkSkillNames.has(skill.name)),
1313
1370
  syncPaths.skillsDir,
1314
1371
  { removeUndesired: false }
1315
1372
  );
1373
+ failures.push(...(relinked.failures ?? []).filter(
1374
+ (failure) => !failedDownloads.some((download) => download.name === failure.name)
1375
+ ));
1376
+ for (const name of relinkSkillNames) {
1377
+ verifiedLinks[name] = relinked.verifiedAgentLinks?.[name] ?? {};
1378
+ }
1316
1379
  }
1380
+ for (const failure of failedDownloads) delete verifiedLinks[failure.name];
1317
1381
  await deps.writeSyncState(
1318
- buildSyncState(pullResponse, finalLocalSkills, lastSyncedAt),
1382
+ buildSyncState(pullResponse, finalLocalSkills, lastSyncedAt, verifiedLinks),
1319
1383
  syncPaths
1320
1384
  );
1321
1385
  return {
@@ -1323,10 +1387,11 @@ async function materializeCloudSkillsForLocalShell(serverUrl, jwt, dependencies
1323
1387
  downloaded,
1324
1388
  deleted: 0,
1325
1389
  removed: 0,
1326
- lastSyncedAt
1390
+ lastSyncedAt,
1391
+ ...failures.length ? { failedLinks: failures } : {}
1327
1392
  };
1328
1393
  }
1329
- async function deactivateDeletedAgentSkills(serverUrl, jwt, pullResponse, previousState, scopedState, skillsDir, deps) {
1394
+ async function deactivateDeletedAgentSkills(serverUrl, jwt, pullResponse, previousState, scopedState, skillsDir, deps, failures) {
1330
1395
  if (isEmptySyncState(scopedState)) {
1331
1396
  return 0;
1332
1397
  }
@@ -1338,22 +1403,6 @@ async function deactivateDeletedAgentSkills(serverUrl, jwt, pullResponse, previo
1338
1403
  if (deletions.length === 0) {
1339
1404
  return 0;
1340
1405
  }
1341
- const latestSkillsById = new Map(pullResponse.skills.map((skill) => [skill.id, skill]));
1342
- let fresh = null;
1343
- try {
1344
- fresh = await deps.pullSkills(serverUrl, jwt);
1345
- } catch (error) {
1346
- console.warn(
1347
- "[skill-sync] Could not re-pull latest agent targets before deactivation; using the top-of-sync snapshot.",
1348
- error
1349
- );
1350
- }
1351
- if (fresh) {
1352
- assertSkillsPullAuthorized(fresh);
1353
- for (const skill of fresh.skills) {
1354
- latestSkillsById.set(skill.id, skill);
1355
- }
1356
- }
1357
1406
  const agentsBySkill = /* @__PURE__ */ new Map();
1358
1407
  for (const deletion of deletions) {
1359
1408
  const entry = agentsBySkill.get(deletion.skillId) ?? {
@@ -1363,31 +1412,35 @@ async function deactivateDeletedAgentSkills(serverUrl, jwt, pullResponse, previo
1363
1412
  entry.agents.add(deletion.agent);
1364
1413
  agentsBySkill.set(deletion.skillId, entry);
1365
1414
  }
1366
- const inMemoryById = new Map(pullResponse.skills.map((skill) => [skill.id, skill]));
1415
+ const fresh = withoutBaseSkills(await deps.pullSkills(serverUrl, jwt));
1416
+ assertSkillsPullAuthorized(fresh);
1417
+ Object.assign(pullResponse, fresh);
1418
+ let needsRefresh = false;
1367
1419
  let deactivated = 0;
1368
1420
  for (const [skillId, { skillName, agents }] of agentsBySkill) {
1369
- const latest = latestSkillsById.get(skillId);
1370
- if (!latest) {
1371
- continue;
1372
- }
1373
- const nextTargets = { ...normalizeAgentTargets(latest.agent_targets) };
1374
- for (const agent of agents) {
1375
- nextTargets[agent] = false;
1376
- }
1421
+ const skill = pullResponse.skills.find((item) => item.id === skillId);
1422
+ const previous = previousState.skills[skillName];
1423
+ if (!skill?.updated_at || previous?.cloudUpdatedAt !== skill.updated_at) continue;
1424
+ const patch = Object.fromEntries([...agents].map((agent) => [agent, false]));
1377
1425
  try {
1378
- await deps.updateAgentTargets(serverUrl, jwt, skillId, nextTargets);
1379
- const inMemory = inMemoryById.get(skillId);
1380
- if (inMemory) {
1381
- inMemory.agent_targets = nextTargets;
1426
+ const saved = await deps.updateAgentTargets(serverUrl, jwt, skillId, patch, skill.updated_at);
1427
+ if (saved.success !== true || !saved.updated_at?.trim() || saved.updated_at === skill.updated_at || !["notis", "claude_code", "cursor", "codex"].every((agent) => typeof saved.agent_targets?.[agent] === "boolean") || ![...agents].every((agent) => saved.agent_targets[agent] === false)) {
1428
+ throw new Error("Assignment update did not return a verified saved revision");
1382
1429
  }
1430
+ skill.agent_targets = saved.agent_targets;
1431
+ skill.updated_at = saved.updated_at;
1383
1432
  deactivated += agents.size;
1384
1433
  } catch (error) {
1385
- console.warn(
1386
- `[skill-sync] Failed to deactivate "${skillName}" for ${[...agents].join(", ")} after local symlink deletion:`,
1387
- error
1388
- );
1434
+ needsRefresh = true;
1435
+ failures.push({ name: skillName, error: "Could not save the local agent removal; refreshed saved assignments" });
1436
+ console.warn(`[skill-sync] Assignment changed or could not be saved for "${skillName}"; refreshing before reconciliation.`, error);
1389
1437
  }
1390
1438
  }
1439
+ if (needsRefresh) {
1440
+ const refreshed = withoutBaseSkills(await deps.pullSkills(serverUrl, jwt));
1441
+ assertSkillsPullAuthorized(refreshed);
1442
+ Object.assign(pullResponse, refreshed);
1443
+ }
1391
1444
  return deactivated;
1392
1445
  }
1393
1446
  async function runSkillSync(serverUrl, jwt, dependencies = {}, options = {}) {
@@ -1430,6 +1483,18 @@ async function runSkillSync(serverUrl, jwt, dependencies = {}, options = {}) {
1430
1483
  pullResponse.skills.filter((skill) => skill.source === "curated").map((skill) => skill.name)
1431
1484
  );
1432
1485
  const protectedSkillNames = /* @__PURE__ */ new Set([...cloudCuratedSkillNames, ...BASE_SKILL_NAMES]);
1486
+ const scopedState = withoutBaseSkillState(await deps.readSyncState(syncPaths));
1487
+ const assignmentFailures = [];
1488
+ const deactivated = syncSettings.agent_targets_conditional_updates === true ? await deactivateDeletedAgentSkills(
1489
+ serverUrl,
1490
+ jwt,
1491
+ pullResponse,
1492
+ scopedState,
1493
+ scopedState,
1494
+ syncPaths.skillsDir,
1495
+ deps,
1496
+ assignmentFailures
1497
+ ) : 0;
1433
1498
  const authUserId = decodeJwtSubject(jwt);
1434
1499
  let previousAuthState = null;
1435
1500
  if (authUserId && authUserId !== syncUserId) {
@@ -1444,22 +1509,12 @@ async function runSkillSync(serverUrl, jwt, dependencies = {}, options = {}) {
1444
1509
  protectedSkillNames
1445
1510
  });
1446
1511
  const localSkills = (await deps.scanLocalSkills(syncPaths)).filter((skill) => !BASE_SKILL_NAMES.has(skill.name));
1447
- const scopedState = withoutBaseSkillState(await deps.readSyncState(syncPaths));
1448
1512
  const previousState = withoutBaseSkillState(applyLegacyFirstRunState(
1449
1513
  localSkills,
1450
1514
  scopedState,
1451
1515
  isEmptySyncState(scopedState) ? !previousAuthState || isEmptySyncState(previousAuthState) ? await deps.readLegacySyncState(syncPaths) : previousAuthState : null
1452
1516
  ));
1453
- const deactivated = await deactivateDeletedAgentSkills(
1454
- serverUrl,
1455
- jwt,
1456
- pullResponse,
1457
- previousState,
1458
- scopedState,
1459
- syncPaths.skillsDir,
1460
- deps
1461
- );
1462
- await deps.syncSymlinks(
1517
+ const gatheredSymlinkResult = await deps.syncSymlinks(
1463
1518
  buildLocalSymlinkCandidates(pullResponse, localSkills, previousState),
1464
1519
  syncPaths.skillsDir
1465
1520
  );
@@ -1490,21 +1545,25 @@ async function runSkillSync(serverUrl, jwt, dependencies = {}, options = {}) {
1490
1545
  deleted += 1;
1491
1546
  }
1492
1547
  }
1548
+ const failedDownloads = [];
1493
1549
  const downloaded = await writePulledSkillsToScopedMirror(
1494
1550
  pullResponse,
1495
1551
  localSkills,
1496
1552
  previousState,
1497
1553
  syncPaths,
1498
- deps
1554
+ deps,
1555
+ failedDownloads
1499
1556
  );
1500
1557
  const finalLocalSkills = (await deps.scanLocalSkills(syncPaths)).filter((skill) => !BASE_SKILL_NAMES.has(skill.name));
1501
1558
  const symlinkResult = await deps.syncSymlinks(
1502
1559
  buildLocalSymlinkCandidates(pullResponse, finalLocalSkills, previousState),
1503
1560
  syncPaths.skillsDir
1504
1561
  );
1562
+ const verifiedLinks = { ...symlinkResult.verifiedAgentLinks ?? {} };
1563
+ for (const failure of failedDownloads) delete verifiedLinks[failure.name];
1505
1564
  const lastSyncedAt = pullResponse.last_synced_at || (/* @__PURE__ */ new Date()).toISOString();
1506
1565
  await deps.writeSyncState(
1507
- buildSyncState(pullResponse, finalLocalSkills, lastSyncedAt),
1566
+ buildSyncState(pullResponse, finalLocalSkills, lastSyncedAt, verifiedLinks),
1508
1567
  syncPaths
1509
1568
  );
1510
1569
  return {
@@ -1514,9 +1573,12 @@ async function runSkillSync(serverUrl, jwt, dependencies = {}, options = {}) {
1514
1573
  downloaded,
1515
1574
  deleted,
1516
1575
  deactivated,
1517
- linked: symlinkResult.linked,
1518
- removed: foreignLinksRemoved + symlinkResult.removed,
1576
+ linked: gatheredSymlinkResult.linked + symlinkResult.linked,
1577
+ removed: foreignLinksRemoved + gatheredSymlinkResult.removed + symlinkResult.removed,
1519
1578
  skipped: symlinkResult.skipped,
1579
+ failedLinks: [...assignmentFailures, ...failedDownloads, ...(symlinkResult.failures ?? []).filter(
1580
+ (failure) => !failedDownloads.some((download) => download.name === failure.name)
1581
+ )],
1520
1582
  lastSyncedAt,
1521
1583
  failedPushes
1522
1584
  };