@notis_ai/cli 0.2.0-beta.16.1 → 0.2.0-beta.160.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 (154) hide show
  1. package/README.md +433 -133
  2. package/config/notis_app_boundary_rules.json +50 -0
  3. package/config/notis_app_design_rules.json +135 -0
  4. package/dist/agent-hooks/notis-agent-hook.mjs +18672 -0
  5. package/dist/base-skills/notis-apps/SKILL.md +70 -0
  6. package/dist/base-skills/notis-apps/references/architecture.md +164 -0
  7. package/dist/base-skills/notis-apps/references/context.md +81 -0
  8. package/dist/base-skills/notis-apps/references/design.md +165 -0
  9. package/dist/base-skills/notis-apps/references/reading.md +89 -0
  10. package/dist/base-skills/notis-apps/references/release.md +99 -0
  11. package/dist/base-skills/notis-apps/references/sdk.md +62 -0
  12. package/dist/base-skills/notis-apps/references/troubleshooting.md +23 -0
  13. package/dist/base-skills/notis-cli/SKILL.md +140 -0
  14. package/dist/base-skills/notis-cli/references/app-delivery.md +18 -0
  15. package/dist/base-skills/notis-cli/references/native-databases.md +20 -0
  16. package/dist/base-skills/notis-cli/references/tool-examples.md +56 -0
  17. package/dist/base-skills/notis-cli/references/troubleshooting.md +39 -0
  18. package/dist/base-skills/notis-query/SKILL.md +67 -0
  19. package/dist/base-skills/notis-query/references/database-discovery.md +59 -0
  20. package/dist/base-skills/notis-query/references/documents.md +50 -0
  21. package/dist/base-skills/notis-query/references/query.md +543 -0
  22. package/dist/skill-sync/index.js +1626 -0
  23. package/dist/skill-sync/index.js.map +7 -0
  24. package/dist/skill-sync-worker.mjs +2990 -0
  25. package/package.json +16 -6
  26. package/skills/notis-apps/cli.md +313 -0
  27. package/skills/notis-cli/AGENT_INSTRUCTIONS.md +39 -0
  28. package/skills/notis-onboarding/BRIEF.md +129 -0
  29. package/skills/notis-query/cli.md +39 -0
  30. package/src/agent-hook-entry.js +5 -0
  31. package/src/cli.js +294 -25
  32. package/src/command-specs/agents.js +392 -0
  33. package/src/command-specs/apps.js +1470 -202
  34. package/src/command-specs/auth.js +114 -137
  35. package/src/command-specs/diagnostics.js +716 -0
  36. package/src/command-specs/handover.js +374 -0
  37. package/src/command-specs/helpers.js +84 -82
  38. package/src/command-specs/index.js +25 -6
  39. package/src/command-specs/meta.js +150 -18
  40. package/src/command-specs/onboarding.js +290 -0
  41. package/src/command-specs/profile.js +358 -0
  42. package/src/command-specs/reports.js +86 -0
  43. package/src/command-specs/skills.js +75 -0
  44. package/src/command-specs/smoke.js +386 -0
  45. package/src/command-specs/tools.js +455 -139
  46. package/src/runtime/agent-browser.js +632 -0
  47. package/src/runtime/agent-memory-state.js +126 -0
  48. package/src/runtime/agent-setup.js +383 -0
  49. package/src/runtime/app-boundary-validator.js +404 -0
  50. package/src/runtime/app-changelog.js +79 -0
  51. package/src/runtime/app-platform.js +2633 -210
  52. package/src/runtime/app-registry-scaffolds.js +367 -0
  53. package/src/runtime/app-test-server.js +292 -0
  54. package/src/runtime/assets/store-screenshot-dark.png +0 -0
  55. package/src/runtime/auth-recovery.js +110 -0
  56. package/src/runtime/base-skills.d.ts +20 -0
  57. package/src/runtime/base-skills.js +167 -0
  58. package/src/runtime/channel.js +133 -0
  59. package/src/runtime/delegated-context.js +68 -0
  60. package/src/runtime/errors.js +1 -0
  61. package/src/runtime/git.js +233 -0
  62. package/src/runtime/login-listener.js +15 -0
  63. package/src/runtime/oauth.js +2622 -0
  64. package/src/runtime/output.js +37 -5
  65. package/src/runtime/ports.js +31 -0
  66. package/src/runtime/profiles.js +906 -55
  67. package/src/runtime/skill-sync/cloud-client.ts +99 -0
  68. package/src/runtime/skill-sync/index.ts +697 -0
  69. package/src/runtime/skill-sync/local-scanner.ts +1046 -0
  70. package/src/runtime/skill-sync/symlink-manager.ts +433 -0
  71. package/src/runtime/skill-sync/sync-plan.ts +22 -0
  72. package/src/runtime/skill-sync/types.ts +110 -0
  73. package/src/runtime/skill-sync/write-cloud-skill.ts +50 -0
  74. package/src/runtime/skill-sync-service.js +109 -0
  75. package/src/runtime/store-screenshot.js +143 -0
  76. package/src/runtime/sync-skills.d.ts +37 -0
  77. package/src/runtime/sync-skills.js +231 -0
  78. package/src/runtime/telemetry.js +92 -0
  79. package/src/runtime/transport.js +324 -45
  80. package/src/skill-sync-worker-entry.js +2 -0
  81. package/src/skill-sync-worker.js +50 -0
  82. package/template/.harness/index.html.tmpl +430 -0
  83. package/template/CHANGELOG.md +5 -0
  84. package/template/app/layout.tsx +5 -2
  85. package/template/app/page.tsx +49 -42
  86. package/template/components/page-heading.tsx +23 -0
  87. package/template/components/ui/badge.tsx +7 -4
  88. package/template/components/ui/card.tsx +24 -11
  89. package/template/components/ui/native-select.tsx +24 -0
  90. package/template/notis.config.ts +24 -6
  91. package/template/package-lock.json +4137 -0
  92. package/template/package.json +5 -5
  93. package/template/packages/{notis-sdk → sdk}/package.json +13 -3
  94. package/template/packages/sdk/src/agentContext.ts +36 -0
  95. package/template/packages/sdk/src/components/DocumentEditor.tsx +103 -0
  96. package/template/packages/sdk/src/components/Markdown.tsx +60 -0
  97. package/template/packages/sdk/src/components/MarkdownEditor.tsx +121 -0
  98. package/template/packages/sdk/src/components/MultiSelectActionBar.tsx +285 -0
  99. package/template/packages/sdk/src/components/MultiSelectCheckbox.tsx +97 -0
  100. package/template/packages/sdk/src/components/MultiSelectDragOverlay.tsx +39 -0
  101. package/template/packages/sdk/src/components/NotisCommentBoundary.tsx +172 -0
  102. package/template/packages/sdk/src/components/NotisSelectionBoundary.tsx +59 -0
  103. package/template/packages/sdk/src/components/ShortcutHints.tsx +56 -0
  104. package/template/packages/sdk/src/components/Skeleton.tsx +24 -0
  105. package/template/packages/sdk/src/config.ts +257 -0
  106. package/template/packages/sdk/src/documents.ts +256 -0
  107. package/template/packages/sdk/src/hooks/useActiveResource.ts +19 -0
  108. package/template/packages/sdk/src/hooks/useAgentContext.ts +23 -0
  109. package/template/packages/sdk/src/hooks/useCloudComputer.ts +64 -0
  110. package/template/packages/sdk/src/hooks/useCollectionInteractions.ts +836 -0
  111. package/template/packages/sdk/src/hooks/useDatabaseSchema.ts +49 -0
  112. package/template/packages/sdk/src/hooks/useDatabaseSubscription.ts +76 -0
  113. package/template/packages/sdk/src/hooks/useDocument.ts +43 -0
  114. package/template/packages/sdk/src/hooks/useDocuments.ts +84 -0
  115. package/template/packages/sdk/src/hooks/useHandover.ts +78 -0
  116. package/template/packages/sdk/src/hooks/useLongPressSelection.ts +79 -0
  117. package/template/packages/sdk/src/hooks/useMultiSelect.ts +95 -0
  118. package/template/packages/{notis-sdk → sdk}/src/hooks/useNotis.ts +10 -4
  119. package/template/packages/{notis-sdk → sdk}/src/hooks/useNotisNavigation.ts +11 -8
  120. package/template/packages/sdk/src/hooks/useQuery.ts +71 -0
  121. package/template/packages/sdk/src/hooks/useTool.ts +65 -0
  122. package/template/packages/sdk/src/hooks/useToolQuery.ts +12 -0
  123. package/template/packages/sdk/src/hooks/useTopBarSearch.ts +81 -0
  124. package/template/packages/sdk/src/hooks/useUpsertDocument.ts +95 -0
  125. package/template/packages/sdk/src/index.ts +161 -0
  126. package/template/packages/sdk/src/interactions/actions.ts +59 -0
  127. package/template/packages/sdk/src/interactions/shortcuts.tsx +694 -0
  128. package/template/packages/sdk/src/interactions/visibility.ts +13 -0
  129. package/template/packages/sdk/src/interactions.ts +45 -0
  130. package/template/packages/sdk/src/provider.tsx +44 -0
  131. package/template/packages/sdk/src/queryCache.ts +170 -0
  132. package/template/packages/sdk/src/runtime.ts +451 -0
  133. package/template/packages/sdk/src/styles.css +213 -0
  134. package/template/packages/sdk/src/tailwind.ts +56 -0
  135. package/template/packages/{notis-sdk → sdk}/src/vite.ts +5 -1
  136. package/template/tailwind.config.ts +1 -0
  137. package/src/command-specs/db.js +0 -163
  138. package/src/runtime/app-preview-server.js +0 -312
  139. package/template/packages/notis-sdk/src/config.ts +0 -48
  140. package/template/packages/notis-sdk/src/helpers.ts +0 -131
  141. package/template/packages/notis-sdk/src/hooks/useAppState.ts +0 -50
  142. package/template/packages/notis-sdk/src/hooks/useCollectionItem.ts +0 -58
  143. package/template/packages/notis-sdk/src/hooks/useDatabase.ts +0 -87
  144. package/template/packages/notis-sdk/src/hooks/useDocument.ts +0 -61
  145. package/template/packages/notis-sdk/src/hooks/useTool.ts +0 -49
  146. package/template/packages/notis-sdk/src/hooks/useUpsertDocument.ts +0 -57
  147. package/template/packages/notis-sdk/src/index.ts +0 -47
  148. package/template/packages/notis-sdk/src/provider.tsx +0 -44
  149. package/template/packages/notis-sdk/src/runtime.ts +0 -159
  150. package/template/packages/notis-sdk/src/styles.css +0 -123
  151. /package/template/packages/{notis-sdk → sdk}/src/hooks/useBackend.ts +0 -0
  152. /package/template/packages/{notis-sdk → sdk}/src/hooks/useTools.ts +0 -0
  153. /package/template/packages/{notis-sdk → sdk}/src/ui.ts +0 -0
  154. /package/template/packages/{notis-sdk → sdk}/tsconfig.json +0 -0
