@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.
- package/README.md +38 -0
- package/dist/agent-hooks/notis-agent-hook.mjs +16620 -0
- package/{skills → dist/base-skills}/notis-apps/SKILL.md +9 -6
- package/{skills → dist/base-skills}/notis-cli/SKILL.md +1 -1
- package/dist/base-skills/notis-query/SKILL.md +705 -0
- package/dist/scaffolds/notis-database/packages/sdk/src/config.ts +8 -0
- package/dist/scaffolds/notis-journal/packages/sdk/src/config.ts +8 -0
- package/dist/scaffolds/notis-notes/packages/sdk/src/config.ts +8 -0
- package/dist/scaffolds/notis-random/packages/sdk/src/config.ts +8 -0
- package/dist/skill-sync/index.js +1528 -0
- package/dist/skill-sync/index.js.map +7 -0
- package/package.json +4 -1
- package/skills/notis-cli/AGENT_INSTRUCTIONS.md +39 -0
- package/skills/notis-onboarding/BRIEF.md +16 -0
- package/src/agent-hook-entry.js +5 -0
- package/src/cli.js +23 -14
- package/src/command-specs/agents.js +392 -0
- package/src/command-specs/auth.js +16 -0
- package/src/command-specs/index.js +6 -0
- package/src/command-specs/onboarding.js +59 -2
- package/src/command-specs/skills.js +56 -0
- package/src/runtime/agent-memory-state.js +126 -0
- package/src/runtime/agent-setup.js +383 -0
- package/src/runtime/base-skills.d.ts +20 -0
- package/src/runtime/base-skills.js +167 -0
- package/src/runtime/skill-sync/cloud-client.ts +96 -0
- package/src/runtime/skill-sync/index.ts +644 -0
- package/src/runtime/skill-sync/local-scanner.ts +1046 -0
- package/src/runtime/skill-sync/symlink-manager.ts +383 -0
- package/src/runtime/skill-sync/sync-plan.ts +22 -0
- package/src/runtime/skill-sync/types.ts +103 -0
- package/src/runtime/skill-sync/write-cloud-skill.ts +50 -0
- package/src/runtime/store-screenshot.js +6 -1
- package/src/runtime/sync-skills.d.ts +37 -0
- package/src/runtime/sync-skills.js +215 -0
- package/template/packages/sdk/src/config.ts +8 -0
|
@@ -0,0 +1,383 @@
|
|
|
1
|
+
import { promises as fs } from 'fs';
|
|
2
|
+
import os from 'os';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
AGENTS_DIR,
|
|
7
|
+
LEGACY_AGENTS_SKILLS_DIR,
|
|
8
|
+
NOTIS_SKILL_SYNC_ROOT,
|
|
9
|
+
safeName,
|
|
10
|
+
} from './local-scanner';
|
|
11
|
+
import { normalizeAgentTargets, type CloudSkill, type NotisSyncState } from './types';
|
|
12
|
+
|
|
13
|
+
const HOME_DIR = os.homedir();
|
|
14
|
+
|
|
15
|
+
const EXTERNAL_AGENT_SKILL_DIRS = {
|
|
16
|
+
claude_code: path.join(HOME_DIR, '.claude', 'skills'),
|
|
17
|
+
cursor: path.join(HOME_DIR, '.cursor', 'skills'),
|
|
18
|
+
codex: path.join(HOME_DIR, '.codex', 'skills'),
|
|
19
|
+
} as const;
|
|
20
|
+
|
|
21
|
+
type ExternalAgent = keyof typeof EXTERNAL_AGENT_SKILL_DIRS;
|
|
22
|
+
|
|
23
|
+
const EXTERNAL_AGENTS = Object.keys(EXTERNAL_AGENT_SKILL_DIRS) as ExternalAgent[];
|
|
24
|
+
|
|
25
|
+
export interface DeletedAgentSymlink {
|
|
26
|
+
skillId: string;
|
|
27
|
+
skillName: string;
|
|
28
|
+
agent: ExternalAgent;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
type AgentSkillDirs = {
|
|
32
|
+
notis: string;
|
|
33
|
+
} & typeof EXTERNAL_AGENT_SKILL_DIRS;
|
|
34
|
+
|
|
35
|
+
export interface SymlinkSyncOptions {
|
|
36
|
+
agentSkillDirs?: Partial<AgentSkillDirs>;
|
|
37
|
+
legacyGlobalSkillsDir?: string;
|
|
38
|
+
removeUndesired?: boolean;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface RemoveSkillSymlinkOptions extends SymlinkSyncOptions {
|
|
42
|
+
ownership?: 'any-managed' | 'exact-skill-dir';
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface SymlinkSyncResult {
|
|
46
|
+
linked: number;
|
|
47
|
+
removed: number;
|
|
48
|
+
skipped: number;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Remove only links into another account's managed mirror. This is safe even
|
|
52
|
+
* when automatic sync is disabled: it never changes either account mirror and
|
|
53
|
+
* preserves links owned by the currently authenticated account. */
|
|
54
|
+
export async function removeForeignAccountSymlinks(
|
|
55
|
+
skillsDir: string,
|
|
56
|
+
options: SymlinkSyncOptions = {},
|
|
57
|
+
): Promise<number> {
|
|
58
|
+
const agentSkillDirs = { ...defaultAgentSkillDirs(skillsDir), ...options.agentSkillDirs };
|
|
59
|
+
const currentRoot = path.resolve(skillsDir);
|
|
60
|
+
const foreignCapableRoots = [
|
|
61
|
+
path.join(path.resolve(NOTIS_SKILL_SYNC_ROOT), 'users'),
|
|
62
|
+
path.join(path.resolve(AGENTS_DIR), 'notis', 'users'),
|
|
63
|
+
path.dirname(path.dirname(currentRoot)),
|
|
64
|
+
];
|
|
65
|
+
let removed = 0;
|
|
66
|
+
const legacyGlobalSkillsDir = options.legacyGlobalSkillsDir || LEGACY_AGENTS_SKILLS_DIR;
|
|
67
|
+
for (const agentDir of new Set([...Object.values(agentSkillDirs), legacyGlobalSkillsDir])) {
|
|
68
|
+
let entries;
|
|
69
|
+
try {
|
|
70
|
+
entries = await fs.readdir(agentDir, { withFileTypes: true });
|
|
71
|
+
} catch (error) {
|
|
72
|
+
if ((error as NodeJS.ErrnoException)?.code === 'ENOENT') continue;
|
|
73
|
+
throw error;
|
|
74
|
+
}
|
|
75
|
+
for (const entry of entries) {
|
|
76
|
+
if (!entry.isSymbolicLink()) continue;
|
|
77
|
+
const entryPath = path.join(agentDir, entry.name);
|
|
78
|
+
try {
|
|
79
|
+
const target = await fs.readlink(entryPath);
|
|
80
|
+
const resolvedTarget = path.resolve(path.dirname(entryPath), target);
|
|
81
|
+
const belongsToAnyAccount = foreignCapableRoots.some((root) => (
|
|
82
|
+
resolvedTarget.startsWith(`${root}${path.sep}`)
|
|
83
|
+
));
|
|
84
|
+
const belongsToCurrentAccount = resolvedTarget === currentRoot
|
|
85
|
+
|| resolvedTarget.startsWith(`${currentRoot}${path.sep}`);
|
|
86
|
+
if (belongsToAnyAccount && !belongsToCurrentAccount) {
|
|
87
|
+
await fs.unlink(entryPath);
|
|
88
|
+
removed += 1;
|
|
89
|
+
}
|
|
90
|
+
} catch (error) {
|
|
91
|
+
// A disappearing link is already safe. Permission and I/O failures
|
|
92
|
+
// must fail closed: otherwise a previous account's link can remain
|
|
93
|
+
// exposed while the repeating sync reports success.
|
|
94
|
+
if ((error as NodeJS.ErrnoException)?.code !== 'ENOENT') throw error;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return removed;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function managedSkillRoots(
|
|
102
|
+
skillsDir: string,
|
|
103
|
+
legacyGlobalSkillsDir: string = LEGACY_AGENTS_SKILLS_DIR,
|
|
104
|
+
): string[] {
|
|
105
|
+
const resolvedSkillsDir = path.resolve(skillsDir);
|
|
106
|
+
const scopedUsersRoot = path.dirname(path.dirname(resolvedSkillsDir));
|
|
107
|
+
const roots = [
|
|
108
|
+
resolvedSkillsDir,
|
|
109
|
+
path.resolve(legacyGlobalSkillsDir),
|
|
110
|
+
path.join(path.resolve(NOTIS_SKILL_SYNC_ROOT), 'users'),
|
|
111
|
+
path.join(path.resolve(AGENTS_DIR), 'notis', 'users'),
|
|
112
|
+
];
|
|
113
|
+
if (path.basename(scopedUsersRoot) === 'users') {
|
|
114
|
+
roots.push(scopedUsersRoot);
|
|
115
|
+
}
|
|
116
|
+
return roots;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async function isManagedSymlink(linkPath: string, managedRoots: string[]): Promise<boolean> {
|
|
120
|
+
try {
|
|
121
|
+
const stats = await fs.lstat(linkPath);
|
|
122
|
+
if (!stats.isSymbolicLink()) {
|
|
123
|
+
return false;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const target = await fs.readlink(linkPath);
|
|
127
|
+
const resolvedTarget = path.resolve(path.dirname(linkPath), target);
|
|
128
|
+
return managedRoots.some((root) => (
|
|
129
|
+
resolvedTarget === root || resolvedTarget.startsWith(`${root}${path.sep}`)
|
|
130
|
+
));
|
|
131
|
+
} catch {
|
|
132
|
+
return false;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async function ensureCorrectSymlink(linkPath: string, targetPath: string): Promise<'linked' | 'skipped' | 'blocked'> {
|
|
137
|
+
try {
|
|
138
|
+
const stats = await fs.lstat(linkPath);
|
|
139
|
+
if (stats.isSymbolicLink()) {
|
|
140
|
+
const currentTarget = await fs.readlink(linkPath);
|
|
141
|
+
const resolvedTarget = path.resolve(path.dirname(linkPath), currentTarget);
|
|
142
|
+
if (resolvedTarget === targetPath) {
|
|
143
|
+
return 'skipped';
|
|
144
|
+
}
|
|
145
|
+
await fs.unlink(linkPath);
|
|
146
|
+
} else {
|
|
147
|
+
return 'blocked';
|
|
148
|
+
}
|
|
149
|
+
} catch {
|
|
150
|
+
// Missing path is expected.
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const relativePath = path.relative(path.dirname(linkPath), targetPath);
|
|
154
|
+
await fs.symlink(relativePath, linkPath);
|
|
155
|
+
return 'linked';
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function defaultAgentSkillDirs(skillsDir: string): AgentSkillDirs {
|
|
159
|
+
return {
|
|
160
|
+
notis: skillsDir,
|
|
161
|
+
...EXTERNAL_AGENT_SKILL_DIRS,
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
async function removeUndesiredManagedSymlinks(
|
|
166
|
+
agentDir: string,
|
|
167
|
+
desiredSkills: Set<string>,
|
|
168
|
+
managedRoots: string[],
|
|
169
|
+
): Promise<number> {
|
|
170
|
+
let removed = 0;
|
|
171
|
+
try {
|
|
172
|
+
await fs.mkdir(agentDir, { recursive: true });
|
|
173
|
+
const existingEntries = await fs.readdir(agentDir, { withFileTypes: true });
|
|
174
|
+
for (const entry of existingEntries) {
|
|
175
|
+
const entryPath = path.join(agentDir, entry.name);
|
|
176
|
+
if (!desiredSkills.has(entry.name) && await isManagedSymlink(entryPath, managedRoots)) {
|
|
177
|
+
await fs.unlink(entryPath);
|
|
178
|
+
removed += 1;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
} catch {
|
|
182
|
+
// Missing or unreadable agent directories should not block skill sync.
|
|
183
|
+
}
|
|
184
|
+
return removed;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export async function removeAllSymlinksForSkill(
|
|
188
|
+
skillName: string,
|
|
189
|
+
skillsDir: string = LEGACY_AGENTS_SKILLS_DIR,
|
|
190
|
+
options: RemoveSkillSymlinkOptions = {},
|
|
191
|
+
): Promise<number> {
|
|
192
|
+
let removed = 0;
|
|
193
|
+
const agentSkillDirs = { ...defaultAgentSkillDirs(skillsDir), ...options.agentSkillDirs };
|
|
194
|
+
const legacyGlobalSkillsDir = options.legacyGlobalSkillsDir || LEGACY_AGENTS_SKILLS_DIR;
|
|
195
|
+
const managedRoots = managedSkillRoots(skillsDir, legacyGlobalSkillsDir);
|
|
196
|
+
const agentDirs = new Set([...Object.values(agentSkillDirs), legacyGlobalSkillsDir]);
|
|
197
|
+
const expectedTarget = path.resolve(skillsDir, safeName(skillName, skillsDir));
|
|
198
|
+
for (const agentDir of agentDirs) {
|
|
199
|
+
const linkPath = path.join(agentDir, safeName(skillName, agentDir));
|
|
200
|
+
const owned = options.ownership === 'exact-skill-dir'
|
|
201
|
+
? await isManagedSymlink(linkPath, [expectedTarget])
|
|
202
|
+
: await isManagedSymlink(linkPath, managedRoots);
|
|
203
|
+
if (owned) {
|
|
204
|
+
await fs.unlink(linkPath);
|
|
205
|
+
removed += 1;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
return removed;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Detect per-agent skill symlinks that we created on a prior sync but that the user has since
|
|
213
|
+
* deleted locally (e.g. `rm ~/.claude/skills/<skill>`). The caller turns each result into a
|
|
214
|
+
* per-agent deactivation so the deletion sticks instead of being recreated on the next pass.
|
|
215
|
+
*
|
|
216
|
+
* A pair is flagged only when the skill is cloud-active for that agent, the previous sync state
|
|
217
|
+
* recorded it active for that agent (so we know we created the link), and the link is now truly
|
|
218
|
+
* absent (lstat ENOENT). Stale/wrong-target links and real files blocking the path are NOT
|
|
219
|
+
* deletions — those are repaired/skipped by syncSymlinks as before. Only external agents are
|
|
220
|
+
* considered; `notis` has no symlink (its dir is the source skills folder).
|
|
221
|
+
*/
|
|
222
|
+
export async function detectDeletedAgentSymlinks(
|
|
223
|
+
cloudSkills: CloudSkill[],
|
|
224
|
+
previousState: NotisSyncState,
|
|
225
|
+
skillsDir: string = LEGACY_AGENTS_SKILLS_DIR,
|
|
226
|
+
options: SymlinkSyncOptions = {},
|
|
227
|
+
): Promise<DeletedAgentSymlink[]> {
|
|
228
|
+
const agentSkillDirs = { ...defaultAgentSkillDirs(skillsDir), ...options.agentSkillDirs };
|
|
229
|
+
const deletions: DeletedAgentSymlink[] = [];
|
|
230
|
+
|
|
231
|
+
for (const agent of EXTERNAL_AGENTS) {
|
|
232
|
+
const agentDir = agentSkillDirs[agent];
|
|
233
|
+
if (path.resolve(agentDir) === path.resolve(skillsDir)) {
|
|
234
|
+
continue;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// A missing (or non-directory) agent skills directory is NOT a per-skill deletion: removing
|
|
238
|
+
// ~/.claude/skills wholesale would otherwise make every entry's link path ENOENT and
|
|
239
|
+
// deactivate every skill for that agent. syncSymlinks recreates the directory and its links,
|
|
240
|
+
// so only absent entries INSIDE an existing agent directory count as explicit user deletions.
|
|
241
|
+
try {
|
|
242
|
+
const dirStat = await fs.stat(agentDir);
|
|
243
|
+
if (!dirStat.isDirectory()) {
|
|
244
|
+
continue;
|
|
245
|
+
}
|
|
246
|
+
} catch {
|
|
247
|
+
continue;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
for (const skill of cloudSkills) {
|
|
251
|
+
if (skill.status !== 'active') {
|
|
252
|
+
continue;
|
|
253
|
+
}
|
|
254
|
+
if (!skill.id || skill.id.startsWith('local-')) {
|
|
255
|
+
continue;
|
|
256
|
+
}
|
|
257
|
+
const previous = previousState.skills[skill.name];
|
|
258
|
+
if (!previous) {
|
|
259
|
+
continue;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const cloudTargets = normalizeAgentTargets(skill.agent_targets);
|
|
263
|
+
const previousTargets = normalizeAgentTargets(previous.agentTargets);
|
|
264
|
+
if (!cloudTargets[agent] || !previousTargets[agent]) {
|
|
265
|
+
continue;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
let safeSkillName: string;
|
|
269
|
+
try {
|
|
270
|
+
safeSkillName = safeName(skill.name, skillsDir);
|
|
271
|
+
} catch {
|
|
272
|
+
continue;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const linkPath = path.join(agentDir, safeName(safeSkillName, agentDir));
|
|
276
|
+
try {
|
|
277
|
+
await fs.lstat(linkPath);
|
|
278
|
+
} catch (error) {
|
|
279
|
+
if ((error as NodeJS.ErrnoException)?.code === 'ENOENT') {
|
|
280
|
+
deletions.push({ skillId: skill.id, skillName: skill.name, agent });
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
return deletions;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
export async function syncSymlinks(
|
|
290
|
+
skills: CloudSkill[],
|
|
291
|
+
skillsDir: string = LEGACY_AGENTS_SKILLS_DIR,
|
|
292
|
+
options: SymlinkSyncOptions = {},
|
|
293
|
+
): Promise<SymlinkSyncResult> {
|
|
294
|
+
await fs.mkdir(skillsDir, { recursive: true });
|
|
295
|
+
|
|
296
|
+
const result: SymlinkSyncResult = {
|
|
297
|
+
linked: 0,
|
|
298
|
+
removed: 0,
|
|
299
|
+
skipped: 0,
|
|
300
|
+
};
|
|
301
|
+
const agentSkillDirs = { ...defaultAgentSkillDirs(skillsDir), ...options.agentSkillDirs };
|
|
302
|
+
const legacyGlobalSkillsDir = options.legacyGlobalSkillsDir || LEGACY_AGENTS_SKILLS_DIR;
|
|
303
|
+
const managedRoots = managedSkillRoots(skillsDir, legacyGlobalSkillsDir);
|
|
304
|
+
|
|
305
|
+
const desiredByAgent = {
|
|
306
|
+
notis: new Set<string>(),
|
|
307
|
+
claude_code: new Set<string>(),
|
|
308
|
+
cursor: new Set<string>(),
|
|
309
|
+
codex: new Set<string>(),
|
|
310
|
+
};
|
|
311
|
+
|
|
312
|
+
for (const skill of skills) {
|
|
313
|
+
if (skill.status !== 'active') {
|
|
314
|
+
continue;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
const safeSkillName = safeName(skill.name, skillsDir);
|
|
318
|
+
const targets = normalizeAgentTargets(skill.agent_targets);
|
|
319
|
+
if (targets.notis) {
|
|
320
|
+
desiredByAgent.notis.add(safeSkillName);
|
|
321
|
+
}
|
|
322
|
+
if (targets.claude_code) {
|
|
323
|
+
desiredByAgent.claude_code.add(safeSkillName);
|
|
324
|
+
}
|
|
325
|
+
if (targets.cursor) {
|
|
326
|
+
desiredByAgent.cursor.add(safeSkillName);
|
|
327
|
+
}
|
|
328
|
+
if (targets.codex) {
|
|
329
|
+
desiredByAgent.codex.add(safeSkillName);
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
for (const [agent, agentDir] of Object.entries(agentSkillDirs) as Array<[keyof AgentSkillDirs, string]>) {
|
|
334
|
+
if (path.resolve(agentDir) === path.resolve(skillsDir)) {
|
|
335
|
+
result.skipped += desiredByAgent[agent].size;
|
|
336
|
+
continue;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
await fs.mkdir(agentDir, { recursive: true });
|
|
340
|
+
|
|
341
|
+
const desiredSkills = desiredByAgent[agent];
|
|
342
|
+
if (options.removeUndesired !== false) {
|
|
343
|
+
result.removed += await removeUndesiredManagedSymlinks(agentDir, desiredSkills, managedRoots);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
for (const skillName of desiredSkills) {
|
|
347
|
+
const targetPath = path.join(skillsDir, skillName);
|
|
348
|
+
const linkPath = path.join(agentDir, safeName(skillName, agentDir));
|
|
349
|
+
try {
|
|
350
|
+
await fs.access(targetPath);
|
|
351
|
+
} catch {
|
|
352
|
+
result.skipped += 1;
|
|
353
|
+
continue;
|
|
354
|
+
}
|
|
355
|
+
const syncOutcome = await ensureCorrectSymlink(linkPath, targetPath);
|
|
356
|
+
if (syncOutcome === 'linked') {
|
|
357
|
+
result.linked += 1;
|
|
358
|
+
} else if (syncOutcome === 'blocked') {
|
|
359
|
+
console.warn(
|
|
360
|
+
`[skill-sync] Could not link "${skillName}" for ${agent}: non-symlink entry blocks ${linkPath}`,
|
|
361
|
+
);
|
|
362
|
+
result.skipped += 1;
|
|
363
|
+
} else {
|
|
364
|
+
result.skipped += 1;
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
if (
|
|
370
|
+
options.removeUndesired !== false
|
|
371
|
+
&& !Object.values(agentSkillDirs).some(
|
|
372
|
+
(agentDir) => path.resolve(agentDir) === path.resolve(legacyGlobalSkillsDir),
|
|
373
|
+
)
|
|
374
|
+
) {
|
|
375
|
+
result.removed += await removeUndesiredManagedSymlinks(
|
|
376
|
+
legacyGlobalSkillsDir,
|
|
377
|
+
new Set(),
|
|
378
|
+
managedRoots,
|
|
379
|
+
);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
return result;
|
|
383
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { LocalSkill, NotisSyncState } from "./types";
|
|
2
|
+
|
|
3
|
+
export function getPushCandidates(
|
|
4
|
+
localSkills: LocalSkill[],
|
|
5
|
+
syncState: NotisSyncState,
|
|
6
|
+
cloudCuratedSkillNames: ReadonlySet<string> = new Set(),
|
|
7
|
+
cloudSkillNames?: ReadonlySet<string>,
|
|
8
|
+
): LocalSkill[] {
|
|
9
|
+
return localSkills.filter((skill) => {
|
|
10
|
+
if (cloudCuratedSkillNames.has(skill.name)) {
|
|
11
|
+
return false;
|
|
12
|
+
}
|
|
13
|
+
const previous = syncState.skills[skill.name];
|
|
14
|
+
if (!previous) {
|
|
15
|
+
return true;
|
|
16
|
+
}
|
|
17
|
+
if (cloudSkillNames && !cloudSkillNames.has(skill.name)) {
|
|
18
|
+
return false;
|
|
19
|
+
}
|
|
20
|
+
return previous.folderHash !== skill.folderHash;
|
|
21
|
+
});
|
|
22
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
export interface AgentTargets {
|
|
2
|
+
notis: boolean;
|
|
3
|
+
claude_code: boolean;
|
|
4
|
+
cursor: boolean;
|
|
5
|
+
codex: boolean;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface SyncedSkill {
|
|
9
|
+
cloudId: string;
|
|
10
|
+
folderHash: string;
|
|
11
|
+
agentTargets: AgentTargets;
|
|
12
|
+
syncedAt: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface NotisSyncState {
|
|
16
|
+
version: 1;
|
|
17
|
+
lastSyncedAt: string | null;
|
|
18
|
+
skills: Record<string, SyncedSkill>;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface LocalSkill {
|
|
22
|
+
name: string;
|
|
23
|
+
skillMd: string;
|
|
24
|
+
description: string;
|
|
25
|
+
folderHash: string;
|
|
26
|
+
directoryPath: string;
|
|
27
|
+
sourceUrl?: string;
|
|
28
|
+
bundleFiles?: BundleFile[];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface BundleFile {
|
|
32
|
+
path: string;
|
|
33
|
+
content_b64: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface CloudSkill {
|
|
37
|
+
id: string;
|
|
38
|
+
name: string;
|
|
39
|
+
description: string | null;
|
|
40
|
+
skill_md: string | null;
|
|
41
|
+
agent_targets?: Partial<AgentTargets> | null;
|
|
42
|
+
skill_folder_hash?: string | null;
|
|
43
|
+
skill_source_url?: string | null;
|
|
44
|
+
bundle_files?: BundleFile[] | null;
|
|
45
|
+
bundle_hydration_failed?: boolean | null;
|
|
46
|
+
source: string;
|
|
47
|
+
status: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface SyncSettings {
|
|
51
|
+
/** Server-verified canonical Notis account id used to scope local mirrors. */
|
|
52
|
+
user_id?: string;
|
|
53
|
+
sync_enabled: boolean;
|
|
54
|
+
last_synced_at: string | null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface EntitlementAccessDetail {
|
|
58
|
+
status: "error";
|
|
59
|
+
code: "entitlement_upgrade_required";
|
|
60
|
+
upgrade_required: true;
|
|
61
|
+
entitlement: string;
|
|
62
|
+
required_plan: string;
|
|
63
|
+
upgrade_url: string;
|
|
64
|
+
message: string;
|
|
65
|
+
next_action: Record<string, unknown>;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface SyncPullResponse {
|
|
69
|
+
skills: CloudSkill[];
|
|
70
|
+
sync_enabled: boolean;
|
|
71
|
+
last_synced_at: string | null;
|
|
72
|
+
/** Legacy servers could return a successful empty denial. Current servers
|
|
73
|
+
* return HTTP 403; either form must preserve existing local state. */
|
|
74
|
+
entitlement_access?: EntitlementAccessDetail;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** A skill the server could not sync, with a user-facing reason (e.g. an invalid
|
|
78
|
+
* SKILL.md description rejected by OpenAI). One bad skill no longer fails the batch. */
|
|
79
|
+
export interface SkillSyncFailure {
|
|
80
|
+
name: string;
|
|
81
|
+
error: string;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export interface SyncPushResponse {
|
|
85
|
+
skills: unknown[];
|
|
86
|
+
failed?: SkillSyncFailure[];
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export const DEFAULT_AGENT_TARGETS: AgentTargets = {
|
|
90
|
+
notis: true,
|
|
91
|
+
claude_code: true,
|
|
92
|
+
cursor: true,
|
|
93
|
+
codex: true,
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
export function normalizeAgentTargets(targets?: Partial<AgentTargets> | null): AgentTargets {
|
|
97
|
+
return {
|
|
98
|
+
notis: Boolean(targets?.notis ?? DEFAULT_AGENT_TARGETS.notis),
|
|
99
|
+
claude_code: Boolean(targets?.claude_code ?? DEFAULT_AGENT_TARGETS.claude_code),
|
|
100
|
+
cursor: Boolean(targets?.cursor ?? DEFAULT_AGENT_TARGETS.cursor),
|
|
101
|
+
codex: Boolean(targets?.codex ?? DEFAULT_AGENT_TARGETS.codex),
|
|
102
|
+
};
|
|
103
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { CloudSkill } from './types';
|
|
2
|
+
|
|
3
|
+
interface WriteCloudSkillDependencies {
|
|
4
|
+
downloadSkillBundle: (bundleUrl: string) => Promise<Buffer>;
|
|
5
|
+
writeCloudSkillToDisk: (skill: CloudSkill, bundleBytes?: Buffer) => Promise<boolean>;
|
|
6
|
+
onWarning?: (message: string, error: unknown) => void;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export async function writeCloudSkillWithBundleFallback(
|
|
10
|
+
skill: CloudSkill,
|
|
11
|
+
dependencies: WriteCloudSkillDependencies,
|
|
12
|
+
): Promise<boolean> {
|
|
13
|
+
if (skill.skill_source_url) {
|
|
14
|
+
try {
|
|
15
|
+
const bundleBytes = await dependencies.downloadSkillBundle(skill.skill_source_url);
|
|
16
|
+
const wroteBundleToDisk = await dependencies.writeCloudSkillToDisk(skill, bundleBytes);
|
|
17
|
+
if (wroteBundleToDisk) {
|
|
18
|
+
return true;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
dependencies.onWarning?.(
|
|
22
|
+
`Bundle sync for "${skill.name}" produced no local changes, falling back to SKILL.md payload.`,
|
|
23
|
+
new Error('Bundle write returned false'),
|
|
24
|
+
);
|
|
25
|
+
} catch (error) {
|
|
26
|
+
dependencies.onWarning?.(
|
|
27
|
+
`Failed to apply bundle sync for "${skill.name}", falling back to SKILL.md payload.`,
|
|
28
|
+
error,
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (skill.bundle_hydration_failed) {
|
|
34
|
+
dependencies.onWarning?.(
|
|
35
|
+
`Skipping markdown fallback for synced skill "${skill.name}" because its stored bundle could not be hydrated by the server.`,
|
|
36
|
+
new Error('Bundle hydration failed'),
|
|
37
|
+
);
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
try {
|
|
42
|
+
return await dependencies.writeCloudSkillToDisk(skill);
|
|
43
|
+
} catch (error) {
|
|
44
|
+
dependencies.onWarning?.(
|
|
45
|
+
`Failed to write synced skill "${skill.name}" to disk.`,
|
|
46
|
+
error,
|
|
47
|
+
);
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import sharp from 'sharp';
|
|
2
1
|
import { fileURLToPath } from 'node:url';
|
|
3
2
|
|
|
4
3
|
const STORE_SCREENSHOT_ASPECT = 16 / 10;
|
|
@@ -19,6 +18,10 @@ const ACCENT_PALETTES = {
|
|
|
19
18
|
|
|
20
19
|
const ACCENT_NAMES = Object.keys(ACCENT_PALETTES);
|
|
21
20
|
|
|
21
|
+
async function loadSharp() {
|
|
22
|
+
return (await import('sharp')).default;
|
|
23
|
+
}
|
|
24
|
+
|
|
22
25
|
function stableHash(value) {
|
|
23
26
|
let hash = 2166136261;
|
|
24
27
|
for (const character of String(value || 'notis-app')) {
|
|
@@ -59,6 +62,7 @@ export function storeScreenshotLayout(width = 2000, height = 1250) {
|
|
|
59
62
|
}
|
|
60
63
|
|
|
61
64
|
async function backdropImage(layout) {
|
|
65
|
+
const sharp = await loadSharp();
|
|
62
66
|
return sharp(SHARED_BACKDROP_PATH)
|
|
63
67
|
.resize(layout.width, layout.height, { fit: 'cover', position: 'centre' })
|
|
64
68
|
.png()
|
|
@@ -88,6 +92,7 @@ export async function composeStoreScreenshot({
|
|
|
88
92
|
focused = false,
|
|
89
93
|
theme = 'light',
|
|
90
94
|
}) {
|
|
95
|
+
const sharp = await loadSharp();
|
|
91
96
|
const resolvedTheme = theme === 'dark' ? 'dark' : 'light';
|
|
92
97
|
const layout = storeScreenshotLayout(width, height);
|
|
93
98
|
const resolvedAccent = resolveStoreScreenshotAccent(accent, seed);
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { BaseSkillReconcileResult } from './base-skills.js';
|
|
2
|
+
|
|
3
|
+
export interface ReconcileAllSkillsResult {
|
|
4
|
+
baseSkills: string[];
|
|
5
|
+
baseInstalled: number;
|
|
6
|
+
baseLinked: number;
|
|
7
|
+
baseBackups: string[];
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface SkillSyncLockOptions {
|
|
11
|
+
home?: string;
|
|
12
|
+
timeoutMs?: number;
|
|
13
|
+
staleMs?: number;
|
|
14
|
+
pollMs?: number;
|
|
15
|
+
now?: () => number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function withSkillSyncLock<T>(
|
|
19
|
+
callback: () => Promise<T> | T,
|
|
20
|
+
options?: SkillSyncLockOptions,
|
|
21
|
+
): Promise<T>;
|
|
22
|
+
|
|
23
|
+
export function reconcileAllSkills<T extends object>(options: {
|
|
24
|
+
serverUrl: string;
|
|
25
|
+
jwt: string;
|
|
26
|
+
honorSyncEnabled: boolean;
|
|
27
|
+
userId?: string | null;
|
|
28
|
+
home?: string;
|
|
29
|
+
runAccountSync: (
|
|
30
|
+
serverUrl: string,
|
|
31
|
+
jwt: string,
|
|
32
|
+
dependencies: Record<string, never>,
|
|
33
|
+
options: { honorSyncEnabled: boolean },
|
|
34
|
+
) => Promise<T>;
|
|
35
|
+
reconcileBase?: (options: { userId?: string | null; home?: string }) => BaseSkillReconcileResult;
|
|
36
|
+
lockOptions?: Omit<SkillSyncLockOptions, 'home'>;
|
|
37
|
+
}): Promise<T & ReconcileAllSkillsResult>;
|