@notis_ai/cli 0.2.0-beta.154.1 → 0.2.0-beta.156.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/dist/agent-hooks/notis-agent-hook.mjs +209 -110
- package/dist/base-skills/notis-apps/SKILL.md +33 -30
- package/dist/skill-sync/index.js +134 -72
- package/dist/skill-sync/index.js.map +2 -2
- package/package.json +1 -1
- package/src/command-specs/apps.js +4 -2
- package/src/command-specs/skills.js +3 -2
- package/src/command-specs/tools.js +5 -0
- package/src/runtime/app-dev-build-supervisor.js +47 -0
- package/src/runtime/app-dev-build.js +41 -0
- package/src/runtime/app-dev-server.js +2 -6
- package/src/runtime/skill-sync/cloud-client.ts +5 -3
- package/src/runtime/skill-sync/index.ts +73 -65
- package/src/runtime/skill-sync/symlink-manager.ts +66 -16
- package/src/runtime/skill-sync/types.ts +5 -0
- package/template/app/page.tsx +8 -7
- package/template/packages/sdk/src/components/MultiSelectActionBar.tsx +49 -8
- package/template/packages/sdk/src/components/Skeleton.tsx +24 -0
- package/template/packages/sdk/src/hooks/useCloudComputer.ts +15 -48
- package/template/packages/sdk/src/hooks/useDatabaseSchema.ts +17 -53
- package/template/packages/sdk/src/hooks/useDatabaseSubscription.ts +2 -2
- package/template/packages/sdk/src/hooks/useDocument.ts +12 -47
- package/template/packages/sdk/src/hooks/useDocuments.ts +18 -58
- package/template/packages/sdk/src/hooks/useQuery.ts +71 -0
- package/template/packages/sdk/src/hooks/useToolQuery.ts +12 -0
- package/template/packages/sdk/src/hooks/useTopBarSearch.ts +15 -7
- package/template/packages/sdk/src/index.ts +8 -0
- package/template/packages/sdk/src/interactions/shortcuts.tsx +1 -1
- package/template/packages/sdk/src/queryCache.ts +162 -0
- package/template/packages/sdk/src/runtime.ts +5 -0
|
@@ -104,7 +104,7 @@ App code never accesses the runtime directly -- it uses SDK hooks (`useTool`, `u
|
|
|
104
104
|
|
|
105
105
|
1. **React + Vite only** -- No Next.js, no custom server
|
|
106
106
|
2. **ES module bundle** -- Vite builds a library-mode bundle with React externalized
|
|
107
|
-
3. **Component rendering** -- Apps render as React components directly in the portal.
|
|
107
|
+
3. **Component rendering** -- Apps render as React components directly in the portal. Do not create your own iframe; the host chooses the trusted shadow-root or isolated Store rendering path.
|
|
108
108
|
The portal owns the `ShadowRoot`, theme tokens, and runtime provider.
|
|
109
109
|
4. **HTTP bridge** -- Runtime calls use fetch to `/portal_views/runtime_query`
|
|
110
110
|
5. **Declarative tools** -- Tool access is declared in `notis.config.ts` by the final names returned by tool discovery and enforced server-side. Views can call native Notis, connected integrations, PostForMe, and MCP tools directly; metered calls use the same credit-cap and usage-billing path as the CLI.
|
|
@@ -293,40 +293,43 @@ For arbitrary app-owned resources that are not Notis collection rows, set `resou
|
|
|
293
293
|
Standard React pages in `app/`. Use generic SDK tool hooks for data and build on top of the scaffolded shadcn components and portal shell classes (`notis-app-shell`, `notis-app-surface`):
|
|
294
294
|
|
|
295
295
|
```tsx
|
|
296
|
-
|
|
297
|
-
import { useEffect, useState } from 'react';
|
|
298
|
-
import { useTool } from '@notis/sdk';
|
|
296
|
+
import { useDocuments, ViewSkeleton } from '@notis/sdk';
|
|
299
297
|
import { Card } from '@/components/ui/card';
|
|
300
298
|
|
|
301
|
-
type QueryTasksArgs = { database_id?: string; database_slug?: string; query: { page_size?: number } };
|
|
302
|
-
type TaskDoc = { document_id?: string; id?: string; title?: string; properties?: Record<string, unknown> };
|
|
303
|
-
type QueryTasksResult = { documents?: TaskDoc[] };
|
|
304
|
-
|
|
305
299
|
export default function TasksPage() {
|
|
306
|
-
const
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
.
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
return (
|
|
318
|
-
<div className="p-6 space-y-4">
|
|
319
|
-
{documents.map((doc) => (
|
|
320
|
-
<Card key={doc.id || doc.document_id} className="p-4">
|
|
321
|
-
<h3>{doc.title || 'Untitled'}</h3>
|
|
322
|
-
<p className="text-muted-foreground">{String(doc.properties?.status || '')}</p>
|
|
323
|
-
</Card>
|
|
324
|
-
))}
|
|
325
|
-
</div>
|
|
326
|
-
);
|
|
300
|
+
const tasks = useDocuments('tasks', { pageSize: 25 });
|
|
301
|
+
return <section className="space-y-4 p-6">
|
|
302
|
+
<h1 className="text-xl font-semibold">Tasks</h1>
|
|
303
|
+
{tasks.error && <p role="alert">{tasks.error.message} <button onClick={tasks.refetch}>Retry</button></p>}
|
|
304
|
+
{tasks.loading ? <ViewSkeleton variant="table" rows={5} /> : tasks.hasData ? (
|
|
305
|
+
tasks.documents.length ? tasks.documents.map((task) => (
|
|
306
|
+
<Card key={task.id} className="p-4"><h2>{task.title || 'Untitled'}</h2></Card>
|
|
307
|
+
)) : <p>No tasks yet.</p>
|
|
308
|
+
) : null}
|
|
309
|
+
</section>;
|
|
327
310
|
}
|
|
328
311
|
```
|
|
329
312
|
|
|
313
|
+
### Instant-view loading contract (required)
|
|
314
|
+
|
|
315
|
+
Build a client-side, multi-route app with one persistent `app/layout.tsx` shell. Navigate with `useNotisNavigation`; never use a document reload for an internal route. “SPA” means preserving that shell and reusing reads, **not** mounting every page or fetching every database at startup.
|
|
316
|
+
|
|
317
|
+
| State | Required UI |
|
|
318
|
+
| --- | --- |
|
|
319
|
+
| First read, no successful data | Keep headings/navigation/layout visible; use content-shaped skeletons only in missing regions. No page spinner or whole-page `Loading...`. |
|
|
320
|
+
| Cached view / successful empty result | Render synchronously from the shared SDK cache. Empty results are real cached results. |
|
|
321
|
+
| Background refresh | Keep current content and selection. Never replace populated content with a skeleton; do not drive the top-bar spinner from mount/refetch state. |
|
|
322
|
+
| Explicit Save / Upload / submitted search | Progress belongs in that button or affected section. Disable only the conflicting action. |
|
|
323
|
+
| Failed read | Show a scoped error and Retry; keep usable cached content. Never show an empty-state message before `hasData` is true. |
|
|
324
|
+
|
|
325
|
+
Use `useDocuments`, `useDocument`, `useDatabaseSchema`, and `useDatabaseSubscription` for native reads. `loading` means no first successful response; `isFetching` includes silent refresh. Do not copy their data into mount-only state, clear rows on error, or gate the entire app on `isFetching`.
|
|
326
|
+
|
|
327
|
+
For another **explicitly identified idempotent read**, use `useToolQuery<Result>(toolName, exactArguments, { readOnly: true })`, or `useQuery(keyArray, readCallback, { readOnly: true })`. Include every filter, selected resource, pagination option, and other input in the key. Call tools within a custom read with `{ readOnly: true, dedupe: true }`; the same SQL/shell tool can also perform writes, so never mark a whole toolkit read-only. Leave mutations as ordinary `useTool` actions.
|
|
328
|
+
|
|
329
|
+
`useQueryClient().prefetch(keyArray, readCallback, { readOnly: true })` prepares small known reads after the current view has rendered or on hover/focus. It shares the host's two-request speculative budget. Match the exact foreground query key. Never prefetch a mutation, login/polling action, provider sweep, `fetchAll` query, or an aggregate that fans out into more requests. Do not invent tool names to prepare a view. Older hosts safely fall back to uncached hook-local reads and skip prefetch.
|
|
330
|
+
|
|
331
|
+
Caches belong to the host's in-memory account/environment/app/version/effective-permission scope. Do not add module-global or `localStorage` caches of user data. Writes and realtime events invalidate reads; logout, access loss, and updates retire scopes. Preserve the last successful snapshot on an ordinary network failure.
|
|
332
|
+
|
|
330
333
|
### Discovering database schema
|
|
331
334
|
|
|
332
335
|
Before writing app code, inspect the database schema to know what properties exist:
|
|
@@ -367,7 +370,7 @@ Do NOT pass Notion-style wrappers (`{select: {name: "Todo"}}`) when upserting.
|
|
|
367
370
|
- If a screen looks like a standalone microsite instead of a portal tool, it is too custom.
|
|
368
371
|
- For Notes-style apps, the folder tree belongs to the portal sidebar when configured via `collection.sidebar`. The page content should complement that chrome, not duplicate or replace it.
|
|
369
372
|
- Never indicate selected items with a heavy left-border bar (e.g. `border-l-2 border-l-foreground` paired with a muted background). It looks dated and clashes with the portal chrome. Use a single subtle background change (`bg-muted` for selected, `hover:bg-muted/50` for hover) and let typography or an icon carry the rest of the state.
|
|
370
|
-
- Do not render any search input inside the app (in-page search rails, "Ask Notis…" pills, command-palette-style bars, etc.). The portal already owns the top-bar search field. Wire your view to it with `useTopBarSearch({ value, onChange, placeholder, onSubmit })` from `@notis/sdk` and let the page filter or refetch on the values it receives.
|
|
373
|
+
- Do not render any search input inside the app (in-page search rails, "Ask Notis…" pills, command-palette-style bars, etc.). The portal already owns the top-bar search field. Wire your view to it with `useTopBarSearch({ value, onChange, placeholder, onSubmit })` from `@notis/sdk` and let the page filter or refetch on the values it receives. Use its `setLoading` only for an explicit submitted search, never initial view loading or background refresh.
|
|
371
374
|
|
|
372
375
|
### Sidebar invariants
|
|
373
376
|
|
package/dist/skill-sync/index.js
CHANGED
|
@@ -798,12 +798,13 @@ async function downloadSkillBundle(bundleUrl) {
|
|
|
798
798
|
}
|
|
799
799
|
return Buffer.from(await response.arrayBuffer());
|
|
800
800
|
}
|
|
801
|
-
async function updateAgentTargets(serverUrl, jwt, skillId, targets) {
|
|
801
|
+
async function updateAgentTargets(serverUrl, jwt, skillId, targets, expectedUpdatedAt) {
|
|
802
802
|
return requestJson(`${serverUrl}/portal_skills/agent-targets`, jwt, {
|
|
803
803
|
method: "PATCH",
|
|
804
804
|
body: {
|
|
805
805
|
skill_id: skillId,
|
|
806
|
-
agent_targets: targets
|
|
806
|
+
agent_targets: targets,
|
|
807
|
+
...expectedUpdatedAt ? { expected_updated_at: expectedUpdatedAt } : {}
|
|
807
808
|
}
|
|
808
809
|
});
|
|
809
810
|
}
|
|
@@ -819,6 +820,18 @@ var EXTERNAL_AGENT_SKILL_DIRS = {
|
|
|
819
820
|
codex: path2.join(HOME_DIR2, ".codex", "skills")
|
|
820
821
|
};
|
|
821
822
|
var EXTERNAL_AGENTS = Object.keys(EXTERNAL_AGENT_SKILL_DIRS);
|
|
823
|
+
var AGENT_FAILURE_LABELS = {
|
|
824
|
+
claude_code: "Claude Code",
|
|
825
|
+
cursor: "Cursor",
|
|
826
|
+
codex: "Codex",
|
|
827
|
+
legacy: "legacy ~/.agents/skills"
|
|
828
|
+
};
|
|
829
|
+
function agentFailureLabel(agent) {
|
|
830
|
+
return AGENT_FAILURE_LABELS[agent] ?? agent;
|
|
831
|
+
}
|
|
832
|
+
function agentFolderFailureName(agent) {
|
|
833
|
+
return `${agentFailureLabel(agent)} skills folder`;
|
|
834
|
+
}
|
|
822
835
|
async function removeForeignAccountSymlinks(skillsDir, options = {}) {
|
|
823
836
|
const agentSkillDirs = { ...defaultAgentSkillDirs(skillsDir), ...options.agentSkillDirs };
|
|
824
837
|
const currentRoot = path2.resolve(skillsDir);
|
|
@@ -879,8 +892,9 @@ async function isManagedSymlink(linkPath, managedRoots) {
|
|
|
879
892
|
const target = await fs2.readlink(linkPath);
|
|
880
893
|
const resolvedTarget = path2.resolve(path2.dirname(linkPath), target);
|
|
881
894
|
return managedRoots.some((root) => resolvedTarget === root || resolvedTarget.startsWith(`${root}${path2.sep}`));
|
|
882
|
-
} catch {
|
|
883
|
-
return false;
|
|
895
|
+
} catch (error) {
|
|
896
|
+
if (error?.code === "ENOENT") return false;
|
|
897
|
+
throw error;
|
|
884
898
|
}
|
|
885
899
|
}
|
|
886
900
|
async function ensureCorrectSymlink(linkPath, targetPath) {
|
|
@@ -896,7 +910,8 @@ async function ensureCorrectSymlink(linkPath, targetPath) {
|
|
|
896
910
|
} else {
|
|
897
911
|
return "blocked";
|
|
898
912
|
}
|
|
899
|
-
} catch {
|
|
913
|
+
} catch (error) {
|
|
914
|
+
if (error?.code !== "ENOENT") throw error;
|
|
900
915
|
}
|
|
901
916
|
const relativePath = path2.relative(path2.dirname(linkPath), targetPath);
|
|
902
917
|
await fs2.symlink(relativePath, linkPath);
|
|
@@ -908,19 +923,26 @@ function defaultAgentSkillDirs(skillsDir) {
|
|
|
908
923
|
...EXTERNAL_AGENT_SKILL_DIRS
|
|
909
924
|
};
|
|
910
925
|
}
|
|
911
|
-
async function removeUndesiredManagedSymlinks(agentDir, desiredSkills, managedRoots) {
|
|
926
|
+
async function removeUndesiredManagedSymlinks(agentDir, desiredSkills, managedRoots, failures, agent) {
|
|
912
927
|
let removed = 0;
|
|
913
928
|
try {
|
|
914
929
|
await fs2.mkdir(agentDir, { recursive: true });
|
|
915
930
|
const existingEntries = await fs2.readdir(agentDir, { withFileTypes: true });
|
|
916
931
|
for (const entry of existingEntries) {
|
|
917
932
|
const entryPath = path2.join(agentDir, entry.name);
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
933
|
+
try {
|
|
934
|
+
if (!desiredSkills.has(entry.name) && await isManagedSymlink(entryPath, managedRoots)) {
|
|
935
|
+
await fs2.unlink(entryPath);
|
|
936
|
+
removed += 1;
|
|
937
|
+
}
|
|
938
|
+
} catch (error) {
|
|
939
|
+
if (error?.code !== "ENOENT") {
|
|
940
|
+
failures.push({ name: entry.name, error: `${agentFailureLabel(agent)}: could not remove skill link (${error.message})` });
|
|
941
|
+
}
|
|
921
942
|
}
|
|
922
943
|
}
|
|
923
|
-
} catch {
|
|
944
|
+
} catch (error) {
|
|
945
|
+
failures.push({ name: agentFolderFailureName(agent), error: `Could not read agent skills directory (${error.message})` });
|
|
924
946
|
}
|
|
925
947
|
return removed;
|
|
926
948
|
}
|
|
@@ -965,7 +987,7 @@ async function detectDeletedAgentSymlinks(cloudSkills, previousState, skillsDir
|
|
|
965
987
|
continue;
|
|
966
988
|
}
|
|
967
989
|
const previous = previousState.skills[skill.name];
|
|
968
|
-
if (!previous) {
|
|
990
|
+
if (!previous || previous.cloudId !== skill.id || previous.verifiedAgentLinks?.[agent] !== true || !skill.updated_at || previous.cloudUpdatedAt !== skill.updated_at) {
|
|
969
991
|
continue;
|
|
970
992
|
}
|
|
971
993
|
const cloudTargets = normalizeAgentTargets(skill.agent_targets);
|
|
@@ -996,7 +1018,9 @@ async function syncSymlinks(skills, skillsDir = LEGACY_AGENTS_SKILLS_DIR, option
|
|
|
996
1018
|
const result = {
|
|
997
1019
|
linked: 0,
|
|
998
1020
|
removed: 0,
|
|
999
|
-
skipped: 0
|
|
1021
|
+
skipped: 0,
|
|
1022
|
+
verifiedAgentLinks: {},
|
|
1023
|
+
failures: []
|
|
1000
1024
|
};
|
|
1001
1025
|
const agentSkillDirs = { ...defaultAgentSkillDirs(skillsDir), ...options.agentSkillDirs };
|
|
1002
1026
|
const legacyGlobalSkillsDir = options.legacyGlobalSkillsDir || LEGACY_AGENTS_SKILLS_DIR;
|
|
@@ -1031,21 +1055,34 @@ async function syncSymlinks(skills, skillsDir = LEGACY_AGENTS_SKILLS_DIR, option
|
|
|
1031
1055
|
result.skipped += desiredByAgent[agent].size;
|
|
1032
1056
|
continue;
|
|
1033
1057
|
}
|
|
1034
|
-
|
|
1058
|
+
try {
|
|
1059
|
+
await fs2.mkdir(agentDir, { recursive: true });
|
|
1060
|
+
} catch (error) {
|
|
1061
|
+
result.failures.push({ name: agentFolderFailureName(agent), error: `Could not create agent skills directory (${error.message})` });
|
|
1062
|
+
continue;
|
|
1063
|
+
}
|
|
1035
1064
|
const desiredSkills = desiredByAgent[agent];
|
|
1036
1065
|
if (options.removeUndesired !== false) {
|
|
1037
|
-
result.removed += await removeUndesiredManagedSymlinks(agentDir, desiredSkills, managedRoots);
|
|
1066
|
+
result.removed += await removeUndesiredManagedSymlinks(agentDir, desiredSkills, managedRoots, result.failures, agent);
|
|
1038
1067
|
}
|
|
1039
1068
|
for (const skillName of desiredSkills) {
|
|
1040
1069
|
const targetPath = path2.join(skillsDir, skillName);
|
|
1041
1070
|
const linkPath = path2.join(agentDir, safeName(skillName, agentDir));
|
|
1042
1071
|
try {
|
|
1043
|
-
await fs2.
|
|
1072
|
+
if (!(await fs2.stat(path2.join(targetPath, "SKILL.md"))).isFile()) throw new Error("Missing SKILL.md");
|
|
1044
1073
|
} catch {
|
|
1045
1074
|
result.skipped += 1;
|
|
1075
|
+
result.failures.push({ name: skillName, error: `${agentFailureLabel(agent)}: SKILL.md is missing or unreadable` });
|
|
1076
|
+
continue;
|
|
1077
|
+
}
|
|
1078
|
+
let syncOutcome;
|
|
1079
|
+
try {
|
|
1080
|
+
syncOutcome = await ensureCorrectSymlink(linkPath, targetPath);
|
|
1081
|
+
} catch (error) {
|
|
1082
|
+
result.skipped += 1;
|
|
1083
|
+
result.failures.push({ name: skillName, error: `${agentFailureLabel(agent)}: could not create skill link (${error.message})` });
|
|
1046
1084
|
continue;
|
|
1047
1085
|
}
|
|
1048
|
-
const syncOutcome = await ensureCorrectSymlink(linkPath, targetPath);
|
|
1049
1086
|
if (syncOutcome === "linked") {
|
|
1050
1087
|
result.linked += 1;
|
|
1051
1088
|
} else if (syncOutcome === "blocked") {
|
|
@@ -1053,9 +1090,13 @@ async function syncSymlinks(skills, skillsDir = LEGACY_AGENTS_SKILLS_DIR, option
|
|
|
1053
1090
|
`[skill-sync] Could not link "${skillName}" for ${agent}: non-symlink entry blocks ${linkPath}`
|
|
1054
1091
|
);
|
|
1055
1092
|
result.skipped += 1;
|
|
1093
|
+
result.failures.push({ name: skillName, error: `${agentFailureLabel(agent)}: an existing file or folder blocks the skill link` });
|
|
1056
1094
|
} else {
|
|
1057
1095
|
result.skipped += 1;
|
|
1058
1096
|
}
|
|
1097
|
+
if (syncOutcome !== "blocked") {
|
|
1098
|
+
result.verifiedAgentLinks[skillName] = { ...result.verifiedAgentLinks[skillName], [agent]: true };
|
|
1099
|
+
}
|
|
1059
1100
|
}
|
|
1060
1101
|
}
|
|
1061
1102
|
if (options.removeUndesired !== false && !Object.values(agentSkillDirs).some(
|
|
@@ -1064,7 +1105,9 @@ async function syncSymlinks(skills, skillsDir = LEGACY_AGENTS_SKILLS_DIR, option
|
|
|
1064
1105
|
result.removed += await removeUndesiredManagedSymlinks(
|
|
1065
1106
|
legacyGlobalSkillsDir,
|
|
1066
1107
|
/* @__PURE__ */ new Set(),
|
|
1067
|
-
managedRoots
|
|
1108
|
+
managedRoots,
|
|
1109
|
+
result.failures,
|
|
1110
|
+
"legacy"
|
|
1068
1111
|
);
|
|
1069
1112
|
}
|
|
1070
1113
|
return result;
|
|
@@ -1188,7 +1231,7 @@ function shouldWriteCloudSkill(cloudSkill, localSkills, previousState) {
|
|
|
1188
1231
|
const localChangedSinceLastSync = !previous || previous.folderHash !== localSkill.folderHash;
|
|
1189
1232
|
return !localChangedSinceLastSync && Boolean(cloudHash) && cloudHash !== localSkill.folderHash;
|
|
1190
1233
|
}
|
|
1191
|
-
function buildSyncState(pullResponse, localSkills, lastSyncedAt) {
|
|
1234
|
+
function buildSyncState(pullResponse, localSkills, lastSyncedAt, verifiedAgentLinks = {}) {
|
|
1192
1235
|
const localSkillMap = toSkillMap(localSkills);
|
|
1193
1236
|
const skills = Object.fromEntries(
|
|
1194
1237
|
pullResponse.skills.map((skill) => {
|
|
@@ -1199,6 +1242,8 @@ function buildSyncState(pullResponse, localSkills, lastSyncedAt) {
|
|
|
1199
1242
|
cloudId: skill.id,
|
|
1200
1243
|
folderHash: localSkill?.folderHash || skill.skill_folder_hash || "",
|
|
1201
1244
|
agentTargets: normalizeAgentTargets(skill.agent_targets),
|
|
1245
|
+
verifiedAgentLinks: skill.status === "active" ? verifiedAgentLinks[skill.name] ?? {} : {},
|
|
1246
|
+
cloudUpdatedAt: skill.updated_at,
|
|
1202
1247
|
syncedAt: lastSyncedAt || (/* @__PURE__ */ new Date()).toISOString()
|
|
1203
1248
|
}
|
|
1204
1249
|
];
|
|
@@ -1254,7 +1299,7 @@ function applyLegacyFirstRunState(localSkills, scopedState, legacyState) {
|
|
|
1254
1299
|
skills: migratedSkills
|
|
1255
1300
|
};
|
|
1256
1301
|
}
|
|
1257
|
-
async function writePulledSkillsToScopedMirror(pullResponse, localSkills, previousState, syncPaths, deps) {
|
|
1302
|
+
async function writePulledSkillsToScopedMirror(pullResponse, localSkills, previousState, syncPaths, deps, failures = []) {
|
|
1258
1303
|
const localSkillMap = toSkillMap(localSkills);
|
|
1259
1304
|
const warnSkillSync = (message, error) => {
|
|
1260
1305
|
console.warn(`[Notis] ${message}`, error);
|
|
@@ -1270,6 +1315,8 @@ async function writePulledSkillsToScopedMirror(pullResponse, localSkills, previo
|
|
|
1270
1315
|
onWarning: warnSkillSync
|
|
1271
1316
|
})) {
|
|
1272
1317
|
downloaded += 1;
|
|
1318
|
+
} else {
|
|
1319
|
+
failures.push({ name: cloudSkill.name, error: "Skill content could not be downloaded or written; sync will retry" });
|
|
1273
1320
|
}
|
|
1274
1321
|
}
|
|
1275
1322
|
return downloaded;
|
|
@@ -1297,25 +1344,42 @@ async function materializeCloudSkillsForLocalShell(serverUrl, jwt, dependencies
|
|
|
1297
1344
|
assertSkillsPullAuthorized(pullResponse);
|
|
1298
1345
|
const previousState = await deps.readSyncState(syncPaths);
|
|
1299
1346
|
const localSkills = await deps.scanLocalSkills(syncPaths);
|
|
1347
|
+
const failedDownloads = [];
|
|
1300
1348
|
const downloaded = await writePulledSkillsToScopedMirror(
|
|
1301
1349
|
pullResponse,
|
|
1302
1350
|
localSkills,
|
|
1303
1351
|
previousState,
|
|
1304
1352
|
syncPaths,
|
|
1305
|
-
deps
|
|
1353
|
+
deps,
|
|
1354
|
+
failedDownloads
|
|
1306
1355
|
);
|
|
1307
1356
|
const finalLocalSkills = await deps.scanLocalSkills(syncPaths);
|
|
1308
1357
|
const lastSyncedAt = pullResponse.last_synced_at || (/* @__PURE__ */ new Date()).toISOString();
|
|
1309
1358
|
const relinkSkillNames = new Set(options.relinkSkillNames || []);
|
|
1359
|
+
const failures = [...failedDownloads];
|
|
1360
|
+
const verifiedLinks = {};
|
|
1361
|
+
for (const skill of pullResponse.skills) {
|
|
1362
|
+
const previous = previousState.skills[skill.name];
|
|
1363
|
+
if (skill.updated_at && previous?.cloudId === skill.id && previous.cloudUpdatedAt === skill.updated_at) {
|
|
1364
|
+
verifiedLinks[skill.name] = { ...previous.verifiedAgentLinks };
|
|
1365
|
+
}
|
|
1366
|
+
}
|
|
1310
1367
|
if (relinkSkillNames.size > 0) {
|
|
1311
|
-
await deps.syncSymlinks(
|
|
1368
|
+
const relinked = await deps.syncSymlinks(
|
|
1312
1369
|
pullResponse.skills.filter((skill) => relinkSkillNames.has(skill.name)),
|
|
1313
1370
|
syncPaths.skillsDir,
|
|
1314
1371
|
{ removeUndesired: false }
|
|
1315
1372
|
);
|
|
1373
|
+
failures.push(...(relinked.failures ?? []).filter(
|
|
1374
|
+
(failure) => !failedDownloads.some((download) => download.name === failure.name)
|
|
1375
|
+
));
|
|
1376
|
+
for (const name of relinkSkillNames) {
|
|
1377
|
+
verifiedLinks[name] = relinked.verifiedAgentLinks?.[name] ?? {};
|
|
1378
|
+
}
|
|
1316
1379
|
}
|
|
1380
|
+
for (const failure of failedDownloads) delete verifiedLinks[failure.name];
|
|
1317
1381
|
await deps.writeSyncState(
|
|
1318
|
-
buildSyncState(pullResponse, finalLocalSkills, lastSyncedAt),
|
|
1382
|
+
buildSyncState(pullResponse, finalLocalSkills, lastSyncedAt, verifiedLinks),
|
|
1319
1383
|
syncPaths
|
|
1320
1384
|
);
|
|
1321
1385
|
return {
|
|
@@ -1323,10 +1387,11 @@ async function materializeCloudSkillsForLocalShell(serverUrl, jwt, dependencies
|
|
|
1323
1387
|
downloaded,
|
|
1324
1388
|
deleted: 0,
|
|
1325
1389
|
removed: 0,
|
|
1326
|
-
lastSyncedAt
|
|
1390
|
+
lastSyncedAt,
|
|
1391
|
+
...failures.length ? { failedLinks: failures } : {}
|
|
1327
1392
|
};
|
|
1328
1393
|
}
|
|
1329
|
-
async function deactivateDeletedAgentSkills(serverUrl, jwt, pullResponse, previousState, scopedState, skillsDir, deps) {
|
|
1394
|
+
async function deactivateDeletedAgentSkills(serverUrl, jwt, pullResponse, previousState, scopedState, skillsDir, deps, failures) {
|
|
1330
1395
|
if (isEmptySyncState(scopedState)) {
|
|
1331
1396
|
return 0;
|
|
1332
1397
|
}
|
|
@@ -1338,22 +1403,6 @@ async function deactivateDeletedAgentSkills(serverUrl, jwt, pullResponse, previo
|
|
|
1338
1403
|
if (deletions.length === 0) {
|
|
1339
1404
|
return 0;
|
|
1340
1405
|
}
|
|
1341
|
-
const latestSkillsById = new Map(pullResponse.skills.map((skill) => [skill.id, skill]));
|
|
1342
|
-
let fresh = null;
|
|
1343
|
-
try {
|
|
1344
|
-
fresh = await deps.pullSkills(serverUrl, jwt);
|
|
1345
|
-
} catch (error) {
|
|
1346
|
-
console.warn(
|
|
1347
|
-
"[skill-sync] Could not re-pull latest agent targets before deactivation; using the top-of-sync snapshot.",
|
|
1348
|
-
error
|
|
1349
|
-
);
|
|
1350
|
-
}
|
|
1351
|
-
if (fresh) {
|
|
1352
|
-
assertSkillsPullAuthorized(fresh);
|
|
1353
|
-
for (const skill of fresh.skills) {
|
|
1354
|
-
latestSkillsById.set(skill.id, skill);
|
|
1355
|
-
}
|
|
1356
|
-
}
|
|
1357
1406
|
const agentsBySkill = /* @__PURE__ */ new Map();
|
|
1358
1407
|
for (const deletion of deletions) {
|
|
1359
1408
|
const entry = agentsBySkill.get(deletion.skillId) ?? {
|
|
@@ -1363,31 +1412,35 @@ async function deactivateDeletedAgentSkills(serverUrl, jwt, pullResponse, previo
|
|
|
1363
1412
|
entry.agents.add(deletion.agent);
|
|
1364
1413
|
agentsBySkill.set(deletion.skillId, entry);
|
|
1365
1414
|
}
|
|
1366
|
-
const
|
|
1415
|
+
const fresh = withoutBaseSkills(await deps.pullSkills(serverUrl, jwt));
|
|
1416
|
+
assertSkillsPullAuthorized(fresh);
|
|
1417
|
+
Object.assign(pullResponse, fresh);
|
|
1418
|
+
let needsRefresh = false;
|
|
1367
1419
|
let deactivated = 0;
|
|
1368
1420
|
for (const [skillId, { skillName, agents }] of agentsBySkill) {
|
|
1369
|
-
const
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
const nextTargets = { ...normalizeAgentTargets(latest.agent_targets) };
|
|
1374
|
-
for (const agent of agents) {
|
|
1375
|
-
nextTargets[agent] = false;
|
|
1376
|
-
}
|
|
1421
|
+
const skill = pullResponse.skills.find((item) => item.id === skillId);
|
|
1422
|
+
const previous = previousState.skills[skillName];
|
|
1423
|
+
if (!skill?.updated_at || previous?.cloudUpdatedAt !== skill.updated_at) continue;
|
|
1424
|
+
const patch = Object.fromEntries([...agents].map((agent) => [agent, false]));
|
|
1377
1425
|
try {
|
|
1378
|
-
await deps.updateAgentTargets(serverUrl, jwt, skillId,
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
inMemory.agent_targets = nextTargets;
|
|
1426
|
+
const saved = await deps.updateAgentTargets(serverUrl, jwt, skillId, patch, skill.updated_at);
|
|
1427
|
+
if (saved.success !== true || !saved.updated_at?.trim() || saved.updated_at === skill.updated_at || !["notis", "claude_code", "cursor", "codex"].every((agent) => typeof saved.agent_targets?.[agent] === "boolean") || ![...agents].every((agent) => saved.agent_targets[agent] === false)) {
|
|
1428
|
+
throw new Error("Assignment update did not return a verified saved revision");
|
|
1382
1429
|
}
|
|
1430
|
+
skill.agent_targets = saved.agent_targets;
|
|
1431
|
+
skill.updated_at = saved.updated_at;
|
|
1383
1432
|
deactivated += agents.size;
|
|
1384
1433
|
} catch (error) {
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
);
|
|
1434
|
+
needsRefresh = true;
|
|
1435
|
+
failures.push({ name: skillName, error: "Could not save the local agent removal; refreshed saved assignments" });
|
|
1436
|
+
console.warn(`[skill-sync] Assignment changed or could not be saved for "${skillName}"; refreshing before reconciliation.`, error);
|
|
1389
1437
|
}
|
|
1390
1438
|
}
|
|
1439
|
+
if (needsRefresh) {
|
|
1440
|
+
const refreshed = withoutBaseSkills(await deps.pullSkills(serverUrl, jwt));
|
|
1441
|
+
assertSkillsPullAuthorized(refreshed);
|
|
1442
|
+
Object.assign(pullResponse, refreshed);
|
|
1443
|
+
}
|
|
1391
1444
|
return deactivated;
|
|
1392
1445
|
}
|
|
1393
1446
|
async function runSkillSync(serverUrl, jwt, dependencies = {}, options = {}) {
|
|
@@ -1430,6 +1483,18 @@ async function runSkillSync(serverUrl, jwt, dependencies = {}, options = {}) {
|
|
|
1430
1483
|
pullResponse.skills.filter((skill) => skill.source === "curated").map((skill) => skill.name)
|
|
1431
1484
|
);
|
|
1432
1485
|
const protectedSkillNames = /* @__PURE__ */ new Set([...cloudCuratedSkillNames, ...BASE_SKILL_NAMES]);
|
|
1486
|
+
const scopedState = withoutBaseSkillState(await deps.readSyncState(syncPaths));
|
|
1487
|
+
const assignmentFailures = [];
|
|
1488
|
+
const deactivated = syncSettings.agent_targets_conditional_updates === true ? await deactivateDeletedAgentSkills(
|
|
1489
|
+
serverUrl,
|
|
1490
|
+
jwt,
|
|
1491
|
+
pullResponse,
|
|
1492
|
+
scopedState,
|
|
1493
|
+
scopedState,
|
|
1494
|
+
syncPaths.skillsDir,
|
|
1495
|
+
deps,
|
|
1496
|
+
assignmentFailures
|
|
1497
|
+
) : 0;
|
|
1433
1498
|
const authUserId = decodeJwtSubject(jwt);
|
|
1434
1499
|
let previousAuthState = null;
|
|
1435
1500
|
if (authUserId && authUserId !== syncUserId) {
|
|
@@ -1444,22 +1509,12 @@ async function runSkillSync(serverUrl, jwt, dependencies = {}, options = {}) {
|
|
|
1444
1509
|
protectedSkillNames
|
|
1445
1510
|
});
|
|
1446
1511
|
const localSkills = (await deps.scanLocalSkills(syncPaths)).filter((skill) => !BASE_SKILL_NAMES.has(skill.name));
|
|
1447
|
-
const scopedState = withoutBaseSkillState(await deps.readSyncState(syncPaths));
|
|
1448
1512
|
const previousState = withoutBaseSkillState(applyLegacyFirstRunState(
|
|
1449
1513
|
localSkills,
|
|
1450
1514
|
scopedState,
|
|
1451
1515
|
isEmptySyncState(scopedState) ? !previousAuthState || isEmptySyncState(previousAuthState) ? await deps.readLegacySyncState(syncPaths) : previousAuthState : null
|
|
1452
1516
|
));
|
|
1453
|
-
const
|
|
1454
|
-
serverUrl,
|
|
1455
|
-
jwt,
|
|
1456
|
-
pullResponse,
|
|
1457
|
-
previousState,
|
|
1458
|
-
scopedState,
|
|
1459
|
-
syncPaths.skillsDir,
|
|
1460
|
-
deps
|
|
1461
|
-
);
|
|
1462
|
-
await deps.syncSymlinks(
|
|
1517
|
+
const gatheredSymlinkResult = await deps.syncSymlinks(
|
|
1463
1518
|
buildLocalSymlinkCandidates(pullResponse, localSkills, previousState),
|
|
1464
1519
|
syncPaths.skillsDir
|
|
1465
1520
|
);
|
|
@@ -1490,21 +1545,25 @@ async function runSkillSync(serverUrl, jwt, dependencies = {}, options = {}) {
|
|
|
1490
1545
|
deleted += 1;
|
|
1491
1546
|
}
|
|
1492
1547
|
}
|
|
1548
|
+
const failedDownloads = [];
|
|
1493
1549
|
const downloaded = await writePulledSkillsToScopedMirror(
|
|
1494
1550
|
pullResponse,
|
|
1495
1551
|
localSkills,
|
|
1496
1552
|
previousState,
|
|
1497
1553
|
syncPaths,
|
|
1498
|
-
deps
|
|
1554
|
+
deps,
|
|
1555
|
+
failedDownloads
|
|
1499
1556
|
);
|
|
1500
1557
|
const finalLocalSkills = (await deps.scanLocalSkills(syncPaths)).filter((skill) => !BASE_SKILL_NAMES.has(skill.name));
|
|
1501
1558
|
const symlinkResult = await deps.syncSymlinks(
|
|
1502
1559
|
buildLocalSymlinkCandidates(pullResponse, finalLocalSkills, previousState),
|
|
1503
1560
|
syncPaths.skillsDir
|
|
1504
1561
|
);
|
|
1562
|
+
const verifiedLinks = { ...symlinkResult.verifiedAgentLinks ?? {} };
|
|
1563
|
+
for (const failure of failedDownloads) delete verifiedLinks[failure.name];
|
|
1505
1564
|
const lastSyncedAt = pullResponse.last_synced_at || (/* @__PURE__ */ new Date()).toISOString();
|
|
1506
1565
|
await deps.writeSyncState(
|
|
1507
|
-
buildSyncState(pullResponse, finalLocalSkills, lastSyncedAt),
|
|
1566
|
+
buildSyncState(pullResponse, finalLocalSkills, lastSyncedAt, verifiedLinks),
|
|
1508
1567
|
syncPaths
|
|
1509
1568
|
);
|
|
1510
1569
|
return {
|
|
@@ -1514,9 +1573,12 @@ async function runSkillSync(serverUrl, jwt, dependencies = {}, options = {}) {
|
|
|
1514
1573
|
downloaded,
|
|
1515
1574
|
deleted,
|
|
1516
1575
|
deactivated,
|
|
1517
|
-
linked: symlinkResult.linked,
|
|
1518
|
-
removed: foreignLinksRemoved + symlinkResult.removed,
|
|
1576
|
+
linked: gatheredSymlinkResult.linked + symlinkResult.linked,
|
|
1577
|
+
removed: foreignLinksRemoved + gatheredSymlinkResult.removed + symlinkResult.removed,
|
|
1519
1578
|
skipped: symlinkResult.skipped,
|
|
1579
|
+
failedLinks: [...assignmentFailures, ...failedDownloads, ...(symlinkResult.failures ?? []).filter(
|
|
1580
|
+
(failure) => !failedDownloads.some((download) => download.name === failure.name)
|
|
1581
|
+
)],
|
|
1520
1582
|
lastSyncedAt,
|
|
1521
1583
|
failedPushes
|
|
1522
1584
|
};
|