@@ -0,0 +1,2990 @@
1
+ // src/skill-sync-worker.js
2
+ import { mkdirSync as mkdirSync3, renameSync as renameSync3, writeFileSync as writeFileSync3 } from "node:fs";
3
+ import { homedir as homedir4 } from "node:os";
4
+ import { join as join5 } from "node:path";
5
+
6
+ // src/runtime/profiles.js
7
+ import { randomUUID } from "node:crypto";
8
+ import {
9
+ existsSync,
10
+ mkdirSync,
11
+ readFileSync,
12
+ renameSync,
13
+ rmSync,
14
+ statSync,
15
+ writeFileSync
16
+ } from "node:fs";
17
+ import { homedir } from "node:os";
18
+ import { dirname, join, parse, resolve } from "node:path";
19
+
20
+ // src/runtime/errors.js
21
+ var EXIT_CODES = {
22
+ ok: 0,
23
+ usage: 2,
24
+ auth: 3,
25
+ network: 4,
26
+ conflict: 5,
27
+ backend: 6,
28
+ unexpected: 7,
29
+ payment: 8
30
+ };
31
+ var CliError = class extends Error {
32
+ constructor({
33
+ code,
34
+ message,
35
+ exitCode = EXIT_CODES.unexpected,
36
+ retryable = false,
37
+ details = {},
38
+ hints = [],
39
+ warnings = [],
40
+ cause
41
+ }) {
42
+ super(message);
43
+ this.name = "CliError";
44
+ this.code = code;
45
+ this.exitCode = exitCode;
46
+ this.retryable = retryable;
47
+ this.details = details;
48
+ this.hints = hints;
49
+ this.warnings = warnings;
50
+ this.cause = cause;
51
+ }
52
+ };
53
+
54
+ // src/runtime/channel.js
55
+ var RELEASE_CHANNELS = ["stable", "beta"];
56
+ var CHANNEL_TAGS = { stable: "latest", beta: "beta" };
57
+ var CLI_PACKAGE_NAME = "@notis_ai/cli";
58
+ function isReleaseChannel(value) {
59
+ return RELEASE_CHANNELS.includes(value);
60
+ }
61
+ function packageTagForChannel(channel) {
62
+ return CHANNEL_TAGS[channel] || CHANNEL_TAGS.stable;
63
+ }
64
+ function cliCommandForChannel(channel) {
65
+ return `npx --package ${CLI_PACKAGE_NAME}@${packageTagForChannel(channel)} -- notis`;
66
+ }
67
+ function channelFromProfile(profile = {}) {
68
+ if (isReleaseChannel(profile.channel)) {
69
+ return profile.channel;
70
+ }
71
+ if (profile.beta === true) return "beta";
72
+ if (profile.beta === false) return "stable";
73
+ for (const candidate of [profile.oauth_api_base, profile.api_base]) {
74
+ if (typeof candidate !== "string" || !candidate) continue;
75
+ try {
76
+ const { hostname } = new URL(candidate);
77
+ if (hostname === "api-beta.notis.ai") return "beta";
78
+ if (hostname === "api.notis.ai") return "stable";
79
+ } catch {
80
+ }
81
+ }
82
+ return null;
83
+ }
84
+
85
+ // src/runtime/auth-recovery.js
86
+ function cliNpx(runtime = {}) {
87
+ return cliCommandForChannel(
88
+ runtime.channel || channelFromProfile({ api_base: runtime.apiBase })
89
+ );
90
+ }
91
+ function quoteShellArgument(value) {
92
+ return `'${String(value).replace(/'/g, `'"'"'`)}'`;
93
+ }
94
+ function profileSuffix(profileName) {
95
+ return profileName && profileName !== "default" ? ` --profile ${quoteShellArgument(profileName)}` : "";
96
+ }
97
+ function getAuthRecovery(runtime = {}, { mode = "expired" } = {}) {
98
+ const CLI_NPX = cliNpx(runtime);
99
+ const suffix = profileSuffix(runtime.profileName);
100
+ const hints = [
101
+ {
102
+ command: `${CLI_NPX} login${suffix}`,
103
+ reason: mode === "missing" ? "Sign in or create an account in the browser and authorize this machine" : "Authorize a fresh scoped CLI credential for this profile"
104
+ },
105
+ {
106
+ command: `${CLI_NPX} profile list`,
107
+ reason: "Check whether another profile on this machine is already signed in"
108
+ },
109
+ {
110
+ command: `${CLI_NPX} doctor${suffix}`,
111
+ reason: "Retry the auth and API checks once authorization completes"
112
+ }
113
+ ];
114
+ return { hints };
115
+ }
116
+
117
+ // src/runtime/profiles.js
118
+ var CONFIG_DIR = join(homedir(), ".notis");
119
+ var CONFIG_FILE = join(CONFIG_DIR, "config.json");
120
+ var WORKSPACE_DIR = join(CONFIG_DIR, "workspace");
121
+ var DEFAULT_API_BASE = "https://api.notis.ai";
122
+ var BETA_API_BASE = "https://api-beta.notis.ai";
123
+ var DEFAULT_PROFILE = "default";
124
+ var PROFILE_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
125
+ var LEGACY_DESKTOP_PROFILE_KEYS = [
126
+ "jwt",
127
+ "auth_mode",
128
+ "refresh_token",
129
+ "access_expires_at",
130
+ "refresh_expires_at",
131
+ "desktop_app_name",
132
+ "desktop_pid"
133
+ ];
134
+ var WORKTREE_RUNTIME_FILENAME = join(".context", "notis-runtime.json");
135
+ var WORKTREE_ROUTING_FILENAME = join(".context", "notis-routing.json");
136
+ var LOCAL_API_HOSTS = /* @__PURE__ */ new Set(["localhost", "127.0.0.1", "::1"]);
137
+ var LIVE_API_HOSTS = /* @__PURE__ */ new Set(["api.notis.ai", "api-beta.notis.ai"]);
138
+ var CONFIG_WRITE_LOCK_TIMEOUT_MS = 5e3;
139
+ var CONFIG_WRITE_LOCK_STALE_MS = 2e3;
140
+ var CONFIG_WRITE_LOCK_POLL_MS = 10;
141
+ function clone(value) {
142
+ return JSON.parse(JSON.stringify(value));
143
+ }
144
+ function isValidProfileName(profileName) {
145
+ return typeof profileName === "string" && PROFILE_NAME_PATTERN.test(profileName) && !Object.hasOwn(Object.prototype, profileName);
146
+ }
147
+ function isSafeStoredProfileName(profileName) {
148
+ return typeof profileName === "string" && profileName.length > 0 && !Object.hasOwn(Object.prototype, profileName);
149
+ }
150
+ function assertValidProfileName(profileName) {
151
+ if (isValidProfileName(profileName)) {
152
+ return;
153
+ }
154
+ throw new CliError({
155
+ code: "profile_name_invalid",
156
+ message: "CLI profile names must start with a letter or number and contain only letters, numbers, dots, underscores, or hyphens (maximum 64 characters)",
157
+ exitCode: EXIT_CODES.usage,
158
+ hints: [
159
+ { command: "notis profile list", reason: "See the valid profiles already on this machine" }
160
+ ]
161
+ });
162
+ }
163
+ function normalizeProfile(rawProfile = {}) {
164
+ const raw = rawProfile && typeof rawProfile === "object" ? rawProfile : {};
165
+ return {
166
+ api_base: typeof raw.api_base === "string" ? raw.api_base : void 0,
167
+ beta: typeof raw.beta === "boolean" ? raw.beta : void 0,
168
+ // Which published CLI build this profile runs, as the deployment reported
169
+ // it at login. Unknown values are dropped rather than trusted: this key
170
+ // decides which code executes on the next run.
171
+ channel: isReleaseChannel(raw.channel) ? raw.channel : void 0,
172
+ label: typeof raw.label === "string" ? raw.label : void 0,
173
+ dev_access_token: typeof raw.dev_access_token === "string" ? raw.dev_access_token : void 0,
174
+ dev_access_expires_at: typeof raw.dev_access_expires_at === "number" ? raw.dev_access_expires_at : void 0,
175
+ dev_user_id: typeof raw.dev_user_id === "string" ? raw.dev_user_id : void 0,
176
+ dev_workspace_root: typeof raw.dev_workspace_root === "string" ? raw.dev_workspace_root : void 0,
177
+ oauth_access_token: typeof raw.oauth_access_token === "string" ? raw.oauth_access_token : void 0,
178
+ oauth_refresh_token: typeof raw.oauth_refresh_token === "string" ? raw.oauth_refresh_token : void 0,
179
+ oauth_access_expires_at: typeof raw.oauth_access_expires_at === "number" ? raw.oauth_access_expires_at : void 0,
180
+ oauth_refresh_expires_at: typeof raw.oauth_refresh_expires_at === "number" ? raw.oauth_refresh_expires_at : void 0,
181
+ oauth_client_id: typeof raw.oauth_client_id === "string" ? raw.oauth_client_id : void 0,
182
+ oauth_issuer: typeof raw.oauth_issuer === "string" ? raw.oauth_issuer : void 0,
183
+ oauth_api_base: typeof raw.oauth_api_base === "string" ? raw.oauth_api_base : void 0,
184
+ oauth_resource: typeof raw.oauth_resource === "string" ? raw.oauth_resource : void 0,
185
+ oauth_scopes: Array.isArray(raw.oauth_scopes) ? raw.oauth_scopes.filter((scope) => typeof scope === "string") : void 0,
186
+ oauth_user_id: typeof raw.oauth_user_id === "string" ? raw.oauth_user_id : void 0
187
+ };
188
+ }
189
+ function normalizeConfig(rawConfig = {}) {
190
+ const raw = rawConfig && typeof rawConfig === "object" ? clone(rawConfig) : {};
191
+ if (raw.profiles && typeof raw.profiles === "object") {
192
+ const profiles = {};
193
+ for (const [name, profile] of Object.entries(raw.profiles)) {
194
+ if (!isSafeStoredProfileName(name) || !profile || typeof profile !== "object") {
195
+ continue;
196
+ }
197
+ profiles[name] = normalizeProfile(profile);
198
+ }
199
+ if (!Object.hasOwn(profiles, DEFAULT_PROFILE)) {
200
+ profiles[DEFAULT_PROFILE] = {};
201
+ }
202
+ return {
203
+ current_profile: typeof raw.current_profile === "string" && Object.hasOwn(profiles, raw.current_profile) ? raw.current_profile : DEFAULT_PROFILE,
204
+ profiles
205
+ };
206
+ }
207
+ return {
208
+ current_profile: DEFAULT_PROFILE,
209
+ profiles: { [DEFAULT_PROFILE]: normalizeProfile(raw) }
210
+ };
211
+ }
212
+ function readJsonFile(path3) {
213
+ try {
214
+ return JSON.parse(readFileSync(path3, "utf-8"));
215
+ } catch {
216
+ return null;
217
+ }
218
+ }
219
+ function findUp(filename, startDir = process.cwd()) {
220
+ let current = resolve(startDir);
221
+ const root = parse(current).root;
222
+ while (true) {
223
+ const candidate = join(current, filename);
224
+ if (existsSync(candidate)) {
225
+ return candidate;
226
+ }
227
+ if (current === root) {
228
+ return null;
229
+ }
230
+ current = dirname(current);
231
+ }
232
+ }
233
+ function processIsAlive(pid) {
234
+ if (!Number.isInteger(pid) || pid <= 0) {
235
+ return false;
236
+ }
237
+ try {
238
+ process.kill(pid, 0);
239
+ return true;
240
+ } catch (error) {
241
+ return error?.code === "EPERM";
242
+ }
243
+ }
244
+ function resolveWorktreeRuntime(startDir = process.cwd()) {
245
+ if (process.env.NODE_ENV === "test" && process.env.NOTIS_TEST_DISABLE_WORKTREE_ROUTING === "1") {
246
+ return null;
247
+ }
248
+ const runtimePath = findUp(WORKTREE_RUNTIME_FILENAME, startDir);
249
+ const routingPath = findUp(WORKTREE_ROUTING_FILENAME, startDir);
250
+ const routing = routingPath ? readJsonFile(routingPath) : null;
251
+ if (!runtimePath) {
252
+ if (routing?.mode === "local-only") {
253
+ return {
254
+ unavailable: new CliError({
255
+ code: "dev_runtime_unavailable",
256
+ message: "This worktree is local-only, but its dev.sh runtime is not active",
257
+ exitCode: EXIT_CODES.network,
258
+ hints: [
259
+ { message: "Start ./dev.sh in this worktree, then retry the command." },
260
+ { command: "notis profile list", reason: "Run against a live account profile instead" },
261
+ { message: `Routing policy: ${routingPath}` }
262
+ ]
263
+ })
264
+ };
265
+ }
266
+ return null;
267
+ }
268
+ const runtime = readJsonFile(runtimePath);
269
+ const apiBase = typeof runtime?.api_base === "string" ? runtime.api_base.replace(/\/+$/, "") : "";
270
+ const profile = typeof runtime?.profile === "string" ? runtime.profile.trim() : "";
271
+ const devAccessToken = typeof runtime?.dev_access_token === "string" ? runtime.dev_access_token.trim() : "";
272
+ const desktopDeepLinkScheme = typeof runtime?.desktop_deep_link_scheme === "string" ? runtime.desktop_deep_link_scheme.trim() : "";
273
+ const pid = Number(runtime?.dev_pid);
274
+ if (runtime?.mode !== "local-only" || !isLocalApiBase(apiBase) || !profile || !devAccessToken || !processIsAlive(pid)) {
275
+ return {
276
+ unavailable: new CliError({
277
+ code: "dev_runtime_unavailable",
278
+ message: "The local-only worktree runtime is stale or invalid",
279
+ exitCode: EXIT_CODES.network,
280
+ hints: [
281
+ { message: "Restart ./dev.sh in this worktree, then retry the command." },
282
+ { command: "notis profile list", reason: "Run against a live account profile instead" },
283
+ { message: `Runtime lease: ${runtimePath}` }
284
+ ]
285
+ })
286
+ };
287
+ }
288
+ return {
289
+ ...runtime,
290
+ api_base: apiBase,
291
+ profile,
292
+ dev_access_token: devAccessToken,
293
+ desktop_deep_link_scheme: desktopDeepLinkScheme || void 0,
294
+ runtime_path: runtimePath,
295
+ routing_path: routingPath
296
+ };
297
+ }
298
+ function resolveConfigFile() {
299
+ const envConfigFile = process.env.NOTIS_CLI_CONFIG_FILE;
300
+ if (envConfigFile) {
301
+ return resolve(envConfigFile);
302
+ }
303
+ return CONFIG_FILE;
304
+ }
305
+ function loadConfig() {
306
+ const configFile = resolveConfigFile();
307
+ if (!existsSync(configFile)) {
308
+ return normalizeConfig({});
309
+ }
310
+ try {
311
+ return normalizeConfig(JSON.parse(readFileSync(configFile, "utf-8")));
312
+ } catch {
313
+ return normalizeConfig({});
314
+ }
315
+ }
316
+ function writeConfig(configFile, config) {
317
+ let rawConfig = null;
318
+ try {
319
+ rawConfig = JSON.parse(readFileSync(configFile, "utf-8"));
320
+ } catch {
321
+ }
322
+ const normalized = normalizeConfig(config);
323
+ const persisted = clone(normalized);
324
+ const rawProfiles = rawConfig?.profiles && typeof rawConfig.profiles === "object" ? rawConfig.profiles : { [DEFAULT_PROFILE]: rawConfig };
325
+ for (const [name, profile] of Object.entries(persisted.profiles)) {
326
+ const rawProfile = Object.hasOwn(rawProfiles || {}, name) ? rawProfiles[name] : null;
327
+ if (!rawProfile || typeof rawProfile !== "object") continue;
328
+ for (const key of LEGACY_DESKTOP_PROFILE_KEYS) {
329
+ if (Object.hasOwn(rawProfile, key)) {
330
+ profile[key] = rawProfile[key];
331
+ }
332
+ }
333
+ }
334
+ writeRawConfig(configFile, persisted);
335
+ }
336
+ function writeRawConfig(configFile, config) {
337
+ mkdirSync(dirname(configFile), { recursive: true });
338
+ const temporaryFile = `${configFile}.${process.pid}.${Date.now()}.tmp`;
339
+ writeFileSync(temporaryFile, JSON.stringify(config, null, 2), { mode: 384 });
340
+ renameSync(temporaryFile, configFile);
341
+ }
342
+ function readLockOwner(lockDirectory) {
343
+ try {
344
+ return JSON.parse(readFileSync(join(lockDirectory, "owner"), "utf-8"));
345
+ } catch {
346
+ return null;
347
+ }
348
+ }
349
+ function lockDirectoryMtime(lockDirectory) {
350
+ try {
351
+ return statSync(lockDirectory).mtimeMs;
352
+ } catch {
353
+ return null;
354
+ }
355
+ }
356
+ function publishConfigWriteLock(lockDirectory, ownerId) {
357
+ const candidateDirectory = `${lockDirectory}.candidate-${ownerId}`;
358
+ try {
359
+ mkdirSync(candidateDirectory);
360
+ writeFileSync(
361
+ join(candidateDirectory, "owner"),
362
+ JSON.stringify({ id: ownerId, at: Date.now() }),
363
+ { mode: 384 }
364
+ );
365
+ try {
366
+ renameSync(candidateDirectory, lockDirectory);
367
+ return true;
368
+ } catch (error) {
369
+ if (!existsSync(lockDirectory)) throw error;
370
+ return false;
371
+ }
372
+ } finally {
373
+ rmSync(candidateDirectory, { recursive: true, force: true });
374
+ }
375
+ }
376
+ function withConfigWriteLock(callback) {
377
+ const configFile = resolveConfigFile();
378
+ const lockDirectory = `${configFile}.write-lock`;
379
+ const ownerId = `${process.pid}.${randomUUID()}`;
380
+ const deadline = Date.now() + CONFIG_WRITE_LOCK_TIMEOUT_MS;
381
+ mkdirSync(dirname(configFile), { recursive: true });
382
+ for (; ; ) {
383
+ if (!existsSync(lockDirectory) && publishConfigWriteLock(lockDirectory, ownerId)) {
384
+ break;
385
+ }
386
+ const owner = readLockOwner(lockDirectory);
387
+ const observedMtime = lockDirectoryMtime(lockDirectory);
388
+ const ownerIsStale = owner?.id && Date.now() - Number(owner.at) > CONFIG_WRITE_LOCK_STALE_MS;
389
+ const ownerlessIsStale = !owner && observedMtime !== null && Date.now() - observedMtime > CONFIG_WRITE_LOCK_STALE_MS;
390
+ if (ownerIsStale || ownerlessIsStale) {
391
+ try {
392
+ const currentOwner = readLockOwner(lockDirectory);
393
+ const ownerUnchanged = owner?.id ? currentOwner?.id === owner.id : !currentOwner && lockDirectoryMtime(lockDirectory) === observedMtime;
394
+ if (ownerUnchanged) {
395
+ rmSync(lockDirectory, { recursive: true, force: true });
396
+ continue;
397
+ }
398
+ } catch {
399
+ }
400
+ }
401
+ if (Date.now() >= deadline) {
402
+ throw new Error(`Timed out waiting to update ${configFile}`);
403
+ }
404
+ Atomics.wait(
405
+ new Int32Array(new SharedArrayBuffer(4)),
406
+ 0,
407
+ 0,
408
+ CONFIG_WRITE_LOCK_POLL_MS
409
+ );
410
+ }
411
+ try {
412
+ return callback(configFile);
413
+ } finally {
414
+ try {
415
+ if (readLockOwner(lockDirectory)?.id === ownerId) {
416
+ rmSync(lockDirectory, { recursive: true, force: true });
417
+ }
418
+ } catch {
419
+ }
420
+ }
421
+ }
422
+ function updateConfig(updater) {
423
+ return withConfigWriteLock((configFile) => {
424
+ const current = loadConfig();
425
+ const updated = updater(normalizeConfig(current));
426
+ const next = normalizeConfig(updated ?? current);
427
+ writeConfig(configFile, next);
428
+ return next;
429
+ });
430
+ }
431
+ function getProfile(config, profileName) {
432
+ const normalized = normalizeConfig(config);
433
+ return Object.hasOwn(normalized.profiles, profileName) ? normalized.profiles[profileName] : {};
434
+ }
435
+ function resolveProfileSelection(globalOptions = {}, worktreeRuntime = null, config, { allowUnknownProfile = false } = {}) {
436
+ const normalized = normalizeConfig(config);
437
+ const requested = globalOptions.profile || process.env.NOTIS_PROFILE || "";
438
+ if (requested) {
439
+ const existingProfile = Object.hasOwn(normalized.profiles, requested);
440
+ if (!existingProfile) {
441
+ assertValidProfileName(requested);
442
+ }
443
+ if (!existingProfile && !allowUnknownProfile) {
444
+ throw new CliError({
445
+ code: "profile_unknown",
446
+ message: `No CLI profile named "${requested}"`,
447
+ exitCode: EXIT_CODES.usage,
448
+ details: { known_profiles: Object.keys(normalized.profiles) },
449
+ hints: [
450
+ { command: "notis profile list", reason: "See which profiles this machine has" },
451
+ {
452
+ command: `notis login --profile ${quoteShellArgument(requested)}`,
453
+ reason: "Authorize a new account under this profile name"
454
+ }
455
+ ]
456
+ });
457
+ }
458
+ return { profileName: requested, source: "explicit" };
459
+ }
460
+ if (worktreeRuntime?.profile) {
461
+ return { profileName: worktreeRuntime.profile, source: "worktree" };
462
+ }
463
+ return {
464
+ profileName: normalized.current_profile || DEFAULT_PROFILE,
465
+ source: "current"
466
+ };
467
+ }
468
+ function ensureProfile(config, profileName) {
469
+ const normalized = normalizeConfig(config);
470
+ if (!Object.hasOwn(normalized.profiles, profileName)) {
471
+ assertValidProfileName(profileName);
472
+ normalized.profiles[profileName] = {};
473
+ }
474
+ return normalized;
475
+ }
476
+ function isLocalApiBase(value) {
477
+ if (typeof value !== "string" || !value) {
478
+ return false;
479
+ }
480
+ try {
481
+ const parsed = new URL(value);
482
+ return ["http:", "https:"].includes(parsed.protocol) && LOCAL_API_HOSTS.has(parsed.hostname);
483
+ } catch {
484
+ return false;
485
+ }
486
+ }
487
+ function isLiveApiBase(value) {
488
+ if (typeof value !== "string" || !value) {
489
+ return false;
490
+ }
491
+ try {
492
+ const parsed = new URL(value);
493
+ return parsed.protocol === "https:" && LIVE_API_HOSTS.has(parsed.hostname);
494
+ } catch {
495
+ return false;
496
+ }
497
+ }
498
+ function resolveDefaultLiveApiBase(profile = {}) {
499
+ if (profile.beta === true) {
500
+ return BETA_API_BASE;
501
+ }
502
+ if (profile.beta === false) {
503
+ return DEFAULT_API_BASE;
504
+ }
505
+ if (isLiveApiBase(profile.api_base)) {
506
+ try {
507
+ if (new URL(profile.api_base).hostname === "api-beta.notis.ai") {
508
+ return BETA_API_BASE;
509
+ }
510
+ } catch {
511
+ }
512
+ }
513
+ return DEFAULT_API_BASE;
514
+ }
515
+ function getApiBase(config, profileName, override) {
516
+ if (override) {
517
+ return override;
518
+ }
519
+ const env = process.env.NOTIS_API_BASE;
520
+ if (env) {
521
+ return env;
522
+ }
523
+ const profile = getProfile(config, profileName);
524
+ const profileApiBase = profile.api_base;
525
+ if (typeof profileApiBase === "string" && profileApiBase && !isLocalApiBase(profileApiBase)) {
526
+ return profileApiBase.replace(/\/+$/, "");
527
+ }
528
+ return resolveDefaultLiveApiBase(profile);
529
+ }
530
+ function isAgentMode(globalOptions = {}) {
531
+ return process.env.NOTIS_AGENT === "1" || Boolean(globalOptions.agentMode);
532
+ }
533
+ function isNonInteractive(globalOptions = {}) {
534
+ if (process.env.NOTIS_NON_INTERACTIVE === "1") {
535
+ return true;
536
+ }
537
+ if (globalOptions.nonInteractive) {
538
+ return true;
539
+ }
540
+ return isAgentMode(globalOptions);
541
+ }
542
+ function resolveOutputMode(globalOptions = {}) {
543
+ if (globalOptions.json) {
544
+ return "json";
545
+ }
546
+ if (globalOptions.output) {
547
+ return globalOptions.output;
548
+ }
549
+ if (process.env.NOTIS_OUTPUT) {
550
+ return process.env.NOTIS_OUTPUT;
551
+ }
552
+ return !process.stdout.isTTY || isAgentMode(globalOptions) ? "json" : "table";
553
+ }
554
+ function resolveTimeoutMs(globalOptions = {}) {
555
+ const raw = globalOptions.timeoutMs || process.env.NOTIS_TIMEOUT_MS;
556
+ if (!raw) {
557
+ return 3e4;
558
+ }
559
+ const parsed = Number.parseInt(raw, 10);
560
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : 3e4;
561
+ }
562
+ function parseDebugEntitlementOverride(value = process.env.NOTIS_DEBUG_ENTITLEMENT_OVERRIDE) {
563
+ if (!value) {
564
+ return null;
565
+ }
566
+ const candidates = [value];
567
+ try {
568
+ candidates.push(Buffer.from(value, "base64url").toString("utf-8"));
569
+ } catch {
570
+ }
571
+ for (const candidate of candidates) {
572
+ try {
573
+ const parsed = JSON.parse(candidate);
574
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
575
+ return parsed;
576
+ }
577
+ } catch {
578
+ }
579
+ }
580
+ throw new CliError({
581
+ code: "debug_entitlement_override_invalid",
582
+ message: "NOTIS_DEBUG_ENTITLEMENT_OVERRIDE must be a JSON object or base64url-encoded JSON object.",
583
+ exitCode: EXIT_CODES.usage
584
+ });
585
+ }
586
+ function getOAuthResource(profile = {}) {
587
+ if (typeof profile.oauth_resource === "string" && profile.oauth_resource) {
588
+ return profile.oauth_resource.replace(/\/+$/, "");
589
+ }
590
+ if (typeof profile.oauth_access_token === "string" && profile.oauth_access_token) {
591
+ try {
592
+ const parts = profile.oauth_access_token.split(".");
593
+ if (parts.length === 3) {
594
+ const payload = JSON.parse(Buffer.from(parts[1], "base64url").toString());
595
+ if (typeof payload.aud === "string" && payload.aud) {
596
+ return payload.aud.replace(/\/+$/, "");
597
+ }
598
+ }
599
+ } catch {
600
+ }
601
+ }
602
+ return null;
603
+ }
604
+ function getOAuthApiBase(profile = {}) {
605
+ if (typeof profile.oauth_api_base === "string" && profile.oauth_api_base) {
606
+ return profile.oauth_api_base.replace(/\/+$/, "");
607
+ }
608
+ const resource = getOAuthResource(profile);
609
+ if (resource?.endsWith("/cli")) {
610
+ return resource.slice(0, -"/cli".length);
611
+ }
612
+ return null;
613
+ }
614
+ function resolveRuntimeProfile(globalOptions = {}, {
615
+ requireAuth = true,
616
+ includeDebugEntitlementOverride = true,
617
+ allowUnknownProfile = false,
618
+ allowUnavailableWorktree = false
619
+ } = {}) {
620
+ const resolvedWorktree = resolveWorktreeRuntime();
621
+ const worktreeUnavailable = resolvedWorktree?.unavailable || null;
622
+ const worktreeRuntime = worktreeUnavailable ? null : resolvedWorktree;
623
+ const config = loadConfig();
624
+ const { profileName, source: profileSource } = resolveProfileSelection(
625
+ globalOptions,
626
+ worktreeRuntime,
627
+ config,
628
+ { allowUnknownProfile }
629
+ );
630
+ if (worktreeUnavailable && !allowUnavailableWorktree && profileSource !== "explicit") {
631
+ throw worktreeUnavailable;
632
+ }
633
+ const devRuntime = worktreeRuntime && worktreeRuntime.profile === profileName ? worktreeRuntime : null;
634
+ const requestedApiBase = globalOptions.apiBase;
635
+ if (devRuntime && requestedApiBase && requestedApiBase.replace(/\/+$/, "") !== devRuntime.api_base) {
636
+ throw new CliError({
637
+ code: "dev_runtime_route_mismatch",
638
+ message: `Profile "${profileName}" is bound to this worktree and cannot route to ${requestedApiBase}`,
639
+ exitCode: EXIT_CODES.usage,
640
+ hints: [
641
+ { message: `Expected local API: ${devRuntime.api_base}` },
642
+ { command: "notis profile list", reason: "Switch to a profile that targets that API instead" }
643
+ ]
644
+ });
645
+ }
646
+ let apiBase = devRuntime ? devRuntime.api_base : getApiBase(config, profileName, globalOptions.apiBase);
647
+ const profile = getProfile(config, profileName);
648
+ const envJwt = !devRuntime ? process.env.NOTIS_JWT : void 0;
649
+ const devJwt = devRuntime?.dev_access_token || profile.dev_access_token;
650
+ const oauthJwt = profile.oauth_access_token;
651
+ let jwt;
652
+ let credentialKind;
653
+ if (requireAuth && !devRuntime && devJwt && !oauthJwt && !process.env.NOTIS_JWT) {
654
+ throw new CliError({
655
+ code: "dev_runtime_unavailable",
656
+ message: `Profile "${profileName}" is a ./dev.sh profile and its local runtime is not active`,
657
+ exitCode: EXIT_CODES.network,
658
+ details: { workspace_root: profile.dev_workspace_root || null },
659
+ hints: [
660
+ profile.dev_workspace_root ? { message: `Start ./dev.sh in ${profile.dev_workspace_root}, then retry.` } : { message: "Start ./dev.sh in the worktree that owns this profile, then retry." },
661
+ { command: "notis profile list", reason: "Switch to a live account profile instead" }
662
+ ]
663
+ });
664
+ }
665
+ if (devRuntime && devJwt) {
666
+ jwt = devJwt;
667
+ credentialKind = "worktree";
668
+ } else if (envJwt) {
669
+ jwt = envJwt;
670
+ credentialKind = "env";
671
+ } else if (oauthJwt && !credentialIsExpired({ credentialKind: "oauth", jwt: oauthJwt }, profile)) {
672
+ jwt = oauthJwt;
673
+ credentialKind = "oauth";
674
+ } else if (oauthJwt) {
675
+ jwt = oauthJwt;
676
+ credentialKind = "oauth";
677
+ }
678
+ const oauthApiBase = getOAuthApiBase(profile);
679
+ const oauthResource = getOAuthResource(profile);
680
+ const normalizedRequestedApiBase = requestedApiBase ? requestedApiBase.replace(/\/+$/, "") : null;
681
+ if (requireAuth && credentialKind === "oauth" && normalizedRequestedApiBase && oauthApiBase && normalizedRequestedApiBase !== oauthApiBase) {
682
+ throw new CliError({
683
+ code: "oauth_api_target_mismatch",
684
+ message: `The OAuth credential for profile ${profileName} belongs to ${oauthApiBase}, not ${normalizedRequestedApiBase}`,
685
+ exitCode: EXIT_CODES.auth,
686
+ hints: [{
687
+ command: [
688
+ cliCommandForChannel(channelFromProfile({ api_base: normalizedRequestedApiBase })),
689
+ `--profile ${quoteShellArgument(profileName)}`,
690
+ `--api-base ${quoteShellArgument(normalizedRequestedApiBase)}`,
691
+ "login"
692
+ ].join(" "),
693
+ reason: "Authorize a separate OAuth grant for the requested Notis environment"
694
+ }]
695
+ });
696
+ }
697
+ if (credentialKind === "oauth" && oauthApiBase && !requestedApiBase) {
698
+ apiBase = oauthApiBase;
699
+ }
700
+ const agentMode = isAgentMode(globalOptions);
701
+ const nonInteractive = isNonInteractive(globalOptions);
702
+ const outputMode = resolveOutputMode(globalOptions);
703
+ const timeoutMs = resolveTimeoutMs(globalOptions);
704
+ const debugEntitlementOverride = includeDebugEntitlementOverride ? parseDebugEntitlementOverride() : null;
705
+ if (requireAuth && !jwt) {
706
+ throw new CliError({
707
+ code: "auth_missing",
708
+ message: `Profile "${profileName}" has no Notis credential`,
709
+ exitCode: EXIT_CODES.auth,
710
+ hints: getAuthRecovery({ profileName, apiBase }, { mode: "missing" }).hints
711
+ });
712
+ }
713
+ if (devRuntime?.expected_user_id && (credentialKind === "oauth" ? profile.oauth_user_id : getJwtSubject(jwt)) !== devRuntime.expected_user_id) {
714
+ throw new CliError({
715
+ code: "dev_runtime_identity_mismatch",
716
+ message: `Profile "${profileName}" no longer holds this worktree's test identity`,
717
+ exitCode: EXIT_CODES.auth,
718
+ hints: [
719
+ { message: "Restart ./dev.sh to restore the approved worktree identity." },
720
+ { message: `Expected user: ${devRuntime.expected_user_id}` }
721
+ ]
722
+ });
723
+ }
724
+ return {
725
+ config,
726
+ profileName,
727
+ profileSource,
728
+ profileLabel: profile.label,
729
+ // Which published build serves this profile. Carried on the runtime so
730
+ // every recovery hint prints the command that will actually run.
731
+ channel: devRuntime ? null : channelFromProfile(
732
+ globalOptions.apiBase || process.env.NOTIS_API_BASE ? { api_base: apiBase } : { ...profile, api_base: apiBase }
733
+ ),
734
+ apiBase,
735
+ requestedApiBase: normalizedRequestedApiBase,
736
+ jwt,
737
+ credentialKind,
738
+ credentialSource: credentialKind,
739
+ oauthAccessToken: profile.oauth_access_token,
740
+ oauthRefreshToken: profile.oauth_refresh_token,
741
+ oauthAccessExpiresAt: profile.oauth_access_expires_at,
742
+ oauthRefreshExpiresAt: profile.oauth_refresh_expires_at,
743
+ oauthClientId: profile.oauth_client_id,
744
+ oauthIssuer: profile.oauth_issuer,
745
+ oauthApiBase,
746
+ oauthResource,
747
+ oauthScopes: profile.oauth_scopes || [],
748
+ oauthUserId: profile.oauth_user_id,
749
+ agentMode,
750
+ nonInteractive,
751
+ outputMode,
752
+ timeoutMs,
753
+ debugEntitlementOverride,
754
+ worktreeRuntime: devRuntime,
755
+ detachedWorktreeRuntime: devRuntime ? null : worktreeRuntime,
756
+ worktreeRuntimeUnavailable: worktreeUnavailable
757
+ };
758
+ }
759
+ function getJwtExpiration(jwt) {
760
+ if (typeof jwt !== "string" || !jwt) {
761
+ return null;
762
+ }
763
+ try {
764
+ const parts = jwt.split(".");
765
+ if (parts.length !== 3) return null;
766
+ const payload = JSON.parse(Buffer.from(parts[1], "base64url").toString());
767
+ return typeof payload.exp === "number" ? payload.exp : null;
768
+ } catch {
769
+ return null;
770
+ }
771
+ }
772
+ function getJwtSubject(jwt) {
773
+ if (typeof jwt !== "string" || !jwt) {
774
+ return null;
775
+ }
776
+ try {
777
+ const parts = jwt.split(".");
778
+ if (parts.length !== 3) return null;
779
+ const payload = JSON.parse(Buffer.from(parts[1], "base64url").toString());
780
+ return typeof payload.sub === "string" && payload.sub ? payload.sub : null;
781
+ } catch {
782
+ return null;
783
+ }
784
+ }
785
+ function credentialIsExpired(runtime, profile = {}, nowSeconds = Math.floor(Date.now() / 1e3)) {
786
+ const credentialKind = runtime?.credentialKind || (runtime?.credentialSource === "env" ? "env" : null);
787
+ switch (credentialKind) {
788
+ case "oauth": {
789
+ const expiration = Number(profile.oauth_access_expires_at);
790
+ return !Number.isFinite(expiration) || expiration <= nowSeconds;
791
+ }
792
+ case "env": {
793
+ const expiration = getJwtExpiration(runtime?.jwt);
794
+ return expiration !== null && expiration <= nowSeconds;
795
+ }
796
+ case "worktree": {
797
+ const rawExpiration = profile.dev_access_expires_at ?? getJwtExpiration(runtime?.jwt);
798
+ if (rawExpiration === null || rawExpiration === void 0 || rawExpiration === "") {
799
+ return true;
800
+ }
801
+ const expiration = Number(rawExpiration);
802
+ return !Number.isFinite(expiration) || expiration <= nowSeconds;
803
+ }
804
+ default:
805
+ return true;
806
+ }
807
+ }
808
+
809
+ // src/runtime/oauth.js
810
+ import {
811
+ createHash,
812
+ randomBytes,
813
+ timingSafeEqual
814
+ } from "node:crypto";
815
+ import {
816
+ mkdirSync as mkdirSync2,
817
+ readdirSync,
818
+ readFileSync as readFileSync2,
819
+ renameSync as renameSync2,
820
+ rmdirSync,
821
+ rmSync as rmSync2,
822
+ statSync as statSync2,
823
+ writeFileSync as writeFileSync2
824
+ } from "node:fs";
825
+ import { homedir as homedir2 } from "node:os";
826
+ import { basename, dirname as dirname2, join as join2 } from "node:path";
827
+ import { fileURLToPath } from "node:url";
828
+ import { createInterface } from "node:readline/promises";
829
+ var OAUTH_LOCK_DIR = join2(homedir2(), ".notis", "oauth.lock");
830
+ var DEFAULT_REFRESH_EXPIRES_IN = 30 * 24 * 60 * 60;
831
+ var PENDING_LOGIN_TTL_SECONDS = 30 * 60;
832
+ var OAUTH_HTTP_TIMEOUT_MS = 1e4;
833
+ function oauthError(code, message, hints = null, details = {}) {
834
+ return new CliError({
835
+ code,
836
+ message,
837
+ exitCode: EXIT_CODES.auth,
838
+ details,
839
+ hints: hints || [
840
+ { command: "notis login", reason: "Start a new browser authorization" },
841
+ { command: "notis doctor", reason: "Inspect the active credential state" }
842
+ ]
843
+ });
844
+ }
845
+ function base64url(value) {
846
+ return Buffer.from(value).toString("base64url");
847
+ }
848
+ function decodeJwtPayload(token) {
849
+ try {
850
+ const parts = String(token || "").split(".");
851
+ if (parts.length !== 3) return {};
852
+ return JSON.parse(Buffer.from(parts[1], "base64url").toString("utf-8"));
853
+ } catch {
854
+ return {};
855
+ }
856
+ }
857
+ async function fetchJson(url, init = {}, fetchImpl = fetch) {
858
+ const controller = new AbortController();
859
+ const timeout = setTimeout(() => controller.abort(), OAUTH_HTTP_TIMEOUT_MS);
860
+ const abortFromCaller = () => controller.abort();
861
+ init.signal?.addEventListener?.("abort", abortFromCaller, { once: true });
862
+ try {
863
+ const response = await fetchImpl(url, { ...init, signal: controller.signal });
864
+ const payload = await response.json().catch(() => ({}));
865
+ if (!response.ok) {
866
+ throw oauthError(
867
+ payload.error || "oauth_request_failed",
868
+ payload.error_description || payload.message || `OAuth request failed with status ${response.status}`,
869
+ null,
870
+ payload
871
+ );
872
+ }
873
+ return payload;
874
+ } catch (error) {
875
+ if (controller.signal.aborted && !init.signal?.aborted) {
876
+ throw oauthError(
877
+ "oauth_request_timeout",
878
+ "The OAuth server did not respond in time. Retry the command."
879
+ );
880
+ }
881
+ throw error;
882
+ } finally {
883
+ clearTimeout(timeout);
884
+ init.signal?.removeEventListener?.("abort", abortFromCaller);
885
+ }
886
+ }
887
+ function storedOAuthMetadata(runtime, profile) {
888
+ const issuer = String(profile.oauth_issuer || "").replace(/\/+$/, "");
889
+ const apiBase = getOAuthApiBase(profile) || String(profile.api_base || runtime.apiBase || "").replace(/\/+$/, "");
890
+ const resource = getOAuthResource(profile) || (apiBase ? `${apiBase}/cli` : "");
891
+ if (!issuer || !apiBase || !resource || !profile.oauth_client_id) {
892
+ throw oauthError(
893
+ "oauth_metadata_missing",
894
+ "The stored OAuth profile is incomplete. Run notis login again."
895
+ );
896
+ }
897
+ return {
898
+ apiBase,
899
+ issuer,
900
+ resource,
901
+ clientId: profile.oauth_client_id,
902
+ tokenEndpoint: `${issuer}/oauth/token`,
903
+ revocationEndpoint: `${issuer}/oauth/revoke`
904
+ };
905
+ }
906
+ function persistOAuthTokenResponse(runtime, metadata, tokenResponse) {
907
+ const now = Math.floor(Date.now() / 1e3);
908
+ const payload = decodeJwtPayload(tokenResponse.access_token);
909
+ const oauthApiBase = (metadata.apiBase || runtime.apiBase || "").replace(/\/+$/, "");
910
+ let beta;
911
+ try {
912
+ const hostname = new URL(oauthApiBase).hostname;
913
+ if (hostname === "api-beta.notis.ai") beta = true;
914
+ else if (hostname === "api.notis.ai") beta = false;
915
+ } catch {
916
+ beta = void 0;
917
+ }
918
+ const config = updateConfig((latest) => {
919
+ const next = ensureProfile(latest, runtime.profileName);
920
+ const profile = next.profiles[runtime.profileName];
921
+ next.profiles[runtime.profileName] = {
922
+ ...profile,
923
+ // The grant defines this profile's endpoint. A profile is one account on
924
+ // one API, and the environment the user just authorized against is the
925
+ // only endpoint the resulting token is accepted by.
926
+ api_base: oauthApiBase || profile.api_base,
927
+ beta: beta ?? profile.beta,
928
+ // The deployment that just authorized this profile also names the
929
+ // published build that belongs to it. Pinning it here is what lets the
930
+ // next run correct itself without the user knowing a channel exists.
931
+ channel: isReleaseChannel(metadata.channel) ? metadata.channel : channelFromProfile({ ...profile, beta: beta ?? profile.beta, api_base: oauthApiBase }) ?? profile.channel,
932
+ oauth_api_base: oauthApiBase || profile.oauth_api_base,
933
+ oauth_resource: metadata.resource,
934
+ oauth_access_token: tokenResponse.access_token,
935
+ oauth_refresh_token: tokenResponse.refresh_token || profile.oauth_refresh_token,
936
+ oauth_access_expires_at: now + Number(tokenResponse.expires_in || 0),
937
+ oauth_refresh_expires_at: now + Number(tokenResponse.refresh_expires_in || DEFAULT_REFRESH_EXPIRES_IN),
938
+ oauth_client_id: metadata.clientId,
939
+ oauth_issuer: metadata.issuer,
940
+ oauth_scopes: String(tokenResponse.scope || "").split(/\s+/).filter(Boolean),
941
+ oauth_user_id: payload.sub || payload.notis_user_id
942
+ };
943
+ return next;
944
+ });
945
+ return config.profiles[runtime.profileName];
946
+ }
947
+ var LISTENER_SCRIPT = fileURLToPath(new URL("./login-listener.js", import.meta.url));
948
+ var LISTENER_HANDSHAKE_TIMEOUT_MS = 1e4;
949
+ var LISTENER_START_LOCK_STALE_MS = LISTENER_HANDSHAKE_TIMEOUT_MS * 2;
950
+ var LISTENER_GLOBAL_LOCK_HEARTBEAT_MS = 5e3;
951
+ function listenerStateFile(runtime) {
952
+ const profileKey = createHash("sha256").update(String(runtime.profileName || "default")).digest("hex").slice(0, 16);
953
+ return `${resolveConfigFile()}.login-listener.${profileKey}`;
954
+ }
955
+ function listenerStartLockDir(runtime) {
956
+ return `${listenerStateFile(runtime)}.lock`;
957
+ }
958
+ function listenerGlobalLockDir() {
959
+ return `${resolveConfigFile()}.oauth-listener-global.lock`;
960
+ }
961
+ function listenerGlobalLockOwnerFile(lockDir) {
962
+ return join2(lockDir, "owner.json");
963
+ }
964
+ function readListenerGlobalLockOwner(lockDir) {
965
+ try {
966
+ return JSON.parse(readFileSync2(listenerGlobalLockOwnerFile(lockDir), "utf-8"));
967
+ } catch {
968
+ return null;
969
+ }
970
+ }
971
+ function writeListenerGlobalLockOwner(lock) {
972
+ const current = readListenerGlobalLockOwner(lock.lockDir);
973
+ if (current && current.owner_token !== lock.ownerToken) return false;
974
+ writeFileSync2(listenerGlobalLockOwnerFile(lock.lockDir), JSON.stringify({
975
+ owner_token: lock.ownerToken,
976
+ owner_pid: process.pid,
977
+ updated_at: Date.now()
978
+ }), { mode: 384 });
979
+ return true;
980
+ }
981
+ function listenerGlobalLockIsStale(lockDir, staleMs) {
982
+ try {
983
+ return Date.now() - statSync2(listenerGlobalLockOwnerFile(lockDir)).mtimeMs > staleMs;
984
+ } catch {
985
+ try {
986
+ return Date.now() - statSync2(lockDir).mtimeMs > staleMs;
987
+ } catch {
988
+ return false;
989
+ }
990
+ }
991
+ }
992
+ function retireStaleListenerGlobalLock(lockDir) {
993
+ const retiredDir = `${lockDir}.stale-${process.pid}-${base64url(randomBytes(8))}`;
994
+ try {
995
+ renameSync2(lockDir, retiredDir);
996
+ } catch {
997
+ return false;
998
+ }
999
+ rmSync2(retiredDir, { recursive: true, force: true });
1000
+ return true;
1001
+ }
1002
+ async function acquireListenerStartLock(runtime) {
1003
+ const lockDir = listenerStartLockDir(runtime);
1004
+ mkdirSync2(dirname2(lockDir), { recursive: true });
1005
+ const deadline = Date.now() + LISTENER_START_LOCK_STALE_MS * 2;
1006
+ for (; ; ) {
1007
+ try {
1008
+ mkdirSync2(lockDir);
1009
+ return lockDir;
1010
+ } catch (error) {
1011
+ if (error?.code !== "EEXIST") throw error;
1012
+ try {
1013
+ if (Date.now() - statSync2(lockDir).mtimeMs > LISTENER_START_LOCK_STALE_MS) {
1014
+ rmdirSync(lockDir);
1015
+ continue;
1016
+ }
1017
+ } catch {
1018
+ }
1019
+ if (Date.now() >= deadline) {
1020
+ throw oauthError(
1021
+ "oauth_listener_lock_timeout",
1022
+ "Timed out waiting for another CLI process to start browser authorization."
1023
+ );
1024
+ }
1025
+ await new Promise((resolve3) => setTimeout(resolve3, 50));
1026
+ }
1027
+ }
1028
+ }
1029
+ function releaseListenerStartLock(lockDir) {
1030
+ if (!lockDir) return;
1031
+ try {
1032
+ rmdirSync(lockDir);
1033
+ } catch {
1034
+ }
1035
+ }
1036
+ async function acquireListenerGlobalLock({
1037
+ staleMs = LISTENER_START_LOCK_STALE_MS,
1038
+ waitMs = LISTENER_START_LOCK_STALE_MS * 2,
1039
+ heartbeatMs = LISTENER_GLOBAL_LOCK_HEARTBEAT_MS
1040
+ } = {}) {
1041
+ const lockDir = listenerGlobalLockDir();
1042
+ mkdirSync2(dirname2(lockDir), { recursive: true });
1043
+ const deadline = Date.now() + waitMs;
1044
+ for (; ; ) {
1045
+ try {
1046
+ mkdirSync2(lockDir);
1047
+ const lock = {
1048
+ lockDir,
1049
+ ownerToken: base64url(randomBytes(24)),
1050
+ heartbeat: null
1051
+ };
1052
+ writeListenerGlobalLockOwner(lock);
1053
+ lock.heartbeat = setInterval(() => {
1054
+ try {
1055
+ writeListenerGlobalLockOwner(lock);
1056
+ } catch {
1057
+ }
1058
+ }, heartbeatMs);
1059
+ lock.heartbeat.unref?.();
1060
+ return lock;
1061
+ } catch (error) {
1062
+ if (error?.code !== "EEXIST") throw error;
1063
+ if (listenerGlobalLockIsStale(lockDir, staleMs) && retireStaleListenerGlobalLock(lockDir)) {
1064
+ continue;
1065
+ }
1066
+ if (Date.now() >= deadline) {
1067
+ throw oauthError(
1068
+ "oauth_listener_global_lock_timeout",
1069
+ "Timed out waiting for another CLI process to finish OAuth account changes."
1070
+ );
1071
+ }
1072
+ await new Promise((resolve3) => setTimeout(resolve3, 50));
1073
+ }
1074
+ }
1075
+ }
1076
+ function releaseListenerGlobalLock(lock) {
1077
+ if (!lock) return;
1078
+ clearInterval(lock.heartbeat);
1079
+ const owner = readListenerGlobalLockOwner(lock.lockDir);
1080
+ if (owner?.owner_token !== lock.ownerToken) return;
1081
+ const releasedDir = `${lock.lockDir}.released-${process.pid}-${lock.ownerToken}`;
1082
+ try {
1083
+ renameSync2(lock.lockDir, releasedDir);
1084
+ } catch {
1085
+ return;
1086
+ }
1087
+ const movedOwner = readListenerGlobalLockOwner(releasedDir);
1088
+ if (movedOwner?.owner_token !== lock.ownerToken) {
1089
+ try {
1090
+ renameSync2(releasedDir, lock.lockDir);
1091
+ } catch {
1092
+ }
1093
+ return;
1094
+ }
1095
+ rmSync2(releasedDir, { recursive: true, force: true });
1096
+ }
1097
+ async function revokeCancelledToken(metadata, tokenResponse, fetchImpl) {
1098
+ const token = tokenResponse?.refresh_token || tokenResponse?.access_token;
1099
+ if (!token || !metadata?.revocationEndpoint || !metadata?.clientId) return;
1100
+ try {
1101
+ await fetchJson(
1102
+ metadata.revocationEndpoint,
1103
+ {
1104
+ method: "POST",
1105
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
1106
+ body: new URLSearchParams({ token, client_id: metadata.clientId })
1107
+ },
1108
+ fetchImpl
1109
+ );
1110
+ } catch {
1111
+ }
1112
+ }
1113
+ function updateRuntimeFromOAuthProfile(runtime, profile) {
1114
+ const oauthApiBase = getOAuthApiBase(profile);
1115
+ runtime.jwt = profile.oauth_access_token;
1116
+ runtime.credentialKind = "oauth";
1117
+ runtime.credentialSource = "oauth";
1118
+ runtime.oauthAccessToken = profile.oauth_access_token;
1119
+ runtime.oauthRefreshToken = profile.oauth_refresh_token;
1120
+ runtime.oauthAccessExpiresAt = profile.oauth_access_expires_at;
1121
+ runtime.oauthRefreshExpiresAt = profile.oauth_refresh_expires_at;
1122
+ runtime.oauthClientId = profile.oauth_client_id;
1123
+ runtime.oauthIssuer = profile.oauth_issuer;
1124
+ runtime.oauthApiBase = oauthApiBase;
1125
+ runtime.oauthResource = getOAuthResource(profile);
1126
+ runtime.oauthScopes = profile.oauth_scopes || [];
1127
+ runtime.oauthUserId = profile.oauth_user_id;
1128
+ if (oauthApiBase && !runtime.requestedApiBase) {
1129
+ runtime.apiBase = oauthApiBase;
1130
+ }
1131
+ }
1132
+ function assertOAuthApiTarget(runtime, profile) {
1133
+ const requestedApiBase = String(runtime.requestedApiBase || "").replace(/\/+$/, "");
1134
+ const oauthApiBase = getOAuthApiBase(profile);
1135
+ if (requestedApiBase && oauthApiBase && requestedApiBase !== oauthApiBase) {
1136
+ throw oauthError(
1137
+ "oauth_api_target_mismatch",
1138
+ `This OAuth grant belongs to ${oauthApiBase}, not ${requestedApiBase}. Run login for the requested environment.`
1139
+ );
1140
+ }
1141
+ }
1142
+ async function ensureFreshOAuthCredential(runtime, fetchImpl = fetch) {
1143
+ if (runtime.credentialKind !== "oauth") {
1144
+ return Boolean(runtime.jwt);
1145
+ }
1146
+ const profile = getProfile(loadConfig(), runtime.profileName);
1147
+ assertOAuthApiTarget(runtime, profile);
1148
+ if (!credentialIsExpired({ credentialKind: "oauth" }, profile)) {
1149
+ updateRuntimeFromOAuthProfile(runtime, profile);
1150
+ return true;
1151
+ }
1152
+ return refreshOAuthCredential(runtime, fetchImpl);
1153
+ }
1154
+ function lockIsStale() {
1155
+ try {
1156
+ return Date.now() - statSync2(OAUTH_LOCK_DIR).mtimeMs > 45e3;
1157
+ } catch {
1158
+ return false;
1159
+ }
1160
+ }
1161
+ async function acquireRefreshLock(runtime, waitMs = 6e4) {
1162
+ mkdirSync2(join2(homedir2(), ".notis"), { recursive: true });
1163
+ const deadline = Date.now() + waitMs;
1164
+ for (; ; ) {
1165
+ try {
1166
+ mkdirSync2(OAUTH_LOCK_DIR);
1167
+ return true;
1168
+ } catch (error) {
1169
+ if (error?.code !== "EEXIST") throw error;
1170
+ const profile = getProfile(loadConfig(), runtime.profileName);
1171
+ if (profile.oauth_access_token && profile.oauth_access_token !== runtime.oauthAccessToken && !credentialIsExpired({ credentialKind: "oauth" }, profile)) {
1172
+ updateRuntimeFromOAuthProfile(runtime, profile);
1173
+ return false;
1174
+ }
1175
+ if (lockIsStale()) {
1176
+ try {
1177
+ rmdirSync(OAUTH_LOCK_DIR);
1178
+ continue;
1179
+ } catch {
1180
+ }
1181
+ }
1182
+ if (Date.now() >= deadline) {
1183
+ throw oauthError("oauth_refresh_lock_timeout", "Timed out waiting for another CLI process to refresh OAuth.");
1184
+ }
1185
+ await new Promise((resolve3) => setTimeout(resolve3, 100));
1186
+ }
1187
+ }
1188
+ }
1189
+ async function refreshOAuthCredential(runtime, fetchImpl = fetch) {
1190
+ const globalLock = await acquireListenerGlobalLock();
1191
+ let profileLock = null;
1192
+ let ownsLock = false;
1193
+ let metadata = null;
1194
+ let rotatedResponse = null;
1195
+ let rotatedPersisted = false;
1196
+ try {
1197
+ profileLock = await acquireListenerStartLock(runtime);
1198
+ ownsLock = await acquireRefreshLock(runtime);
1199
+ if (!ownsLock) return true;
1200
+ const config = loadConfig();
1201
+ const profile = getProfile(config, runtime.profileName);
1202
+ assertOAuthApiTarget(runtime, profile);
1203
+ if (profile.oauth_access_token && profile.oauth_access_token !== runtime.oauthAccessToken && !credentialIsExpired({ credentialKind: "oauth" }, profile)) {
1204
+ updateRuntimeFromOAuthProfile(runtime, profile);
1205
+ return true;
1206
+ }
1207
+ if (!profile.oauth_refresh_token || !profile.oauth_client_id || !profile.oauth_issuer) {
1208
+ return false;
1209
+ }
1210
+ metadata = storedOAuthMetadata(runtime, profile);
1211
+ rotatedResponse = await fetchJson(
1212
+ metadata.tokenEndpoint,
1213
+ {
1214
+ method: "POST",
1215
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
1216
+ body: new URLSearchParams({
1217
+ grant_type: "refresh_token",
1218
+ refresh_token: profile.oauth_refresh_token,
1219
+ client_id: profile.oauth_client_id,
1220
+ resource: metadata.resource
1221
+ })
1222
+ },
1223
+ fetchImpl
1224
+ );
1225
+ const updated = persistOAuthTokenResponse(runtime, metadata, rotatedResponse);
1226
+ rotatedPersisted = true;
1227
+ updateRuntimeFromOAuthProfile(runtime, updated);
1228
+ return true;
1229
+ } catch (error) {
1230
+ if (rotatedResponse && !rotatedPersisted) {
1231
+ await revokeCancelledToken(metadata, rotatedResponse, fetchImpl);
1232
+ }
1233
+ if (error instanceof CliError) {
1234
+ throw new CliError({
1235
+ code: error.code,
1236
+ message: error.message,
1237
+ exitCode: error.exitCode,
1238
+ retryable: error.retryable,
1239
+ details: error.details,
1240
+ hints: getAuthRecovery(runtime).hints,
1241
+ warnings: error.warnings,
1242
+ cause: error
1243
+ });
1244
+ }
1245
+ throw error;
1246
+ } finally {
1247
+ if (ownsLock) {
1248
+ try {
1249
+ rmdirSync(OAUTH_LOCK_DIR);
1250
+ } catch {
1251
+ }
1252
+ }
1253
+ releaseListenerStartLock(profileLock);
1254
+ releaseListenerGlobalLock(globalLock);
1255
+ }
1256
+ }
1257
+
1258
+ // dist/skill-sync/index.js
1259
+ import { createHash as createHash2 } from "node:crypto";
1260
+ import { createHash as createHash3 } from "crypto";
1261
+ import { execFile } from "child_process";
1262
+ import { promises as fs } from "fs";
1263
+ import os from "os";
1264
+ import path from "path";
1265
+ import { promisify } from "util";
1266
+ import { promises as fs2 } from "fs";
1267
+ import os2 from "os";
1268
+ import path2 from "path";
1269
+ var DEFAULT_AGENT_TARGETS = {
1270
+ notis: true,
1271
+ claude_code: true,
1272
+ cursor: true,
1273
+ codex: true
1274
+ };
1275
+ function normalizeAgentTargets(targets) {
1276
+ return {
1277
+ notis: Boolean(targets?.notis ?? DEFAULT_AGENT_TARGETS.notis),
1278
+ claude_code: Boolean(targets?.claude_code ?? DEFAULT_AGENT_TARGETS.claude_code),
1279
+ cursor: Boolean(targets?.cursor ?? DEFAULT_AGENT_TARGETS.cursor),
1280
+ codex: Boolean(targets?.codex ?? DEFAULT_AGENT_TARGETS.codex)
1281
+ };
1282
+ }
1283
+ var HOME_DIR = os.homedir();
1284
+ var execFileAsync = promisify(execFile);
1285
+ var AGENTS_DIR = path.join(HOME_DIR, ".agents");
1286
+ var LEGACY_AGENTS_SKILLS_DIR = path.join(AGENTS_DIR, "skills");
1287
+ var NOTIS_SKILL_SYNC_ROOT = path.join(HOME_DIR, ".notis", "skills");
1288
+ var LEGACY_NOTIS_SYNC_STATE_PATH = path.join(
1289
+ AGENTS_DIR,
1290
+ ".notis-sync.json"
1291
+ );
1292
+ var AGENTS_SKILLS_DIR = LEGACY_AGENTS_SKILLS_DIR;
1293
+ var SKILL_LOCK_PATH = path.join(AGENTS_DIR, ".skill-lock.json");
1294
+ var DEFAULT_SYNC_STATE = {
1295
+ version: 1,
1296
+ lastSyncedAt: null,
1297
+ skills: {}
1298
+ };
1299
+ var EXCLUDED_TOP_LEVEL_ROOT_NAMES = /* @__PURE__ */ new Set([
1300
+ "backup",
1301
+ "backups",
1302
+ "builtin",
1303
+ "builtins",
1304
+ "cache",
1305
+ "caches",
1306
+ "marketplace",
1307
+ "marketplaces",
1308
+ "plugin",
1309
+ "plugins",
1310
+ "temp",
1311
+ "tmp",
1312
+ "worktree",
1313
+ "worktrees"
1314
+ ]);
1315
+ var DEFAULT_SYNC_PATHS = {
1316
+ agentsDir: AGENTS_DIR,
1317
+ syncRoot: NOTIS_SKILL_SYNC_ROOT,
1318
+ legacySkillsDir: LEGACY_AGENTS_SKILLS_DIR,
1319
+ legacyScopedSkillsDir: LEGACY_AGENTS_SKILLS_DIR,
1320
+ legacyScopedSyncStatePath: LEGACY_NOTIS_SYNC_STATE_PATH,
1321
+ skillsDir: LEGACY_AGENTS_SKILLS_DIR,
1322
+ syncStatePath: LEGACY_NOTIS_SYNC_STATE_PATH,
1323
+ skillLockPath: SKILL_LOCK_PATH,
1324
+ gatherMetadataPath: path.join(NOTIS_SKILL_SYNC_ROOT, "skill-gather-metadata.json")
1325
+ };
1326
+ function getDefaultSyncRootForAgentsDir(resolvedAgentsDir) {
1327
+ if (resolvedAgentsDir === path.resolve(AGENTS_DIR)) {
1328
+ return NOTIS_SKILL_SYNC_ROOT;
1329
+ }
1330
+ return path.join(resolvedAgentsDir, ".notis", "skills");
1331
+ }
1332
+ function isResolvedChildPath(baseDir, candidatePath) {
1333
+ return candidatePath.startsWith(`${baseDir}${path.sep}`);
1334
+ }
1335
+ function sanitizePathSegment(value) {
1336
+ const raw = value.trim();
1337
+ const sanitized = raw.replace(/[^A-Za-z0-9_-]+/g, "_").replace(/^_+|_+$/g, "");
1338
+ if (!sanitized) {
1339
+ throw new Error("Invalid sync user id");
1340
+ }
1341
+ if (sanitized === raw) {
1342
+ return sanitized;
1343
+ }
1344
+ return `${sanitized}-${createHash3("sha256").update(raw).digest("hex").slice(0, 12)}`;
1345
+ }
1346
+ function getSkillSyncPathsForUser(authUserId, agentsDir = AGENTS_DIR) {
1347
+ const safeUserId = sanitizePathSegment(authUserId);
1348
+ const resolvedAgentsDir = path.resolve(agentsDir);
1349
+ const syncRoot = getDefaultSyncRootForAgentsDir(resolvedAgentsDir);
1350
+ const userRoot = path.join(syncRoot, "users", safeUserId);
1351
+ const legacyUserRoot = path.join(resolvedAgentsDir, "notis", "users", safeUserId);
1352
+ return {
1353
+ agentsDir: resolvedAgentsDir,
1354
+ syncRoot,
1355
+ legacySkillsDir: path.join(resolvedAgentsDir, "skills"),
1356
+ legacyScopedSkillsDir: path.join(legacyUserRoot, "skills"),
1357
+ legacyScopedSyncStatePath: path.join(legacyUserRoot, ".notis-sync.json"),
1358
+ skillsDir: path.join(userRoot, "skills"),
1359
+ syncStatePath: path.join(userRoot, ".notis-sync.json"),
1360
+ skillLockPath: path.join(resolvedAgentsDir, ".skill-lock.json"),
1361
+ gatherMetadataPath: path.join(userRoot, ".notis-gathered-skills.json")
1362
+ };
1363
+ }
1364
+ function assertRelativeBundlePath(relativePath) {
1365
+ const normalized = relativePath.replace(/\\/g, "/").replace(/^\/+|\/+$/g, "");
1366
+ if (!normalized || normalized.startsWith("../") || `/${normalized}/`.includes("/../")) {
1367
+ throw new Error(`Invalid bundle file path: ${relativePath}`);
1368
+ }
1369
+ return normalized;
1370
+ }
1371
+ function stripWrappingQuotes(value) {
1372
+ return value.replace(/^['"]|['"]$/g, "").trim();
1373
+ }
1374
+ function parseFrontMatter(skillMd) {
1375
+ const match = skillMd.match(/^---\s*\n([\s\S]*?)\n---\s*(?:\n|$)/);
1376
+ if (!match) {
1377
+ return { description: "" };
1378
+ }
1379
+ let description = "";
1380
+ for (const line of match[1].split("\n")) {
1381
+ const trimmed = line.trim();
1382
+ if (!trimmed || trimmed.startsWith("#")) {
1383
+ continue;
1384
+ }
1385
+ const descriptionMatch = trimmed.match(/^description\s*:\s*(.+)$/i);
1386
+ if (descriptionMatch) {
1387
+ description = stripWrappingQuotes(descriptionMatch[1]);
1388
+ break;
1389
+ }
1390
+ }
1391
+ return { description };
1392
+ }
1393
+ async function readJsonFile2(filePath) {
1394
+ try {
1395
+ const raw = await fs.readFile(filePath, "utf8");
1396
+ return JSON.parse(raw);
1397
+ } catch {
1398
+ return null;
1399
+ }
1400
+ }
1401
+ async function listFilesRecursive(dirPath) {
1402
+ const entries = await fs.readdir(dirPath, { withFileTypes: true });
1403
+ const nested = await Promise.all(
1404
+ entries.map(async (entry) => {
1405
+ if (entry.name === ".DS_Store") {
1406
+ return [];
1407
+ }
1408
+ const fullPath = path.join(dirPath, entry.name);
1409
+ if (entry.isDirectory()) {
1410
+ return listFilesRecursive(fullPath);
1411
+ }
1412
+ if (entry.isFile()) {
1413
+ return [fullPath];
1414
+ }
1415
+ return [];
1416
+ })
1417
+ );
1418
+ return nested.flat().sort();
1419
+ }
1420
+ async function computeFolderHash(dirPath) {
1421
+ const hash = createHash3("sha256");
1422
+ const filePaths = await listFilesRecursive(dirPath);
1423
+ for (const filePath of filePaths) {
1424
+ const relativePath = path.relative(dirPath, filePath);
1425
+ hash.update(relativePath);
1426
+ hash.update("\0");
1427
+ hash.update(await fs.readFile(filePath));
1428
+ hash.update("\0");
1429
+ }
1430
+ return hash.digest("hex");
1431
+ }
1432
+ function resolveSkillBundleFilePath(skillDir, relativePath) {
1433
+ const normalizedPath = assertRelativeBundlePath(relativePath);
1434
+ const resolvedSkillDir = path.resolve(skillDir);
1435
+ const candidatePath = path.resolve(resolvedSkillDir, normalizedPath);
1436
+ if (candidatePath === resolvedSkillDir || !isResolvedChildPath(resolvedSkillDir, candidatePath)) {
1437
+ throw new Error(
1438
+ `Bundle file resolves outside the expected directory: ${relativePath}`
1439
+ );
1440
+ }
1441
+ return candidatePath;
1442
+ }
1443
+ async function readSkillMdIfValid(skillDir) {
1444
+ try {
1445
+ await fs.readFile(path.join(skillDir, "SKILL.md"), "utf8");
1446
+ return true;
1447
+ } catch {
1448
+ return false;
1449
+ }
1450
+ }
1451
+ async function moveDirectory(sourceDir, destinationDir) {
1452
+ await fs.mkdir(path.dirname(destinationDir), { recursive: true });
1453
+ try {
1454
+ await fs.rename(sourceDir, destinationDir);
1455
+ } catch (error) {
1456
+ if (error.code !== "EXDEV") {
1457
+ throw error;
1458
+ }
1459
+ await fs.cp(sourceDir, destinationDir, {
1460
+ recursive: true,
1461
+ errorOnExist: true,
1462
+ force: false
1463
+ });
1464
+ await fs.rm(sourceDir, { recursive: true, force: true });
1465
+ }
1466
+ }
1467
+ async function createDirectorySymlink(targetDir, linkPath) {
1468
+ await fs.mkdir(path.dirname(linkPath), { recursive: true });
1469
+ await fs.symlink(path.relative(path.dirname(linkPath), targetDir), linkPath, "dir");
1470
+ }
1471
+ function backupRootFor(paths, timestamp) {
1472
+ return path.join(paths.syncRoot, "skill-dedupe-backups", timestamp);
1473
+ }
1474
+ function relativeBackupPath(label, skillName) {
1475
+ return path.join(
1476
+ label.replace(/[^A-Za-z0-9_.-]+/g, "_"),
1477
+ safeName(skillName)
1478
+ );
1479
+ }
1480
+ function isExcludedTopLevelSkillEntry(entryName) {
1481
+ return entryName.startsWith(".") || isTransientSkillDirectoryName(entryName) || EXCLUDED_TOP_LEVEL_ROOT_NAMES.has(entryName.toLowerCase());
1482
+ }
1483
+ function isTransientSkillDirectoryName(entryName) {
1484
+ return /\.(?:backup|staging)-/.test(entryName);
1485
+ }
1486
+ function defaultTopLevelSkillSources(paths) {
1487
+ const sources = [];
1488
+ if (path.resolve(paths.legacyScopedSkillsDir) !== path.resolve(paths.skillsDir)) {
1489
+ sources.push({
1490
+ label: "notis-legacy-scoped",
1491
+ root: paths.legacyScopedSkillsDir,
1492
+ priority: 1
1493
+ });
1494
+ }
1495
+ sources.push(
1496
+ { label: "agents", root: paths.legacySkillsDir, priority: 2 },
1497
+ {
1498
+ label: "codex",
1499
+ root: path.join(HOME_DIR, ".codex", "skills"),
1500
+ priority: 3
1501
+ },
1502
+ {
1503
+ label: "cursor",
1504
+ root: path.join(HOME_DIR, ".cursor", "skills"),
1505
+ priority: 4
1506
+ },
1507
+ {
1508
+ label: "claude",
1509
+ root: path.join(HOME_DIR, ".claude", "skills"),
1510
+ priority: 5
1511
+ }
1512
+ );
1513
+ return sources;
1514
+ }
1515
+ function isManagedTopLevelSymlinkTarget(resolvedPath, paths) {
1516
+ const managedRoots = [
1517
+ path.resolve(paths.skillsDir),
1518
+ path.resolve(paths.legacySkillsDir),
1519
+ path.resolve(paths.legacyScopedSkillsDir),
1520
+ path.join(path.resolve(paths.syncRoot), "base"),
1521
+ path.join(path.resolve(paths.syncRoot), "users"),
1522
+ path.join(path.resolve(paths.agentsDir), "notis", "users")
1523
+ ];
1524
+ return managedRoots.some(
1525
+ (root) => resolvedPath === root || resolvedPath.startsWith(`${root}${path.sep}`)
1526
+ );
1527
+ }
1528
+ async function listTopLevelSkillCandidates(paths, options) {
1529
+ const sourceRoots = options.sourceRoots ? options.sourceRoots.map((source, index) => ({
1530
+ ...source,
1531
+ priority: index + 1
1532
+ })) : defaultTopLevelSkillSources(paths);
1533
+ const candidates = [];
1534
+ try {
1535
+ const scopedEntries = await fs.readdir(paths.skillsDir, {
1536
+ withFileTypes: true
1537
+ });
1538
+ for (const entry of scopedEntries) {
1539
+ if (!entry.isDirectory() && !entry.isSymbolicLink() || entry.name.startsWith(".") || isTransientSkillDirectoryName(entry.name)) {
1540
+ continue;
1541
+ }
1542
+ const skillDir = path.join(paths.skillsDir, entry.name);
1543
+ let resolvedPath = path.resolve(skillDir);
1544
+ if (entry.isSymbolicLink()) {
1545
+ try {
1546
+ resolvedPath = path.resolve(path.dirname(skillDir), await fs.readlink(skillDir));
1547
+ } catch {
1548
+ continue;
1549
+ }
1550
+ }
1551
+ if (await readSkillMdIfValid(skillDir)) {
1552
+ candidates.push({
1553
+ name: safeName(entry.name, paths.skillsDir),
1554
+ root: paths.skillsDir,
1555
+ label: "notis-managed",
1556
+ path: skillDir,
1557
+ resolvedPath,
1558
+ priority: 0,
1559
+ isScoped: true,
1560
+ isSymlink: entry.isSymbolicLink()
1561
+ });
1562
+ }
1563
+ }
1564
+ } catch {
1565
+ }
1566
+ for (const source of sourceRoots) {
1567
+ let entries;
1568
+ try {
1569
+ entries = await fs.readdir(source.root, { withFileTypes: true });
1570
+ } catch {
1571
+ continue;
1572
+ }
1573
+ for (const entry of entries) {
1574
+ if (isExcludedTopLevelSkillEntry(entry.name) || !entry.isDirectory() && !entry.isSymbolicLink()) {
1575
+ continue;
1576
+ }
1577
+ const candidatePath = path.join(source.root, entry.name);
1578
+ let resolvedPath;
1579
+ try {
1580
+ resolvedPath = entry.isSymbolicLink() ? path.resolve(
1581
+ path.dirname(candidatePath),
1582
+ await fs.readlink(candidatePath)
1583
+ ) : path.resolve(candidatePath);
1584
+ } catch {
1585
+ continue;
1586
+ }
1587
+ if (entry.isSymbolicLink() && isManagedTopLevelSymlinkTarget(resolvedPath, paths)) {
1588
+ continue;
1589
+ }
1590
+ if (!await readSkillMdIfValid(resolvedPath)) {
1591
+ continue;
1592
+ }
1593
+ candidates.push({
1594
+ name: safeName(entry.name, paths.skillsDir),
1595
+ root: source.root,
1596
+ label: source.label,
1597
+ path: candidatePath,
1598
+ resolvedPath,
1599
+ priority: source.priority,
1600
+ isScoped: false,
1601
+ isSymlink: entry.isSymbolicLink()
1602
+ });
1603
+ }
1604
+ }
1605
+ return candidates.sort(
1606
+ (a, b) => a.priority - b.priority || a.name.localeCompare(b.name)
1607
+ );
1608
+ }
1609
+ async function gatherTopLevelLocalSkills(paths, options = {}) {
1610
+ await ensureCanonicalSkillsDir(paths);
1611
+ const protectedSkillNames = options.protectedSkillNames || /* @__PURE__ */ new Set();
1612
+ const timestamp = options.timestamp || (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
1613
+ const backupRoot = backupRootFor(paths, timestamp);
1614
+ const candidates = await listTopLevelSkillCandidates(paths, options);
1615
+ const byName = /* @__PURE__ */ new Map();
1616
+ for (const candidate of candidates) {
1617
+ const existing = byName.get(candidate.name) || [];
1618
+ existing.push(candidate);
1619
+ byName.set(candidate.name, existing);
1620
+ }
1621
+ let gathered = 0;
1622
+ let backedUp = 0;
1623
+ let skipped = 0;
1624
+ const metadata = {
1625
+ version: 1,
1626
+ gatheredAt: (/* @__PURE__ */ new Date()).toISOString(),
1627
+ skills: {}
1628
+ };
1629
+ for (const [skillName, skillCandidates] of byName) {
1630
+ const protectedFromLocalGather = protectedSkillNames.has(skillName);
1631
+ const canonical = protectedFromLocalGather ? skillCandidates.find((candidate) => candidate.isScoped) || null : skillCandidates[0];
1632
+ const destinationName = safeName(skillName, paths.skillsDir);
1633
+ const destinationDir = path.join(paths.skillsDir, destinationName);
1634
+ const skippedSources = [];
1635
+ if (!canonical) {
1636
+ for (const candidate of skillCandidates) {
1637
+ if (candidate.isSymlink) {
1638
+ skipped += 1;
1639
+ skippedSources.push(candidate.path);
1640
+ continue;
1641
+ }
1642
+ const backupDir = path.join(
1643
+ backupRoot,
1644
+ relativeBackupPath(candidate.label, skillName)
1645
+ );
1646
+ try {
1647
+ await moveDirectory(candidate.path, backupDir);
1648
+ backedUp += 1;
1649
+ skippedSources.push(candidate.path);
1650
+ } catch {
1651
+ skipped += 1;
1652
+ }
1653
+ }
1654
+ metadata.skills[skillName] = {
1655
+ canonicalPath: null,
1656
+ skippedSources,
1657
+ protectedFromLocalGather: true
1658
+ };
1659
+ continue;
1660
+ }
1661
+ if (!canonical.isScoped && !await pathExists(destinationDir)) {
1662
+ try {
1663
+ if (canonical.isSymlink) {
1664
+ await createDirectorySymlink(canonical.resolvedPath, destinationDir);
1665
+ } else {
1666
+ await moveDirectory(canonical.path, destinationDir);
1667
+ }
1668
+ gathered += 1;
1669
+ } catch {
1670
+ skipped += 1;
1671
+ }
1672
+ }
1673
+ for (const candidate of skillCandidates) {
1674
+ if (candidate === canonical || candidate.isScoped) {
1675
+ continue;
1676
+ }
1677
+ if (candidate.isSymlink) {
1678
+ skippedSources.push(candidate.path);
1679
+ continue;
1680
+ }
1681
+ const backupDir = path.join(
1682
+ backupRoot,
1683
+ relativeBackupPath(candidate.label, skillName)
1684
+ );
1685
+ try {
1686
+ await moveDirectory(candidate.path, backupDir);
1687
+ backedUp += 1;
1688
+ skippedSources.push(candidate.path);
1689
+ } catch {
1690
+ skipped += 1;
1691
+ }
1692
+ }
1693
+ metadata.skills[skillName] = {
1694
+ canonicalPath: await pathExists(destinationDir) ? destinationDir : canonical.path,
1695
+ skippedSources,
1696
+ ...protectedFromLocalGather ? { protectedFromLocalGather: true } : {}
1697
+ };
1698
+ }
1699
+ await fs.mkdir(path.dirname(paths.gatherMetadataPath), { recursive: true });
1700
+ await fs.writeFile(
1701
+ paths.gatherMetadataPath,
1702
+ `${JSON.stringify(metadata, null, 2)}
1703
+ `,
1704
+ "utf8"
1705
+ );
1706
+ return {
1707
+ gathered,
1708
+ backedUp,
1709
+ skipped,
1710
+ metadataPath: paths.gatherMetadataPath
1711
+ };
1712
+ }
1713
+ async function readSkillLock(paths = DEFAULT_SYNC_PATHS) {
1714
+ const lockData = await readJsonFile2(paths.skillLockPath);
1715
+ const sourceUrls = {};
1716
+ for (const [skillName, entry] of Object.entries(lockData?.skills || {})) {
1717
+ if (typeof entry?.sourceUrl === "string" && entry.sourceUrl.trim()) {
1718
+ sourceUrls[skillName] = entry.sourceUrl.trim();
1719
+ }
1720
+ }
1721
+ return sourceUrls;
1722
+ }
1723
+ async function ensureCanonicalSkillsDir(paths = DEFAULT_SYNC_PATHS) {
1724
+ await fs.mkdir(paths.skillsDir, { recursive: true });
1725
+ }
1726
+ async function scanLocalSkills(paths = DEFAULT_SYNC_PATHS) {
1727
+ await ensureCanonicalSkillsDir(paths);
1728
+ const sourceUrls = await readSkillLock(paths);
1729
+ const entries = await fs.readdir(paths.skillsDir, { withFileTypes: true });
1730
+ const skills = await Promise.all(
1731
+ entries.map(async (entry) => {
1732
+ if (!entry.isDirectory() && !entry.isSymbolicLink() || isTransientSkillDirectoryName(entry.name)) {
1733
+ return null;
1734
+ }
1735
+ const skillDir = path.join(paths.skillsDir, entry.name);
1736
+ const skillMdPath = path.join(skillDir, "SKILL.md");
1737
+ try {
1738
+ const skillMd = await fs.readFile(skillMdPath, "utf8");
1739
+ const { description } = parseFrontMatter(skillMd);
1740
+ const folderHash = await computeFolderHash(skillDir);
1741
+ const skill = {
1742
+ name: entry.name,
1743
+ skillMd,
1744
+ description,
1745
+ folderHash,
1746
+ directoryPath: skillDir
1747
+ };
1748
+ if (sourceUrls[entry.name]) {
1749
+ skill.sourceUrl = sourceUrls[entry.name];
1750
+ }
1751
+ return skill;
1752
+ } catch {
1753
+ return null;
1754
+ }
1755
+ })
1756
+ );
1757
+ return skills.filter((skill) => skill !== null).sort((a, b) => a.name.localeCompare(b.name));
1758
+ }
1759
+ function normalizeSyncState(state) {
1760
+ if (!state || state.version !== 1 || typeof state.skills !== "object") {
1761
+ return null;
1762
+ }
1763
+ const normalizeStoredAgentTargets = (targets) => ({
1764
+ notis: Boolean(targets?.notis ?? true),
1765
+ claude_code: Boolean(targets?.claude_code ?? true),
1766
+ cursor: Boolean(targets?.cursor ?? true),
1767
+ codex: Boolean(targets?.codex ?? true)
1768
+ });
1769
+ return {
1770
+ version: 1,
1771
+ lastSyncedAt: typeof state.lastSyncedAt === "string" ? state.lastSyncedAt : null,
1772
+ skills: Object.fromEntries(
1773
+ Object.entries(state.skills || {}).map(([skillName, skillState]) => [
1774
+ skillName,
1775
+ {
1776
+ ...skillState,
1777
+ agentTargets: normalizeStoredAgentTargets(skillState.agentTargets)
1778
+ }
1779
+ ])
1780
+ )
1781
+ };
1782
+ }
1783
+ async function readSyncState(paths = DEFAULT_SYNC_PATHS) {
1784
+ const scopedState = normalizeSyncState(
1785
+ await readJsonFile2(paths.syncStatePath)
1786
+ );
1787
+ if (scopedState) {
1788
+ return scopedState;
1789
+ }
1790
+ return DEFAULT_SYNC_STATE;
1791
+ }
1792
+ async function readLegacySyncState(paths = DEFAULT_SYNC_PATHS) {
1793
+ const legacyStatePaths = [
1794
+ paths.legacyScopedSyncStatePath,
1795
+ path.resolve(paths.syncStatePath) === path.resolve(LEGACY_NOTIS_SYNC_STATE_PATH) ? paths.syncStatePath : path.join(paths.agentsDir, ".notis-sync.json")
1796
+ ];
1797
+ for (const legacyStatePath of legacyStatePaths) {
1798
+ const state = normalizeSyncState(
1799
+ await readJsonFile2(legacyStatePath)
1800
+ );
1801
+ if (state) {
1802
+ return state;
1803
+ }
1804
+ }
1805
+ return null;
1806
+ }
1807
+ async function writeSyncState(state, paths = DEFAULT_SYNC_PATHS) {
1808
+ await fs.mkdir(path.dirname(paths.syncStatePath), { recursive: true });
1809
+ await fs.writeFile(
1810
+ paths.syncStatePath,
1811
+ `${JSON.stringify(state, null, 2)}
1812
+ `,
1813
+ "utf8"
1814
+ );
1815
+ }
1816
+ function safeName(name, baseDir = AGENTS_SKILLS_DIR) {
1817
+ const sanitizedName = name.trim().replace(/[\\/]+/g, "").replace(/\.\./g, "");
1818
+ if (!sanitizedName) {
1819
+ throw new Error("Invalid skill name");
1820
+ }
1821
+ const resolvedBaseDir = path.resolve(baseDir);
1822
+ const resolvedPath = path.resolve(resolvedBaseDir, sanitizedName);
1823
+ if (!isResolvedChildPath(resolvedBaseDir, resolvedPath)) {
1824
+ throw new Error("Skill name resolves outside the expected directory");
1825
+ }
1826
+ return sanitizedName;
1827
+ }
1828
+ async function deleteLocalSkill(skillName, paths = DEFAULT_SYNC_PATHS) {
1829
+ const skillDir = path.join(
1830
+ paths.skillsDir,
1831
+ safeName(skillName, paths.skillsDir)
1832
+ );
1833
+ try {
1834
+ await fs.rm(skillDir, { recursive: true, force: true });
1835
+ return true;
1836
+ } catch {
1837
+ return false;
1838
+ }
1839
+ }
1840
+ async function createZipFromDirectory(directoryPath) {
1841
+ const zipPath = path.join(
1842
+ os.tmpdir(),
1843
+ `notis-skill-${Date.now()}-${Math.random().toString(36).slice(2)}.zip`
1844
+ );
1845
+ const parentDir = path.dirname(directoryPath);
1846
+ const directoryName = path.basename(directoryPath);
1847
+ if (process.platform === "win32") {
1848
+ await execFileAsync("powershell.exe", [
1849
+ "-NoProfile",
1850
+ "-Command",
1851
+ `Compress-Archive -Path '${directoryPath.replace(/'/g, "''")}' -DestinationPath '${zipPath.replace(/'/g, "''")}' -Force`
1852
+ ]);
1853
+ return zipPath;
1854
+ }
1855
+ await execFileAsync("zip", ["-qry", zipPath, directoryName], {
1856
+ cwd: parentDir
1857
+ });
1858
+ return zipPath;
1859
+ }
1860
+ async function extractZipToDirectory(zipPath, destinationDir) {
1861
+ const extractRoot = await fs.mkdtemp(
1862
+ path.join(os.tmpdir(), "notis-skill-extract-")
1863
+ );
1864
+ try {
1865
+ if (process.platform === "win32") {
1866
+ await execFileAsync("powershell.exe", [
1867
+ "-NoProfile",
1868
+ "-Command",
1869
+ `Expand-Archive -Path '${zipPath.replace(/'/g, "''")}' -DestinationPath '${extractRoot.replace(/'/g, "''")}' -Force`
1870
+ ]);
1871
+ } else {
1872
+ await execFileAsync("unzip", ["-qq", zipPath, "-d", extractRoot]);
1873
+ }
1874
+ const extractedEntries = await fs.readdir(extractRoot, {
1875
+ withFileTypes: true
1876
+ });
1877
+ const extractedDirectory = extractedEntries.find(
1878
+ (entry) => entry.isDirectory()
1879
+ );
1880
+ const sourceDir = extractedDirectory ? path.join(extractRoot, extractedDirectory.name) : extractRoot;
1881
+ await fs.rm(destinationDir, { recursive: true, force: true });
1882
+ await fs.mkdir(path.dirname(destinationDir), { recursive: true });
1883
+ await fs.cp(sourceDir, destinationDir, { recursive: true });
1884
+ } finally {
1885
+ await fs.rm(extractRoot, { recursive: true, force: true });
1886
+ }
1887
+ }
1888
+ async function pathExists(targetPath) {
1889
+ try {
1890
+ await fs.access(targetPath);
1891
+ return true;
1892
+ } catch {
1893
+ return false;
1894
+ }
1895
+ }
1896
+ async function replaceSkillDirectoryAtomically(skillDir, populateDir) {
1897
+ const parentDir = path.dirname(skillDir);
1898
+ const skillName = path.basename(skillDir);
1899
+ await fs.mkdir(parentDir, { recursive: true });
1900
+ const stagingDir = await fs.mkdtemp(
1901
+ path.join(parentDir, `${skillName}.staging-`)
1902
+ );
1903
+ const backupDir = path.join(
1904
+ parentDir,
1905
+ `${skillName}.backup-${Date.now()}-${Math.random().toString(36).slice(2)}`
1906
+ );
1907
+ let movedExisting = false;
1908
+ let promotedStaging = false;
1909
+ let cleanupError = null;
1910
+ try {
1911
+ await populateDir(stagingDir);
1912
+ if (await pathExists(skillDir)) {
1913
+ await fs.rename(skillDir, backupDir);
1914
+ movedExisting = true;
1915
+ }
1916
+ await fs.rename(stagingDir, skillDir);
1917
+ promotedStaging = true;
1918
+ } catch (error) {
1919
+ if (movedExisting && !promotedStaging && await pathExists(backupDir)) {
1920
+ await fs.rename(backupDir, skillDir);
1921
+ }
1922
+ throw error;
1923
+ } finally {
1924
+ if (!promotedStaging && await pathExists(stagingDir)) {
1925
+ await fs.rm(stagingDir, { recursive: true, force: true });
1926
+ }
1927
+ if ((promotedStaging || !movedExisting) && await pathExists(backupDir)) {
1928
+ try {
1929
+ await fs.rm(backupDir, { recursive: true, force: true });
1930
+ } catch (error) {
1931
+ if (!cleanupError) {
1932
+ cleanupError = error instanceof Error ? error : new Error(String(error));
1933
+ }
1934
+ }
1935
+ }
1936
+ if (cleanupError) {
1937
+ console.warn(
1938
+ `[Notis] Failed to clean up temporary skill directory backup for "${skillDir}"`,
1939
+ cleanupError
1940
+ );
1941
+ }
1942
+ }
1943
+ }
1944
+ function bundleFilesIncludeSkillMd(bundleFiles) {
1945
+ return bundleFiles.some((bundleFile) => {
1946
+ const basename2 = path.basename(bundleFile.path).toLowerCase();
1947
+ return basename2 === "skill.md" || basename2 === "skills.md";
1948
+ });
1949
+ }
1950
+ async function createSkillBundleBase64(skill) {
1951
+ const zipPath = await createZipFromDirectory(skill.directoryPath);
1952
+ try {
1953
+ const zipBytes = await fs.readFile(zipPath);
1954
+ return zipBytes.toString("base64");
1955
+ } finally {
1956
+ await fs.rm(zipPath, { force: true });
1957
+ }
1958
+ }
1959
+ async function writeCloudSkillToDisk(skill, bundleBytes, paths = DEFAULT_SYNC_PATHS) {
1960
+ const skillDir = path.join(
1961
+ paths.skillsDir,
1962
+ safeName(skill.name, paths.skillsDir)
1963
+ );
1964
+ if (bundleBytes?.length) {
1965
+ const bundlePath = path.join(
1966
+ os.tmpdir(),
1967
+ `notis-skill-download-${Date.now()}-${Math.random().toString(36).slice(2)}.zip`
1968
+ );
1969
+ try {
1970
+ await fs.writeFile(bundlePath, bundleBytes);
1971
+ await extractZipToDirectory(bundlePath, skillDir);
1972
+ return true;
1973
+ } finally {
1974
+ await fs.rm(bundlePath, { force: true });
1975
+ }
1976
+ }
1977
+ if (!skill.skill_md && (!skill.bundle_files || skill.bundle_files.length === 0)) {
1978
+ return false;
1979
+ }
1980
+ if (skill.bundle_files && skill.bundle_files.length > 0) {
1981
+ if (!bundleFilesIncludeSkillMd(skill.bundle_files)) {
1982
+ throw new Error(
1983
+ `Synced bundle for "${skill.name}" is missing SKILL.md or SKILLS.md`
1984
+ );
1985
+ }
1986
+ await replaceSkillDirectoryAtomically(skillDir, async (stagingDir) => {
1987
+ for (const bundleFile of skill.bundle_files || []) {
1988
+ const filePath = resolveSkillBundleFilePath(
1989
+ stagingDir,
1990
+ bundleFile.path
1991
+ );
1992
+ await fs.mkdir(path.dirname(filePath), { recursive: true });
1993
+ await fs.writeFile(
1994
+ filePath,
1995
+ Buffer.from(bundleFile.content_b64, "base64")
1996
+ );
1997
+ }
1998
+ });
1999
+ return true;
2000
+ }
2001
+ await replaceSkillDirectoryAtomically(skillDir, async (stagingDir) => {
2002
+ await fs.writeFile(
2003
+ path.join(stagingDir, "SKILL.md"),
2004
+ skill.skill_md ?? "",
2005
+ "utf8"
2006
+ );
2007
+ });
2008
+ return true;
2009
+ }
2010
+ async function requestJson(url, jwt, options = {}) {
2011
+ const response = await fetch(url, {
2012
+ signal: AbortSignal.timeout(9e4),
2013
+ method: options.method || "POST",
2014
+ headers: {
2015
+ "Content-Type": "application/json",
2016
+ Authorization: `Bearer ${jwt}`
2017
+ },
2018
+ body: options.body ? JSON.stringify(options.body) : void 0
2019
+ });
2020
+ if (!response.ok) {
2021
+ const text = await response.text();
2022
+ throw new Error(`${options.method || "POST"} ${url} \u2192 ${response.status}: ${text}`);
2023
+ }
2024
+ return response.json();
2025
+ }
2026
+ async function fetchSyncSettings(serverUrl, jwt) {
2027
+ return requestJson(`${serverUrl}/portal_skills/sync-settings`, jwt, {
2028
+ body: {}
2029
+ });
2030
+ }
2031
+ async function pullSkills(serverUrl, jwt) {
2032
+ return requestJson(`${serverUrl}/portal_skills/sync-pull`, jwt, {
2033
+ body: {}
2034
+ });
2035
+ }
2036
+ async function pushChangedSkills(serverUrl, jwt, changedSkills) {
2037
+ const payloadSkills = await Promise.all(changedSkills.map(async (skill) => ({
2038
+ name: skill.name,
2039
+ description: skill.description,
2040
+ skill_md: skill.skillMd,
2041
+ source_url: skill.sourceUrl,
2042
+ folder_hash: skill.folderHash,
2043
+ bundle_base64: await createSkillBundleBase64(skill)
2044
+ })));
2045
+ return requestJson(`${serverUrl}/portal_skills/sync-push`, jwt, {
2046
+ body: {
2047
+ skills: payloadSkills
2048
+ }
2049
+ });
2050
+ }
2051
+ async function downloadSkillBundle(bundleUrl) {
2052
+ const response = await fetch(bundleUrl, { signal: AbortSignal.timeout(9e4) });
2053
+ if (!response.ok) {
2054
+ const text = await response.text();
2055
+ throw new Error(`GET ${bundleUrl} \u2192 ${response.status}: ${text}`);
2056
+ }
2057
+ return Buffer.from(await response.arrayBuffer());
2058
+ }
2059
+ async function updateAgentTargets(serverUrl, jwt, skillId, targets, expectedUpdatedAt) {
2060
+ return requestJson(`${serverUrl}/portal_skills/agent-targets`, jwt, {
2061
+ method: "PATCH",
2062
+ body: {
2063
+ skill_id: skillId,
2064
+ agent_targets: targets,
2065
+ ...expectedUpdatedAt ? { expected_updated_at: expectedUpdatedAt } : {}
2066
+ }
2067
+ });
2068
+ }
2069
+ var HOME_DIR2 = os2.homedir();
2070
+ var EXTERNAL_AGENT_SKILL_DIRS = {
2071
+ claude_code: path2.join(HOME_DIR2, ".claude", "skills"),
2072
+ cursor: path2.join(HOME_DIR2, ".cursor", "skills"),
2073
+ codex: path2.join(HOME_DIR2, ".codex", "skills")
2074
+ };
2075
+ var EXTERNAL_AGENTS = Object.keys(EXTERNAL_AGENT_SKILL_DIRS);
2076
+ var AGENT_FAILURE_LABELS = {
2077
+ claude_code: "Claude Code",
2078
+ cursor: "Cursor",
2079
+ codex: "Codex",
2080
+ legacy: "legacy ~/.agents/skills"
2081
+ };
2082
+ function agentFailureLabel(agent) {
2083
+ return AGENT_FAILURE_LABELS[agent] ?? agent;
2084
+ }
2085
+ function agentFolderFailureName(agent) {
2086
+ return `${agentFailureLabel(agent)} skills folder`;
2087
+ }
2088
+ async function removeForeignAccountSymlinks(skillsDir, options = {}) {
2089
+ const agentSkillDirs = { ...defaultAgentSkillDirs(skillsDir), ...options.agentSkillDirs };
2090
+ const currentRoot = path2.resolve(skillsDir);
2091
+ const foreignCapableRoots = [
2092
+ path2.join(path2.resolve(NOTIS_SKILL_SYNC_ROOT), "users"),
2093
+ path2.join(path2.resolve(AGENTS_DIR), "notis", "users"),
2094
+ path2.dirname(path2.dirname(currentRoot))
2095
+ ];
2096
+ let removed = 0;
2097
+ const legacyGlobalSkillsDir = options.legacyGlobalSkillsDir || LEGACY_AGENTS_SKILLS_DIR;
2098
+ for (const agentDir of /* @__PURE__ */ new Set([...Object.values(agentSkillDirs), legacyGlobalSkillsDir])) {
2099
+ let entries;
2100
+ try {
2101
+ entries = await fs2.readdir(agentDir, { withFileTypes: true });
2102
+ } catch (error) {
2103
+ if (error?.code === "ENOENT") continue;
2104
+ throw error;
2105
+ }
2106
+ for (const entry of entries) {
2107
+ if (!entry.isSymbolicLink()) continue;
2108
+ const entryPath = path2.join(agentDir, entry.name);
2109
+ try {
2110
+ const target = await fs2.readlink(entryPath);
2111
+ const resolvedTarget = path2.resolve(path2.dirname(entryPath), target);
2112
+ const belongsToAnyAccount = foreignCapableRoots.some((root) => resolvedTarget.startsWith(`${root}${path2.sep}`));
2113
+ const belongsToCurrentAccount = resolvedTarget === currentRoot || resolvedTarget.startsWith(`${currentRoot}${path2.sep}`);
2114
+ if (belongsToAnyAccount && !belongsToCurrentAccount) {
2115
+ await fs2.unlink(entryPath);
2116
+ removed += 1;
2117
+ }
2118
+ } catch (error) {
2119
+ if (error?.code !== "ENOENT") throw error;
2120
+ }
2121
+ }
2122
+ }
2123
+ return removed;
2124
+ }
2125
+ function managedSkillRoots(skillsDir, legacyGlobalSkillsDir = LEGACY_AGENTS_SKILLS_DIR) {
2126
+ const resolvedSkillsDir = path2.resolve(skillsDir);
2127
+ const scopedUsersRoot = path2.dirname(path2.dirname(resolvedSkillsDir));
2128
+ const roots = [
2129
+ resolvedSkillsDir,
2130
+ path2.resolve(legacyGlobalSkillsDir),
2131
+ path2.join(path2.resolve(NOTIS_SKILL_SYNC_ROOT), "users"),
2132
+ path2.join(path2.resolve(AGENTS_DIR), "notis", "users")
2133
+ ];
2134
+ if (path2.basename(scopedUsersRoot) === "users") {
2135
+ roots.push(scopedUsersRoot);
2136
+ }
2137
+ return roots;
2138
+ }
2139
+ async function isManagedSymlink(linkPath, managedRoots) {
2140
+ try {
2141
+ const stats = await fs2.lstat(linkPath);
2142
+ if (!stats.isSymbolicLink()) {
2143
+ return false;
2144
+ }
2145
+ const target = await fs2.readlink(linkPath);
2146
+ const resolvedTarget = path2.resolve(path2.dirname(linkPath), target);
2147
+ return managedRoots.some((root) => resolvedTarget === root || resolvedTarget.startsWith(`${root}${path2.sep}`));
2148
+ } catch (error) {
2149
+ if (error?.code === "ENOENT") return false;
2150
+ throw error;
2151
+ }
2152
+ }
2153
+ async function ensureCorrectSymlink(linkPath, targetPath) {
2154
+ try {
2155
+ const stats = await fs2.lstat(linkPath);
2156
+ if (stats.isSymbolicLink()) {
2157
+ const currentTarget = await fs2.readlink(linkPath);
2158
+ const resolvedTarget = path2.resolve(path2.dirname(linkPath), currentTarget);
2159
+ if (resolvedTarget === targetPath) {
2160
+ return "skipped";
2161
+ }
2162
+ await fs2.unlink(linkPath);
2163
+ } else {
2164
+ return "blocked";
2165
+ }
2166
+ } catch (error) {
2167
+ if (error?.code !== "ENOENT") throw error;
2168
+ }
2169
+ const relativePath = path2.relative(path2.dirname(linkPath), targetPath);
2170
+ await fs2.symlink(relativePath, linkPath);
2171
+ return "linked";
2172
+ }
2173
+ function defaultAgentSkillDirs(skillsDir) {
2174
+ return {
2175
+ notis: skillsDir,
2176
+ ...EXTERNAL_AGENT_SKILL_DIRS
2177
+ };
2178
+ }
2179
+ async function removeUndesiredManagedSymlinks(agentDir, desiredSkills, managedRoots, failures, agent) {
2180
+ let removed = 0;
2181
+ try {
2182
+ await fs2.mkdir(agentDir, { recursive: true });
2183
+ const existingEntries = await fs2.readdir(agentDir, { withFileTypes: true });
2184
+ for (const entry of existingEntries) {
2185
+ const entryPath = path2.join(agentDir, entry.name);
2186
+ try {
2187
+ if (!desiredSkills.has(entry.name) && await isManagedSymlink(entryPath, managedRoots)) {
2188
+ await fs2.unlink(entryPath);
2189
+ removed += 1;
2190
+ }
2191
+ } catch (error) {
2192
+ if (error?.code !== "ENOENT") {
2193
+ failures.push({ name: entry.name, error: `${agentFailureLabel(agent)}: could not remove skill link (${error.message})` });
2194
+ }
2195
+ }
2196
+ }
2197
+ } catch (error) {
2198
+ failures.push({ name: agentFolderFailureName(agent), error: `Could not read agent skills directory (${error.message})` });
2199
+ }
2200
+ return removed;
2201
+ }
2202
+ async function removeAllSymlinksForSkill(skillName, skillsDir = LEGACY_AGENTS_SKILLS_DIR, options = {}) {
2203
+ let removed = 0;
2204
+ const agentSkillDirs = { ...defaultAgentSkillDirs(skillsDir), ...options.agentSkillDirs };
2205
+ const legacyGlobalSkillsDir = options.legacyGlobalSkillsDir || LEGACY_AGENTS_SKILLS_DIR;
2206
+ const managedRoots = managedSkillRoots(skillsDir, legacyGlobalSkillsDir);
2207
+ const agentDirs = /* @__PURE__ */ new Set([...Object.values(agentSkillDirs), legacyGlobalSkillsDir]);
2208
+ const expectedTarget = path2.resolve(skillsDir, safeName(skillName, skillsDir));
2209
+ for (const agentDir of agentDirs) {
2210
+ const linkPath = path2.join(agentDir, safeName(skillName, agentDir));
2211
+ const owned = options.ownership === "exact-skill-dir" ? await isManagedSymlink(linkPath, [expectedTarget]) : await isManagedSymlink(linkPath, managedRoots);
2212
+ if (owned) {
2213
+ await fs2.unlink(linkPath);
2214
+ removed += 1;
2215
+ }
2216
+ }
2217
+ return removed;
2218
+ }
2219
+ async function detectDeletedAgentSymlinks(cloudSkills, previousState, skillsDir = LEGACY_AGENTS_SKILLS_DIR, options = {}) {
2220
+ const agentSkillDirs = { ...defaultAgentSkillDirs(skillsDir), ...options.agentSkillDirs };
2221
+ const deletions = [];
2222
+ for (const agent of EXTERNAL_AGENTS) {
2223
+ const agentDir = agentSkillDirs[agent];
2224
+ if (path2.resolve(agentDir) === path2.resolve(skillsDir)) {
2225
+ continue;
2226
+ }
2227
+ try {
2228
+ const dirStat = await fs2.stat(agentDir);
2229
+ if (!dirStat.isDirectory()) {
2230
+ continue;
2231
+ }
2232
+ } catch {
2233
+ continue;
2234
+ }
2235
+ for (const skill of cloudSkills) {
2236
+ if (skill.status !== "active") {
2237
+ continue;
2238
+ }
2239
+ if (!skill.id || skill.id.startsWith("local-")) {
2240
+ continue;
2241
+ }
2242
+ const previous = previousState.skills[skill.name];
2243
+ if (!previous || previous.cloudId !== skill.id || previous.verifiedAgentLinks?.[agent] !== true || !skill.updated_at || previous.cloudUpdatedAt !== skill.updated_at) {
2244
+ continue;
2245
+ }
2246
+ const cloudTargets = normalizeAgentTargets(skill.agent_targets);
2247
+ const previousTargets = normalizeAgentTargets(previous.agentTargets);
2248
+ if (!cloudTargets[agent] || !previousTargets[agent]) {
2249
+ continue;
2250
+ }
2251
+ let safeSkillName;
2252
+ try {
2253
+ safeSkillName = safeName(skill.name, skillsDir);
2254
+ } catch {
2255
+ continue;
2256
+ }
2257
+ const linkPath = path2.join(agentDir, safeName(safeSkillName, agentDir));
2258
+ try {
2259
+ await fs2.lstat(linkPath);
2260
+ } catch (error) {
2261
+ if (error?.code === "ENOENT") {
2262
+ deletions.push({ skillId: skill.id, skillName: skill.name, agent });
2263
+ }
2264
+ }
2265
+ }
2266
+ }
2267
+ return deletions;
2268
+ }
2269
+ async function syncSymlinks(skills, skillsDir = LEGACY_AGENTS_SKILLS_DIR, options = {}) {
2270
+ await fs2.mkdir(skillsDir, { recursive: true });
2271
+ const result = {
2272
+ linked: 0,
2273
+ removed: 0,
2274
+ skipped: 0,
2275
+ verifiedAgentLinks: {},
2276
+ failures: []
2277
+ };
2278
+ const agentSkillDirs = { ...defaultAgentSkillDirs(skillsDir), ...options.agentSkillDirs };
2279
+ const legacyGlobalSkillsDir = options.legacyGlobalSkillsDir || LEGACY_AGENTS_SKILLS_DIR;
2280
+ const managedRoots = managedSkillRoots(skillsDir, legacyGlobalSkillsDir);
2281
+ const desiredByAgent = {
2282
+ notis: /* @__PURE__ */ new Set(),
2283
+ claude_code: /* @__PURE__ */ new Set(),
2284
+ cursor: /* @__PURE__ */ new Set(),
2285
+ codex: /* @__PURE__ */ new Set()
2286
+ };
2287
+ for (const skill of skills) {
2288
+ if (skill.status !== "active") {
2289
+ continue;
2290
+ }
2291
+ const safeSkillName = safeName(skill.name, skillsDir);
2292
+ const targets = normalizeAgentTargets(skill.agent_targets);
2293
+ if (targets.notis) {
2294
+ desiredByAgent.notis.add(safeSkillName);
2295
+ }
2296
+ if (targets.claude_code) {
2297
+ desiredByAgent.claude_code.add(safeSkillName);
2298
+ }
2299
+ if (targets.cursor) {
2300
+ desiredByAgent.cursor.add(safeSkillName);
2301
+ }
2302
+ if (targets.codex) {
2303
+ desiredByAgent.codex.add(safeSkillName);
2304
+ }
2305
+ }
2306
+ for (const [agent, agentDir] of Object.entries(agentSkillDirs)) {
2307
+ if (path2.resolve(agentDir) === path2.resolve(skillsDir)) {
2308
+ result.skipped += desiredByAgent[agent].size;
2309
+ continue;
2310
+ }
2311
+ try {
2312
+ await fs2.mkdir(agentDir, { recursive: true });
2313
+ } catch (error) {
2314
+ result.failures.push({ name: agentFolderFailureName(agent), error: `Could not create agent skills directory (${error.message})` });
2315
+ continue;
2316
+ }
2317
+ const desiredSkills = desiredByAgent[agent];
2318
+ if (options.removeUndesired !== false) {
2319
+ result.removed += await removeUndesiredManagedSymlinks(agentDir, desiredSkills, managedRoots, result.failures, agent);
2320
+ }
2321
+ for (const skillName of desiredSkills) {
2322
+ const targetPath = path2.join(skillsDir, skillName);
2323
+ const linkPath = path2.join(agentDir, safeName(skillName, agentDir));
2324
+ try {
2325
+ if (!(await fs2.stat(path2.join(targetPath, "SKILL.md"))).isFile()) throw new Error("Missing SKILL.md");
2326
+ } catch {
2327
+ result.skipped += 1;
2328
+ result.failures.push({ name: skillName, error: `${agentFailureLabel(agent)}: SKILL.md is missing or unreadable` });
2329
+ continue;
2330
+ }
2331
+ let syncOutcome;
2332
+ try {
2333
+ syncOutcome = await ensureCorrectSymlink(linkPath, targetPath);
2334
+ } catch (error) {
2335
+ result.skipped += 1;
2336
+ result.failures.push({ name: skillName, error: `${agentFailureLabel(agent)}: could not create skill link (${error.message})` });
2337
+ continue;
2338
+ }
2339
+ if (syncOutcome === "linked") {
2340
+ result.linked += 1;
2341
+ } else if (syncOutcome === "blocked") {
2342
+ console.warn(
2343
+ `[skill-sync] Could not link "${skillName}" for ${agent}: non-symlink entry blocks ${linkPath}`
2344
+ );
2345
+ result.skipped += 1;
2346
+ result.failures.push({ name: skillName, error: `${agentFailureLabel(agent)}: an existing file or folder blocks the skill link` });
2347
+ } else {
2348
+ result.skipped += 1;
2349
+ }
2350
+ if (syncOutcome !== "blocked") {
2351
+ result.verifiedAgentLinks[skillName] = { ...result.verifiedAgentLinks[skillName], [agent]: true };
2352
+ }
2353
+ }
2354
+ }
2355
+ if (options.removeUndesired !== false && !Object.values(agentSkillDirs).some(
2356
+ (agentDir) => path2.resolve(agentDir) === path2.resolve(legacyGlobalSkillsDir)
2357
+ )) {
2358
+ result.removed += await removeUndesiredManagedSymlinks(
2359
+ legacyGlobalSkillsDir,
2360
+ /* @__PURE__ */ new Set(),
2361
+ managedRoots,
2362
+ result.failures,
2363
+ "legacy"
2364
+ );
2365
+ }
2366
+ return result;
2367
+ }
2368
+ function getPushCandidates(localSkills, syncState, cloudCuratedSkillNames = /* @__PURE__ */ new Set(), cloudSkillNames) {
2369
+ return localSkills.filter((skill) => {
2370
+ if (cloudCuratedSkillNames.has(skill.name)) {
2371
+ return false;
2372
+ }
2373
+ const previous = syncState.skills[skill.name];
2374
+ if (!previous) {
2375
+ return true;
2376
+ }
2377
+ if (cloudSkillNames && !cloudSkillNames.has(skill.name)) {
2378
+ return false;
2379
+ }
2380
+ return previous.folderHash !== skill.folderHash;
2381
+ });
2382
+ }
2383
+ async function writeCloudSkillWithBundleFallback(skill, dependencies) {
2384
+ if (skill.skill_source_url) {
2385
+ try {
2386
+ const bundleBytes = await dependencies.downloadSkillBundle(skill.skill_source_url);
2387
+ const wroteBundleToDisk = await dependencies.writeCloudSkillToDisk(skill, bundleBytes);
2388
+ if (wroteBundleToDisk) {
2389
+ return true;
2390
+ }
2391
+ dependencies.onWarning?.(
2392
+ `Bundle sync for "${skill.name}" produced no local changes, falling back to SKILL.md payload.`,
2393
+ new Error("Bundle write returned false")
2394
+ );
2395
+ } catch (error) {
2396
+ dependencies.onWarning?.(
2397
+ `Failed to apply bundle sync for "${skill.name}", falling back to SKILL.md payload.`,
2398
+ error
2399
+ );
2400
+ }
2401
+ }
2402
+ if (skill.bundle_hydration_failed) {
2403
+ dependencies.onWarning?.(
2404
+ `Skipping markdown fallback for synced skill "${skill.name}" because its stored bundle could not be hydrated by the server.`,
2405
+ new Error("Bundle hydration failed")
2406
+ );
2407
+ return false;
2408
+ }
2409
+ try {
2410
+ return await dependencies.writeCloudSkillToDisk(skill);
2411
+ } catch (error) {
2412
+ dependencies.onWarning?.(
2413
+ `Failed to write synced skill "${skill.name}" to disk.`,
2414
+ error
2415
+ );
2416
+ return false;
2417
+ }
2418
+ }
2419
+ var BASE_SKILL_NAMES = /* @__PURE__ */ new Set(["notis-apps", "notis-query", "notis-cli"]);
2420
+ function withoutBaseSkills(pullResponse) {
2421
+ return {
2422
+ ...pullResponse,
2423
+ skills: pullResponse.skills.filter((skill) => !BASE_SKILL_NAMES.has(skill.name))
2424
+ };
2425
+ }
2426
+ function withoutBaseSkillState(state) {
2427
+ return {
2428
+ ...state,
2429
+ skills: Object.fromEntries(
2430
+ Object.entries(state.skills).filter(([name]) => !BASE_SKILL_NAMES.has(name))
2431
+ )
2432
+ };
2433
+ }
2434
+ var DEFAULT_RUN_SKILL_SYNC_DEPS = {
2435
+ fetchSyncSettings,
2436
+ pullSkills,
2437
+ pushChangedSkills,
2438
+ downloadSkillBundle,
2439
+ gatherTopLevelLocalSkills,
2440
+ readLegacySyncState,
2441
+ readSyncState,
2442
+ scanLocalSkills,
2443
+ deleteLocalSkill,
2444
+ writeCloudSkillToDisk,
2445
+ writeSyncState,
2446
+ removeAllSymlinksForSkill,
2447
+ syncSymlinks,
2448
+ detectDeletedAgentSymlinks,
2449
+ removeForeignAccountSymlinks,
2450
+ updateAgentTargets
2451
+ };
2452
+ function toSkillMap(skills) {
2453
+ return new Map(skills.map((skill) => [skill.name, skill]));
2454
+ }
2455
+ function decodeJwtSubject(jwt) {
2456
+ try {
2457
+ const parts = jwt.split(".");
2458
+ if (parts.length !== 3) return null;
2459
+ const decoded = JSON.parse(
2460
+ Buffer.from(parts[1], "base64url").toString()
2461
+ );
2462
+ return typeof decoded.sub === "string" && decoded.sub.trim() ? decoded.sub.trim() : null;
2463
+ } catch {
2464
+ return null;
2465
+ }
2466
+ }
2467
+ function cloudContentHash(skill) {
2468
+ return createHash2("sha256").update(JSON.stringify({
2469
+ md: skill.skill_md,
2470
+ hash: skill.skill_folder_hash,
2471
+ source: skill.skill_source_url,
2472
+ files: skill.bundle_files?.slice().sort((a, b) => a.path.localeCompare(b.path)),
2473
+ hydrationFailed: skill.bundle_hydration_failed === true
2474
+ })).digest("hex");
2475
+ }
2476
+ function shouldWriteCloudSkill(cloudSkill, localSkills, previousState) {
2477
+ const skillName = cloudSkill.name;
2478
+ const cloudHash = cloudSkill.skill_folder_hash || "";
2479
+ const localSkill = localSkills.get(skillName);
2480
+ if (!localSkill) {
2481
+ return true;
2482
+ }
2483
+ const previous = previousState.skills[skillName];
2484
+ if (previous?.folderHash === localSkill.folderHash && previous.cloudContentHash === cloudContentHash(cloudSkill)) return false;
2485
+ if (cloudSkill.source === "curated") {
2486
+ return cloudHash ? cloudHash !== localSkill.folderHash : true;
2487
+ }
2488
+ const localChangedSinceLastSync = !previous || previous.folderHash !== localSkill.folderHash;
2489
+ return !localChangedSinceLastSync && (Boolean(cloudHash) && cloudHash !== localSkill.folderHash || Boolean(previous?.cloudContentHash && previous.cloudContentHash !== cloudContentHash(cloudSkill)));
2490
+ }
2491
+ function buildSyncState(pullResponse, localSkills, lastSyncedAt, verifiedAgentLinks = {}, failedContentNames = /* @__PURE__ */ new Set()) {
2492
+ const localSkillMap = toSkillMap(localSkills);
2493
+ const skills = Object.fromEntries(
2494
+ pullResponse.skills.map((skill) => {
2495
+ const localSkill = localSkillMap.get(skill.name);
2496
+ return [
2497
+ skill.name,
2498
+ {
2499
+ cloudId: skill.id,
2500
+ folderHash: localSkill?.folderHash || skill.skill_folder_hash || "",
2501
+ agentTargets: normalizeAgentTargets(skill.agent_targets),
2502
+ verifiedAgentLinks: skill.status === "active" ? verifiedAgentLinks[skill.name] ?? {} : {},
2503
+ cloudUpdatedAt: skill.updated_at,
2504
+ ...!failedContentNames.has(skill.name) && !skill.skill_source_url ? { cloudContentHash: cloudContentHash(skill) } : {},
2505
+ syncedAt: lastSyncedAt || (/* @__PURE__ */ new Date()).toISOString()
2506
+ }
2507
+ ];
2508
+ })
2509
+ );
2510
+ return {
2511
+ version: 1,
2512
+ lastSyncedAt,
2513
+ skills
2514
+ };
2515
+ }
2516
+ function buildLocalSymlinkCandidates(pullResponse, localSkills, previousState) {
2517
+ const cloudSkillNames = new Set(
2518
+ pullResponse.skills.map((skill) => skill.name)
2519
+ );
2520
+ const localOnlySkills = localSkills.filter((skill) => !cloudSkillNames.has(skill.name)).map((skill) => {
2521
+ const previous = previousState.skills[skill.name];
2522
+ return {
2523
+ id: previous?.cloudId || `local-${skill.name}`,
2524
+ name: skill.name,
2525
+ description: skill.description || null,
2526
+ skill_md: skill.skillMd,
2527
+ agent_targets: previous?.agentTargets,
2528
+ skill_folder_hash: skill.folderHash,
2529
+ source: "local",
2530
+ status: "active"
2531
+ };
2532
+ });
2533
+ return [...pullResponse.skills, ...localOnlySkills];
2534
+ }
2535
+ function isEmptySyncState(state) {
2536
+ return state.lastSyncedAt === null && Object.keys(state.skills).length === 0;
2537
+ }
2538
+ function applyLegacyFirstRunState(localSkills, scopedState, legacyState) {
2539
+ if (!isEmptySyncState(scopedState) || !legacyState) {
2540
+ return scopedState;
2541
+ }
2542
+ const migratedSkills = Object.fromEntries(
2543
+ localSkills.flatMap((skill) => {
2544
+ const previous = legacyState.skills[skill.name];
2545
+ if (!previous || previous.folderHash !== skill.folderHash) {
2546
+ return [];
2547
+ }
2548
+ return [[skill.name, previous]];
2549
+ })
2550
+ );
2551
+ if (Object.keys(migratedSkills).length === 0) {
2552
+ return scopedState;
2553
+ }
2554
+ return {
2555
+ version: 1,
2556
+ lastSyncedAt: legacyState.lastSyncedAt,
2557
+ skills: migratedSkills
2558
+ };
2559
+ }
2560
+ async function writePulledSkillsToScopedMirror(pullResponse, localSkills, previousState, syncPaths, deps, failures = [], writtenSkillNames = /* @__PURE__ */ new Set()) {
2561
+ const localSkillMap = toSkillMap(localSkills);
2562
+ const warnSkillSync = (message, error) => {
2563
+ console.warn(`[Notis] ${message}`, error);
2564
+ };
2565
+ let downloaded = 0;
2566
+ for (const cloudSkill of pullResponse.skills) {
2567
+ if (!shouldWriteCloudSkill(cloudSkill, localSkillMap, previousState)) {
2568
+ continue;
2569
+ }
2570
+ if (await writeCloudSkillWithBundleFallback(cloudSkill, {
2571
+ downloadSkillBundle: deps.downloadSkillBundle,
2572
+ writeCloudSkillToDisk: (skill, bundleBytes) => deps.writeCloudSkillToDisk(skill, bundleBytes, syncPaths),
2573
+ onWarning: warnSkillSync
2574
+ })) {
2575
+ downloaded += 1;
2576
+ writtenSkillNames.add(cloudSkill.name);
2577
+ } else {
2578
+ failures.push({ name: cloudSkill.name, error: "Skill content could not be downloaded or written; sync will retry" });
2579
+ }
2580
+ }
2581
+ return downloaded;
2582
+ }
2583
+ function assertSkillsPullAuthorized(pullResponse) {
2584
+ if (pullResponse.entitlement_access?.code === "entitlement_upgrade_required" && pullResponse.entitlement_access.entitlement === "skills") {
2585
+ throw new Error(
2586
+ "Skill sync access was denied; preserving existing local skills."
2587
+ );
2588
+ }
2589
+ }
2590
+ async function deactivateDeletedAgentSkills(serverUrl, jwt, pullResponse, previousState, scopedState, skillsDir, deps, failures) {
2591
+ if (isEmptySyncState(scopedState)) {
2592
+ return 0;
2593
+ }
2594
+ const deletions = await deps.detectDeletedAgentSymlinks(
2595
+ pullResponse.skills,
2596
+ previousState,
2597
+ skillsDir
2598
+ );
2599
+ if (deletions.length === 0) {
2600
+ return 0;
2601
+ }
2602
+ const agentsBySkill = /* @__PURE__ */ new Map();
2603
+ for (const deletion of deletions) {
2604
+ const entry = agentsBySkill.get(deletion.skillId) ?? {
2605
+ skillName: deletion.skillName,
2606
+ agents: /* @__PURE__ */ new Set()
2607
+ };
2608
+ entry.agents.add(deletion.agent);
2609
+ agentsBySkill.set(deletion.skillId, entry);
2610
+ }
2611
+ const fresh = withoutBaseSkills(await deps.pullSkills(serverUrl, jwt));
2612
+ assertSkillsPullAuthorized(fresh);
2613
+ Object.assign(pullResponse, fresh);
2614
+ let needsRefresh = false;
2615
+ let deactivated = 0;
2616
+ for (const [skillId, { skillName, agents }] of agentsBySkill) {
2617
+ const skill = pullResponse.skills.find((item) => item.id === skillId);
2618
+ const previous = previousState.skills[skillName];
2619
+ if (!skill?.updated_at || previous?.cloudUpdatedAt !== skill.updated_at) continue;
2620
+ const patch = Object.fromEntries([...agents].map((agent) => [agent, false]));
2621
+ try {
2622
+ const saved = await deps.updateAgentTargets(serverUrl, jwt, skillId, patch, skill.updated_at);
2623
+ 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)) {
2624
+ throw new Error("Assignment update did not return a verified saved revision");
2625
+ }
2626
+ skill.agent_targets = saved.agent_targets;
2627
+ skill.updated_at = saved.updated_at;
2628
+ deactivated += agents.size;
2629
+ } catch (error) {
2630
+ needsRefresh = true;
2631
+ failures.push({ name: skillName, error: "Could not save the local agent removal; refreshed saved assignments" });
2632
+ console.warn(`[skill-sync] Assignment changed or could not be saved for "${skillName}"; refreshing before reconciliation.`, error);
2633
+ }
2634
+ }
2635
+ if (needsRefresh) {
2636
+ const refreshed = withoutBaseSkills(await deps.pullSkills(serverUrl, jwt));
2637
+ assertSkillsPullAuthorized(refreshed);
2638
+ Object.assign(pullResponse, refreshed);
2639
+ }
2640
+ return deactivated;
2641
+ }
2642
+ async function runSkillSync(serverUrl, jwt, dependencies = {}, options = {}) {
2643
+ const deps = {
2644
+ ...DEFAULT_RUN_SKILL_SYNC_DEPS,
2645
+ ...dependencies
2646
+ };
2647
+ const syncSettings = await deps.fetchSyncSettings(
2648
+ serverUrl,
2649
+ jwt
2650
+ );
2651
+ const syncUserId = syncSettings.user_id?.trim() || decodeJwtSubject(jwt);
2652
+ if (!syncUserId) {
2653
+ throw new Error(
2654
+ "Cannot sync skills without a server-verified account identity."
2655
+ );
2656
+ }
2657
+ const syncPaths = getSkillSyncPathsForUser(syncUserId);
2658
+ const foreignLinksRemoved = await deps.removeForeignAccountSymlinks(
2659
+ syncPaths.skillsDir
2660
+ );
2661
+ if (options.honorSyncEnabled !== false && !syncSettings.sync_enabled) {
2662
+ return {
2663
+ syncEnabled: false,
2664
+ pushed: 0,
2665
+ pulled: 0,
2666
+ downloaded: 0,
2667
+ deleted: 0,
2668
+ deactivated: 0,
2669
+ linked: 0,
2670
+ removed: foreignLinksRemoved,
2671
+ skipped: 0,
2672
+ lastSyncedAt: syncSettings.last_synced_at,
2673
+ failedPushes: []
2674
+ };
2675
+ }
2676
+ let pullResponse = withoutBaseSkills(await deps.pullSkills(serverUrl, jwt));
2677
+ assertSkillsPullAuthorized(pullResponse);
2678
+ const cloudCuratedSkillNames = new Set(
2679
+ pullResponse.skills.filter((skill) => skill.source === "curated").map((skill) => skill.name)
2680
+ );
2681
+ const protectedSkillNames = /* @__PURE__ */ new Set([...cloudCuratedSkillNames, ...BASE_SKILL_NAMES]);
2682
+ const scopedState = withoutBaseSkillState(await deps.readSyncState(syncPaths));
2683
+ const assignmentFailures = [];
2684
+ const deactivated = syncSettings.agent_targets_conditional_updates === true ? await deactivateDeletedAgentSkills(
2685
+ serverUrl,
2686
+ jwt,
2687
+ pullResponse,
2688
+ scopedState,
2689
+ scopedState,
2690
+ syncPaths.skillsDir,
2691
+ deps,
2692
+ assignmentFailures
2693
+ ) : 0;
2694
+ const authUserId = decodeJwtSubject(jwt);
2695
+ let previousAuthState = null;
2696
+ if (authUserId && authUserId !== syncUserId) {
2697
+ const previousAuthPaths = getSkillSyncPathsForUser(authUserId);
2698
+ previousAuthState = await deps.readSyncState(previousAuthPaths);
2699
+ await deps.gatherTopLevelLocalSkills(syncPaths, {
2700
+ sourceRoots: [{ label: "previous-auth-scope", root: previousAuthPaths.skillsDir }],
2701
+ protectedSkillNames
2702
+ });
2703
+ }
2704
+ await deps.gatherTopLevelLocalSkills(syncPaths, {
2705
+ protectedSkillNames
2706
+ });
2707
+ const localSkills = (await deps.scanLocalSkills(syncPaths)).filter((skill) => !BASE_SKILL_NAMES.has(skill.name));
2708
+ const previousState = withoutBaseSkillState(applyLegacyFirstRunState(
2709
+ localSkills,
2710
+ scopedState,
2711
+ isEmptySyncState(scopedState) ? !previousAuthState || isEmptySyncState(previousAuthState) ? await deps.readLegacySyncState(syncPaths) : previousAuthState : null
2712
+ ));
2713
+ const gatheredSymlinkResult = await deps.syncSymlinks(
2714
+ buildLocalSymlinkCandidates(pullResponse, localSkills, previousState),
2715
+ syncPaths.skillsDir
2716
+ );
2717
+ const pushCandidates = getPushCandidates(
2718
+ localSkills,
2719
+ previousState,
2720
+ cloudCuratedSkillNames,
2721
+ new Set(pullResponse.skills.map((skill) => skill.name))
2722
+ );
2723
+ const failedPushes = [];
2724
+ if (pushCandidates.length > 0) {
2725
+ const pushResult = await deps.pushChangedSkills(serverUrl, jwt, pushCandidates);
2726
+ if (Array.isArray(pushResult?.failed) && pushResult.failed.length > 0) {
2727
+ failedPushes.push(...pushResult.failed);
2728
+ console.warn(
2729
+ `[skill-sync] ${pushResult.failed.length} skill(s) were rejected during push: ` + pushResult.failed.map((f) => `${f.name} (${f.error})`).join("; ")
2730
+ );
2731
+ }
2732
+ pullResponse = withoutBaseSkills(await deps.pullSkills(serverUrl, jwt));
2733
+ assertSkillsPullAuthorized(pullResponse);
2734
+ }
2735
+ const cloudSkillNames = new Set(pullResponse.skills.map((s) => s.name));
2736
+ let deleted = 0;
2737
+ for (const skillName of Object.keys(previousState.skills)) {
2738
+ if (!cloudSkillNames.has(skillName)) {
2739
+ await deps.deleteLocalSkill(skillName, syncPaths);
2740
+ await deps.removeAllSymlinksForSkill(skillName, syncPaths.skillsDir);
2741
+ deleted += 1;
2742
+ }
2743
+ }
2744
+ const failedDownloads = [];
2745
+ const downloaded = await writePulledSkillsToScopedMirror(
2746
+ pullResponse,
2747
+ localSkills,
2748
+ previousState,
2749
+ syncPaths,
2750
+ deps,
2751
+ failedDownloads
2752
+ );
2753
+ const finalLocalSkills = (await deps.scanLocalSkills(syncPaths)).filter((skill) => !BASE_SKILL_NAMES.has(skill.name));
2754
+ const symlinkResult = await deps.syncSymlinks(
2755
+ buildLocalSymlinkCandidates(pullResponse, finalLocalSkills, previousState),
2756
+ syncPaths.skillsDir
2757
+ );
2758
+ const verifiedLinks = { ...symlinkResult.verifiedAgentLinks ?? {} };
2759
+ for (const failure of failedDownloads) delete verifiedLinks[failure.name];
2760
+ const lastSyncedAt = pullResponse.last_synced_at || (/* @__PURE__ */ new Date()).toISOString();
2761
+ await deps.writeSyncState(
2762
+ buildSyncState(pullResponse, finalLocalSkills, lastSyncedAt, verifiedLinks, new Set(failedDownloads.map((item) => item.name))),
2763
+ syncPaths
2764
+ );
2765
+ return {
2766
+ syncEnabled: true,
2767
+ pushed: pushCandidates.length,
2768
+ pulled: pullResponse.skills.length,
2769
+ downloaded,
2770
+ deleted,
2771
+ deactivated,
2772
+ linked: gatheredSymlinkResult.linked + symlinkResult.linked,
2773
+ removed: foreignLinksRemoved + gatheredSymlinkResult.removed + symlinkResult.removed,
2774
+ skipped: symlinkResult.skipped,
2775
+ failedLinks: [...assignmentFailures, ...failedDownloads, ...(symlinkResult.failures ?? []).filter(
2776
+ (failure) => !failedDownloads.some((download) => download.name === failure.name)
2777
+ )],
2778
+ lastSyncedAt,
2779
+ failedPushes
2780
+ };
2781
+ }
2782
+
2783
+ // src/runtime/sync-skills.js
2784
+ import { createHash as createHash4, randomUUID as randomUUID2 } from "node:crypto";
2785
+ import { mkdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
2786
+ import { homedir as homedir3 } from "node:os";
2787
+ import { dirname as dirname4, join as join4 } from "node:path";
2788
+
2789
+ // src/runtime/base-skills.js
2790
+ import { dirname as dirname3, join as join3, relative, resolve as resolve2 } from "node:path";
2791
+ import { fileURLToPath as fileURLToPath2 } from "node:url";
2792
+ var BASE_SKILL_NAMES2 = Object.freeze([
2793
+ "notis-apps",
2794
+ "notis-query",
2795
+ "notis-cli"
2796
+ ]);
2797
+ var HERE = dirname3(fileURLToPath2(import.meta.url));
2798
+
2799
+ // src/runtime/sync-skills.js
2800
+ var DEFAULT_LOCK_TIMEOUT_MS = 3e4;
2801
+ var DEFAULT_LOCK_STALE_MS = 10 * 6e4;
2802
+ var DEFAULT_LOCK_POLL_MS = 50;
2803
+ async function lockSnapshot(lockDirectory) {
2804
+ try {
2805
+ const [raw, metadata] = await Promise.all([
2806
+ readFile(join4(lockDirectory, "owner"), "utf8").catch(() => ""),
2807
+ stat(lockDirectory)
2808
+ ]);
2809
+ let owner = {};
2810
+ try {
2811
+ owner = raw ? JSON.parse(raw) : {};
2812
+ } catch {
2813
+ }
2814
+ return {
2815
+ id: typeof owner.id === "string" ? owner.id : null,
2816
+ pid: Number.isInteger(Number(owner.pid)) ? Number(owner.pid) : null,
2817
+ at: Number.isFinite(Number(owner.at)) ? Number(owner.at) : metadata.mtimeMs,
2818
+ mtimeMs: metadata.mtimeMs
2819
+ };
2820
+ } catch {
2821
+ return null;
2822
+ }
2823
+ }
2824
+ function sameLockSnapshot(left, right) {
2825
+ return Boolean(
2826
+ left && right && left.id === right.id && left.pid === right.pid && left.at === right.at && left.mtimeMs === right.mtimeMs
2827
+ );
2828
+ }
2829
+ function processIsAlive2(pid) {
2830
+ if (!Number.isInteger(pid) || pid <= 0) return false;
2831
+ try {
2832
+ process.kill(pid, 0);
2833
+ return true;
2834
+ } catch (error) {
2835
+ return error?.code === "EPERM";
2836
+ }
2837
+ }
2838
+ function delay(milliseconds) {
2839
+ return new Promise((resolve3) => setTimeout(resolve3, milliseconds));
2840
+ }
2841
+ async function quarantineStaleLock(lockDirectory, snapshot) {
2842
+ const quarantineRoot = join4(dirname4(lockDirectory), ".stale-operation-locks");
2843
+ const snapshotToken = createHash4("sha256").update(JSON.stringify(snapshot)).digest("hex");
2844
+ await mkdir(quarantineRoot, { recursive: true, mode: 448 });
2845
+ try {
2846
+ await rename(lockDirectory, join4(quarantineRoot, snapshotToken));
2847
+ return true;
2848
+ } catch (error) {
2849
+ if (["EEXIST", "ENOTEMPTY", "ENOENT"].includes(error?.code)) return false;
2850
+ throw error;
2851
+ }
2852
+ }
2853
+ async function writeLockOwnerAtomically(lockDirectory, owner) {
2854
+ const temporaryOwnerPath = join4(lockDirectory, `.owner.${owner.id}.tmp`);
2855
+ const ownerPath = join4(lockDirectory, "owner");
2856
+ await writeFile(temporaryOwnerPath, JSON.stringify(owner), { mode: 384 });
2857
+ await rename(temporaryOwnerPath, ownerPath);
2858
+ }
2859
+ async function releaseOwnedLock(lockDirectory, ownerId) {
2860
+ const owner = await lockSnapshot(lockDirectory);
2861
+ if (owner?.id !== ownerId) return;
2862
+ const quarantineRoot = join4(dirname4(lockDirectory), ".stale-operation-locks");
2863
+ const releasedDirectory = join4(quarantineRoot, `released.${ownerId}`);
2864
+ await mkdir(quarantineRoot, { recursive: true, mode: 448 });
2865
+ try {
2866
+ await rename(lockDirectory, releasedDirectory);
2867
+ } catch (error) {
2868
+ if (error?.code === "ENOENT") return;
2869
+ throw error;
2870
+ }
2871
+ await rm(releasedDirectory, { recursive: true, force: true });
2872
+ }
2873
+ async function withSkillSyncLock(callback, {
2874
+ home = homedir3(),
2875
+ timeoutMs = DEFAULT_LOCK_TIMEOUT_MS,
2876
+ staleMs = DEFAULT_LOCK_STALE_MS,
2877
+ pollMs = DEFAULT_LOCK_POLL_MS,
2878
+ now = () => Date.now()
2879
+ } = {}) {
2880
+ const lockDirectory = join4(home, ".notis", "skills", ".operation-lock");
2881
+ const ownerId = `${process.pid}.${randomUUID2()}`;
2882
+ const deadline = now() + timeoutMs;
2883
+ await mkdir(dirname4(lockDirectory), { recursive: true, mode: 448 });
2884
+ for (; ; ) {
2885
+ try {
2886
+ await mkdir(lockDirectory, { mode: 448 });
2887
+ try {
2888
+ await writeLockOwnerAtomically(lockDirectory, {
2889
+ id: ownerId,
2890
+ pid: process.pid,
2891
+ at: now()
2892
+ });
2893
+ } catch (error) {
2894
+ await rm(lockDirectory, { recursive: true, force: true });
2895
+ throw error;
2896
+ }
2897
+ break;
2898
+ } catch (error) {
2899
+ if (error?.code !== "EEXIST") throw error;
2900
+ }
2901
+ const observed = await lockSnapshot(lockDirectory);
2902
+ if (observed && now() - observed.at > staleMs && !processIsAlive2(observed.pid)) {
2903
+ await delay(Math.max(pollMs, 10));
2904
+ const current = await lockSnapshot(lockDirectory);
2905
+ if (sameLockSnapshot(observed, current) && !processIsAlive2(current?.pid)) {
2906
+ if (await quarantineStaleLock(lockDirectory, current)) continue;
2907
+ }
2908
+ }
2909
+ if (now() >= deadline) {
2910
+ throw new Error("Timed out waiting for another Notis skill sync to finish.");
2911
+ }
2912
+ await delay(pollMs);
2913
+ }
2914
+ const heartbeatMs = Math.max(10, Math.min(3e4, Math.floor(staleMs / 3)));
2915
+ let heartbeatStopped = false;
2916
+ let heartbeatInFlight = Promise.resolve();
2917
+ const refreshHeartbeat = async () => {
2918
+ if (heartbeatStopped) return;
2919
+ const temporaryOwnerPath = join4(lockDirectory, `.owner.${ownerId}.tmp`);
2920
+ await writeFile(
2921
+ temporaryOwnerPath,
2922
+ JSON.stringify({ id: ownerId, pid: process.pid, at: now() }),
2923
+ { mode: 384 }
2924
+ );
2925
+ const owner = await lockSnapshot(lockDirectory);
2926
+ if (owner?.id !== ownerId) {
2927
+ await rm(temporaryOwnerPath, { force: true });
2928
+ return;
2929
+ }
2930
+ await rename(temporaryOwnerPath, join4(lockDirectory, "owner"));
2931
+ };
2932
+ const heartbeat = setInterval(() => {
2933
+ heartbeatInFlight = heartbeatInFlight.then(refreshHeartbeat).catch(() => void 0);
2934
+ }, heartbeatMs);
2935
+ heartbeat.unref?.();
2936
+ try {
2937
+ return await callback();
2938
+ } finally {
2939
+ heartbeatStopped = true;
2940
+ clearInterval(heartbeat);
2941
+ await heartbeatInFlight;
2942
+ await releaseOwnedLock(lockDirectory, ownerId);
2943
+ }
2944
+ }
2945
+
2946
+ // src/skill-sync-worker.js
2947
+ async function runAutomaticSkillSync({ profile, apiBase, userId }, {
2948
+ resolveRuntime = resolveRuntimeProfile,
2949
+ refresh = ensureFreshOAuthCredential,
2950
+ settings = fetchSyncSettings,
2951
+ sync = runSkillSync,
2952
+ lock = withSkillSyncLock
2953
+ } = {}) {
2954
+ const runtime = resolveRuntime({ profile }, { requireAuth: true });
2955
+ if (runtime.credentialKind !== "oauth" || runtime.apiBase !== apiBase || runtime.oauthUserId !== userId) throw new Error("Automatic skill sync account changed; run notis skills sync to rebind");
2956
+ await refresh(runtime);
2957
+ const saved = await settings(runtime.apiBase, runtime.jwt);
2958
+ if (saved.user_id !== userId) throw new Error("Automatic skill sync identity mismatch");
2959
+ if (!saved.sync_enabled) return { status: "disabled" };
2960
+ const result = await lock(() => sync(runtime.apiBase, runtime.jwt, {
2961
+ fetchSyncSettings: async () => saved
2962
+ }, { honorSyncEnabled: true }));
2963
+ return { status: result.failedLinks?.length || result.failedPushes?.length ? "partial" : "synced", ...result };
2964
+ }
2965
+ async function main(args = process.argv.slice(2)) {
2966
+ const [profile, apiBase, userId] = args;
2967
+ const root = join5(homedir4(), ".notis", "skills", "service");
2968
+ const record = (value) => {
2969
+ mkdirSync3(root, { recursive: true, mode: 448 });
2970
+ const target = join5(root, "status.json");
2971
+ const temporary = `${target}.${process.pid}.tmp`;
2972
+ writeFileSync3(temporary, JSON.stringify({ ...value, profile, at: (/* @__PURE__ */ new Date()).toISOString() }, null, 2), { mode: 384 });
2973
+ renameSync3(temporary, target);
2974
+ };
2975
+ const deadline = setTimeout(() => {
2976
+ record({ status: "error", code: "sync_timeout" });
2977
+ process.exit(1);
2978
+ }, 24e4);
2979
+ try {
2980
+ record(await runAutomaticSkillSync({ profile, apiBase, userId }));
2981
+ } catch (error) {
2982
+ record({ status: "error", code: error.code || "sync_failed" });
2983
+ process.exitCode = 1;
2984
+ } finally {
2985
+ clearTimeout(deadline);
2986
+ }
2987
+ }
2988
+
2989
+ // src/skill-sync-worker-entry.js
2990
+ await main();