@notis_ai/cli 0.2.0-beta.197.1 → 0.2.0-beta.198.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.
@@ -1,5 +1,6 @@
1
1
  import { createHash } from 'node:crypto';
2
- import { join } from 'node:path';
2
+ import { promises as fs } from 'node:fs';
3
+ import { dirname, join } from 'node:path';
3
4
  import type {
4
5
  AgentTargets,
5
6
  LocalSkill,
@@ -11,7 +12,7 @@ import type {
11
12
  } from "./types";
12
13
  import { normalizeAgentTargets } from "./types";
13
14
  import { assertNativePull, checkedNativeReference, isNativeSkill, nativeState } from './native-identity';
14
- import { bindNativeFolders, hasNativeFolderHistory, nativeLinkRows, nativeLocalReady, previousNativeState, retainMissingNativeState } from './native-plan';
15
+ import { bindNativeFolders, hasNativeFolderHistory, nativeLinkRows, nativeLocalReady, pendingLegacyUpgrade, previousNativeState, retainMissingNativeState } from './native-plan';
15
16
  import {
16
17
  downloadSkillBundle,
17
18
  fetchSyncSettings,
@@ -65,6 +66,10 @@ export interface RunSkillSyncOptions {
65
66
  honorSyncEnabled?: boolean;
66
67
  /** All account paths, scanner roots and agent links use this explicit home. */
67
68
  syncHome?: string;
69
+ /** False when another account is the CLI default: sync this account's own
70
+ * folder only and leave the agent folders (Claude Code, Codex, Cursor) to the
71
+ * default's skills. Defaults to true. */
72
+ linkAgentFolders?: boolean;
68
73
  }
69
74
 
70
75
  const BASE_SKILL_NAMES = new Set(['notis-apps', 'notis-query', 'notis-cli']);
@@ -143,6 +148,58 @@ const DEFAULT_RUN_SKILL_SYNC_DEPS: RunSkillSyncDependencies = {
143
148
  updateAgentTargets,
144
149
  };
145
150
 
151
+ // Another account is the CLI default and owns the agent folders: this account
152
+ // syncs its own folder without linking, unlinking or adopting anything there.
153
+ const MIRROR_ONLY_DEPS: Pick<
154
+ RunSkillSyncDependencies,
155
+ 'removeForeignAccountSymlinks' | 'removeAllSymlinksForSkill' | 'syncSymlinks'
156
+ > = {
157
+ removeForeignAccountSymlinks: async () => 0,
158
+ removeAllSymlinksForSkill: async () => 0,
159
+ syncSymlinks: async () => ({ linked: 0, removed: 0, skipped: 0, verifiedAgentLinks: {} }),
160
+ };
161
+
162
+ /**
163
+ * The account linking now owns the agent folders, so the links the other
164
+ * accounts on this computer last verified are gone. Forget them: otherwise that
165
+ * account's next linking sync would read each missing link as the user removing
166
+ * the skill and deactivate it.
167
+ */
168
+ async function forgetOtherAccountsAgentLinks(
169
+ paths: ReturnType<typeof getSkillSyncPathsForUser>,
170
+ ): Promise<void> {
171
+ const usersDir = dirname(dirname(paths.syncStatePath));
172
+ let entries: import('node:fs').Dirent[];
173
+ try {
174
+ entries = await fs.readdir(usersDir, { withFileTypes: true });
175
+ } catch {
176
+ return;
177
+ }
178
+ for (const entry of entries) {
179
+ if (!entry.isDirectory()) continue;
180
+ const statePath = join(usersDir, entry.name, '.notis-sync.json');
181
+ if (statePath === paths.syncStatePath) continue;
182
+ let state: { skills?: Record<string, { verifiedAgentLinks?: unknown }> } | null = null;
183
+ try {
184
+ state = JSON.parse(await fs.readFile(statePath, 'utf8'));
185
+ } catch {
186
+ continue;
187
+ }
188
+ if (!state || typeof state !== 'object' || !state.skills || typeof state.skills !== 'object') continue;
189
+ let changed = false;
190
+ for (const skill of Object.values(state.skills)) {
191
+ if (skill && typeof skill === 'object' && 'verifiedAgentLinks' in skill) {
192
+ delete skill.verifiedAgentLinks;
193
+ changed = true;
194
+ }
195
+ }
196
+ if (!changed) continue;
197
+ const temporaryPath = `${statePath}.${process.pid}.${Date.now()}.tmp`;
198
+ await fs.writeFile(temporaryPath, `${JSON.stringify(state, null, 2)}\n`, 'utf8');
199
+ await fs.rename(temporaryPath, statePath);
200
+ }
201
+ }
202
+
146
203
  export function isolatedLinkOptions(paths: ReturnType<typeof getSkillSyncPathsForUser>) {
147
204
  if (!paths.isolatedNamespace && !paths.homeDir) return undefined;
148
205
  const root = paths.isolatedNamespace
@@ -299,6 +356,11 @@ function buildSyncState(
299
356
  const localSkill = localSkillMap.get(skill.name);
300
357
  const appliedCloudFolderHash = appliedRevisions[skill.name];
301
358
  const appliedFileList = appliedFiles[skill.name];
359
+ if (pendingLegacyUpgrade(skill, localSkill, previousState)) {
360
+ // Keep the legacy baseline so the upgrade retries; never mark a usable folder unavailable.
361
+ const { nativeUnavailable: _unavailable, ...previous } = previousNativeState(previousState, skill)!;
362
+ return [skill.name, { ...previous, verifiedAgentLinks: verifiedAgentLinks[skill.name] ?? {} }];
363
+ }
302
364
  if (isNativeSkill(skill) && !nativeLocalReady(skill, localSkill)) {
303
365
  const previous = previousNativeState(previousState, skill);
304
366
  return [skill.name, { ...(previous || {
@@ -368,7 +430,7 @@ function buildLocalSymlinkCandidates(
368
430
  };
369
431
  });
370
432
 
371
- return [...nativeLinkRows(pullResponse, localSkills), ...localOnlySkills];
433
+ return [...nativeLinkRows(pullResponse, localSkills, previousState), ...localOnlySkills];
372
434
  }
373
435
 
374
436
  function isEmptySyncState(state: NotisSyncState): boolean {
@@ -428,6 +490,10 @@ async function writePulledSkillsToScopedMirror(
428
490
  for (const cloudSkill of selectCloudSkillsToApply(pullResponse.skills)) {
429
491
  const local = localSkillMap.get(cloudSkill.name);
430
492
  const acknowledged = isNativeSkill(cloudSkill) && local && acknowledgedNative.get(cloudSkill.id) === local.folderHash;
493
+ // An unchanged pre-native folder has no applied-file record, so the files it shares
494
+ // with the incoming native Skill are the previous sync's output, not local edits.
495
+ const legacyBaseline = Boolean(local && pendingLegacyUpgrade(cloudSkill, local, previousState)
496
+ && previousNativeState(previousState, cloudSkill)?.folderHash === local.folderHash);
431
497
  if (!acknowledged && !shouldWriteCloudSkill(cloudSkill, localSkillMap, previousState)) {
432
498
  if (isNativeSkill(cloudSkill) && !nativeLocalReady(cloudSkill, local)) {
433
499
  failures.push({ name: cloudSkill.name, error: 'Local native Skill changes were retained; resolve the version or identity conflict before using them.' });
@@ -443,6 +509,7 @@ async function writePulledSkillsToScopedMirror(
443
509
  // produced locally and survives this one.
444
510
  previouslyApplied: previousNativeState(previousState, skill)?.appliedFiles,
445
511
  ...(isNativeSkill(skill) && local ? { expectedFolderHash: local.folderHash } : {}),
512
+ ...(legacyBaseline ? { legacyBaseline } : {}),
446
513
  }),
447
514
  onWarning: warnSkillSync,
448
515
  });
@@ -542,7 +609,7 @@ export async function materializeCloudSkillsForLocalShell(
542
609
  }
543
610
  if (relinkSkillNames.size > 0) {
544
611
  const relinked = await deps.syncSymlinks(
545
- nativeLinkRows(pullResponse, finalLocalSkills).filter((skill) => relinkSkillNames.has(skill.name)),
612
+ nativeLinkRows(pullResponse, finalLocalSkills, previousState).filter((skill) => relinkSkillNames.has(skill.name)),
546
613
  syncPaths.skillsDir,
547
614
  { ...isolatedLinkOptions(syncPaths), removeUndesired: false },
548
615
  );
@@ -580,7 +647,8 @@ export async function materializeCloudSkillsForLocalShell(
580
647
  for (const [name, previous] of Object.entries(previousState.skills)) {
581
648
  if (!previous.nativeReference && !previous.nativeUnavailable) continue;
582
649
  const current = pullResponse.skills.find(skill => skill.id === previous.cloudId);
583
- if (!current || !nativeLocalReady(current, finalLocalSkills.find(local => local.name === current.name))) {
650
+ const local = current && finalLocalSkills.find(row => row.name === current.name);
651
+ if (!current || (!nativeLocalReady(current, local) && !pendingLegacyUpgrade(current, local, previousState))) {
584
652
  removed += await deps.removeAllSymlinksForSkill(name, syncPaths.skillsDir, isolatedLinkOptions(syncPaths));
585
653
  }
586
654
  }
@@ -702,9 +770,11 @@ export async function runSkillSync(
702
770
  dependencies: Partial<RunSkillSyncDependencies> = {},
703
771
  options: RunSkillSyncOptions = {},
704
772
  ): Promise<RunSkillSyncResult> {
773
+ const linkAgentFolders = options.linkAgentFolders !== false;
705
774
  const deps = {
706
775
  ...DEFAULT_RUN_SKILL_SYNC_DEPS,
707
776
  ...dependencies,
777
+ ...(linkAgentFolders ? {} : MIRROR_ONLY_DEPS),
708
778
  };
709
779
  const syncSettings: SyncSettings = await deps.fetchSyncSettings(
710
780
  serverUrl,
@@ -749,6 +819,9 @@ export async function runSkillSync(
749
819
  const foreignLinksRemoved = await deps.removeForeignAccountSymlinks(
750
820
  syncPaths.skillsDir, isolatedLinkOptions(syncPaths),
751
821
  );
822
+ if (linkAgentFolders && !syncPaths.isolatedNamespace) {
823
+ await forgetOtherAccountsAgentLinks(syncPaths);
824
+ }
752
825
 
753
826
  const cloudCuratedSkillNames = new Set(
754
827
  pullResponse.skills
@@ -758,7 +831,9 @@ export async function runSkillSync(
758
831
  const protectedSkillNames = new Set([...cloudCuratedSkillNames, ...BASE_SKILL_NAMES]);
759
832
  const scopedState = withoutBaseSkillState(await deps.readSyncState(syncPaths));
760
833
  const assignmentFailures: SkillSyncFailure[] = [];
761
- const deactivated = !syncPaths.isolatedNamespace && syncSettings.agent_targets_conditional_updates === true
834
+ // A mirror-only sync has no links by design, so none of them is missing.
835
+ const deactivated = linkAgentFolders && !syncPaths.isolatedNamespace
836
+ && syncSettings.agent_targets_conditional_updates === true
762
837
  ? await deactivateDeletedAgentSkills(
763
838
  serverUrl, jwt, pullResponse, scopedState, scopedState, syncPaths.skillsDir, deps, assignmentFailures, isolatedLinkOptions(syncPaths),
764
839
  )
@@ -774,7 +849,8 @@ export async function runSkillSync(
774
849
  protectedSkillNames,
775
850
  });
776
851
  }
777
- if (!syncPaths.isolatedNamespace) {
852
+ if (linkAgentFolders && !syncPaths.isolatedNamespace) {
853
+ // Skills found loose in the agent folders belong to the account linking them.
778
854
  await deps.gatherTopLevelLocalSkills(syncPaths, { protectedSkillNames });
779
855
  }
780
856
  const localSkills = (await deps.scanLocalSkills(syncPaths))
@@ -1052,7 +1052,7 @@ async function pathExists(targetPath: string): Promise<boolean> {
1052
1052
  async function replaceSkillDirectoryAtomically(
1053
1053
  skillDir: string,
1054
1054
  populateDir: (stagingDir: string) => Promise<void>,
1055
- preserve?: { previouslyApplied?: readonly string[]; expectedFolderHash?: string; native?: boolean },
1055
+ preserve?: { previouslyApplied?: readonly string[]; expectedFolderHash?: string; native?: boolean; legacyBaseline?: boolean },
1056
1056
  ): Promise<void> {
1057
1057
  const parentDir = path.dirname(skillDir);
1058
1058
  const skillName = path.basename(skillDir);
@@ -1084,7 +1084,8 @@ async function replaceSkillDirectoryAtomically(
1084
1084
  await populateDir(stagingDir);
1085
1085
 
1086
1086
  if (preserve && (await pathExists(skillDir))) {
1087
- await preserveUnmanagedFiles(skillDir, stagingDir, preserve.previouslyApplied, preserve.native);
1087
+ await preserveUnmanagedFiles(skillDir, stagingDir, preserve.previouslyApplied,
1088
+ preserve.native && !preserve.legacyBaseline);
1088
1089
  }
1089
1090
 
1090
1091
  if (preserve?.native) {
@@ -1178,7 +1179,7 @@ export async function writeCloudSkillToDisk(
1178
1179
  skill: CloudSkill,
1179
1180
  bundleBytes?: Buffer,
1180
1181
  paths: SkillSyncPaths = DEFAULT_SYNC_PATHS,
1181
- options: { previouslyApplied?: readonly string[]; expectedFolderHash?: string } = {},
1182
+ options: { previouslyApplied?: readonly string[]; expectedFolderHash?: string; legacyBaseline?: boolean } = {},
1182
1183
  ): Promise<SkillWriteOutcome | false> {
1183
1184
  if (isNativeSkill(skill)) {
1184
1185
  assertNativePull({ skills: [{ ...skill, name: 'native-' + skill.id }], skill_sync_protocol: 2, sync_scope: skill.native_scope,
@@ -1190,7 +1191,9 @@ export async function writeCloudSkillToDisk(
1190
1191
  safeName(skill.name, paths.skillsDir),
1191
1192
  );
1192
1193
  const preserve = { previouslyApplied: options.previouslyApplied, expectedFolderHash: options.expectedFolderHash,
1193
- native: isNativeSkill(skill) };
1194
+ native: isNativeSkill(skill),
1195
+ // An unchanged legacy folder upgrading in place: shared files take the cloud version.
1196
+ legacyBaseline: isNativeSkill(skill) && options.legacyBaseline === true && options.expectedFolderHash !== undefined };
1194
1197
 
1195
1198
  if (bundleBytes?.length) {
1196
1199
  const bundlePath = path.join(
@@ -34,9 +34,20 @@ export function nativeLocalReady(skill: CloudSkill, local?: LocalSkill): boolean
34
34
  && sameNativeReference(local.nativeReference, skill.native_reference));
35
35
  }
36
36
 
37
- export function nativeLinkRows(response: SyncPullResponse, locals: LocalSkill[]): CloudSkill[] {
38
- return response.skills.map(skill => isNativeSkill(skill) && !nativeLocalReady(skill, locals.find(row => row.name === skill.name))
39
- ? { ...skill, status: 'disabled' } : skill);
37
+ /** A pre-native folder (no identity marker, no native state) the cloud now serves
38
+ * as a native Skill. It stays usable as before until its upgrade write lands. */
39
+ export function pendingLegacyUpgrade(skill: CloudSkill, local: LocalSkill | undefined, state: NotisSyncState): boolean {
40
+ if (!isNativeSkill(skill) || !local || local.nativeReference || local.cloudId) return false;
41
+ const previous = previousNativeState(state, skill);
42
+ return Boolean(previous && !previous.nativeReference);
43
+ }
44
+
45
+ export function nativeLinkRows(response: SyncPullResponse, locals: LocalSkill[], state?: NotisSyncState): CloudSkill[] {
46
+ return response.skills.map(skill => {
47
+ const local = locals.find(row => row.name === skill.name);
48
+ return isNativeSkill(skill) && !nativeLocalReady(skill, local) && !(state && pendingLegacyUpgrade(skill, local, state))
49
+ ? { ...skill, status: 'disabled' } : skill;
50
+ });
40
51
  }
41
52
 
42
53
  export function retainMissingNativeState(previous: NotisSyncState, next: NotisSyncState): NotisSyncState {
@@ -30,8 +30,10 @@ export function reconcileAllSkills<T extends object>(options: {
30
30
  serverUrl: string,
31
31
  jwt: string,
32
32
  dependencies: Record<string, never>,
33
- options: { honorSyncEnabled: boolean },
33
+ options: { honorSyncEnabled: boolean; syncHome?: string; linkAgentFolders?: boolean },
34
34
  ) => Promise<T>;
35
35
  reconcileBase?: (options: { userId?: string | null; home?: string }) => BaseSkillReconcileResult;
36
36
  lockOptions?: Omit<SkillSyncLockOptions, 'home'>;
37
+ /** False syncs the account's own folder only; another account owns the agent folders. */
38
+ linkAgentFolders?: boolean;
37
39
  }): Promise<T & ReconcileAllSkillsResult>;
@@ -211,14 +211,22 @@ export async function reconcileAllSkills({
211
211
  runAccountSync,
212
212
  reconcileBase = reconcileBaseSkills,
213
213
  lockOptions = {},
214
+ linkAgentFolders = true,
214
215
  }) {
215
216
  return withSkillSyncLock(async () => {
216
- const base = reconcileBase({ userId, ...(home ? { home } : {}) });
217
+ // Base skills are linked from the account that owns the agent folders.
218
+ const base = linkAgentFolders
219
+ ? reconcileBase({ userId, ...(home ? { home } : {}) })
220
+ : { skills: [], installed: 0, linked: 0, unchanged: 0, backups: [] };
217
221
  const account = await runAccountSync(
218
222
  serverUrl,
219
223
  jwt,
220
224
  {},
221
- { honorSyncEnabled, ...(home ? { syncHome: home } : {}) },
225
+ {
226
+ honorSyncEnabled,
227
+ ...(home ? { syncHome: home } : {}),
228
+ ...(linkAgentFolders ? {} : { linkAgentFolders: false }),
229
+ },
222
230
  );
223
231
  return {
224
232
  ...account,
@@ -1,14 +1,23 @@
1
1
  import { mkdirSync, renameSync, writeFileSync } from 'node:fs';
2
2
  import { homedir } from 'node:os';
3
3
  import { join } from 'node:path';
4
- import { resolveRuntimeProfile } from './runtime/profiles.js';
4
+ import { getCurrentProfileName, loadConfig, resolveRuntimeProfile } from './runtime/profiles.js';
5
5
  import { ensureFreshOAuthCredential } from './runtime/oauth.js';
6
6
  import { runSkillSync, fetchSyncSettings } from '../dist/skill-sync/index.js';
7
7
  import { withSkillSyncLock } from './runtime/sync-skills.js';
8
8
 
9
+ function currentCliProfile() {
10
+ try {
11
+ return getCurrentProfileName(loadConfig());
12
+ } catch {
13
+ return null;
14
+ }
15
+ }
16
+
9
17
  export async function runAutomaticSkillSync({ profile, apiBase, userId }, {
10
18
  resolveRuntime = resolveRuntimeProfile, refresh = ensureFreshOAuthCredential,
11
19
  settings = fetchSyncSettings, sync = runSkillSync, lock = withSkillSyncLock,
20
+ currentProfile = currentCliProfile,
12
21
  } = {}) {
13
22
  const runtime = resolveRuntime({ profile }, { requireAuth: true });
14
23
  if (runtime.credentialKind !== 'oauth' || runtime.apiBase !== apiBase
@@ -18,9 +27,12 @@ export async function runAutomaticSkillSync({ profile, apiBase, userId }, {
18
27
  if (saved.user_id !== userId) throw new Error('Automatic skill sync identity mismatch');
19
28
  // Do not gather, unlink foreign accounts or write anything when opted out.
20
29
  if (!saved.sync_enabled) return { status: 'disabled' };
30
+ // The service stays bound to its profile; after `notis profile use` moves the
31
+ // default away, it keeps that account's folder in sync without linking it.
32
+ const linkAgentFolders = (currentProfile() ?? profile) === profile;
21
33
  const result = await lock(() => sync(runtime.apiBase, runtime.jwt, {
22
34
  fetchSyncSettings: async () => saved,
23
- }, { honorSyncEnabled: true }));
35
+ }, { honorSyncEnabled: true, ...(linkAgentFolders ? {} : { linkAgentFolders: false }) }));
24
36
  return { status: (result.failedLinks?.length || result.failedPushes?.length) ? 'partial' : 'synced', ...result };
25
37
  }
26
38