@notis_ai/cli 0.2.0-beta.136.1 → 0.2.0-beta.139.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 (36) hide show
  1. package/README.md +38 -0
  2. package/dist/agent-hooks/notis-agent-hook.mjs +16620 -0
  3. package/{skills → dist/base-skills}/notis-apps/SKILL.md +9 -6
  4. package/{skills → dist/base-skills}/notis-cli/SKILL.md +1 -1
  5. package/dist/base-skills/notis-query/SKILL.md +705 -0
  6. package/dist/scaffolds/notis-database/packages/sdk/src/config.ts +8 -0
  7. package/dist/scaffolds/notis-journal/packages/sdk/src/config.ts +8 -0
  8. package/dist/scaffolds/notis-notes/packages/sdk/src/config.ts +8 -0
  9. package/dist/scaffolds/notis-random/packages/sdk/src/config.ts +8 -0
  10. package/dist/skill-sync/index.js +1528 -0
  11. package/dist/skill-sync/index.js.map +7 -0
  12. package/package.json +4 -1
  13. package/skills/notis-cli/AGENT_INSTRUCTIONS.md +39 -0
  14. package/skills/notis-onboarding/BRIEF.md +16 -0
  15. package/src/agent-hook-entry.js +5 -0
  16. package/src/cli.js +23 -14
  17. package/src/command-specs/agents.js +392 -0
  18. package/src/command-specs/auth.js +16 -0
  19. package/src/command-specs/index.js +6 -0
  20. package/src/command-specs/onboarding.js +59 -2
  21. package/src/command-specs/skills.js +56 -0
  22. package/src/runtime/agent-memory-state.js +126 -0
  23. package/src/runtime/agent-setup.js +383 -0
  24. package/src/runtime/base-skills.d.ts +20 -0
  25. package/src/runtime/base-skills.js +167 -0
  26. package/src/runtime/skill-sync/cloud-client.ts +96 -0
  27. package/src/runtime/skill-sync/index.ts +644 -0
  28. package/src/runtime/skill-sync/local-scanner.ts +1046 -0
  29. package/src/runtime/skill-sync/symlink-manager.ts +383 -0
  30. package/src/runtime/skill-sync/sync-plan.ts +22 -0
  31. package/src/runtime/skill-sync/types.ts +103 -0
  32. package/src/runtime/skill-sync/write-cloud-skill.ts +50 -0
  33. package/src/runtime/store-screenshot.js +6 -1
  34. package/src/runtime/sync-skills.d.ts +37 -0
  35. package/src/runtime/sync-skills.js +215 -0
  36. package/template/packages/sdk/src/config.ts +8 -0
