@notis_ai/cli 0.2.0-beta.19.1 → 0.2.0-beta.196.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 (216) hide show
  1. package/README.md +822 -135
  2. package/bin/check-runtime.js +15 -0
  3. package/bin/notis.js +2 -0
  4. package/config/notis_app_boundary_rules.json +50 -0
  5. package/config/notis_app_design_rules.json +135 -0
  6. package/config/portal-route-roots.json +48 -0
  7. package/dist/agent-hooks/notis-agent-hook.mjs +45587 -0
  8. package/dist/base-skills/notis-apps/SKILL.md +93 -0
  9. package/dist/base-skills/notis-apps/references/architecture.md +114 -0
  10. package/dist/base-skills/notis-apps/references/context.md +81 -0
  11. package/dist/base-skills/notis-apps/references/design.md +97 -0
  12. package/dist/base-skills/notis-apps/references/memory.md +58 -0
  13. package/dist/base-skills/notis-apps/references/reading.md +67 -0
  14. package/dist/base-skills/notis-apps/references/release.md +107 -0
  15. package/dist/base-skills/notis-apps/references/sdk.md +116 -0
  16. package/dist/base-skills/notis-apps/references/troubleshooting.md +24 -0
  17. package/dist/base-skills/notis-apps/references/views.md +142 -0
  18. package/dist/base-skills/notis-cli/SKILL.md +154 -0
  19. package/dist/base-skills/notis-cli/references/app-delivery.md +19 -0
  20. package/dist/base-skills/notis-cli/references/intelligence.md +94 -0
  21. package/dist/base-skills/notis-cli/references/native-databases.md +21 -0
  22. package/dist/base-skills/notis-cli/references/tool-examples.md +56 -0
  23. package/dist/base-skills/notis-cli/references/troubleshooting.md +39 -0
  24. package/dist/base-skills/notis-query/SKILL.md +41 -0
  25. package/dist/base-skills/notis-query/references/database-discovery.md +60 -0
  26. package/dist/base-skills/notis-query/references/documents.md +63 -0
  27. package/dist/base-skills/notis-query/references/query.md +76 -0
  28. package/{template/packages/notis-sdk → dist/sdk}/package.json +14 -4
  29. package/dist/sdk/src/agentContext.ts +36 -0
  30. package/dist/sdk/src/bundleModule.ts +79 -0
  31. package/dist/sdk/src/components/Dialog.tsx +46 -0
  32. package/dist/sdk/src/components/DocumentEditor.tsx +106 -0
  33. package/dist/sdk/src/components/DocumentPage.tsx +141 -0
  34. package/dist/sdk/src/components/Markdown.tsx +60 -0
  35. package/dist/sdk/src/components/MarkdownEditor.tsx +121 -0
  36. package/dist/sdk/src/components/MultiSelectActionBar.tsx +285 -0
  37. package/dist/sdk/src/components/MultiSelectCheckbox.tsx +97 -0
  38. package/dist/sdk/src/components/MultiSelectDragOverlay.tsx +39 -0
  39. package/dist/sdk/src/components/NotisCommentBoundary.tsx +172 -0
  40. package/dist/sdk/src/components/NotisSelectionBoundary.tsx +59 -0
  41. package/dist/sdk/src/components/RecordComponents.tsx +33 -0
  42. package/dist/sdk/src/components/RenderChartData.tsx +19 -0
  43. package/dist/sdk/src/components/ShortcutHints.tsx +56 -0
  44. package/dist/sdk/src/components/Skeleton.tsx +24 -0
  45. package/dist/sdk/src/config.ts +239 -0
  46. package/dist/sdk/src/documents.ts +258 -0
  47. package/dist/sdk/src/hooks/useAction.ts +28 -0
  48. package/dist/sdk/src/hooks/useActiveResource.ts +19 -0
  49. package/dist/sdk/src/hooks/useAgentContext.ts +23 -0
  50. package/dist/sdk/src/hooks/useCloudComputer.ts +95 -0
  51. package/dist/sdk/src/hooks/useCloudComputerShell.ts +114 -0
  52. package/dist/sdk/src/hooks/useCollectionInteractions.ts +838 -0
  53. package/dist/sdk/src/hooks/useDatabaseSchema.ts +49 -0
  54. package/dist/sdk/src/hooks/useDatabaseSubscription.ts +76 -0
  55. package/dist/sdk/src/hooks/useDocument.ts +43 -0
  56. package/dist/sdk/src/hooks/useDocuments.ts +84 -0
  57. package/dist/sdk/src/hooks/useHandover.ts +78 -0
  58. package/dist/sdk/src/hooks/useLongPressSelection.ts +79 -0
  59. package/dist/sdk/src/hooks/useMultiSelect.ts +95 -0
  60. package/dist/sdk/src/hooks/useNativeDocumentBody.ts +50 -0
  61. package/dist/sdk/src/hooks/useNativeDocuments.ts +83 -0
  62. package/dist/sdk/src/hooks/useNotis.ts +42 -0
  63. package/dist/sdk/src/hooks/useNotisNavigation.ts +67 -0
  64. package/dist/sdk/src/hooks/useQuery.ts +74 -0
  65. package/dist/sdk/src/hooks/useTool.ts +65 -0
  66. package/dist/sdk/src/hooks/useToolQuery.ts +12 -0
  67. package/dist/sdk/src/hooks/useTopBarSearch.ts +81 -0
  68. package/dist/sdk/src/hooks/useUpsertDocument.ts +95 -0
  69. package/dist/sdk/src/hooks/useViewParams.ts +121 -0
  70. package/dist/sdk/src/hooks/useViewerReads.ts +129 -0
  71. package/dist/sdk/src/index.ts +200 -0
  72. package/dist/sdk/src/interactions/actions.ts +59 -0
  73. package/dist/sdk/src/interactions/shortcuts.tsx +740 -0
  74. package/dist/sdk/src/interactions/visibility.ts +13 -0
  75. package/dist/sdk/src/interactions.ts +45 -0
  76. package/dist/sdk/src/nativeData.ts +39 -0
  77. package/dist/sdk/src/presentation.tsx +82 -0
  78. package/dist/sdk/src/provider.tsx +49 -0
  79. package/dist/sdk/src/queryCache.ts +172 -0
  80. package/dist/sdk/src/queryErrors.ts +51 -0
  81. package/dist/sdk/src/runtime.ts +603 -0
  82. package/dist/sdk/src/space.ts +425 -0
  83. package/dist/sdk/src/styles.css +290 -0
  84. package/dist/sdk/src/tailwind.ts +66 -0
  85. package/dist/sdk/src/vite.ts +83 -0
  86. package/dist/skill-sync/index.js +2143 -0
  87. package/dist/skill-sync/index.js.map +7 -0
  88. package/dist/skill-sync-worker.mjs +3487 -0
  89. package/dist/space-harness/host.js +66 -0
  90. package/dist/view-renderer/dist/host.css +1 -0
  91. package/dist/view-renderer/dist/host.js +1042 -0
  92. package/dist/view-renderer/dist/theme.css +73 -0
  93. package/dist/view-renderer/src/dom.js +180 -0
  94. package/dist/view-renderer/src/errors.js +8 -0
  95. package/dist/view-renderer/src/host.tsx +173 -0
  96. package/dist/view-renderer/src/index.js +4 -0
  97. package/dist/view-renderer/src/pool.js +76 -0
  98. package/dist/view-renderer/src/remote-broker.js +59 -0
  99. package/dist/view-renderer/src/renderer.js +201 -0
  100. package/package.json +25 -8
  101. package/skills/notis-apps/cli.md +406 -0
  102. package/skills/notis-cli/AGENT_INSTRUCTIONS.md +39 -0
  103. package/skills/notis-onboarding/BRIEF.md +133 -0
  104. package/skills/notis-query/cli.md +39 -0
  105. package/src/agent-hook-entry.js +5 -0
  106. package/src/cli.js +294 -25
  107. package/src/command-specs/agents.js +392 -0
  108. package/src/command-specs/apps.js +1476 -203
  109. package/src/command-specs/auth.js +114 -137
  110. package/src/command-specs/diagnostics.js +730 -0
  111. package/src/command-specs/files.js +29 -0
  112. package/src/command-specs/handover.js +374 -0
  113. package/src/command-specs/helpers.js +86 -82
  114. package/src/command-specs/index.js +31 -6
  115. package/src/command-specs/meta.js +150 -18
  116. package/src/command-specs/onboarding.js +290 -0
  117. package/src/command-specs/profile.js +358 -0
  118. package/src/command-specs/skills.js +304 -0
  119. package/src/command-specs/smoke.js +386 -0
  120. package/src/command-specs/spaces.js +572 -0
  121. package/src/command-specs/tools.js +455 -139
  122. package/src/command-specs/views.js +69 -0
  123. package/src/runtime/agent-browser.js +700 -0
  124. package/src/runtime/agent-memory-state.js +126 -0
  125. package/src/runtime/agent-setup.js +383 -0
  126. package/src/runtime/app-boundary-validator.js +407 -0
  127. package/src/runtime/app-changelog.js +79 -0
  128. package/src/runtime/app-data-literals.js +82 -0
  129. package/src/runtime/app-platform.js +2752 -237
  130. package/src/runtime/app-registry-scaffolds.js +367 -0
  131. package/src/runtime/app-test-server.js +293 -0
  132. package/src/runtime/assets/store-screenshot-dark.png +0 -0
  133. package/src/runtime/auth-recovery.js +110 -0
  134. package/src/runtime/base-skills.d.ts +20 -0
  135. package/src/runtime/base-skills.js +167 -0
  136. package/src/runtime/channel.js +133 -0
  137. package/src/runtime/confined-space-build.js +74 -0
  138. package/src/runtime/confined-space-verify.js +49 -0
  139. package/src/runtime/delegated-context.js +68 -0
  140. package/src/runtime/errors.js +1 -0
  141. package/src/runtime/git.js +233 -0
  142. package/src/runtime/login-listener.js +15 -0
  143. package/src/runtime/native-record-files.js +47 -0
  144. package/src/runtime/oauth.js +2622 -0
  145. package/src/runtime/output.js +37 -5
  146. package/src/runtime/ports.js +31 -0
  147. package/src/runtime/profiles.js +906 -55
  148. package/src/runtime/retired-sdk-files.js +20 -0
  149. package/src/runtime/screenshot-framing.js +85 -0
  150. package/src/runtime/skill-sync/cloud-client.ts +121 -0
  151. package/src/runtime/skill-sync/index.ts +905 -0
  152. package/src/runtime/skill-sync/local-scanner.ts +1259 -0
  153. package/src/runtime/skill-sync/native-identity.ts +102 -0
  154. package/src/runtime/skill-sync/native-plan.ts +59 -0
  155. package/src/runtime/skill-sync/symlink-manager.ts +433 -0
  156. package/src/runtime/skill-sync/sync-plan.ts +54 -0
  157. package/src/runtime/skill-sync/types.ts +157 -0
  158. package/src/runtime/skill-sync/write-cloud-skill.ts +50 -0
  159. package/src/runtime/skill-sync-service.js +109 -0
  160. package/src/runtime/source-root.js +32 -0
  161. package/src/runtime/space-collection-scaffold.js +66 -0
  162. package/src/runtime/space-database-structures.js +229 -0
  163. package/src/runtime/space-fixture-broker.js +50 -0
  164. package/src/runtime/space-lock.js +337 -0
  165. package/src/runtime/space-platform.js +105 -0
  166. package/src/runtime/space-source.js +281 -0
  167. package/src/runtime/space-test-server.js +327 -0
  168. package/src/runtime/space-verification.js +89 -0
  169. package/src/runtime/space-verify-browser-worker.js +51 -0
  170. package/src/runtime/space-view-manifest.js +254 -0
  171. package/src/runtime/store-screenshot.js +146 -0
  172. package/src/runtime/sync-skills.d.ts +37 -0
  173. package/src/runtime/sync-skills.js +231 -0
  174. package/src/runtime/telemetry.js +92 -0
  175. package/src/runtime/transport.js +331 -45
  176. package/src/runtime/view-render.js +49 -0
  177. package/src/skill-sync-worker-entry.js +2 -0
  178. package/src/skill-sync-worker.js +50 -0
  179. package/src/space-harness-host.ts +103 -0
  180. package/template/.harness/index.html.tmpl +435 -0
  181. package/template/CHANGELOG.md +5 -0
  182. package/template/app/globals.css +29 -3
  183. package/template/app/layout.tsx +6 -3
  184. package/template/app/page.tsx +49 -42
  185. package/template/components/page-heading.tsx +23 -0
  186. package/template/components/ui/badge.tsx +7 -4
  187. package/template/components/ui/button.tsx +1 -1
  188. package/template/components/ui/card.tsx +24 -11
  189. package/template/components/ui/native-select.tsx +24 -0
  190. package/template/notis.config.ts +24 -6
  191. package/template/package-lock.json +3642 -0
  192. package/template/package.json +19 -16
  193. package/template/postcss.config.mjs +1 -1
  194. package/template/tailwind.config.ts +1 -6
  195. package/template/tsconfig.json +1 -0
  196. package/src/command-specs/db.js +0 -163
  197. package/src/runtime/app-preview-server.js +0 -312
  198. package/template/packages/notis-sdk/src/config.ts +0 -48
  199. package/template/packages/notis-sdk/src/helpers.ts +0 -131
  200. package/template/packages/notis-sdk/src/hooks/useAppState.ts +0 -50
  201. package/template/packages/notis-sdk/src/hooks/useCollectionItem.ts +0 -58
  202. package/template/packages/notis-sdk/src/hooks/useDatabase.ts +0 -87
  203. package/template/packages/notis-sdk/src/hooks/useDocument.ts +0 -61
  204. package/template/packages/notis-sdk/src/hooks/useNotis.ts +0 -31
  205. package/template/packages/notis-sdk/src/hooks/useNotisNavigation.ts +0 -49
  206. package/template/packages/notis-sdk/src/hooks/useTool.ts +0 -49
  207. package/template/packages/notis-sdk/src/hooks/useUpsertDocument.ts +0 -57
  208. package/template/packages/notis-sdk/src/index.ts +0 -47
  209. package/template/packages/notis-sdk/src/provider.tsx +0 -44
  210. package/template/packages/notis-sdk/src/runtime.ts +0 -159
  211. package/template/packages/notis-sdk/src/styles.css +0 -123
  212. package/template/packages/notis-sdk/src/vite.ts +0 -54
  213. package/template/packages/notis-sdk/tsconfig.json +0 -15
  214. /package/{template/packages/notis-sdk → dist/sdk}/src/hooks/useBackend.ts +0 -0
  215. /package/{template/packages/notis-sdk → dist/sdk}/src/hooks/useTools.ts +0 -0
  216. /package/{template/packages/notis-sdk → dist/sdk}/src/ui.ts +0 -0
