@notis_ai/cli 0.2.0-beta.19.1 → 0.2.0-beta.191.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +421 -133
- package/bin/check-runtime.js +15 -0
- package/bin/notis.js +2 -0
- package/config/notis_app_boundary_rules.json +50 -0
- package/config/notis_app_design_rules.json +135 -0
- package/dist/agent-hooks/notis-agent-hook.mjs +19299 -0
- package/dist/base-skills/notis-apps/SKILL.md +72 -0
- package/dist/base-skills/notis-apps/references/architecture.md +167 -0
- package/dist/base-skills/notis-apps/references/context.md +81 -0
- package/dist/base-skills/notis-apps/references/design.md +178 -0
- package/dist/base-skills/notis-apps/references/reading.md +89 -0
- package/dist/base-skills/notis-apps/references/release.md +145 -0
- package/dist/base-skills/notis-apps/references/sdk.md +63 -0
- package/dist/base-skills/notis-apps/references/troubleshooting.md +23 -0
- package/dist/base-skills/notis-cli/SKILL.md +154 -0
- package/dist/base-skills/notis-cli/references/app-delivery.md +18 -0
- package/dist/base-skills/notis-cli/references/intelligence.md +94 -0
- package/dist/base-skills/notis-cli/references/native-databases.md +21 -0
- package/dist/base-skills/notis-cli/references/tool-examples.md +56 -0
- package/dist/base-skills/notis-cli/references/troubleshooting.md +39 -0
- package/dist/base-skills/notis-query/SKILL.md +67 -0
- package/dist/base-skills/notis-query/references/database-discovery.md +70 -0
- package/dist/base-skills/notis-query/references/documents.md +50 -0
- package/dist/base-skills/notis-query/references/query.md +543 -0
- package/{template/packages/notis-sdk → dist/sdk}/package.json +14 -4
- package/dist/sdk/src/agentContext.ts +36 -0
- package/dist/sdk/src/components/Dialog.tsx +46 -0
- package/dist/sdk/src/components/DocumentEditor.tsx +103 -0
- package/dist/sdk/src/components/Markdown.tsx +60 -0
- package/dist/sdk/src/components/MarkdownEditor.tsx +121 -0
- package/dist/sdk/src/components/MultiSelectActionBar.tsx +285 -0
- package/dist/sdk/src/components/MultiSelectCheckbox.tsx +97 -0
- package/dist/sdk/src/components/MultiSelectDragOverlay.tsx +39 -0
- package/dist/sdk/src/components/NotisCommentBoundary.tsx +172 -0
- package/dist/sdk/src/components/NotisSelectionBoundary.tsx +59 -0
- package/dist/sdk/src/components/ShortcutHints.tsx +56 -0
- package/dist/sdk/src/components/Skeleton.tsx +24 -0
- package/dist/sdk/src/config.ts +261 -0
- package/dist/sdk/src/documents.ts +256 -0
- package/dist/sdk/src/hooks/useActiveResource.ts +19 -0
- package/dist/sdk/src/hooks/useAgentContext.ts +23 -0
- package/dist/sdk/src/hooks/useCloudComputer.ts +64 -0
- package/dist/sdk/src/hooks/useCollectionInteractions.ts +838 -0
- package/dist/sdk/src/hooks/useDatabaseSchema.ts +49 -0
- package/dist/sdk/src/hooks/useDatabaseSubscription.ts +76 -0
- package/dist/sdk/src/hooks/useDocument.ts +43 -0
- package/dist/sdk/src/hooks/useDocuments.ts +84 -0
- package/dist/sdk/src/hooks/useHandover.ts +77 -0
- package/dist/sdk/src/hooks/useLongPressSelection.ts +79 -0
- package/dist/sdk/src/hooks/useMultiSelect.ts +95 -0
- package/{template/packages/notis-sdk → dist/sdk}/src/hooks/useNotis.ts +12 -4
- package/{template/packages/notis-sdk → dist/sdk}/src/hooks/useNotisNavigation.ts +11 -8
- package/dist/sdk/src/hooks/useQuery.ts +71 -0
- package/dist/sdk/src/hooks/useTool.ts +65 -0
- package/dist/sdk/src/hooks/useToolQuery.ts +12 -0
- package/dist/sdk/src/hooks/useTopBarSearch.ts +81 -0
- package/dist/sdk/src/hooks/useUpsertDocument.ts +95 -0
- package/dist/sdk/src/index.ts +164 -0
- package/dist/sdk/src/interactions/actions.ts +59 -0
- package/dist/sdk/src/interactions/shortcuts.tsx +726 -0
- package/dist/sdk/src/interactions/visibility.ts +13 -0
- package/dist/sdk/src/interactions.ts +45 -0
- package/dist/sdk/src/provider.tsx +44 -0
- package/dist/sdk/src/queryCache.ts +170 -0
- package/dist/sdk/src/runtime.ts +465 -0
- package/dist/sdk/src/styles.css +266 -0
- package/dist/sdk/src/tailwind.ts +66 -0
- package/dist/sdk/src/vite.ts +73 -0
- package/dist/skill-sync/index.js +1753 -0
- package/dist/skill-sync/index.js.map +7 -0
- package/dist/skill-sync-worker.mjs +3113 -0
- package/package.json +18 -7
- package/skills/notis-apps/cli.md +299 -0
- package/skills/notis-cli/AGENT_INSTRUCTIONS.md +39 -0
- package/skills/notis-onboarding/BRIEF.md +129 -0
- package/skills/notis-query/cli.md +39 -0
- package/src/agent-hook-entry.js +5 -0
- package/src/cli.js +294 -25
- package/src/command-specs/agents.js +392 -0
- package/src/command-specs/apps.js +1483 -203
- package/src/command-specs/auth.js +114 -137
- package/src/command-specs/diagnostics.js +730 -0
- package/src/command-specs/handover.js +374 -0
- package/src/command-specs/helpers.js +85 -82
- package/src/command-specs/index.js +25 -6
- package/src/command-specs/meta.js +150 -18
- package/src/command-specs/onboarding.js +290 -0
- package/src/command-specs/profile.js +358 -0
- package/src/command-specs/reports.js +97 -0
- package/src/command-specs/skills.js +75 -0
- package/src/command-specs/smoke.js +386 -0
- package/src/command-specs/tools.js +455 -139
- package/src/runtime/agent-browser.js +677 -0
- package/src/runtime/agent-memory-state.js +126 -0
- package/src/runtime/agent-setup.js +383 -0
- package/src/runtime/app-boundary-validator.js +404 -0
- package/src/runtime/app-changelog.js +79 -0
- package/src/runtime/app-platform.js +2652 -205
- package/src/runtime/app-registry-scaffolds.js +367 -0
- package/src/runtime/app-test-server.js +293 -0
- package/src/runtime/assets/store-screenshot-dark.png +0 -0
- package/src/runtime/auth-recovery.js +110 -0
- package/src/runtime/base-skills.d.ts +20 -0
- package/src/runtime/base-skills.js +167 -0
- package/src/runtime/channel.js +133 -0
- package/src/runtime/delegated-context.js +68 -0
- package/src/runtime/errors.js +1 -0
- package/src/runtime/git.js +233 -0
- package/src/runtime/login-listener.js +15 -0
- package/src/runtime/oauth.js +2622 -0
- package/src/runtime/output.js +37 -5
- package/src/runtime/ports.js +31 -0
- package/src/runtime/profiles.js +906 -55
- package/src/runtime/screenshot-framing.js +85 -0
- package/src/runtime/skill-sync/cloud-client.ts +99 -0
- package/src/runtime/skill-sync/index.ts +786 -0
- package/src/runtime/skill-sync/local-scanner.ts +1151 -0
- package/src/runtime/skill-sync/symlink-manager.ts +433 -0
- package/src/runtime/skill-sync/sync-plan.ts +47 -0
- package/src/runtime/skill-sync/types.ts +131 -0
- package/src/runtime/skill-sync/write-cloud-skill.ts +50 -0
- package/src/runtime/skill-sync-service.js +109 -0
- package/src/runtime/store-screenshot.js +146 -0
- package/src/runtime/sync-skills.d.ts +37 -0
- package/src/runtime/sync-skills.js +231 -0
- package/src/runtime/telemetry.js +92 -0
- package/src/runtime/transport.js +324 -45
- package/src/skill-sync-worker-entry.js +2 -0
- package/src/skill-sync-worker.js +50 -0
- package/template/.harness/index.html.tmpl +435 -0
- package/template/CHANGELOG.md +5 -0
- package/template/app/globals.css +28 -3
- package/template/app/layout.tsx +6 -3
- package/template/app/page.tsx +49 -42
- package/template/components/page-heading.tsx +23 -0
- package/template/components/ui/badge.tsx +7 -4
- package/template/components/ui/button.tsx +1 -1
- package/template/components/ui/card.tsx +24 -11
- package/template/components/ui/native-select.tsx +24 -0
- package/template/notis.config.ts +24 -6
- package/template/package-lock.json +3642 -0
- package/template/package.json +19 -16
- package/template/postcss.config.mjs +1 -1
- package/template/tailwind.config.ts +1 -6
- package/template/tsconfig.json +1 -0
- package/src/command-specs/db.js +0 -163
- package/src/runtime/app-preview-server.js +0 -312
- package/template/packages/notis-sdk/src/config.ts +0 -48
- package/template/packages/notis-sdk/src/helpers.ts +0 -131
- package/template/packages/notis-sdk/src/hooks/useAppState.ts +0 -50
- package/template/packages/notis-sdk/src/hooks/useCollectionItem.ts +0 -58
- package/template/packages/notis-sdk/src/hooks/useDatabase.ts +0 -87
- package/template/packages/notis-sdk/src/hooks/useDocument.ts +0 -61
- package/template/packages/notis-sdk/src/hooks/useTool.ts +0 -49
- package/template/packages/notis-sdk/src/hooks/useUpsertDocument.ts +0 -57
- package/template/packages/notis-sdk/src/index.ts +0 -47
- package/template/packages/notis-sdk/src/provider.tsx +0 -44
- package/template/packages/notis-sdk/src/runtime.ts +0 -159
- package/template/packages/notis-sdk/src/styles.css +0 -123
- package/template/packages/notis-sdk/src/vite.ts +0 -54
- package/template/packages/notis-sdk/tsconfig.json +0 -15
- /package/{template/packages/notis-sdk → dist/sdk}/src/hooks/useBackend.ts +0 -0
- /package/{template/packages/notis-sdk → dist/sdk}/src/hooks/useTools.ts +0 -0
- /package/{template/packages/notis-sdk → dist/sdk}/src/ui.ts +0 -0
|
@@ -0,0 +1,1151 @@
|
|
|
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, SkillWriteOutcome } 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
|
+
// Interpreter caches a script run leaves beside itself are not skill content: they
|
|
167
|
+
// must neither mark a skill as changed nor ship in its bundle (app bundling applies
|
|
168
|
+
// the same rule).
|
|
169
|
+
const IGNORED_SKILL_DIRECTORIES = new Set(["__pycache__", ".pytest_cache"]);
|
|
170
|
+
|
|
171
|
+
function isIgnoredSkillEntry(entry: Dirent): boolean {
|
|
172
|
+
if (entry.name === ".DS_Store") return true;
|
|
173
|
+
if (entry.isDirectory()) return IGNORED_SKILL_DIRECTORIES.has(entry.name);
|
|
174
|
+
return /\.py[co]$/i.test(entry.name);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
async function listFilesRecursive(dirPath: string): Promise<string[]> {
|
|
178
|
+
const entries = await fs.readdir(dirPath, { withFileTypes: true });
|
|
179
|
+
const nested = await Promise.all(
|
|
180
|
+
entries.map(async (entry) => {
|
|
181
|
+
if (isIgnoredSkillEntry(entry)) {
|
|
182
|
+
return [];
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const fullPath = path.join(dirPath, entry.name);
|
|
186
|
+
if (entry.isDirectory()) {
|
|
187
|
+
return listFilesRecursive(fullPath);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if (entry.isFile()) {
|
|
191
|
+
return [fullPath];
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
return [];
|
|
195
|
+
}),
|
|
196
|
+
);
|
|
197
|
+
|
|
198
|
+
return nested.flat().sort();
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
async function computeFolderHash(dirPath: string): Promise<string> {
|
|
202
|
+
const hash = createHash("sha256");
|
|
203
|
+
const filePaths = await listFilesRecursive(dirPath);
|
|
204
|
+
|
|
205
|
+
for (const filePath of filePaths) {
|
|
206
|
+
const relativePath = path.relative(dirPath, filePath);
|
|
207
|
+
hash.update(relativePath);
|
|
208
|
+
hash.update("\0");
|
|
209
|
+
hash.update(await fs.readFile(filePath));
|
|
210
|
+
hash.update("\0");
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
return hash.digest("hex");
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function resolveSkillBundleFilePath(
|
|
217
|
+
skillDir: string,
|
|
218
|
+
relativePath: string,
|
|
219
|
+
): string {
|
|
220
|
+
const normalizedPath = assertRelativeBundlePath(relativePath);
|
|
221
|
+
const resolvedSkillDir = path.resolve(skillDir);
|
|
222
|
+
const candidatePath = path.resolve(resolvedSkillDir, normalizedPath);
|
|
223
|
+
if (
|
|
224
|
+
candidatePath === resolvedSkillDir ||
|
|
225
|
+
!isResolvedChildPath(resolvedSkillDir, candidatePath)
|
|
226
|
+
) {
|
|
227
|
+
throw new Error(
|
|
228
|
+
`Bundle file resolves outside the expected directory: ${relativePath}`,
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
return candidatePath;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
async function readSkillMdIfValid(skillDir: string): Promise<boolean> {
|
|
235
|
+
try {
|
|
236
|
+
await fs.readFile(path.join(skillDir, "SKILL.md"), "utf8");
|
|
237
|
+
return true;
|
|
238
|
+
} catch {
|
|
239
|
+
return false;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
async function moveDirectory(
|
|
244
|
+
sourceDir: string,
|
|
245
|
+
destinationDir: string,
|
|
246
|
+
): Promise<void> {
|
|
247
|
+
await fs.mkdir(path.dirname(destinationDir), { recursive: true });
|
|
248
|
+
try {
|
|
249
|
+
await fs.rename(sourceDir, destinationDir);
|
|
250
|
+
} catch (error) {
|
|
251
|
+
if ((error as NodeJS.ErrnoException).code !== "EXDEV") {
|
|
252
|
+
throw error;
|
|
253
|
+
}
|
|
254
|
+
await fs.cp(sourceDir, destinationDir, {
|
|
255
|
+
recursive: true,
|
|
256
|
+
errorOnExist: true,
|
|
257
|
+
force: false,
|
|
258
|
+
});
|
|
259
|
+
await fs.rm(sourceDir, { recursive: true, force: true });
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
async function createDirectorySymlink(
|
|
264
|
+
targetDir: string,
|
|
265
|
+
linkPath: string,
|
|
266
|
+
): Promise<void> {
|
|
267
|
+
await fs.mkdir(path.dirname(linkPath), { recursive: true });
|
|
268
|
+
await fs.symlink(path.relative(path.dirname(linkPath), targetDir), linkPath, "dir");
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function backupRootFor(paths: SkillSyncPaths, timestamp: string): string {
|
|
272
|
+
return path.join(paths.syncRoot, "skill-dedupe-backups", timestamp);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function relativeBackupPath(label: string, skillName: string): string {
|
|
276
|
+
return path.join(
|
|
277
|
+
label.replace(/[^A-Za-z0-9_.-]+/g, "_"),
|
|
278
|
+
safeName(skillName),
|
|
279
|
+
);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function isExcludedTopLevelSkillEntry(entryName: string): boolean {
|
|
283
|
+
return (
|
|
284
|
+
entryName.startsWith(".") ||
|
|
285
|
+
isTransientSkillDirectoryName(entryName) ||
|
|
286
|
+
EXCLUDED_TOP_LEVEL_ROOT_NAMES.has(entryName.toLowerCase())
|
|
287
|
+
);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function isTransientSkillDirectoryName(entryName: string): boolean {
|
|
291
|
+
return /\.(?:backup|staging)-/.test(entryName);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
interface TopLevelSkillSource {
|
|
295
|
+
label: string;
|
|
296
|
+
root: string;
|
|
297
|
+
priority: number;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
interface TopLevelSkillCandidate {
|
|
301
|
+
name: string;
|
|
302
|
+
root: string;
|
|
303
|
+
label: string;
|
|
304
|
+
path: string;
|
|
305
|
+
resolvedPath: string;
|
|
306
|
+
priority: number;
|
|
307
|
+
isScoped: boolean;
|
|
308
|
+
isSymlink: boolean;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
export interface GatherTopLevelLocalSkillsOptions {
|
|
312
|
+
sourceRoots?: Array<{ label: string; root: string }>;
|
|
313
|
+
protectedSkillNames?: ReadonlySet<string>;
|
|
314
|
+
timestamp?: string;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
export interface GatherTopLevelLocalSkillsResult {
|
|
318
|
+
gathered: number;
|
|
319
|
+
backedUp: number;
|
|
320
|
+
skipped: number;
|
|
321
|
+
metadataPath: string;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function defaultTopLevelSkillSources(
|
|
325
|
+
paths: SkillSyncPaths,
|
|
326
|
+
): TopLevelSkillSource[] {
|
|
327
|
+
const sources: TopLevelSkillSource[] = [];
|
|
328
|
+
if (path.resolve(paths.legacyScopedSkillsDir) !== path.resolve(paths.skillsDir)) {
|
|
329
|
+
sources.push({
|
|
330
|
+
label: "notis-legacy-scoped",
|
|
331
|
+
root: paths.legacyScopedSkillsDir,
|
|
332
|
+
priority: 1,
|
|
333
|
+
});
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
sources.push(
|
|
337
|
+
{ label: "agents", root: paths.legacySkillsDir, priority: 2 },
|
|
338
|
+
{
|
|
339
|
+
label: "codex",
|
|
340
|
+
root: path.join(HOME_DIR, ".codex", "skills"),
|
|
341
|
+
priority: 3,
|
|
342
|
+
},
|
|
343
|
+
{
|
|
344
|
+
label: "cursor",
|
|
345
|
+
root: path.join(HOME_DIR, ".cursor", "skills"),
|
|
346
|
+
priority: 4,
|
|
347
|
+
},
|
|
348
|
+
{
|
|
349
|
+
label: "claude",
|
|
350
|
+
root: path.join(HOME_DIR, ".claude", "skills"),
|
|
351
|
+
priority: 5,
|
|
352
|
+
},
|
|
353
|
+
);
|
|
354
|
+
|
|
355
|
+
return sources;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
function isManagedTopLevelSymlinkTarget(
|
|
359
|
+
resolvedPath: string,
|
|
360
|
+
paths: SkillSyncPaths,
|
|
361
|
+
): boolean {
|
|
362
|
+
const managedRoots = [
|
|
363
|
+
path.resolve(paths.skillsDir),
|
|
364
|
+
path.resolve(paths.legacySkillsDir),
|
|
365
|
+
path.resolve(paths.legacyScopedSkillsDir),
|
|
366
|
+
path.join(path.resolve(paths.syncRoot), "base"),
|
|
367
|
+
path.join(path.resolve(paths.syncRoot), "users"),
|
|
368
|
+
path.join(path.resolve(paths.agentsDir), "notis", "users"),
|
|
369
|
+
];
|
|
370
|
+
return managedRoots.some(
|
|
371
|
+
(root) =>
|
|
372
|
+
resolvedPath === root || resolvedPath.startsWith(`${root}${path.sep}`),
|
|
373
|
+
);
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
async function listTopLevelSkillCandidates(
|
|
377
|
+
paths: SkillSyncPaths,
|
|
378
|
+
options: GatherTopLevelLocalSkillsOptions,
|
|
379
|
+
): Promise<TopLevelSkillCandidate[]> {
|
|
380
|
+
const sourceRoots = options.sourceRoots
|
|
381
|
+
? options.sourceRoots.map((source, index) => ({
|
|
382
|
+
...source,
|
|
383
|
+
priority: index + 1,
|
|
384
|
+
}))
|
|
385
|
+
: defaultTopLevelSkillSources(paths);
|
|
386
|
+
const candidates: TopLevelSkillCandidate[] = [];
|
|
387
|
+
|
|
388
|
+
try {
|
|
389
|
+
const scopedEntries = await fs.readdir(paths.skillsDir, {
|
|
390
|
+
withFileTypes: true,
|
|
391
|
+
});
|
|
392
|
+
for (const entry of scopedEntries) {
|
|
393
|
+
if (
|
|
394
|
+
(!entry.isDirectory() && !entry.isSymbolicLink()) ||
|
|
395
|
+
entry.name.startsWith(".") ||
|
|
396
|
+
isTransientSkillDirectoryName(entry.name)
|
|
397
|
+
) {
|
|
398
|
+
continue;
|
|
399
|
+
}
|
|
400
|
+
const skillDir = path.join(paths.skillsDir, entry.name);
|
|
401
|
+
let resolvedPath = path.resolve(skillDir);
|
|
402
|
+
if (entry.isSymbolicLink()) {
|
|
403
|
+
try {
|
|
404
|
+
resolvedPath = path.resolve(path.dirname(skillDir), await fs.readlink(skillDir));
|
|
405
|
+
} catch {
|
|
406
|
+
continue;
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
if (await readSkillMdIfValid(skillDir)) {
|
|
410
|
+
candidates.push({
|
|
411
|
+
name: safeName(entry.name, paths.skillsDir),
|
|
412
|
+
root: paths.skillsDir,
|
|
413
|
+
label: "notis-managed",
|
|
414
|
+
path: skillDir,
|
|
415
|
+
resolvedPath,
|
|
416
|
+
priority: 0,
|
|
417
|
+
isScoped: true,
|
|
418
|
+
isSymlink: entry.isSymbolicLink(),
|
|
419
|
+
});
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
} catch {
|
|
423
|
+
// Missing scoped mirror is expected before the first sync.
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
for (const source of sourceRoots) {
|
|
427
|
+
let entries: Dirent[];
|
|
428
|
+
try {
|
|
429
|
+
entries = await fs.readdir(source.root, { withFileTypes: true });
|
|
430
|
+
} catch {
|
|
431
|
+
continue;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
for (const entry of entries) {
|
|
435
|
+
if (
|
|
436
|
+
isExcludedTopLevelSkillEntry(entry.name) ||
|
|
437
|
+
(!entry.isDirectory() && !entry.isSymbolicLink())
|
|
438
|
+
) {
|
|
439
|
+
continue;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
const candidatePath = path.join(source.root, entry.name);
|
|
443
|
+
let resolvedPath: string;
|
|
444
|
+
try {
|
|
445
|
+
resolvedPath = entry.isSymbolicLink()
|
|
446
|
+
? path.resolve(
|
|
447
|
+
path.dirname(candidatePath),
|
|
448
|
+
await fs.readlink(candidatePath),
|
|
449
|
+
)
|
|
450
|
+
: path.resolve(candidatePath);
|
|
451
|
+
} catch {
|
|
452
|
+
continue;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
if (
|
|
456
|
+
entry.isSymbolicLink() &&
|
|
457
|
+
isManagedTopLevelSymlinkTarget(resolvedPath, paths)
|
|
458
|
+
) {
|
|
459
|
+
continue;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
if (!(await readSkillMdIfValid(resolvedPath))) {
|
|
463
|
+
continue;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
candidates.push({
|
|
467
|
+
name: safeName(entry.name, paths.skillsDir),
|
|
468
|
+
root: source.root,
|
|
469
|
+
label: source.label,
|
|
470
|
+
path: candidatePath,
|
|
471
|
+
resolvedPath,
|
|
472
|
+
priority: source.priority,
|
|
473
|
+
isScoped: false,
|
|
474
|
+
isSymlink: entry.isSymbolicLink(),
|
|
475
|
+
});
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
return candidates.sort(
|
|
480
|
+
(a, b) => a.priority - b.priority || a.name.localeCompare(b.name),
|
|
481
|
+
);
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
export async function gatherTopLevelLocalSkills(
|
|
485
|
+
paths: SkillSyncPaths,
|
|
486
|
+
options: GatherTopLevelLocalSkillsOptions = {},
|
|
487
|
+
): Promise<GatherTopLevelLocalSkillsResult> {
|
|
488
|
+
await ensureCanonicalSkillsDir(paths);
|
|
489
|
+
|
|
490
|
+
const protectedSkillNames = options.protectedSkillNames || new Set<string>();
|
|
491
|
+
const timestamp =
|
|
492
|
+
options.timestamp || new Date().toISOString().replace(/[:.]/g, "-");
|
|
493
|
+
const backupRoot = backupRootFor(paths, timestamp);
|
|
494
|
+
const candidates = await listTopLevelSkillCandidates(paths, options);
|
|
495
|
+
const byName = new Map<string, TopLevelSkillCandidate[]>();
|
|
496
|
+
|
|
497
|
+
for (const candidate of candidates) {
|
|
498
|
+
const existing = byName.get(candidate.name) || [];
|
|
499
|
+
existing.push(candidate);
|
|
500
|
+
byName.set(candidate.name, existing);
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
let gathered = 0;
|
|
504
|
+
let backedUp = 0;
|
|
505
|
+
let skipped = 0;
|
|
506
|
+
const metadata: {
|
|
507
|
+
version: 1;
|
|
508
|
+
gatheredAt: string;
|
|
509
|
+
skills: Record<
|
|
510
|
+
string,
|
|
511
|
+
{
|
|
512
|
+
canonicalPath: string | null;
|
|
513
|
+
skippedSources: string[];
|
|
514
|
+
protectedFromLocalGather?: boolean;
|
|
515
|
+
}
|
|
516
|
+
>;
|
|
517
|
+
} = {
|
|
518
|
+
version: 1,
|
|
519
|
+
gatheredAt: new Date().toISOString(),
|
|
520
|
+
skills: {},
|
|
521
|
+
};
|
|
522
|
+
|
|
523
|
+
for (const [skillName, skillCandidates] of byName) {
|
|
524
|
+
const protectedFromLocalGather = protectedSkillNames.has(skillName);
|
|
525
|
+
const canonical = protectedFromLocalGather
|
|
526
|
+
? skillCandidates.find((candidate) => candidate.isScoped) || null
|
|
527
|
+
: skillCandidates[0];
|
|
528
|
+
const destinationName = safeName(skillName, paths.skillsDir);
|
|
529
|
+
const destinationDir = path.join(paths.skillsDir, destinationName);
|
|
530
|
+
const skippedSources: string[] = [];
|
|
531
|
+
|
|
532
|
+
if (!canonical) {
|
|
533
|
+
for (const candidate of skillCandidates) {
|
|
534
|
+
if (candidate.isSymlink) {
|
|
535
|
+
skipped += 1;
|
|
536
|
+
skippedSources.push(candidate.path);
|
|
537
|
+
continue;
|
|
538
|
+
}
|
|
539
|
+
const backupDir = path.join(
|
|
540
|
+
backupRoot,
|
|
541
|
+
relativeBackupPath(candidate.label, skillName),
|
|
542
|
+
);
|
|
543
|
+
try {
|
|
544
|
+
await moveDirectory(candidate.path, backupDir);
|
|
545
|
+
backedUp += 1;
|
|
546
|
+
skippedSources.push(candidate.path);
|
|
547
|
+
} catch {
|
|
548
|
+
skipped += 1;
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
metadata.skills[skillName] = {
|
|
552
|
+
canonicalPath: null,
|
|
553
|
+
skippedSources,
|
|
554
|
+
protectedFromLocalGather: true,
|
|
555
|
+
};
|
|
556
|
+
continue;
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
if (!canonical.isScoped && !(await pathExists(destinationDir))) {
|
|
560
|
+
try {
|
|
561
|
+
if (canonical.isSymlink) {
|
|
562
|
+
await createDirectorySymlink(canonical.resolvedPath, destinationDir);
|
|
563
|
+
} else {
|
|
564
|
+
await moveDirectory(canonical.path, destinationDir);
|
|
565
|
+
}
|
|
566
|
+
gathered += 1;
|
|
567
|
+
} catch {
|
|
568
|
+
skipped += 1;
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
for (const candidate of skillCandidates) {
|
|
573
|
+
if (candidate === canonical || candidate.isScoped) {
|
|
574
|
+
continue;
|
|
575
|
+
}
|
|
576
|
+
if (candidate.isSymlink) {
|
|
577
|
+
skippedSources.push(candidate.path);
|
|
578
|
+
continue;
|
|
579
|
+
}
|
|
580
|
+
const backupDir = path.join(
|
|
581
|
+
backupRoot,
|
|
582
|
+
relativeBackupPath(candidate.label, skillName),
|
|
583
|
+
);
|
|
584
|
+
try {
|
|
585
|
+
await moveDirectory(candidate.path, backupDir);
|
|
586
|
+
backedUp += 1;
|
|
587
|
+
skippedSources.push(candidate.path);
|
|
588
|
+
} catch {
|
|
589
|
+
skipped += 1;
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
metadata.skills[skillName] = {
|
|
594
|
+
canonicalPath: (await pathExists(destinationDir))
|
|
595
|
+
? destinationDir
|
|
596
|
+
: canonical.path,
|
|
597
|
+
skippedSources,
|
|
598
|
+
...(protectedFromLocalGather ? { protectedFromLocalGather: true } : {}),
|
|
599
|
+
};
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
await fs.mkdir(path.dirname(paths.gatherMetadataPath), { recursive: true });
|
|
603
|
+
await fs.writeFile(
|
|
604
|
+
paths.gatherMetadataPath,
|
|
605
|
+
`${JSON.stringify(metadata, null, 2)}\n`,
|
|
606
|
+
"utf8",
|
|
607
|
+
);
|
|
608
|
+
|
|
609
|
+
return {
|
|
610
|
+
gathered,
|
|
611
|
+
backedUp,
|
|
612
|
+
skipped,
|
|
613
|
+
metadataPath: paths.gatherMetadataPath,
|
|
614
|
+
};
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
async function readSkillLock(
|
|
618
|
+
paths: SkillSyncPaths = DEFAULT_SYNC_PATHS,
|
|
619
|
+
): Promise<Record<string, string>> {
|
|
620
|
+
type SkillLock = {
|
|
621
|
+
skills?: Record<string, { sourceUrl?: string }>;
|
|
622
|
+
};
|
|
623
|
+
|
|
624
|
+
const lockData = await readJsonFile<SkillLock>(paths.skillLockPath);
|
|
625
|
+
const sourceUrls: Record<string, string> = {};
|
|
626
|
+
|
|
627
|
+
for (const [skillName, entry] of Object.entries(lockData?.skills || {})) {
|
|
628
|
+
if (typeof entry?.sourceUrl === "string" && entry.sourceUrl.trim()) {
|
|
629
|
+
sourceUrls[skillName] = entry.sourceUrl.trim();
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
return sourceUrls;
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
export async function ensureCanonicalSkillsDir(
|
|
637
|
+
paths: SkillSyncPaths = DEFAULT_SYNC_PATHS,
|
|
638
|
+
): Promise<void> {
|
|
639
|
+
await fs.mkdir(paths.skillsDir, { recursive: true });
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
export async function initializeScopedSkillsFromLegacy(
|
|
643
|
+
paths: SkillSyncPaths,
|
|
644
|
+
): Promise<number> {
|
|
645
|
+
const usingLegacyPath =
|
|
646
|
+
path.resolve(paths.skillsDir) === path.resolve(paths.legacySkillsDir);
|
|
647
|
+
if (usingLegacyPath || (await pathExists(paths.syncStatePath))) {
|
|
648
|
+
await ensureCanonicalSkillsDir(paths);
|
|
649
|
+
return 0;
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
await ensureCanonicalSkillsDir(paths);
|
|
653
|
+
let entries: Dirent[];
|
|
654
|
+
try {
|
|
655
|
+
entries = await fs.readdir(paths.legacySkillsDir, { withFileTypes: true });
|
|
656
|
+
} catch {
|
|
657
|
+
return 0;
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
let copied = 0;
|
|
661
|
+
for (const entry of entries) {
|
|
662
|
+
if (!entry.isDirectory()) {
|
|
663
|
+
continue;
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
const legacySkillDir = path.join(paths.legacySkillsDir, entry.name);
|
|
667
|
+
const targetSkillName = safeName(entry.name, paths.skillsDir);
|
|
668
|
+
const scopedSkillDir = path.join(paths.skillsDir, targetSkillName);
|
|
669
|
+
try {
|
|
670
|
+
await fs.readFile(path.join(legacySkillDir, "SKILL.md"), "utf8");
|
|
671
|
+
if (await pathExists(scopedSkillDir)) {
|
|
672
|
+
continue;
|
|
673
|
+
}
|
|
674
|
+
await fs.cp(legacySkillDir, scopedSkillDir, {
|
|
675
|
+
recursive: true,
|
|
676
|
+
errorOnExist: true,
|
|
677
|
+
force: false,
|
|
678
|
+
});
|
|
679
|
+
copied += 1;
|
|
680
|
+
} catch {
|
|
681
|
+
// Ignore invalid legacy skill folders and existing scoped copies.
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
return copied;
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
export async function scanLocalSkills(
|
|
689
|
+
paths: SkillSyncPaths = DEFAULT_SYNC_PATHS,
|
|
690
|
+
): Promise<LocalSkill[]> {
|
|
691
|
+
await ensureCanonicalSkillsDir(paths);
|
|
692
|
+
|
|
693
|
+
const sourceUrls = await readSkillLock(paths);
|
|
694
|
+
const entries = await fs.readdir(paths.skillsDir, { withFileTypes: true });
|
|
695
|
+
const skills: Array<LocalSkill | null> = await Promise.all(
|
|
696
|
+
entries.map(async (entry) => {
|
|
697
|
+
if (
|
|
698
|
+
(!entry.isDirectory() && !entry.isSymbolicLink()) ||
|
|
699
|
+
isTransientSkillDirectoryName(entry.name)
|
|
700
|
+
) {
|
|
701
|
+
return null;
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
const skillDir = path.join(paths.skillsDir, entry.name);
|
|
705
|
+
const skillMdPath = path.join(skillDir, "SKILL.md");
|
|
706
|
+
|
|
707
|
+
try {
|
|
708
|
+
const skillMd = await fs.readFile(skillMdPath, "utf8");
|
|
709
|
+
const { description } = parseFrontMatter(skillMd);
|
|
710
|
+
const folderHash = await computeFolderHash(skillDir);
|
|
711
|
+
const skill: LocalSkill = {
|
|
712
|
+
name: entry.name,
|
|
713
|
+
skillMd,
|
|
714
|
+
description,
|
|
715
|
+
folderHash,
|
|
716
|
+
directoryPath: skillDir,
|
|
717
|
+
};
|
|
718
|
+
if (sourceUrls[entry.name]) {
|
|
719
|
+
skill.sourceUrl = sourceUrls[entry.name];
|
|
720
|
+
}
|
|
721
|
+
return skill;
|
|
722
|
+
} catch {
|
|
723
|
+
return null;
|
|
724
|
+
}
|
|
725
|
+
}),
|
|
726
|
+
);
|
|
727
|
+
|
|
728
|
+
return skills
|
|
729
|
+
.filter((skill): skill is LocalSkill => skill !== null)
|
|
730
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
function normalizeSyncState(
|
|
734
|
+
state: NotisSyncState | null,
|
|
735
|
+
): NotisSyncState | null {
|
|
736
|
+
if (!state || state.version !== 1 || typeof state.skills !== "object") {
|
|
737
|
+
return null;
|
|
738
|
+
}
|
|
739
|
+
const normalizeStoredAgentTargets = (
|
|
740
|
+
targets: Partial<NotisSyncState["skills"][string]["agentTargets"]> | null | undefined,
|
|
741
|
+
): NotisSyncState["skills"][string]["agentTargets"] => ({
|
|
742
|
+
notis: Boolean(targets?.notis ?? true),
|
|
743
|
+
claude_code: Boolean(targets?.claude_code ?? true),
|
|
744
|
+
cursor: Boolean(targets?.cursor ?? true),
|
|
745
|
+
codex: Boolean(targets?.codex ?? true),
|
|
746
|
+
});
|
|
747
|
+
|
|
748
|
+
return {
|
|
749
|
+
version: 1,
|
|
750
|
+
lastSyncedAt:
|
|
751
|
+
typeof state.lastSyncedAt === "string" ? state.lastSyncedAt : null,
|
|
752
|
+
skills: Object.fromEntries(
|
|
753
|
+
Object.entries(state.skills || {}).map(([skillName, skillState]) => [
|
|
754
|
+
skillName,
|
|
755
|
+
{
|
|
756
|
+
...skillState,
|
|
757
|
+
agentTargets: normalizeStoredAgentTargets(skillState.agentTargets),
|
|
758
|
+
},
|
|
759
|
+
]),
|
|
760
|
+
),
|
|
761
|
+
};
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
export async function readSyncState(
|
|
765
|
+
paths: SkillSyncPaths = DEFAULT_SYNC_PATHS,
|
|
766
|
+
): Promise<NotisSyncState> {
|
|
767
|
+
const scopedState = normalizeSyncState(
|
|
768
|
+
await readJsonFile<NotisSyncState>(paths.syncStatePath),
|
|
769
|
+
);
|
|
770
|
+
if (scopedState) {
|
|
771
|
+
return scopedState;
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
return DEFAULT_SYNC_STATE;
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
export async function readLegacySyncState(
|
|
778
|
+
paths: SkillSyncPaths = DEFAULT_SYNC_PATHS,
|
|
779
|
+
): Promise<NotisSyncState | null> {
|
|
780
|
+
const legacyStatePaths = [
|
|
781
|
+
paths.legacyScopedSyncStatePath,
|
|
782
|
+
path.resolve(paths.syncStatePath) === path.resolve(LEGACY_NOTIS_SYNC_STATE_PATH)
|
|
783
|
+
? paths.syncStatePath
|
|
784
|
+
: path.join(paths.agentsDir, ".notis-sync.json"),
|
|
785
|
+
];
|
|
786
|
+
|
|
787
|
+
for (const legacyStatePath of legacyStatePaths) {
|
|
788
|
+
const state = normalizeSyncState(
|
|
789
|
+
await readJsonFile<NotisSyncState>(legacyStatePath),
|
|
790
|
+
);
|
|
791
|
+
if (state) {
|
|
792
|
+
return state;
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
return null;
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
export async function writeSyncState(
|
|
800
|
+
state: NotisSyncState,
|
|
801
|
+
paths: SkillSyncPaths = DEFAULT_SYNC_PATHS,
|
|
802
|
+
): Promise<void> {
|
|
803
|
+
await fs.mkdir(path.dirname(paths.syncStatePath), { recursive: true });
|
|
804
|
+
await fs.writeFile(
|
|
805
|
+
paths.syncStatePath,
|
|
806
|
+
`${JSON.stringify(state, null, 2)}\n`,
|
|
807
|
+
"utf8",
|
|
808
|
+
);
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
export function safeName(
|
|
812
|
+
name: string,
|
|
813
|
+
baseDir: string = AGENTS_SKILLS_DIR,
|
|
814
|
+
): string {
|
|
815
|
+
const sanitizedName = name
|
|
816
|
+
.trim()
|
|
817
|
+
.replace(/[\\/]+/g, "")
|
|
818
|
+
.replace(/\.\./g, "");
|
|
819
|
+
if (!sanitizedName) {
|
|
820
|
+
throw new Error("Invalid skill name");
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
const resolvedBaseDir = path.resolve(baseDir);
|
|
824
|
+
const resolvedPath = path.resolve(resolvedBaseDir, sanitizedName);
|
|
825
|
+
if (!isResolvedChildPath(resolvedBaseDir, resolvedPath)) {
|
|
826
|
+
throw new Error("Skill name resolves outside the expected directory");
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
return sanitizedName;
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
export async function deleteLocalSkill(
|
|
833
|
+
skillName: string,
|
|
834
|
+
paths: SkillSyncPaths = DEFAULT_SYNC_PATHS,
|
|
835
|
+
): Promise<boolean> {
|
|
836
|
+
const skillDir = path.join(
|
|
837
|
+
paths.skillsDir,
|
|
838
|
+
safeName(skillName, paths.skillsDir),
|
|
839
|
+
);
|
|
840
|
+
try {
|
|
841
|
+
await fs.rm(skillDir, { recursive: true, force: true });
|
|
842
|
+
return true;
|
|
843
|
+
} catch {
|
|
844
|
+
return false;
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
async function createZipFromDirectory(directoryPath: string): Promise<string> {
|
|
849
|
+
const zipPath = path.join(
|
|
850
|
+
os.tmpdir(),
|
|
851
|
+
`notis-skill-${Date.now()}-${Math.random().toString(36).slice(2)}.zip`,
|
|
852
|
+
);
|
|
853
|
+
const directoryName = path.basename(directoryPath);
|
|
854
|
+
// Zip a copy holding exactly the files the folder hash covers, so ignored
|
|
855
|
+
// caches never ship.
|
|
856
|
+
const stagingRoot = await fs.mkdtemp(path.join(os.tmpdir(), "notis-skill-bundle-"));
|
|
857
|
+
try {
|
|
858
|
+
const stagedDir = path.join(stagingRoot, directoryName);
|
|
859
|
+
for (const filePath of await listFilesRecursive(directoryPath)) {
|
|
860
|
+
const target = path.join(stagedDir, path.relative(directoryPath, filePath));
|
|
861
|
+
await fs.mkdir(path.dirname(target), { recursive: true });
|
|
862
|
+
await fs.copyFile(filePath, target);
|
|
863
|
+
}
|
|
864
|
+
await fs.mkdir(stagedDir, { recursive: true });
|
|
865
|
+
|
|
866
|
+
if (process.platform === "win32") {
|
|
867
|
+
// Windows PowerShell's Compress-Archive writes entry names with
|
|
868
|
+
// backslashes, which unzip on the cloud computer as literal file names
|
|
869
|
+
// ("cached\\SKILL.md"). bsdtar, shipped in System32 since Windows 10
|
|
870
|
+
// 1803, writes the forward slashes the zip format requires. Use the
|
|
871
|
+
// system copy explicitly: on a Git Bash PATH, "tar" resolves to GNU tar,
|
|
872
|
+
// which cannot write zip archives.
|
|
873
|
+
const systemTar = path.join(
|
|
874
|
+
process.env.SystemRoot || "C:\\Windows",
|
|
875
|
+
"System32",
|
|
876
|
+
"tar.exe",
|
|
877
|
+
);
|
|
878
|
+
if (await pathExists(systemTar)) {
|
|
879
|
+
await execFileAsync(systemTar, [
|
|
880
|
+
"-c",
|
|
881
|
+
"--format",
|
|
882
|
+
"zip",
|
|
883
|
+
"-f",
|
|
884
|
+
zipPath,
|
|
885
|
+
"-C",
|
|
886
|
+
stagingRoot,
|
|
887
|
+
directoryName,
|
|
888
|
+
]);
|
|
889
|
+
return zipPath;
|
|
890
|
+
}
|
|
891
|
+
await execFileAsync("powershell.exe", [
|
|
892
|
+
"-NoProfile",
|
|
893
|
+
"-Command",
|
|
894
|
+
`Compress-Archive -Path '${stagedDir.replace(/'/g, "''")}' -DestinationPath '${zipPath.replace(/'/g, "''")}' -Force`,
|
|
895
|
+
]);
|
|
896
|
+
return zipPath;
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
await execFileAsync("zip", ["-qry", zipPath, directoryName], {
|
|
900
|
+
cwd: stagingRoot,
|
|
901
|
+
});
|
|
902
|
+
return zipPath;
|
|
903
|
+
} finally {
|
|
904
|
+
await fs.rm(stagingRoot, { recursive: true, force: true });
|
|
905
|
+
}
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
async function extractZipToDirectory(
|
|
909
|
+
zipPath: string,
|
|
910
|
+
destinationDir: string,
|
|
911
|
+
): Promise<string[]> {
|
|
912
|
+
const extractRoot = await fs.mkdtemp(
|
|
913
|
+
path.join(os.tmpdir(), "notis-skill-extract-"),
|
|
914
|
+
);
|
|
915
|
+
|
|
916
|
+
try {
|
|
917
|
+
if (process.platform === "win32") {
|
|
918
|
+
await execFileAsync("powershell.exe", [
|
|
919
|
+
"-NoProfile",
|
|
920
|
+
"-Command",
|
|
921
|
+
`Expand-Archive -Path '${zipPath.replace(/'/g, "''")}' -DestinationPath '${extractRoot.replace(/'/g, "''")}' -Force`,
|
|
922
|
+
]);
|
|
923
|
+
} else {
|
|
924
|
+
await execFileAsync("unzip", ["-qq", zipPath, "-d", extractRoot]);
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
const extractedEntries = await fs.readdir(extractRoot, {
|
|
928
|
+
withFileTypes: true,
|
|
929
|
+
});
|
|
930
|
+
const extractedDirectory = extractedEntries.find((entry) =>
|
|
931
|
+
entry.isDirectory(),
|
|
932
|
+
);
|
|
933
|
+
const sourceDir = extractedDirectory
|
|
934
|
+
? path.join(extractRoot, extractedDirectory.name)
|
|
935
|
+
: extractRoot;
|
|
936
|
+
|
|
937
|
+
await fs.rm(destinationDir, { recursive: true, force: true });
|
|
938
|
+
await fs.mkdir(path.dirname(destinationDir), { recursive: true });
|
|
939
|
+
await fs.cp(sourceDir, destinationDir, { recursive: true });
|
|
940
|
+
return await listRelativeFiles(destinationDir);
|
|
941
|
+
} finally {
|
|
942
|
+
await fs.rm(extractRoot, { recursive: true, force: true });
|
|
943
|
+
}
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
async function listRelativeFiles(dirPath: string): Promise<string[]> {
|
|
947
|
+
try {
|
|
948
|
+
const files = await listFilesRecursive(dirPath);
|
|
949
|
+
return files.map((filePath) => toPosixRelativePath(dirPath, filePath)).sort();
|
|
950
|
+
} catch (error) {
|
|
951
|
+
if ((error as NodeJS.ErrnoException)?.code === "ENOENT") return [];
|
|
952
|
+
throw error;
|
|
953
|
+
}
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
function toPosixRelativePath(rootDir: string, filePath: string): string {
|
|
957
|
+
return path.relative(rootDir, filePath).split(path.sep).join("/");
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
/**
|
|
961
|
+
* Carry local-only files across a cloud write. A skill that writes while it runs
|
|
962
|
+
* (a run ledger under its own folder) would otherwise lose that work every time the
|
|
963
|
+
* cloud content changed, because the write replaces the whole directory.
|
|
964
|
+
*
|
|
965
|
+
* Selective, not a blanket merge:
|
|
966
|
+
* - a path the incoming content provides always wins;
|
|
967
|
+
* - a path the previous write applied and the cloud has now dropped stays deleted,
|
|
968
|
+
* so upstream removals still propagate;
|
|
969
|
+
* - anything else is local and is kept.
|
|
970
|
+
*
|
|
971
|
+
* With no record of the previous write (state written before this existed) nothing
|
|
972
|
+
* can be attributed to the cloud, so local files are kept rather than destroyed.
|
|
973
|
+
*/
|
|
974
|
+
async function preserveUnmanagedFiles(
|
|
975
|
+
existingDir: string,
|
|
976
|
+
stagingDir: string,
|
|
977
|
+
previouslyApplied?: readonly string[],
|
|
978
|
+
): Promise<void> {
|
|
979
|
+
const managed = previouslyApplied ? new Set(previouslyApplied) : null;
|
|
980
|
+
for (const existingFile of await listFilesRecursive(existingDir)) {
|
|
981
|
+
const relativePath = toPosixRelativePath(existingDir, existingFile);
|
|
982
|
+
if (managed?.has(relativePath)) continue;
|
|
983
|
+
const stagedPath = path.join(stagingDir, ...relativePath.split("/"));
|
|
984
|
+
if (await pathExists(stagedPath)) continue;
|
|
985
|
+
await fs.mkdir(path.dirname(stagedPath), { recursive: true });
|
|
986
|
+
await fs.copyFile(existingFile, stagedPath);
|
|
987
|
+
}
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
async function pathExists(targetPath: string): Promise<boolean> {
|
|
991
|
+
try {
|
|
992
|
+
await fs.access(targetPath);
|
|
993
|
+
return true;
|
|
994
|
+
} catch {
|
|
995
|
+
return false;
|
|
996
|
+
}
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
async function replaceSkillDirectoryAtomically(
|
|
1000
|
+
skillDir: string,
|
|
1001
|
+
populateDir: (stagingDir: string) => Promise<void>,
|
|
1002
|
+
preserve?: { previouslyApplied?: readonly string[] },
|
|
1003
|
+
): Promise<void> {
|
|
1004
|
+
const parentDir = path.dirname(skillDir);
|
|
1005
|
+
const skillName = path.basename(skillDir);
|
|
1006
|
+
await fs.mkdir(parentDir, { recursive: true });
|
|
1007
|
+
|
|
1008
|
+
const stagingDir = await fs.mkdtemp(
|
|
1009
|
+
path.join(parentDir, `${skillName}.staging-`),
|
|
1010
|
+
);
|
|
1011
|
+
const backupDir = path.join(
|
|
1012
|
+
parentDir,
|
|
1013
|
+
`${skillName}.backup-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
|
1014
|
+
);
|
|
1015
|
+
|
|
1016
|
+
let movedExisting = false;
|
|
1017
|
+
let promotedStaging = false;
|
|
1018
|
+
let cleanupError: Error | null = null;
|
|
1019
|
+
|
|
1020
|
+
try {
|
|
1021
|
+
await populateDir(stagingDir);
|
|
1022
|
+
|
|
1023
|
+
if (preserve && (await pathExists(skillDir))) {
|
|
1024
|
+
await preserveUnmanagedFiles(skillDir, stagingDir, preserve.previouslyApplied);
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
if (await pathExists(skillDir)) {
|
|
1028
|
+
await fs.rename(skillDir, backupDir);
|
|
1029
|
+
movedExisting = true;
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
await fs.rename(stagingDir, skillDir);
|
|
1033
|
+
promotedStaging = true;
|
|
1034
|
+
} catch (error) {
|
|
1035
|
+
if (movedExisting && !promotedStaging && (await pathExists(backupDir))) {
|
|
1036
|
+
await fs.rename(backupDir, skillDir);
|
|
1037
|
+
}
|
|
1038
|
+
throw error;
|
|
1039
|
+
} finally {
|
|
1040
|
+
if (!promotedStaging && (await pathExists(stagingDir))) {
|
|
1041
|
+
await fs.rm(stagingDir, { recursive: true, force: true });
|
|
1042
|
+
}
|
|
1043
|
+
if ((promotedStaging || !movedExisting) && (await pathExists(backupDir))) {
|
|
1044
|
+
try {
|
|
1045
|
+
await fs.rm(backupDir, { recursive: true, force: true });
|
|
1046
|
+
} catch (error) {
|
|
1047
|
+
if (!cleanupError) {
|
|
1048
|
+
cleanupError =
|
|
1049
|
+
error instanceof Error ? error : new Error(String(error));
|
|
1050
|
+
}
|
|
1051
|
+
}
|
|
1052
|
+
}
|
|
1053
|
+
if (cleanupError) {
|
|
1054
|
+
console.warn(
|
|
1055
|
+
`[Notis] Failed to clean up temporary skill directory backup for "${skillDir}"`,
|
|
1056
|
+
cleanupError,
|
|
1057
|
+
);
|
|
1058
|
+
}
|
|
1059
|
+
}
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
function bundleFilesIncludeSkillMd(
|
|
1063
|
+
bundleFiles: Array<{ path: string }>,
|
|
1064
|
+
): boolean {
|
|
1065
|
+
return bundleFiles.some((bundleFile) => {
|
|
1066
|
+
const basename = path.basename(bundleFile.path).toLowerCase();
|
|
1067
|
+
return basename === "skill.md" || basename === "skills.md";
|
|
1068
|
+
});
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
export async function createSkillBundleBase64(
|
|
1072
|
+
skill: LocalSkill,
|
|
1073
|
+
): Promise<string> {
|
|
1074
|
+
const zipPath = await createZipFromDirectory(skill.directoryPath);
|
|
1075
|
+
try {
|
|
1076
|
+
const zipBytes = await fs.readFile(zipPath);
|
|
1077
|
+
return zipBytes.toString("base64");
|
|
1078
|
+
} finally {
|
|
1079
|
+
await fs.rm(zipPath, { force: true });
|
|
1080
|
+
}
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
export async function writeCloudSkillToDisk(
|
|
1084
|
+
skill: CloudSkill,
|
|
1085
|
+
bundleBytes?: Buffer,
|
|
1086
|
+
paths: SkillSyncPaths = DEFAULT_SYNC_PATHS,
|
|
1087
|
+
options: { previouslyApplied?: readonly string[] } = {},
|
|
1088
|
+
): Promise<SkillWriteOutcome | false> {
|
|
1089
|
+
const skillDir = path.join(
|
|
1090
|
+
paths.skillsDir,
|
|
1091
|
+
safeName(skill.name, paths.skillsDir),
|
|
1092
|
+
);
|
|
1093
|
+
const preserve = { previouslyApplied: options.previouslyApplied };
|
|
1094
|
+
|
|
1095
|
+
if (bundleBytes?.length) {
|
|
1096
|
+
const bundlePath = path.join(
|
|
1097
|
+
os.tmpdir(),
|
|
1098
|
+
`notis-skill-download-${Date.now()}-${Math.random().toString(36).slice(2)}.zip`,
|
|
1099
|
+
);
|
|
1100
|
+
try {
|
|
1101
|
+
await fs.writeFile(bundlePath, bundleBytes);
|
|
1102
|
+
let appliedFiles: string[] = [];
|
|
1103
|
+
await replaceSkillDirectoryAtomically(skillDir, async (stagingDir) => {
|
|
1104
|
+
appliedFiles = await extractZipToDirectory(bundlePath, stagingDir);
|
|
1105
|
+
}, preserve);
|
|
1106
|
+
return { written: true, appliedFiles };
|
|
1107
|
+
} finally {
|
|
1108
|
+
await fs.rm(bundlePath, { force: true });
|
|
1109
|
+
}
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
if (
|
|
1113
|
+
!skill.skill_md &&
|
|
1114
|
+
(!skill.bundle_files || skill.bundle_files.length === 0)
|
|
1115
|
+
) {
|
|
1116
|
+
return false;
|
|
1117
|
+
}
|
|
1118
|
+
|
|
1119
|
+
if (skill.bundle_files && skill.bundle_files.length > 0) {
|
|
1120
|
+
if (!bundleFilesIncludeSkillMd(skill.bundle_files)) {
|
|
1121
|
+
throw new Error(
|
|
1122
|
+
`Synced bundle for "${skill.name}" is missing SKILL.md or SKILLS.md`,
|
|
1123
|
+
);
|
|
1124
|
+
}
|
|
1125
|
+
const appliedFiles: string[] = [];
|
|
1126
|
+
await replaceSkillDirectoryAtomically(skillDir, async (stagingDir) => {
|
|
1127
|
+
for (const bundleFile of skill.bundle_files || []) {
|
|
1128
|
+
const filePath = resolveSkillBundleFilePath(
|
|
1129
|
+
stagingDir,
|
|
1130
|
+
bundleFile.path,
|
|
1131
|
+
);
|
|
1132
|
+
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
|
1133
|
+
await fs.writeFile(
|
|
1134
|
+
filePath,
|
|
1135
|
+
Buffer.from(bundleFile.content_b64, "base64"),
|
|
1136
|
+
);
|
|
1137
|
+
appliedFiles.push(toPosixRelativePath(stagingDir, filePath));
|
|
1138
|
+
}
|
|
1139
|
+
}, preserve);
|
|
1140
|
+
return { written: true, appliedFiles: appliedFiles.sort() };
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
await replaceSkillDirectoryAtomically(skillDir, async (stagingDir) => {
|
|
1144
|
+
await fs.writeFile(
|
|
1145
|
+
path.join(stagingDir, "SKILL.md"),
|
|
1146
|
+
skill.skill_md ?? "",
|
|
1147
|
+
"utf8",
|
|
1148
|
+
);
|
|
1149
|
+
}, preserve);
|
|
1150
|
+
return { written: true, appliedFiles: ["SKILL.md"] };
|
|
1151
|
+
}
|