@@ -0,0 +1,1046 @@
1
+ import { createHash } from "crypto";
2
+ import { execFile } from "child_process";
3
+ import type { Dirent } from "fs";
4
+ import { promises as fs } from "fs";
5
+ import os from "os";
6
+ import path from "path";
7
+ import { promisify } from "util";
8
+
9
+ import type { CloudSkill, LocalSkill, NotisSyncState } from "./types";
10
+
11
+ const HOME_DIR = os.homedir();
12
+ const execFileAsync = promisify(execFile);
13
+
14
+ export const AGENTS_DIR = path.join(HOME_DIR, ".agents");
15
+ export const LEGACY_AGENTS_SKILLS_DIR = path.join(AGENTS_DIR, "skills");
16
+ export const NOTIS_SKILL_SYNC_ROOT = path.join(HOME_DIR, ".notis", "skills");
17
+ export const LEGACY_NOTIS_SYNC_STATE_PATH = path.join(
18
+ AGENTS_DIR,
19
+ ".notis-sync.json",
20
+ );
21
+ export const AGENTS_SKILLS_DIR = LEGACY_AGENTS_SKILLS_DIR;
22
+ export const NOTIS_SYNC_STATE_PATH = LEGACY_NOTIS_SYNC_STATE_PATH;
23
+ const SKILL_LOCK_PATH = path.join(AGENTS_DIR, ".skill-lock.json");
24
+
25
+ const DEFAULT_SYNC_STATE: NotisSyncState = {
26
+ version: 1,
27
+ lastSyncedAt: null,
28
+ skills: {},
29
+ };
30
+ const EXCLUDED_TOP_LEVEL_ROOT_NAMES = new Set([
31
+ "backup",
32
+ "backups",
33
+ "builtin",
34
+ "builtins",
35
+ "cache",
36
+ "caches",
37
+ "marketplace",
38
+ "marketplaces",
39
+ "plugin",
40
+ "plugins",
41
+ "temp",
42
+ "tmp",
43
+ "worktree",
44
+ "worktrees",
45
+ ]);
46
+
47
+ export interface SkillSyncPaths {
48
+ agentsDir: string;
49
+ syncRoot: string;
50
+ legacySkillsDir: string;
51
+ legacyScopedSkillsDir: string;
52
+ legacyScopedSyncStatePath: string;
53
+ skillsDir: string;
54
+ syncStatePath: string;
55
+ skillLockPath: string;
56
+ gatherMetadataPath: string;
57
+ }
58
+
59
+ const DEFAULT_SYNC_PATHS: SkillSyncPaths = {
60
+ agentsDir: AGENTS_DIR,
61
+ syncRoot: NOTIS_SKILL_SYNC_ROOT,
62
+ legacySkillsDir: LEGACY_AGENTS_SKILLS_DIR,
63
+ legacyScopedSkillsDir: LEGACY_AGENTS_SKILLS_DIR,
64
+ legacyScopedSyncStatePath: LEGACY_NOTIS_SYNC_STATE_PATH,
65
+ skillsDir: LEGACY_AGENTS_SKILLS_DIR,
66
+ syncStatePath: LEGACY_NOTIS_SYNC_STATE_PATH,
67
+ skillLockPath: SKILL_LOCK_PATH,
68
+ gatherMetadataPath: path.join(NOTIS_SKILL_SYNC_ROOT, "skill-gather-metadata.json"),
69
+ };
70
+
71
+ function getDefaultSyncRootForAgentsDir(resolvedAgentsDir: string): string {
72
+ if (resolvedAgentsDir === path.resolve(AGENTS_DIR)) {
73
+ return NOTIS_SKILL_SYNC_ROOT;
74
+ }
75
+ return path.join(resolvedAgentsDir, ".notis", "skills");
76
+ }
77
+
78
+ function isResolvedChildPath(baseDir: string, candidatePath: string): boolean {
79
+ return candidatePath.startsWith(`${baseDir}${path.sep}`);
80
+ }
81
+
82
+ function sanitizePathSegment(value: string): string {
83
+ const raw = value.trim();
84
+ const sanitized = raw
85
+ .replace(/[^A-Za-z0-9_-]+/g, "_")
86
+ .replace(/^_+|_+$/g, "");
87
+ if (!sanitized) {
88
+ throw new Error("Invalid sync user id");
89
+ }
90
+ if (sanitized === raw) {
91
+ return sanitized;
92
+ }
93
+ return `${sanitized}-${createHash("sha256").update(raw).digest("hex").slice(0, 12)}`;
94
+ }
95
+
96
+ export function getSkillSyncPathsForUser(
97
+ authUserId: string,
98
+ agentsDir: string = AGENTS_DIR,
99
+ ): SkillSyncPaths {
100
+ const safeUserId = sanitizePathSegment(authUserId);
101
+ const resolvedAgentsDir = path.resolve(agentsDir);
102
+ const syncRoot = getDefaultSyncRootForAgentsDir(resolvedAgentsDir);
103
+ const userRoot = path.join(syncRoot, "users", safeUserId);
104
+ const legacyUserRoot = path.join(resolvedAgentsDir, "notis", "users", safeUserId);
105
+
106
+ return {
107
+ agentsDir: resolvedAgentsDir,
108
+ syncRoot,
109
+ legacySkillsDir: path.join(resolvedAgentsDir, "skills"),
110
+ legacyScopedSkillsDir: path.join(legacyUserRoot, "skills"),
111
+ legacyScopedSyncStatePath: path.join(legacyUserRoot, ".notis-sync.json"),
112
+ skillsDir: path.join(userRoot, "skills"),
113
+ syncStatePath: path.join(userRoot, ".notis-sync.json"),
114
+ skillLockPath: path.join(resolvedAgentsDir, ".skill-lock.json"),
115
+ gatherMetadataPath: path.join(userRoot, ".notis-gathered-skills.json"),
116
+ };
117
+ }
118
+
119
+ function assertRelativeBundlePath(relativePath: string): string {
120
+ const normalized = relativePath.replace(/\\/g, "/").replace(/^\/+|\/+$/g, "");
121
+ if (
122
+ !normalized ||
123
+ normalized.startsWith("../") ||
124
+ `/${normalized}/`.includes("/../")
125
+ ) {
126
+ throw new Error(`Invalid bundle file path: ${relativePath}`);
127
+ }
128
+ return normalized;
129
+ }
130
+
131
+ function stripWrappingQuotes(value: string): string {
132
+ return value.replace(/^['"]|['"]$/g, "").trim();
133
+ }
134
+
135
+ function parseFrontMatter(skillMd: string): { description: string } {
136
+ const match = skillMd.match(/^---\s*\n([\s\S]*?)\n---\s*(?:\n|$)/);
137
+ if (!match) {
138
+ return { description: "" };
139
+ }
140
+
141
+ let description = "";
142
+ for (const line of match[1].split("\n")) {
143
+ const trimmed = line.trim();
144
+ if (!trimmed || trimmed.startsWith("#")) {
145
+ continue;
146
+ }
147
+ const descriptionMatch = trimmed.match(/^description\s*:\s*(.+)$/i);
148
+ if (descriptionMatch) {
149
+ description = stripWrappingQuotes(descriptionMatch[1]);
150
+ break;
151
+ }
152
+ }
153
+
154
+ return { description };
155
+ }
156
+
157
+ async function readJsonFile<T>(filePath: string): Promise<T | null> {
158
+ try {
159
+ const raw = await fs.readFile(filePath, "utf8");
160
+ return JSON.parse(raw) as T;
161
+ } catch {
162
+ return null;
163
+ }
164
+ }
165
+
166
+ async function listFilesRecursive(dirPath: string): Promise<string[]> {
167
+ const entries = await fs.readdir(dirPath, { withFileTypes: true });
168
+ const nested = await Promise.all(
169
+ entries.map(async (entry) => {
170
+ if (entry.name === ".DS_Store") {
171
+ return [];
172
+ }
173
+
174
+ const fullPath = path.join(dirPath, entry.name);
175
+ if (entry.isDirectory()) {
176
+ return listFilesRecursive(fullPath);
177
+ }
178
+
179
+ if (entry.isFile()) {
180
+ return [fullPath];
181
+ }
182
+
183
+ return [];
184
+ }),
185
+ );
186
+
187
+ return nested.flat().sort();
188
+ }
189
+
190
+ async function computeFolderHash(dirPath: string): Promise<string> {
191
+ const hash = createHash("sha256");
192
+ const filePaths = await listFilesRecursive(dirPath);
193
+
194
+ for (const filePath of filePaths) {
195
+ const relativePath = path.relative(dirPath, filePath);
196
+ hash.update(relativePath);
197
+ hash.update("\0");
198
+ hash.update(await fs.readFile(filePath));
199
+ hash.update("\0");
200
+ }
201
+
202
+ return hash.digest("hex");
203
+ }
204
+
205
+ function resolveSkillBundleFilePath(
206
+ skillDir: string,
207
+ relativePath: string,
208
+ ): string {
209
+ const normalizedPath = assertRelativeBundlePath(relativePath);
210
+ const resolvedSkillDir = path.resolve(skillDir);
211
+ const candidatePath = path.resolve(resolvedSkillDir, normalizedPath);
212
+ if (
213
+ candidatePath === resolvedSkillDir ||
214
+ !isResolvedChildPath(resolvedSkillDir, candidatePath)
215
+ ) {
216
+ throw new Error(
217
+ `Bundle file resolves outside the expected directory: ${relativePath}`,
218
+ );
219
+ }
220
+ return candidatePath;
221
+ }
222
+
223
+ async function readSkillMdIfValid(skillDir: string): Promise<boolean> {
224
+ try {
225
+ await fs.readFile(path.join(skillDir, "SKILL.md"), "utf8");
226
+ return true;
227
+ } catch {
228
+ return false;
229
+ }
230
+ }
231
+
232
+ async function moveDirectory(
233
+ sourceDir: string,
234
+ destinationDir: string,
235
+ ): Promise<void> {
236
+ await fs.mkdir(path.dirname(destinationDir), { recursive: true });
237
+ try {
238
+ await fs.rename(sourceDir, destinationDir);
239
+ } catch (error) {
240
+ if ((error as NodeJS.ErrnoException).code !== "EXDEV") {
241
+ throw error;
242
+ }
243
+ await fs.cp(sourceDir, destinationDir, {
244
+ recursive: true,
245
+ errorOnExist: true,
246
+ force: false,
247
+ });
248
+ await fs.rm(sourceDir, { recursive: true, force: true });
249
+ }
250
+ }
251
+
252
+ async function createDirectorySymlink(
253
+ targetDir: string,
254
+ linkPath: string,
255
+ ): Promise<void> {
256
+ await fs.mkdir(path.dirname(linkPath), { recursive: true });
257
+ await fs.symlink(path.relative(path.dirname(linkPath), targetDir), linkPath, "dir");
258
+ }
259
+
260
+ function backupRootFor(paths: SkillSyncPaths, timestamp: string): string {
261
+ return path.join(paths.syncRoot, "skill-dedupe-backups", timestamp);
262
+ }
263
+
264
+ function relativeBackupPath(label: string, skillName: string): string {
265
+ return path.join(
266
+ label.replace(/[^A-Za-z0-9_.-]+/g, "_"),
267
+ safeName(skillName),
268
+ );
269
+ }
270
+
271
+ function isExcludedTopLevelSkillEntry(entryName: string): boolean {
272
+ return (
273
+ entryName.startsWith(".") ||
274
+ isTransientSkillDirectoryName(entryName) ||
275
+ EXCLUDED_TOP_LEVEL_ROOT_NAMES.has(entryName.toLowerCase())
276
+ );
277
+ }
278
+
279
+ function isTransientSkillDirectoryName(entryName: string): boolean {
280
+ return /\.(?:backup|staging)-/.test(entryName);
281
+ }
282
+
283
+ interface TopLevelSkillSource {
284
+ label: string;
285
+ root: string;
286
+ priority: number;
287
+ }
288
+
289
+ interface TopLevelSkillCandidate {
290
+ name: string;
291
+ root: string;
292
+ label: string;
293
+ path: string;
294
+ resolvedPath: string;
295
+ priority: number;
296
+ isScoped: boolean;
297
+ isSymlink: boolean;
298
+ }
299
+
300
+ export interface GatherTopLevelLocalSkillsOptions {
301
+ sourceRoots?: Array<{ label: string; root: string }>;
302
+ protectedSkillNames?: ReadonlySet<string>;
303
+ timestamp?: string;
304
+ }
305
+
306
+ export interface GatherTopLevelLocalSkillsResult {
307
+ gathered: number;
308
+ backedUp: number;
309
+ skipped: number;
310
+ metadataPath: string;
311
+ }
312
+
313
+ function defaultTopLevelSkillSources(
314
+ paths: SkillSyncPaths,
315
+ ): TopLevelSkillSource[] {
316
+ const sources: TopLevelSkillSource[] = [];
317
+ if (path.resolve(paths.legacyScopedSkillsDir) !== path.resolve(paths.skillsDir)) {
318
+ sources.push({
319
+ label: "notis-legacy-scoped",
320
+ root: paths.legacyScopedSkillsDir,
321
+ priority: 1,
322
+ });
323
+ }
324
+
325
+ sources.push(
326
+ { label: "agents", root: paths.legacySkillsDir, priority: 2 },
327
+ {
328
+ label: "codex",
329
+ root: path.join(HOME_DIR, ".codex", "skills"),
330
+ priority: 3,
331
+ },
332
+ {
333
+ label: "cursor",
334
+ root: path.join(HOME_DIR, ".cursor", "skills"),
335
+ priority: 4,
336
+ },
337
+ {
338
+ label: "claude",
339
+ root: path.join(HOME_DIR, ".claude", "skills"),
340
+ priority: 5,
341
+ },
342
+ );
343
+
344
+ return sources;
345
+ }
346
+
347
+ function isManagedTopLevelSymlinkTarget(
348
+ resolvedPath: string,
349
+ paths: SkillSyncPaths,
350
+ ): boolean {
351
+ const managedRoots = [
352
+ path.resolve(paths.skillsDir),
353
+ path.resolve(paths.legacySkillsDir),
354
+ path.resolve(paths.legacyScopedSkillsDir),
355
+ path.join(path.resolve(paths.syncRoot), "base"),
356
+ path.join(path.resolve(paths.syncRoot), "users"),
357
+ path.join(path.resolve(paths.agentsDir), "notis", "users"),
358
+ ];
359
+ return managedRoots.some(
360
+ (root) =>
361
+ resolvedPath === root || resolvedPath.startsWith(`${root}${path.sep}`),
362
+ );
363
+ }
364
+
365
+ async function listTopLevelSkillCandidates(
366
+ paths: SkillSyncPaths,
367
+ options: GatherTopLevelLocalSkillsOptions,
368
+ ): Promise<TopLevelSkillCandidate[]> {
369
+ const sourceRoots = options.sourceRoots
370
+ ? options.sourceRoots.map((source, index) => ({
371
+ ...source,
372
+ priority: index + 1,
373
+ }))
374
+ : defaultTopLevelSkillSources(paths);
375
+ const candidates: TopLevelSkillCandidate[] = [];
376
+
377
+ try {
378
+ const scopedEntries = await fs.readdir(paths.skillsDir, {
379
+ withFileTypes: true,
380
+ });
381
+ for (const entry of scopedEntries) {
382
+ if (
383
+ (!entry.isDirectory() && !entry.isSymbolicLink()) ||
384
+ entry.name.startsWith(".") ||
385
+ isTransientSkillDirectoryName(entry.name)
386
+ ) {
387
+ continue;
388
+ }
389
+ const skillDir = path.join(paths.skillsDir, entry.name);
390
+ let resolvedPath = path.resolve(skillDir);
391
+ if (entry.isSymbolicLink()) {
392
+ try {
393
+ resolvedPath = path.resolve(path.dirname(skillDir), await fs.readlink(skillDir));
394
+ } catch {
395
+ continue;
396
+ }
397
+ }
398
+ if (await readSkillMdIfValid(skillDir)) {
399
+ candidates.push({
400
+ name: safeName(entry.name, paths.skillsDir),
401
+ root: paths.skillsDir,
402
+ label: "notis-managed",
403
+ path: skillDir,
404
+ resolvedPath,
405
+ priority: 0,
406
+ isScoped: true,
407
+ isSymlink: entry.isSymbolicLink(),
408
+ });
409
+ }
410
+ }
411
+ } catch {
412
+ // Missing scoped mirror is expected before the first sync.
413
+ }
414
+
415
+ for (const source of sourceRoots) {
416
+ let entries: Dirent[];
417
+ try {
418
+ entries = await fs.readdir(source.root, { withFileTypes: true });
419
+ } catch {
420
+ continue;
421
+ }
422
+
423
+ for (const entry of entries) {
424
+ if (
425
+ isExcludedTopLevelSkillEntry(entry.name) ||
426
+ (!entry.isDirectory() && !entry.isSymbolicLink())
427
+ ) {
428
+ continue;
429
+ }
430
+
431
+ const candidatePath = path.join(source.root, entry.name);
432
+ let resolvedPath: string;
433
+ try {
434
+ resolvedPath = entry.isSymbolicLink()
435
+ ? path.resolve(
436
+ path.dirname(candidatePath),
437
+ await fs.readlink(candidatePath),
438
+ )
439
+ : path.resolve(candidatePath);
440
+ } catch {
441
+ continue;
442
+ }
443
+
444
+ if (
445
+ entry.isSymbolicLink() &&
446
+ isManagedTopLevelSymlinkTarget(resolvedPath, paths)
447
+ ) {
448
+ continue;
449
+ }
450
+
451
+ if (!(await readSkillMdIfValid(resolvedPath))) {
452
+ continue;
453
+ }
454
+
455
+ candidates.push({
456
+ name: safeName(entry.name, paths.skillsDir),
457
+ root: source.root,
458
+ label: source.label,
459
+ path: candidatePath,
460
+ resolvedPath,
461
+ priority: source.priority,
462
+ isScoped: false,
463
+ isSymlink: entry.isSymbolicLink(),
464
+ });
465
+ }
466
+ }
467
+
468
+ return candidates.sort(
469
+ (a, b) => a.priority - b.priority || a.name.localeCompare(b.name),
470
+ );
471
+ }
472
+
473
+ export async function gatherTopLevelLocalSkills(
474
+ paths: SkillSyncPaths,
475
+ options: GatherTopLevelLocalSkillsOptions = {},
476
+ ): Promise<GatherTopLevelLocalSkillsResult> {
477
+ await ensureCanonicalSkillsDir(paths);
478
+
479
+ const protectedSkillNames = options.protectedSkillNames || new Set<string>();
480
+ const timestamp =
481
+ options.timestamp || new Date().toISOString().replace(/[:.]/g, "-");
482
+ const backupRoot = backupRootFor(paths, timestamp);
483
+ const candidates = await listTopLevelSkillCandidates(paths, options);
484
+ const byName = new Map<string, TopLevelSkillCandidate[]>();
485
+
486
+ for (const candidate of candidates) {
487
+ const existing = byName.get(candidate.name) || [];
488
+ existing.push(candidate);
489
+ byName.set(candidate.name, existing);
490
+ }
491
+
492
+ let gathered = 0;
493
+ let backedUp = 0;
494
+ let skipped = 0;
495
+ const metadata: {
496
+ version: 1;
497
+ gatheredAt: string;
498
+ skills: Record<
499
+ string,
500
+ {
501
+ canonicalPath: string | null;
502
+ skippedSources: string[];
503
+ protectedFromLocalGather?: boolean;
504
+ }
505
+ >;
506
+ } = {
507
+ version: 1,
508
+ gatheredAt: new Date().toISOString(),
509
+ skills: {},
510
+ };
511
+
512
+ for (const [skillName, skillCandidates] of byName) {
513
+ const protectedFromLocalGather = protectedSkillNames.has(skillName);
514
+ const canonical = protectedFromLocalGather
515
+ ? skillCandidates.find((candidate) => candidate.isScoped) || null
516
+ : skillCandidates[0];
517
+ const destinationName = safeName(skillName, paths.skillsDir);
518
+ const destinationDir = path.join(paths.skillsDir, destinationName);
519
+ const skippedSources: string[] = [];
520
+
521
+ if (!canonical) {
522
+ for (const candidate of skillCandidates) {
523
+ if (candidate.isSymlink) {
524
+ skipped += 1;
525
+ skippedSources.push(candidate.path);
526
+ continue;
527
+ }
528
+ const backupDir = path.join(
529
+ backupRoot,
530
+ relativeBackupPath(candidate.label, skillName),
531
+ );
532
+ try {
533
+ await moveDirectory(candidate.path, backupDir);
534
+ backedUp += 1;
535
+ skippedSources.push(candidate.path);
536
+ } catch {
537
+ skipped += 1;
538
+ }
539
+ }
540
+ metadata.skills[skillName] = {
541
+ canonicalPath: null,
542
+ skippedSources,
543
+ protectedFromLocalGather: true,
544
+ };
545
+ continue;
546
+ }
547
+
548
+ if (!canonical.isScoped && !(await pathExists(destinationDir))) {
549
+ try {
550
+ if (canonical.isSymlink) {
551
+ await createDirectorySymlink(canonical.resolvedPath, destinationDir);
552
+ } else {
553
+ await moveDirectory(canonical.path, destinationDir);
554
+ }
555
+ gathered += 1;
556
+ } catch {
557
+ skipped += 1;
558
+ }
559
+ }
560
+
561
+ for (const candidate of skillCandidates) {
562
+ if (candidate === canonical || candidate.isScoped) {
563
+ continue;
564
+ }
565
+ if (candidate.isSymlink) {
566
+ skippedSources.push(candidate.path);
567
+ continue;
568
+ }
569
+ const backupDir = path.join(
570
+ backupRoot,
571
+ relativeBackupPath(candidate.label, skillName),
572
+ );
573
+ try {
574
+ await moveDirectory(candidate.path, backupDir);
575
+ backedUp += 1;
576
+ skippedSources.push(candidate.path);
577
+ } catch {
578
+ skipped += 1;
579
+ }
580
+ }
581
+
582
+ metadata.skills[skillName] = {
583
+ canonicalPath: (await pathExists(destinationDir))
584
+ ? destinationDir
585
+ : canonical.path,
586
+ skippedSources,
587
+ ...(protectedFromLocalGather ? { protectedFromLocalGather: true } : {}),
588
+ };
589
+ }
590
+
591
+ await fs.mkdir(path.dirname(paths.gatherMetadataPath), { recursive: true });
592
+ await fs.writeFile(
593
+ paths.gatherMetadataPath,
594
+ `${JSON.stringify(metadata, null, 2)}\n`,
595
+ "utf8",
596
+ );
597
+
598
+ return {
599
+ gathered,
600
+ backedUp,
601
+ skipped,
602
+ metadataPath: paths.gatherMetadataPath,
603
+ };
604
+ }
605
+
606
+ async function readSkillLock(
607
+ paths: SkillSyncPaths = DEFAULT_SYNC_PATHS,
608
+ ): Promise<Record<string, string>> {
609
+ type SkillLock = {
610
+ skills?: Record<string, { sourceUrl?: string }>;
611
+ };
612
+
613
+ const lockData = await readJsonFile<SkillLock>(paths.skillLockPath);
614
+ const sourceUrls: Record<string, string> = {};
615
+
616
+ for (const [skillName, entry] of Object.entries(lockData?.skills || {})) {
617
+ if (typeof entry?.sourceUrl === "string" && entry.sourceUrl.trim()) {
618
+ sourceUrls[skillName] = entry.sourceUrl.trim();
619
+ }
620
+ }
621
+
622
+ return sourceUrls;
623
+ }
624
+
625
+ export async function ensureCanonicalSkillsDir(
626
+ paths: SkillSyncPaths = DEFAULT_SYNC_PATHS,
627
+ ): Promise<void> {
628
+ await fs.mkdir(paths.skillsDir, { recursive: true });
629
+ }
630
+
631
+ export async function initializeScopedSkillsFromLegacy(
632
+ paths: SkillSyncPaths,
633
+ ): Promise<number> {
634
+ const usingLegacyPath =
635
+ path.resolve(paths.skillsDir) === path.resolve(paths.legacySkillsDir);
636
+ if (usingLegacyPath || (await pathExists(paths.syncStatePath))) {
637
+ await ensureCanonicalSkillsDir(paths);
638
+ return 0;
639
+ }
640
+
641
+ await ensureCanonicalSkillsDir(paths);
642
+ let entries: Dirent[];
643
+ try {
644
+ entries = await fs.readdir(paths.legacySkillsDir, { withFileTypes: true });
645
+ } catch {
646
+ return 0;
647
+ }
648
+
649
+ let copied = 0;
650
+ for (const entry of entries) {
651
+ if (!entry.isDirectory()) {
652
+ continue;
653
+ }
654
+
655
+ const legacySkillDir = path.join(paths.legacySkillsDir, entry.name);
656
+ const targetSkillName = safeName(entry.name, paths.skillsDir);
657
+ const scopedSkillDir = path.join(paths.skillsDir, targetSkillName);
658
+ try {
659
+ await fs.readFile(path.join(legacySkillDir, "SKILL.md"), "utf8");
660
+ if (await pathExists(scopedSkillDir)) {
661
+ continue;
662
+ }
663
+ await fs.cp(legacySkillDir, scopedSkillDir, {
664
+ recursive: true,
665
+ errorOnExist: true,
666
+ force: false,
667
+ });
668
+ copied += 1;
669
+ } catch {
670
+ // Ignore invalid legacy skill folders and existing scoped copies.
671
+ }
672
+ }
673
+
674
+ return copied;
675
+ }
676
+
677
+ export async function scanLocalSkills(
678
+ paths: SkillSyncPaths = DEFAULT_SYNC_PATHS,
679
+ ): Promise<LocalSkill[]> {
680
+ await ensureCanonicalSkillsDir(paths);
681
+
682
+ const sourceUrls = await readSkillLock(paths);
683
+ const entries = await fs.readdir(paths.skillsDir, { withFileTypes: true });
684
+ const skills: Array<LocalSkill | null> = await Promise.all(
685
+ entries.map(async (entry) => {
686
+ if (
687
+ (!entry.isDirectory() && !entry.isSymbolicLink()) ||
688
+ isTransientSkillDirectoryName(entry.name)
689
+ ) {
690
+ return null;
691
+ }
692
+
693
+ const skillDir = path.join(paths.skillsDir, entry.name);
694
+ const skillMdPath = path.join(skillDir, "SKILL.md");
695
+
696
+ try {
697
+ const skillMd = await fs.readFile(skillMdPath, "utf8");
698
+ const { description } = parseFrontMatter(skillMd);
699
+ const folderHash = await computeFolderHash(skillDir);
700
+ const skill: LocalSkill = {
701
+ name: entry.name,
702
+ skillMd,
703
+ description,
704
+ folderHash,
705
+ directoryPath: skillDir,
706
+ };
707
+ if (sourceUrls[entry.name]) {
708
+ skill.sourceUrl = sourceUrls[entry.name];
709
+ }
710
+ return skill;
711
+ } catch {
712
+ return null;
713
+ }
714
+ }),
715
+ );
716
+
717
+ return skills
718
+ .filter((skill): skill is LocalSkill => skill !== null)
719
+ .sort((a, b) => a.name.localeCompare(b.name));
720
+ }
721
+
722
+ function normalizeSyncState(
723
+ state: NotisSyncState | null,
724
+ ): NotisSyncState | null {
725
+ if (!state || state.version !== 1 || typeof state.skills !== "object") {
726
+ return null;
727
+ }
728
+ const normalizeStoredAgentTargets = (
729
+ targets: Partial<NotisSyncState["skills"][string]["agentTargets"]> | null | undefined,
730
+ ): NotisSyncState["skills"][string]["agentTargets"] => ({
731
+ notis: Boolean(targets?.notis ?? true),
732
+ claude_code: Boolean(targets?.claude_code ?? true),
733
+ cursor: Boolean(targets?.cursor ?? true),
734
+ codex: Boolean(targets?.codex ?? true),
735
+ });
736
+
737
+ return {
738
+ version: 1,
739
+ lastSyncedAt:
740
+ typeof state.lastSyncedAt === "string" ? state.lastSyncedAt : null,
741
+ skills: Object.fromEntries(
742
+ Object.entries(state.skills || {}).map(([skillName, skillState]) => [
743
+ skillName,
744
+ {
745
+ ...skillState,
746
+ agentTargets: normalizeStoredAgentTargets(skillState.agentTargets),
747
+ },
748
+ ]),
749
+ ),
750
+ };
751
+ }
752
+
753
+ export async function readSyncState(
754
+ paths: SkillSyncPaths = DEFAULT_SYNC_PATHS,
755
+ ): Promise<NotisSyncState> {
756
+ const scopedState = normalizeSyncState(
757
+ await readJsonFile<NotisSyncState>(paths.syncStatePath),
758
+ );
759
+ if (scopedState) {
760
+ return scopedState;
761
+ }
762
+
763
+ return DEFAULT_SYNC_STATE;
764
+ }
765
+
766
+ export async function readLegacySyncState(
767
+ paths: SkillSyncPaths = DEFAULT_SYNC_PATHS,
768
+ ): Promise<NotisSyncState | null> {
769
+ const legacyStatePaths = [
770
+ paths.legacyScopedSyncStatePath,
771
+ path.resolve(paths.syncStatePath) === path.resolve(LEGACY_NOTIS_SYNC_STATE_PATH)
772
+ ? paths.syncStatePath
773
+ : path.join(paths.agentsDir, ".notis-sync.json"),
774
+ ];
775
+
776
+ for (const legacyStatePath of legacyStatePaths) {
777
+ const state = normalizeSyncState(
778
+ await readJsonFile<NotisSyncState>(legacyStatePath),
779
+ );
780
+ if (state) {
781
+ return state;
782
+ }
783
+ }
784
+
785
+ return null;
786
+ }
787
+
788
+ export async function writeSyncState(
789
+ state: NotisSyncState,
790
+ paths: SkillSyncPaths = DEFAULT_SYNC_PATHS,
791
+ ): Promise<void> {
792
+ await fs.mkdir(path.dirname(paths.syncStatePath), { recursive: true });
793
+ await fs.writeFile(
794
+ paths.syncStatePath,
795
+ `${JSON.stringify(state, null, 2)}\n`,
796
+ "utf8",
797
+ );
798
+ }
799
+
800
+ export function safeName(
801
+ name: string,
802
+ baseDir: string = AGENTS_SKILLS_DIR,
803
+ ): string {
804
+ const sanitizedName = name
805
+ .trim()
806
+ .replace(/[\\/]+/g, "")
807
+ .replace(/\.\./g, "");
808
+ if (!sanitizedName) {
809
+ throw new Error("Invalid skill name");
810
+ }
811
+
812
+ const resolvedBaseDir = path.resolve(baseDir);
813
+ const resolvedPath = path.resolve(resolvedBaseDir, sanitizedName);
814
+ if (!isResolvedChildPath(resolvedBaseDir, resolvedPath)) {
815
+ throw new Error("Skill name resolves outside the expected directory");
816
+ }
817
+
818
+ return sanitizedName;
819
+ }
820
+
821
+ export async function deleteLocalSkill(
822
+ skillName: string,
823
+ paths: SkillSyncPaths = DEFAULT_SYNC_PATHS,
824
+ ): Promise<boolean> {
825
+ const skillDir = path.join(
826
+ paths.skillsDir,
827
+ safeName(skillName, paths.skillsDir),
828
+ );
829
+ try {
830
+ await fs.rm(skillDir, { recursive: true, force: true });
831
+ return true;
832
+ } catch {
833
+ return false;
834
+ }
835
+ }
836
+
837
+ async function createZipFromDirectory(directoryPath: string): Promise<string> {
838
+ const zipPath = path.join(
839
+ os.tmpdir(),
840
+ `notis-skill-${Date.now()}-${Math.random().toString(36).slice(2)}.zip`,
841
+ );
842
+ const parentDir = path.dirname(directoryPath);
843
+ const directoryName = path.basename(directoryPath);
844
+
845
+ if (process.platform === "win32") {
846
+ await execFileAsync("powershell.exe", [
847
+ "-NoProfile",
848
+ "-Command",
849
+ `Compress-Archive -Path '${directoryPath.replace(/'/g, "''")}' -DestinationPath '${zipPath.replace(/'/g, "''")}' -Force`,
850
+ ]);
851
+ return zipPath;
852
+ }
853
+
854
+ await execFileAsync("zip", ["-qry", zipPath, directoryName], {
855
+ cwd: parentDir,
856
+ });
857
+ return zipPath;
858
+ }
859
+
860
+ async function extractZipToDirectory(
861
+ zipPath: string,
862
+ destinationDir: string,
863
+ ): Promise<void> {
864
+ const extractRoot = await fs.mkdtemp(
865
+ path.join(os.tmpdir(), "notis-skill-extract-"),
866
+ );
867
+
868
+ try {
869
+ if (process.platform === "win32") {
870
+ await execFileAsync("powershell.exe", [
871
+ "-NoProfile",
872
+ "-Command",
873
+ `Expand-Archive -Path '${zipPath.replace(/'/g, "''")}' -DestinationPath '${extractRoot.replace(/'/g, "''")}' -Force`,
874
+ ]);
875
+ } else {
876
+ await execFileAsync("unzip", ["-qq", zipPath, "-d", extractRoot]);
877
+ }
878
+
879
+ const extractedEntries = await fs.readdir(extractRoot, {
880
+ withFileTypes: true,
881
+ });
882
+ const extractedDirectory = extractedEntries.find((entry) =>
883
+ entry.isDirectory(),
884
+ );
885
+ const sourceDir = extractedDirectory
886
+ ? path.join(extractRoot, extractedDirectory.name)
887
+ : extractRoot;
888
+
889
+ await fs.rm(destinationDir, { recursive: true, force: true });
890
+ await fs.mkdir(path.dirname(destinationDir), { recursive: true });
891
+ await fs.cp(sourceDir, destinationDir, { recursive: true });
892
+ } finally {
893
+ await fs.rm(extractRoot, { recursive: true, force: true });
894
+ }
895
+ }
896
+
897
+ async function pathExists(targetPath: string): Promise<boolean> {
898
+ try {
899
+ await fs.access(targetPath);
900
+ return true;
901
+ } catch {
902
+ return false;
903
+ }
904
+ }
905
+
906
+ async function replaceSkillDirectoryAtomically(
907
+ skillDir: string,
908
+ populateDir: (stagingDir: string) => Promise<void>,
909
+ ): Promise<void> {
910
+ const parentDir = path.dirname(skillDir);
911
+ const skillName = path.basename(skillDir);
912
+ await fs.mkdir(parentDir, { recursive: true });
913
+
914
+ const stagingDir = await fs.mkdtemp(
915
+ path.join(parentDir, `${skillName}.staging-`),
916
+ );
917
+ const backupDir = path.join(
918
+ parentDir,
919
+ `${skillName}.backup-${Date.now()}-${Math.random().toString(36).slice(2)}`,
920
+ );
921
+
922
+ let movedExisting = false;
923
+ let promotedStaging = false;
924
+ let cleanupError: Error | null = null;
925
+
926
+ try {
927
+ await populateDir(stagingDir);
928
+
929
+ if (await pathExists(skillDir)) {
930
+ await fs.rename(skillDir, backupDir);
931
+ movedExisting = true;
932
+ }
933
+
934
+ await fs.rename(stagingDir, skillDir);
935
+ promotedStaging = true;
936
+ } catch (error) {
937
+ if (movedExisting && !promotedStaging && (await pathExists(backupDir))) {
938
+ await fs.rename(backupDir, skillDir);
939
+ }
940
+ throw error;
941
+ } finally {
942
+ if (!promotedStaging && (await pathExists(stagingDir))) {
943
+ await fs.rm(stagingDir, { recursive: true, force: true });
944
+ }
945
+ if ((promotedStaging || !movedExisting) && (await pathExists(backupDir))) {
946
+ try {
947
+ await fs.rm(backupDir, { recursive: true, force: true });
948
+ } catch (error) {
949
+ if (!cleanupError) {
950
+ cleanupError =
951
+ error instanceof Error ? error : new Error(String(error));
952
+ }
953
+ }
954
+ }
955
+ if (cleanupError) {
956
+ console.warn(
957
+ `[Notis] Failed to clean up temporary skill directory backup for "${skillDir}"`,
958
+ cleanupError,
959
+ );
960
+ }
961
+ }
962
+ }
963
+
964
+ function bundleFilesIncludeSkillMd(
965
+ bundleFiles: Array<{ path: string }>,
966
+ ): boolean {
967
+ return bundleFiles.some((bundleFile) => {
968
+ const basename = path.basename(bundleFile.path).toLowerCase();
969
+ return basename === "skill.md" || basename === "skills.md";
970
+ });
971
+ }
972
+
973
+ export async function createSkillBundleBase64(
974
+ skill: LocalSkill,
975
+ ): Promise<string> {
976
+ const zipPath = await createZipFromDirectory(skill.directoryPath);
977
+ try {
978
+ const zipBytes = await fs.readFile(zipPath);
979
+ return zipBytes.toString("base64");
980
+ } finally {
981
+ await fs.rm(zipPath, { force: true });
982
+ }
983
+ }
984
+
985
+ export async function writeCloudSkillToDisk(
986
+ skill: CloudSkill,
987
+ bundleBytes?: Buffer,
988
+ paths: SkillSyncPaths = DEFAULT_SYNC_PATHS,
989
+ ): Promise<boolean> {
990
+ const skillDir = path.join(
991
+ paths.skillsDir,
992
+ safeName(skill.name, paths.skillsDir),
993
+ );
994
+
995
+ if (bundleBytes?.length) {
996
+ const bundlePath = path.join(
997
+ os.tmpdir(),
998
+ `notis-skill-download-${Date.now()}-${Math.random().toString(36).slice(2)}.zip`,
999
+ );
1000
+ try {
1001
+ await fs.writeFile(bundlePath, bundleBytes);
1002
+ await extractZipToDirectory(bundlePath, skillDir);
1003
+ return true;
1004
+ } finally {
1005
+ await fs.rm(bundlePath, { force: true });
1006
+ }
1007
+ }
1008
+
1009
+ if (
1010
+ !skill.skill_md &&
1011
+ (!skill.bundle_files || skill.bundle_files.length === 0)
1012
+ ) {
1013
+ return false;
1014
+ }
1015
+
1016
+ if (skill.bundle_files && skill.bundle_files.length > 0) {
1017
+ if (!bundleFilesIncludeSkillMd(skill.bundle_files)) {
1018
+ throw new Error(
1019
+ `Synced bundle for "${skill.name}" is missing SKILL.md or SKILLS.md`,
1020
+ );
1021
+ }
1022
+ await replaceSkillDirectoryAtomically(skillDir, async (stagingDir) => {
1023
+ for (const bundleFile of skill.bundle_files || []) {
1024
+ const filePath = resolveSkillBundleFilePath(
1025
+ stagingDir,
1026
+ bundleFile.path,
1027
+ );
1028
+ await fs.mkdir(path.dirname(filePath), { recursive: true });
1029
+ await fs.writeFile(
1030
+ filePath,
1031
+ Buffer.from(bundleFile.content_b64, "base64"),
1032
+ );
1033
+ }
1034
+ });
1035
+ return true;
1036
+ }
1037
+
1038
+ await replaceSkillDirectoryAtomically(skillDir, async (stagingDir) => {
1039
+ await fs.writeFile(
1040
+ path.join(stagingDir, "SKILL.md"),
1041
+ skill.skill_md ?? "",
1042
+ "utf8",
1043
+ );
1044
+ });
1045
+ return true;
1046
+ }