@@ -0,0 +1,905 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { join } from 'node:path';
3
+ import type {
4
+ AgentTargets,
5
+ LocalSkill,
6
+ NotisSyncState,
7
+ SkillSyncFailure,
8
+ SyncPullResponse,
9
+ SyncSettings,
10
+ SyncedSkill,
11
+ } from "./types";
12
+ import { normalizeAgentTargets } from "./types";
13
+ import { assertNativePull, checkedNativeReference, isNativeSkill, nativeState } from './native-identity';
14
+ import { bindNativeFolders, hasNativeFolderHistory, nativeLinkRows, nativeLocalReady, previousNativeState, retainMissingNativeState } from './native-plan';
15
+ import {
16
+ downloadSkillBundle,
17
+ fetchSyncSettings,
18
+ pullSkills,
19
+ pushChangedSkills,
20
+ updateAgentTargets,
21
+ } from "./cloud-client";
22
+ import {
23
+ deleteLocalSkill,
24
+ gatherTopLevelLocalSkills,
25
+ getSkillSyncPathsForUser,
26
+ readLegacySyncState,
27
+ readSyncState,
28
+ scanLocalSkills,
29
+ writeCloudSkillToDisk,
30
+ writeSyncState,
31
+ } from "./local-scanner";
32
+ import {
33
+ detectDeletedAgentSymlinks,
34
+ removeForeignAccountSymlinks,
35
+ removeAllSymlinksForSkill,
36
+ syncSymlinks,
37
+ type DeletedAgentSymlink,
38
+ } from "./symlink-manager";
39
+ import { getPushCandidates, selectCloudSkillsToApply } from "./sync-plan";
40
+ import { writeCloudSkillWithBundleFallback } from "./write-cloud-skill";
41
+ export { fetchSyncSettings } from './cloud-client';
42
+
43
+ export interface RunSkillSyncResult {
44
+ syncEnabled: boolean;
45
+ pushed: number;
46
+ pulled: number;
47
+ downloaded: number;
48
+ deleted: number;
49
+ /** Skills deactivated for a specific agent because the user deleted that agent's local
50
+ * symlink (e.g. `rm ~/.claude/skills/<skill>`); the deletion is honored instead of recreated. */
51
+ deactivated: number;
52
+ linked: number;
53
+ removed: number;
54
+ skipped: number;
55
+ lastSyncedAt: string | null;
56
+ /** Skills the server rejected (e.g. an invalid SKILL.md description). The rest of
57
+ * the batch still syncs; these are surfaced so the user knows what to fix. */
58
+ failedPushes: SkillSyncFailure[];
59
+ failedLinks?: SkillSyncFailure[];
60
+ }
61
+
62
+ export interface RunSkillSyncOptions {
63
+ /** Electron's scheduled invocation honors the account preference. A manual
64
+ * CLI sync always runs, even when automatic Desktop refresh is disabled. */
65
+ honorSyncEnabled?: boolean;
66
+ /** All account paths, scanner roots and agent links use this explicit home. */
67
+ syncHome?: string;
68
+ }
69
+
70
+ const BASE_SKILL_NAMES = new Set(['notis-apps', 'notis-query', 'notis-cli']);
71
+
72
+ function withoutBaseSkills(pullResponse: SyncPullResponse): SyncPullResponse {
73
+ return {
74
+ ...pullResponse,
75
+ skills: pullResponse.skills.filter((skill) => !BASE_SKILL_NAMES.has(skill.name)),
76
+ };
77
+ }
78
+
79
+ function withoutBaseSkillState(state: NotisSyncState): NotisSyncState {
80
+ return {
81
+ ...state,
82
+ skills: Object.fromEntries(
83
+ Object.entries(state.skills).filter(([name]) => !BASE_SKILL_NAMES.has(name)),
84
+ ),
85
+ };
86
+ }
87
+
88
+ export interface MaterializeCloudSkillsResult {
89
+ /** Cloud skills verified present in the final scoped disk scan. */
90
+ materializedSkillNames: string[];
91
+ pulled: number;
92
+ downloaded: number;
93
+ deleted: number;
94
+ removed: number;
95
+ lastSyncedAt: string | null;
96
+ failedLinks?: SkillSyncFailure[];
97
+ }
98
+
99
+ export interface MaterializeCloudSkillsOptions {
100
+ /** Server-verified Notis identity, when Desktop's auth subject differs. */
101
+ canonicalUserId?: string;
102
+ /** Previously authenticated settings must agree with the pull before I/O. */
103
+ syncSettings?: SyncSettings;
104
+ /** Re-link only these cloud skills without removing or adopting other links. */
105
+ relinkSkillNames?: readonly string[];
106
+ }
107
+
108
+ interface RunSkillSyncDependencies {
109
+ fetchSyncSettings: typeof fetchSyncSettings;
110
+ pullSkills: typeof pullSkills;
111
+ pushChangedSkills: typeof pushChangedSkills;
112
+ downloadSkillBundle: typeof downloadSkillBundle;
113
+ gatherTopLevelLocalSkills: typeof gatherTopLevelLocalSkills;
114
+ readLegacySyncState: typeof readLegacySyncState;
115
+ readSyncState: typeof readSyncState;
116
+ scanLocalSkills: typeof scanLocalSkills;
117
+ deleteLocalSkill: typeof deleteLocalSkill;
118
+ writeCloudSkillToDisk: typeof writeCloudSkillToDisk;
119
+ writeSyncState: typeof writeSyncState;
120
+ removeAllSymlinksForSkill: typeof removeAllSymlinksForSkill;
121
+ syncSymlinks: typeof syncSymlinks;
122
+ detectDeletedAgentSymlinks: typeof detectDeletedAgentSymlinks;
123
+ removeForeignAccountSymlinks: typeof removeForeignAccountSymlinks;
124
+ updateAgentTargets: typeof updateAgentTargets;
125
+ }
126
+
127
+ const DEFAULT_RUN_SKILL_SYNC_DEPS: RunSkillSyncDependencies = {
128
+ fetchSyncSettings,
129
+ pullSkills,
130
+ pushChangedSkills,
131
+ downloadSkillBundle,
132
+ gatherTopLevelLocalSkills,
133
+ readLegacySyncState,
134
+ readSyncState,
135
+ scanLocalSkills,
136
+ deleteLocalSkill,
137
+ writeCloudSkillToDisk,
138
+ writeSyncState,
139
+ removeAllSymlinksForSkill,
140
+ syncSymlinks,
141
+ detectDeletedAgentSymlinks,
142
+ removeForeignAccountSymlinks,
143
+ updateAgentTargets,
144
+ };
145
+
146
+ export function isolatedLinkOptions(paths: ReturnType<typeof getSkillSyncPathsForUser>) {
147
+ if (!paths.isolatedNamespace && !paths.homeDir) return undefined;
148
+ const root = paths.isolatedNamespace
149
+ ? join(paths.syncRoot, 'environments', paths.isolatedNamespace, 'agents') : paths.homeDir!;
150
+ return { agentSkillDirs: { notis: paths.skillsDir, claude_code: join(root, '.claude', 'skills'),
151
+ cursor: join(root, '.cursor', 'skills'), codex: join(root, '.codex', 'skills') },
152
+ legacyGlobalSkillsDir: join(root, '.agents', 'skills') };
153
+ }
154
+
155
+ function assertSyncEnvironment(pull: SyncPullResponse, settings: SyncSettings): void {
156
+ if ((pull.sync_namespace || null) !== (settings.sync_namespace || null)
157
+ || (settings.sync_scope !== undefined && pull.sync_scope !== settings.sync_scope)) {
158
+ throw new Error('Skill sync environment changed. Existing mirrors were retained.');
159
+ }
160
+ }
161
+ function toSkillMap(skills: LocalSkill[]): Map<string, LocalSkill> {
162
+ return new Map(skills.map((skill) => [skill.name, skill]));
163
+ }
164
+
165
+ function decodeJwtSubject(jwt: string): string | null {
166
+ try {
167
+ const parts = jwt.split(".");
168
+ if (parts.length !== 3) return null;
169
+ const decoded = JSON.parse(
170
+ Buffer.from(parts[1], "base64url").toString(),
171
+ ) as { sub?: unknown };
172
+ return typeof decoded.sub === "string" && decoded.sub.trim()
173
+ ? decoded.sub.trim()
174
+ : null;
175
+ } catch {
176
+ return null;
177
+ }
178
+ }
179
+
180
+ function cloudContentHash(skill: SyncPullResponse['skills'][number]): string {
181
+ return createHash('sha256').update(JSON.stringify({
182
+ md: skill.skill_md,
183
+ hash: skill.skill_folder_hash,
184
+ source: skill.skill_source_url,
185
+ files: skill.bundle_files?.slice().sort((a, b) => a.path.localeCompare(b.path)),
186
+ hydrationFailed: skill.bundle_hydration_failed === true,
187
+ })).digest('hex');
188
+ }
189
+
190
+ export function shouldWriteCloudSkill(
191
+ cloudSkill: SyncPullResponse["skills"][number],
192
+ localSkills: Map<string, LocalSkill>,
193
+ previousState: NotisSyncState,
194
+ ): boolean {
195
+ const skillName = cloudSkill.name;
196
+ const cloudHash = cloudSkill.skill_folder_hash || "";
197
+ const localSkill = localSkills.get(skillName);
198
+ if (!localSkill) {
199
+ return true;
200
+ }
201
+
202
+ if (isNativeSkill(cloudSkill)) {
203
+ if (localSkill.nativeScope && localSkill.nativeScope !== cloudSkill.native_scope) return false;
204
+ if (nativeLocalReady(cloudSkill, localSkill)) return false;
205
+ const nativePrevious = previousNativeState(previousState, cloudSkill);
206
+ return (localSkill.nativeFilesCurrent === true && localSkill.folderHash === localSkill.nativeBaseFolderHash)
207
+ || (!localSkill.nativeReference && nativePrevious?.cloudId === cloudSkill.id
208
+ && nativePrevious.folderHash === localSkill.folderHash);
209
+ }
210
+
211
+ const previous = previousState.skills[skillName];
212
+ if (previous?.folderHash === localSkill.folderHash
213
+ && previous.cloudContentHash === cloudContentHash(cloudSkill)) return false;
214
+
215
+ // Without a stored content baseline (skills served through a signed source URL keep
216
+ // none, because that URL changes on every pull) the only other signal is
217
+ // `skill_folder_hash`, which the server does NOT always compute the way the local
218
+ // folder hash is computed. A bundle-entry hash can never equal the hash of the
219
+ // extracted directory, and comparing the two replaced the folder on every sync
220
+ // tick, deleting whatever a running skill had written inside it. Compare cloud against cloud instead: rewrite when the cloud
221
+ // revision we last applied has actually moved.
222
+ if (previous
223
+ && previous.cloudContentHash === undefined
224
+ && previous.cloudId === cloudSkill.id
225
+ && previous.appliedCloudFolderHash !== undefined
226
+ && previous.appliedCloudFolderHash === cloudHash
227
+ && previous.cloudUpdatedAt === cloudSkill.updated_at
228
+ && previous.folderHash === localSkill.folderHash) {
229
+ return false;
230
+ }
231
+
232
+ if (cloudSkill.source === "curated") {
233
+ return cloudHash ? cloudHash !== localSkill.folderHash : true;
234
+ }
235
+
236
+ const localChangedSinceLastSync =
237
+ !previous || previous.folderHash !== localSkill.folderHash;
238
+ return (
239
+ !localChangedSinceLastSync &&
240
+ ((Boolean(cloudHash) && cloudHash !== localSkill.folderHash)
241
+ || Boolean(previous?.cloudContentHash && previous.cloudContentHash !== cloudContentHash(cloudSkill)))
242
+ );
243
+ }
244
+
245
+ type AppliedCloudRevisions = Record<string, string | undefined>;
246
+
247
+ /**
248
+ * The cloud revision each folder's last successful write applied: this run's revision
249
+ * for the skills we just wrote, the previously recorded one otherwise. A failed write
250
+ * records nothing, so the next sync retries it.
251
+ */
252
+ function collectAppliedCloudRevisions(
253
+ pullResponse: SyncPullResponse,
254
+ previousState: NotisSyncState,
255
+ writtenSkillNames: ReadonlySet<string>,
256
+ ): AppliedCloudRevisions {
257
+ const applied: AppliedCloudRevisions = {};
258
+ for (const skill of selectCloudSkillsToApply(pullResponse.skills)) {
259
+ applied[skill.name] = writtenSkillNames.has(skill.name)
260
+ ? skill.skill_folder_hash || ""
261
+ : previousState.skills[skill.name]?.appliedCloudFolderHash;
262
+ }
263
+ return applied;
264
+ }
265
+
266
+ /**
267
+ * The files each folder's last successful write put on disk: this run's list for the
268
+ * skills we just wrote, the previously recorded one otherwise. Missing means "no
269
+ * record", which keeps local files rather than deleting what cannot be attributed.
270
+ */
271
+ function collectAppliedFiles(
272
+ pullResponse: SyncPullResponse,
273
+ previousState: NotisSyncState,
274
+ appliedFilesByName: Record<string, string[]>,
275
+ ): Record<string, string[] | undefined> {
276
+ const applied: Record<string, string[] | undefined> = {};
277
+ for (const skill of selectCloudSkillsToApply(pullResponse.skills)) {
278
+ applied[skill.name] = appliedFilesByName[skill.name]
279
+ ?? previousState.skills[skill.name]?.appliedFiles;
280
+ }
281
+ return applied;
282
+ }
283
+
284
+ function buildSyncState(
285
+ pullResponse: SyncPullResponse,
286
+ localSkills: LocalSkill[],
287
+ lastSyncedAt: string | null,
288
+ verifiedAgentLinks: Record<string, Partial<AgentTargets>> = {},
289
+ failedContentNames: ReadonlySet<string> = new Set(),
290
+ appliedRevisions: AppliedCloudRevisions = {},
291
+ appliedFiles: Record<string, string[] | undefined> = {},
292
+ previousState: NotisSyncState = { version: 1, lastSyncedAt: null, skills: {} },
293
+ ): NotisSyncState {
294
+ const localSkillMap = toSkillMap(localSkills);
295
+ // The same row selection the writer used, so the state describes the revision that
296
+ // is actually on disk rather than whichever duplicate row the server listed last.
297
+ const skills = Object.fromEntries<SyncedSkill>(
298
+ selectCloudSkillsToApply(pullResponse.skills).map((skill): [string, SyncedSkill] => {
299
+ const localSkill = localSkillMap.get(skill.name);
300
+ const appliedCloudFolderHash = appliedRevisions[skill.name];
301
+ const appliedFileList = appliedFiles[skill.name];
302
+ if (isNativeSkill(skill) && !nativeLocalReady(skill, localSkill)) {
303
+ const previous = previousNativeState(previousState, skill);
304
+ return [skill.name, { ...(previous || {
305
+ cloudId: skill.id, folderHash: localSkill?.nativeBaseFolderHash || '',
306
+ agentTargets: normalizeAgentTargets(skill.agent_targets), syncedAt: lastSyncedAt || new Date().toISOString(),
307
+ ...(localSkill?.nativeReference ? { nativeReference: localSkill.nativeReference, nativeScope: localSkill.nativeScope } : {}),
308
+ }), nativeUnavailable: true, verifiedAgentLinks: {} }];
309
+ }
310
+ return [
311
+ skill.name,
312
+ {
313
+ cloudId: skill.id,
314
+ ...nativeState(skill),
315
+ folderHash: localSkill?.folderHash || skill.skill_folder_hash || "",
316
+ agentTargets: normalizeAgentTargets(skill.agent_targets),
317
+ verifiedAgentLinks: skill.status === "active" ? verifiedAgentLinks[skill.name] ?? {} : {},
318
+ cloudUpdatedAt: skill.updated_at,
319
+ ...(!failedContentNames.has(skill.name) && !skill.skill_source_url
320
+ ? { cloudContentHash: cloudContentHash(skill) } : {}),
321
+ ...(appliedCloudFolderHash !== undefined ? { appliedCloudFolderHash } : {}),
322
+ ...(appliedFileList !== undefined ? { appliedFiles: appliedFileList } : {}),
323
+ syncedAt: lastSyncedAt || new Date().toISOString(),
324
+ },
325
+ ];
326
+ }),
327
+ );
328
+
329
+ for (const local of localSkills) {
330
+ if (!local.nativeReference || Object.values(skills).some(row => row.cloudId === local.cloudId)) continue;
331
+ skills[local.name] = { cloudId: local.cloudId!, nativeReference: local.nativeReference,
332
+ nativeScope: local.nativeScope, nativeUnavailable: true, verifiedAgentLinks: {},
333
+ folderHash: local.nativeBaseFolderHash || '', agentTargets: normalizeAgentTargets(),
334
+ syncedAt: lastSyncedAt || new Date().toISOString() };
335
+ }
336
+ return retainMissingNativeState(previousState, {
337
+ version: 1,
338
+ lastSyncedAt,
339
+ skills,
340
+ });
341
+ }
342
+
343
+ function buildLocalSymlinkCandidates(
344
+ pullResponse: SyncPullResponse,
345
+ localSkills: LocalSkill[],
346
+ previousState: NotisSyncState,
347
+ ): SyncPullResponse["skills"] {
348
+ const cloudSkillNames = new Set(
349
+ pullResponse.skills.map((skill) => skill.name),
350
+ );
351
+ const localOnlySkills = localSkills
352
+ .filter((skill) => !cloudSkillNames.has(skill.name))
353
+ .filter(skill => !skill.nativeReference && !previousState.skills[skill.name]?.nativeReference
354
+ && !previousState.skills[skill.name]?.nativeUnavailable
355
+ && !hasNativeFolderHistory(previousState, skill.name)
356
+ && !/^native-[a-f0-9-]{36}$/.test(skill.name))
357
+ .map((skill) => {
358
+ const previous = previousState.skills[skill.name];
359
+ return {
360
+ id: previous?.cloudId || `local-${skill.name}`,
361
+ name: skill.name,
362
+ description: skill.description || null,
363
+ skill_md: skill.skillMd,
364
+ agent_targets: previous?.agentTargets,
365
+ skill_folder_hash: skill.folderHash,
366
+ source: "local",
367
+ status: "active",
368
+ };
369
+ });
370
+
371
+ return [...nativeLinkRows(pullResponse, localSkills), ...localOnlySkills];
372
+ }
373
+
374
+ function isEmptySyncState(state: NotisSyncState): boolean {
375
+ return state.lastSyncedAt === null && Object.keys(state.skills).length === 0;
376
+ }
377
+
378
+ function applyLegacyFirstRunState(
379
+ localSkills: LocalSkill[],
380
+ scopedState: NotisSyncState,
381
+ legacyState: NotisSyncState | null,
382
+ ): NotisSyncState {
383
+ if (!isEmptySyncState(scopedState) || !legacyState) {
384
+ return scopedState;
385
+ }
386
+
387
+ const migratedSkills = Object.fromEntries(
388
+ localSkills.flatMap((skill) => {
389
+ const previous = legacyState.skills[skill.name];
390
+ if (!previous || previous.folderHash !== skill.folderHash) {
391
+ return [];
392
+ }
393
+ return [[skill.name, previous]];
394
+ }),
395
+ );
396
+
397
+ if (Object.keys(migratedSkills).length === 0) {
398
+ return scopedState;
399
+ }
400
+
401
+ return {
402
+ version: 1,
403
+ lastSyncedAt: legacyState.lastSyncedAt,
404
+ skills: migratedSkills,
405
+ };
406
+ }
407
+
408
+ async function writePulledSkillsToScopedMirror(
409
+ pullResponse: SyncPullResponse,
410
+ localSkills: LocalSkill[],
411
+ previousState: NotisSyncState,
412
+ syncPaths: ReturnType<typeof getSkillSyncPathsForUser>,
413
+ deps: Pick<
414
+ RunSkillSyncDependencies,
415
+ "downloadSkillBundle" | "writeCloudSkillToDisk"
416
+ >,
417
+ failures: SkillSyncFailure[] = [],
418
+ writtenSkillNames: Set<string> = new Set(),
419
+ appliedFilesByName: Record<string, string[]> = {},
420
+ acknowledgedNative: ReadonlyMap<string, string> = new Map(),
421
+ ): Promise<number> {
422
+ const localSkillMap = toSkillMap(localSkills);
423
+ const warnSkillSync = (message: string, error: unknown): void => {
424
+ console.warn(`[Notis] ${message}`, error);
425
+ };
426
+
427
+ let downloaded = 0;
428
+ for (const cloudSkill of selectCloudSkillsToApply(pullResponse.skills)) {
429
+ const local = localSkillMap.get(cloudSkill.name);
430
+ const acknowledged = isNativeSkill(cloudSkill) && local && acknowledgedNative.get(cloudSkill.id) === local.folderHash;
431
+ if (!acknowledged && !shouldWriteCloudSkill(cloudSkill, localSkillMap, previousState)) {
432
+ if (isNativeSkill(cloudSkill) && !nativeLocalReady(cloudSkill, local)) {
433
+ failures.push({ name: cloudSkill.name, error: 'Local native Skill changes were retained; resolve the version or identity conflict before using them.' });
434
+ }
435
+ continue;
436
+ }
437
+
438
+ const outcome = await writeCloudSkillWithBundleFallback(cloudSkill, {
439
+ downloadSkillBundle: deps.downloadSkillBundle,
440
+ writeCloudSkillToDisk: (skill, bundleBytes) =>
441
+ deps.writeCloudSkillToDisk(skill, bundleBytes, syncPaths, {
442
+ // What the last write put there. Everything else in the folder was
443
+ // produced locally and survives this one.
444
+ previouslyApplied: previousNativeState(previousState, skill)?.appliedFiles,
445
+ ...(isNativeSkill(skill) && local ? { expectedFolderHash: local.folderHash } : {}),
446
+ }),
447
+ onWarning: warnSkillSync,
448
+ });
449
+ if (outcome) {
450
+ downloaded += 1;
451
+ writtenSkillNames.add(cloudSkill.name);
452
+ if (typeof outcome === "object" && Array.isArray(outcome.appliedFiles)) {
453
+ appliedFilesByName[cloudSkill.name] = outcome.appliedFiles;
454
+ }
455
+ } else {
456
+ failures.push({ name: cloudSkill.name, error: "Skill content could not be downloaded or written; sync will retry" });
457
+ }
458
+ }
459
+
460
+ return downloaded;
461
+ }
462
+
463
+ function assertSkillsPullAuthorized(
464
+ pullResponse: SyncPullResponse,
465
+ ): void {
466
+ if (!Array.isArray(pullResponse.skills)) throw new Error('Skill inventory is unavailable; existing files were retained.');
467
+ assertNativePull(pullResponse);
468
+ if (
469
+ pullResponse.entitlement_access?.code === "entitlement_upgrade_required"
470
+ && pullResponse.entitlement_access.entitlement === "skills"
471
+ ) {
472
+ // Current servers return HTTP 403, so cloud-client rejects before producing
473
+ // a SyncPullResponse. Keep this guard for an older server's successful
474
+ // empty-denial envelope, but fail closed: it is never authorization to
475
+ // delete a user's managed local mirror.
476
+ throw new Error(
477
+ "Skill sync access was denied; preserving existing local skills.",
478
+ );
479
+ }
480
+ }
481
+
482
+ export async function materializeCloudSkillsForLocalShell(
483
+ serverUrl: string,
484
+ jwt: string,
485
+ dependencies: Partial<Pick<
486
+ RunSkillSyncDependencies,
487
+ | "downloadSkillBundle"
488
+ | "deleteLocalSkill"
489
+ | "pullSkills"
490
+ | "readSyncState"
491
+ | "removeAllSymlinksForSkill"
492
+ | "scanLocalSkills"
493
+ | "syncSymlinks"
494
+ | "writeCloudSkillToDisk"
495
+ | "writeSyncState"
496
+ >> = {},
497
+ options: MaterializeCloudSkillsOptions = {},
498
+ ): Promise<MaterializeCloudSkillsResult> {
499
+ const deps = {
500
+ ...DEFAULT_RUN_SKILL_SYNC_DEPS,
501
+ ...dependencies,
502
+ };
503
+ const authUserId = decodeJwtSubject(jwt);
504
+ if (!authUserId) {
505
+ throw new Error(
506
+ "Cannot materialize skills without a valid authenticated desktop session.",
507
+ );
508
+ }
509
+
510
+ let pullResponse = await deps.pullSkills(serverUrl, jwt);
511
+ assertSkillsPullAuthorized(pullResponse);
512
+ if (options.syncSettings) assertSyncEnvironment(pullResponse, options.syncSettings);
513
+ const syncPaths = getSkillSyncPathsForUser(options.canonicalUserId?.trim() || authUserId, undefined, pullResponse.sync_namespace);
514
+ const previousState = await deps.readSyncState(syncPaths);
515
+
516
+ const localSkills = await deps.scanLocalSkills(syncPaths);
517
+ pullResponse = bindNativeFolders(pullResponse, localSkills, previousState);
518
+ const failedDownloads: SkillSyncFailure[] = [];
519
+ const writtenSkillNames = new Set<string>();
520
+ const appliedFilesByName: Record<string, string[]> = {};
521
+ const downloaded = await writePulledSkillsToScopedMirror(
522
+ pullResponse,
523
+ localSkills,
524
+ previousState,
525
+ syncPaths,
526
+ deps,
527
+ failedDownloads,
528
+ writtenSkillNames,
529
+ appliedFilesByName,
530
+ );
531
+ const finalLocalSkills = await deps.scanLocalSkills(syncPaths);
532
+ const lastSyncedAt = pullResponse.last_synced_at || new Date().toISOString();
533
+
534
+ const relinkSkillNames = new Set(options.relinkSkillNames || []);
535
+ const failures = [...failedDownloads];
536
+ const verifiedLinks: Record<string, Partial<AgentTargets>> = {};
537
+ for (const skill of pullResponse.skills) {
538
+ const previous = previousState.skills[skill.name];
539
+ if (skill.updated_at && previous?.cloudId === skill.id && previous.cloudUpdatedAt === skill.updated_at) {
540
+ verifiedLinks[skill.name] = { ...previous.verifiedAgentLinks };
541
+ }
542
+ }
543
+ if (relinkSkillNames.size > 0) {
544
+ const relinked = await deps.syncSymlinks(
545
+ nativeLinkRows(pullResponse, finalLocalSkills).filter((skill) => relinkSkillNames.has(skill.name)),
546
+ syncPaths.skillsDir,
547
+ { ...isolatedLinkOptions(syncPaths), removeUndesired: false },
548
+ );
549
+ failures.push(...(relinked.failures ?? []).filter(
550
+ (failure) => !failedDownloads.some((download) => download.name === failure.name),
551
+ ));
552
+ for (const name of relinkSkillNames) {
553
+ verifiedLinks[name] = relinked.verifiedAgentLinks?.[name] ?? {};
554
+ }
555
+ }
556
+ for (const failure of failedDownloads) delete verifiedLinks[failure.name];
557
+
558
+ const materializedState = buildSyncState(
559
+ pullResponse, finalLocalSkills, lastSyncedAt, verifiedLinks,
560
+ new Set(failedDownloads.map(item => item.name)),
561
+ collectAppliedCloudRevisions(pullResponse, previousState, writtenSkillNames),
562
+ collectAppliedFiles(pullResponse, previousState, appliedFilesByName),
563
+ previousState,
564
+ );
565
+ // Pull-only refresh is not an upload acknowledgement. Keep content baselines
566
+ // unless we actually wrote cloud content, and retain cloud-missing entries so
567
+ // the next two-way sync can delete them instead of uploading them as new.
568
+ for (const [name, entry] of Object.entries(materializedState.skills)) {
569
+ if (writtenSkillNames.has(name)) continue;
570
+ const previous = previousState.skills[name];
571
+ if (previous) {
572
+ entry.folderHash = previous.folderHash;
573
+ entry.cloudContentHash = previous.cloudContentHash;
574
+ } else {
575
+ delete materializedState.skills[name];
576
+ }
577
+ }
578
+ materializedState.skills = { ...previousState.skills, ...materializedState.skills };
579
+ let removed = 0;
580
+ for (const [name, previous] of Object.entries(previousState.skills)) {
581
+ if (!previous.nativeReference && !previous.nativeUnavailable) continue;
582
+ const current = pullResponse.skills.find(skill => skill.id === previous.cloudId);
583
+ if (!current || !nativeLocalReady(current, finalLocalSkills.find(local => local.name === current.name))) {
584
+ removed += await deps.removeAllSymlinksForSkill(name, syncPaths.skillsDir, isolatedLinkOptions(syncPaths));
585
+ }
586
+ }
587
+ await deps.writeSyncState(materializedState, syncPaths);
588
+
589
+ return {
590
+ materializedSkillNames: pullResponse.skills
591
+ .filter((skill) => finalLocalSkills.some((local) => local.name === skill.name)
592
+ && (!isNativeSkill(skill) || (skill.status === 'active' && skill.agent_targets?.notis !== false
593
+ && nativeLocalReady(skill, finalLocalSkills.find(local => local.name === skill.name))))
594
+ && !failedDownloads.some((failure) => failure.name === skill.name))
595
+ .map((skill) => skill.name),
596
+ pulled: pullResponse.skills.length,
597
+ downloaded,
598
+ deleted: 0,
599
+ removed,
600
+ lastSyncedAt,
601
+ ...(failures.length ? { failedLinks: failures } : {}),
602
+ };
603
+ }
604
+
605
+ /**
606
+ * When the user deletes a skill's symlink for a single agent (e.g. `rm ~/.claude/skills/<skill>`),
607
+ * honor that as "remove this skill from that agent" by deactivating it in the portal, instead of
608
+ * recreating the symlink on the next sync. The cloud `agent_targets` are mutated in place so the
609
+ * subsequent symlink reconciliation treats the link as undesired. Returns the number of
610
+ * (skill, agent) pairs deactivated.
611
+ */
612
+ async function deactivateDeletedAgentSkills(
613
+ serverUrl: string,
614
+ jwt: string,
615
+ pullResponse: SyncPullResponse,
616
+ previousState: NotisSyncState,
617
+ scopedState: NotisSyncState,
618
+ skillsDir: string,
619
+ deps: Pick<
620
+ RunSkillSyncDependencies,
621
+ "detectDeletedAgentSymlinks" | "updateAgentTargets" | "pullSkills"
622
+ >,
623
+ failures: SkillSyncFailure[],
624
+ linkOptions?: ReturnType<typeof isolatedLinkOptions>,
625
+ ): Promise<number> {
626
+ // First sync (incl. legacy migration) has no reliable "we created this link" signal, so we
627
+ // cannot tell a user deletion apart from a never-created link — skip detection entirely.
628
+ if (isEmptySyncState(scopedState)) {
629
+ return 0;
630
+ }
631
+
632
+ const deletions = await deps.detectDeletedAgentSymlinks(
633
+ pullResponse.skills.map(skill => {
634
+ const previous = isNativeSkill(skill) ? Object.entries(previousState.skills).find(([, row]) => row.cloudId === skill.id) : undefined;
635
+ return previous ? { ...skill, name: previous[0] } : skill;
636
+ }),
637
+ previousState,
638
+ skillsDir,
639
+ linkOptions,
640
+ );
641
+ if (deletions.length === 0) {
642
+ return 0;
643
+ }
644
+
645
+ const agentsBySkill = new Map<string, { skillName: string; agents: Set<DeletedAgentSymlink["agent"]> }>();
646
+ for (const deletion of deletions) {
647
+ const entry = agentsBySkill.get(deletion.skillId) ?? {
648
+ skillName: deletion.skillName,
649
+ agents: new Set<DeletedAgentSymlink["agent"]>(),
650
+ };
651
+ entry.agents.add(deletion.agent);
652
+ agentsBySkill.set(deletion.skillId, entry);
653
+ }
654
+
655
+ const fresh = withoutBaseSkills(await deps.pullSkills(serverUrl, jwt));
656
+ assertSkillsPullAuthorized(fresh);
657
+ assertSyncEnvironment(fresh, pullResponse);
658
+ Object.assign(pullResponse, fresh);
659
+ let needsRefresh = false;
660
+ let deactivated = 0;
661
+ for (const [skillId, { skillName, agents }] of agentsBySkill) {
662
+ const skill = pullResponse.skills.find((item) => item.id === skillId);
663
+ const previous = previousState.skills[skillName];
664
+ if (!skill) continue;
665
+ const native = isNativeSkill(skill);
666
+ if (native) {
667
+ if (!skill.native_settings || previous?.nativeSettingsRevision !== skill.native_settings.revision) continue;
668
+ } else if (!skill.updated_at || previous?.cloudUpdatedAt !== skill.updated_at) continue;
669
+ const patch = Object.fromEntries([...agents].map((agent) => [agent, false]));
670
+ try {
671
+ const saved = await deps.updateAgentTargets(serverUrl, jwt, skillId, patch,
672
+ native ? undefined : skill.updated_at, native ? { reference: skill.native_reference!, settingsRevision: skill.native_settings!.revision } : undefined);
673
+ if (saved.success !== true || !saved.updated_at?.trim()
674
+ || (!native && saved.updated_at === skill.updated_at)
675
+ || !['notis', 'claude_code', 'cursor', 'codex'].every((agent) =>
676
+ typeof saved.agent_targets?.[agent as keyof AgentTargets] === 'boolean')
677
+ || ![...agents].every((agent) => saved.agent_targets[agent] === false)) {
678
+ throw new Error('Assignment update did not return a verified saved revision');
679
+ }
680
+ skill.agent_targets = saved.agent_targets;
681
+ skill.updated_at = saved.updated_at;
682
+ if (native) needsRefresh = true;
683
+ deactivated += agents.size;
684
+ } catch (error) {
685
+ needsRefresh = true;
686
+ failures.push({ name: skillName, error: 'Could not save the local agent removal; refreshed saved assignments' });
687
+ console.warn(`[skill-sync] Assignment changed or could not be saved for "${skillName}"; refreshing before reconciliation.`, error);
688
+ }
689
+ }
690
+ if (needsRefresh) {
691
+ const refreshed = withoutBaseSkills(await deps.pullSkills(serverUrl, jwt));
692
+ assertSkillsPullAuthorized(refreshed);
693
+ assertSyncEnvironment(refreshed, pullResponse);
694
+ Object.assign(pullResponse, refreshed);
695
+ }
696
+ return deactivated;
697
+ }
698
+
699
+ export async function runSkillSync(
700
+ serverUrl: string,
701
+ jwt: string,
702
+ dependencies: Partial<RunSkillSyncDependencies> = {},
703
+ options: RunSkillSyncOptions = {},
704
+ ): Promise<RunSkillSyncResult> {
705
+ const deps = {
706
+ ...DEFAULT_RUN_SKILL_SYNC_DEPS,
707
+ ...dependencies,
708
+ };
709
+ const syncSettings: SyncSettings = await deps.fetchSyncSettings(
710
+ serverUrl,
711
+ jwt,
712
+ );
713
+ // Supabase Desktop sessions use the auth-user id as JWT `sub`, while CLI
714
+ // OAuth credentials use the canonical Notis `users.user_id`. Trust the
715
+ // authenticated server response so both transports share one local mirror.
716
+ // The token fallback keeps the CLI compatible with an older server during
717
+ // rollout, where sync-settings did not yet return user_id.
718
+ const syncUserId = syncSettings.user_id?.trim() || decodeJwtSubject(jwt);
719
+ if (!syncUserId) {
720
+ throw new Error(
721
+ "Cannot sync skills without a server-verified account identity.",
722
+ );
723
+ }
724
+ const syncPaths = getSkillSyncPathsForUser(syncUserId, undefined, syncSettings.sync_namespace, options.syncHome);
725
+ if (options.honorSyncEnabled !== false && !syncSettings.sync_enabled) {
726
+ // Disabled sync intentionally performs no pull. Only the authenticated
727
+ // settings scope may remove foreign links; account mirrors stay untouched.
728
+ const foreignLinksRemoved = await deps.removeForeignAccountSymlinks(
729
+ syncPaths.skillsDir, isolatedLinkOptions(syncPaths),
730
+ );
731
+ return {
732
+ syncEnabled: false,
733
+ pushed: 0,
734
+ pulled: 0,
735
+ downloaded: 0,
736
+ deleted: 0,
737
+ deactivated: 0,
738
+ linked: 0,
739
+ removed: foreignLinksRemoved,
740
+ skipped: 0,
741
+ lastSyncedAt: syncSettings.last_synced_at,
742
+ failedPushes: [],
743
+ };
744
+ }
745
+
746
+ let pullResponse = withoutBaseSkills(await deps.pullSkills(serverUrl, jwt));
747
+ assertSkillsPullAuthorized(pullResponse);
748
+ assertSyncEnvironment(pullResponse, syncSettings);
749
+ const foreignLinksRemoved = await deps.removeForeignAccountSymlinks(
750
+ syncPaths.skillsDir, isolatedLinkOptions(syncPaths),
751
+ );
752
+
753
+ const cloudCuratedSkillNames = new Set(
754
+ pullResponse.skills
755
+ .filter((skill) => skill.source === "curated")
756
+ .map((skill) => skill.name),
757
+ );
758
+ const protectedSkillNames = new Set([...cloudCuratedSkillNames, ...BASE_SKILL_NAMES]);
759
+ const scopedState = withoutBaseSkillState(await deps.readSyncState(syncPaths));
760
+ const assignmentFailures: SkillSyncFailure[] = [];
761
+ const deactivated = !syncPaths.isolatedNamespace && syncSettings.agent_targets_conditional_updates === true
762
+ ? await deactivateDeletedAgentSkills(
763
+ serverUrl, jwt, pullResponse, scopedState, scopedState, syncPaths.skillsDir, deps, assignmentFailures, isolatedLinkOptions(syncPaths),
764
+ )
765
+ : 0;
766
+ assertSyncEnvironment(pullResponse, syncSettings);
767
+ const authUserId = decodeJwtSubject(jwt);
768
+ let previousAuthState: NotisSyncState | null = null;
769
+ if (!syncPaths.isolatedNamespace && authUserId && authUserId !== syncUserId) {
770
+ const previousAuthPaths = getSkillSyncPathsForUser(authUserId, undefined, undefined, options.syncHome);
771
+ previousAuthState = await deps.readSyncState(previousAuthPaths);
772
+ await deps.gatherTopLevelLocalSkills(syncPaths, {
773
+ sourceRoots: [{ label: "previous-auth-scope", root: previousAuthPaths.skillsDir }],
774
+ protectedSkillNames,
775
+ });
776
+ }
777
+ if (!syncPaths.isolatedNamespace) {
778
+ await deps.gatherTopLevelLocalSkills(syncPaths, { protectedSkillNames });
779
+ }
780
+ const localSkills = (await deps.scanLocalSkills(syncPaths))
781
+ .filter((skill) => !BASE_SKILL_NAMES.has(skill.name));
782
+ const previousState = withoutBaseSkillState(applyLegacyFirstRunState(
783
+ localSkills,
784
+ scopedState,
785
+ !syncPaths.isolatedNamespace && isEmptySyncState(scopedState)
786
+ ? (!previousAuthState || isEmptySyncState(previousAuthState)
787
+ ? await deps.readLegacySyncState(syncPaths)
788
+ : previousAuthState)
789
+ : null,
790
+ ));
791
+ pullResponse = bindNativeFolders(pullResponse, localSkills, previousState);
792
+ const gatheredSymlinkResult = await deps.syncSymlinks(
793
+ buildLocalSymlinkCandidates(pullResponse, localSkills, previousState),
794
+ syncPaths.skillsDir,
795
+ isolatedLinkOptions(syncPaths),
796
+ );
797
+ const pushCandidates = getPushCandidates(
798
+ localSkills,
799
+ previousState,
800
+ cloudCuratedSkillNames,
801
+ new Set(pullResponse.skills.map((skill) => skill.name)),
802
+ pullResponse.skills,
803
+ );
804
+
805
+ const failedPushes: SkillSyncFailure[] = [];
806
+ const acknowledgedNative = new Map<string, string>();
807
+ let writeLocalSkills = localSkills;
808
+ if (pushCandidates.length > 0) {
809
+ const pushResult = await deps.pushChangedSkills(serverUrl, jwt, pushCandidates);
810
+ for (const result of pushResult.skills || []) {
811
+ const saved = result as { id?: string; native_reference?: unknown };
812
+ const candidate = pushCandidates.find(local => local.nativeReference && local.cloudId === saved.id);
813
+ if (candidate && saved.native_reference) {
814
+ checkedNativeReference(saved.native_reference, candidate.cloudId);
815
+ acknowledgedNative.set(candidate.cloudId!, candidate.folderHash);
816
+ }
817
+ }
818
+ if (Array.isArray(pushResult?.failed) && pushResult.failed.length > 0) {
819
+ failedPushes.push(...pushResult.failed);
820
+ console.warn(
821
+ `[skill-sync] ${pushResult.failed.length} skill(s) were rejected during push: ` +
822
+ pushResult.failed.map((f) => `${f.name} (${f.error})`).join("; "),
823
+ );
824
+ }
825
+ pullResponse = withoutBaseSkills(await deps.pullSkills(serverUrl, jwt));
826
+ assertSkillsPullAuthorized(pullResponse);
827
+ assertSyncEnvironment(pullResponse, syncSettings);
828
+ writeLocalSkills = (await deps.scanLocalSkills(syncPaths)).filter(skill => !BASE_SKILL_NAMES.has(skill.name));
829
+ pullResponse = bindNativeFolders(pullResponse, writeLocalSkills, previousState);
830
+ }
831
+
832
+ // Delete phase: remove skills that were previously synced but are no longer in the cloud
833
+ const cloudSkillNames = new Set(pullResponse.skills.map((s) => s.name));
834
+ let deleted = 0;
835
+ for (const skillName of Object.keys(previousState.skills)) {
836
+ if (!cloudSkillNames.has(skillName)) {
837
+ const previous = previousState.skills[skillName];
838
+ if (previous.nativeReference || previous.nativeUnavailable
839
+ || pullResponse.skills.some(skill => isNativeSkill(skill) && skill.id === previous.cloudId)) {
840
+ await deps.removeAllSymlinksForSkill(skillName, syncPaths.skillsDir, isolatedLinkOptions(syncPaths));
841
+ continue;
842
+ }
843
+ await deps.deleteLocalSkill(skillName, syncPaths);
844
+ await deps.removeAllSymlinksForSkill(skillName, syncPaths.skillsDir, isolatedLinkOptions(syncPaths));
845
+ deleted += 1;
846
+ }
847
+ }
848
+
849
+ const failedDownloads: SkillSyncFailure[] = [];
850
+ const writtenSkillNames = new Set<string>();
851
+ const appliedFilesByName: Record<string, string[]> = {};
852
+ const downloaded = await writePulledSkillsToScopedMirror(
853
+ pullResponse,
854
+ writeLocalSkills,
855
+ previousState,
856
+ syncPaths,
857
+ deps,
858
+ failedDownloads,
859
+ writtenSkillNames,
860
+ appliedFilesByName,
861
+ acknowledgedNative,
862
+ );
863
+
864
+ const finalLocalSkills = (await deps.scanLocalSkills(syncPaths))
865
+ .filter((skill) => !BASE_SKILL_NAMES.has(skill.name));
866
+ const symlinkResult = await deps.syncSymlinks(
867
+ buildLocalSymlinkCandidates(pullResponse, finalLocalSkills, previousState),
868
+ syncPaths.skillsDir,
869
+ isolatedLinkOptions(syncPaths),
870
+ );
871
+ const verifiedLinks = { ...(symlinkResult.verifiedAgentLinks ?? {}) };
872
+ for (const failure of failedDownloads) delete verifiedLinks[failure.name];
873
+ const lastSyncedAt = pullResponse.last_synced_at || new Date().toISOString();
874
+
875
+ await deps.writeSyncState(
876
+ buildSyncState(
877
+ pullResponse,
878
+ finalLocalSkills,
879
+ lastSyncedAt,
880
+ verifiedLinks,
881
+ new Set(failedDownloads.map(item => item.name)),
882
+ collectAppliedCloudRevisions(pullResponse, previousState, writtenSkillNames),
883
+ collectAppliedFiles(pullResponse, previousState, appliedFilesByName),
884
+ previousState,
885
+ ),
886
+ syncPaths,
887
+ );
888
+
889
+ return {
890
+ syncEnabled: true,
891
+ pushed: pushCandidates.length,
892
+ pulled: pullResponse.skills.length,
893
+ downloaded,
894
+ deleted,
895
+ deactivated,
896
+ linked: gatheredSymlinkResult.linked + symlinkResult.linked,
897
+ removed: foreignLinksRemoved + gatheredSymlinkResult.removed + symlinkResult.removed,
898
+ skipped: symlinkResult.skipped,
899
+ failedLinks: [...assignmentFailures, ...failedDownloads, ...(symlinkResult.failures ?? []).filter(
900
+ (failure) => !failedDownloads.some((download) => download.name === failure.name),
901
+ )],
902
+ lastSyncedAt,
903
+ failedPushes,
904
+ };
905
+ }