@notis_ai/cli 0.2.0-beta.154.1 → 0.2.0-beta.155.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.
@@ -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
- if (!desiredSkills.has(entry.name) && await isManagedSymlink(entryPath, managedRoots)) {
919
- await fs2.unlink(entryPath);
920
- removed += 1;
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
- await fs2.mkdir(agentDir, { recursive: true });
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.access(targetPath);
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 inMemoryById = new Map(pullResponse.skills.map((skill) => [skill.id, skill]));
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 latest = latestSkillsById.get(skillId);
1370
- if (!latest) {
1371
- continue;
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, nextTargets);
1379
- const inMemory = inMemoryById.get(skillId);
1380
- if (inMemory) {
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
- console.warn(
1386
- `[skill-sync] Failed to deactivate "${skillName}" for ${[...agents].join(", ")} after local symlink deletion:`,
1387
- error
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 deactivated = await deactivateDeletedAgentSkills(
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
  };