@notis_ai/cli 0.2.0-beta.19.1 → 0.2.0-beta.196.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 +822 -135
- package/bin/check-runtime.js +15 -0
- package/bin/notis.js +2 -0
- package/config/notis_app_boundary_rules.json +50 -0
- package/config/notis_app_design_rules.json +135 -0
- package/config/portal-route-roots.json +48 -0
- package/dist/agent-hooks/notis-agent-hook.mjs +45587 -0
- package/dist/base-skills/notis-apps/SKILL.md +93 -0
- package/dist/base-skills/notis-apps/references/architecture.md +114 -0
- package/dist/base-skills/notis-apps/references/context.md +81 -0
- package/dist/base-skills/notis-apps/references/design.md +97 -0
- package/dist/base-skills/notis-apps/references/memory.md +58 -0
- package/dist/base-skills/notis-apps/references/reading.md +67 -0
- package/dist/base-skills/notis-apps/references/release.md +107 -0
- package/dist/base-skills/notis-apps/references/sdk.md +116 -0
- package/dist/base-skills/notis-apps/references/troubleshooting.md +24 -0
- package/dist/base-skills/notis-apps/references/views.md +142 -0
- package/dist/base-skills/notis-cli/SKILL.md +154 -0
- package/dist/base-skills/notis-cli/references/app-delivery.md +19 -0
- package/dist/base-skills/notis-cli/references/intelligence.md +94 -0
- package/dist/base-skills/notis-cli/references/native-databases.md +21 -0
- package/dist/base-skills/notis-cli/references/tool-examples.md +56 -0
- package/dist/base-skills/notis-cli/references/troubleshooting.md +39 -0
- package/dist/base-skills/notis-query/SKILL.md +41 -0
- package/dist/base-skills/notis-query/references/database-discovery.md +60 -0
- package/dist/base-skills/notis-query/references/documents.md +63 -0
- package/dist/base-skills/notis-query/references/query.md +76 -0
- package/{template/packages/notis-sdk → dist/sdk}/package.json +14 -4
- package/dist/sdk/src/agentContext.ts +36 -0
- package/dist/sdk/src/bundleModule.ts +79 -0
- package/dist/sdk/src/components/Dialog.tsx +46 -0
- package/dist/sdk/src/components/DocumentEditor.tsx +106 -0
- package/dist/sdk/src/components/DocumentPage.tsx +141 -0
- package/dist/sdk/src/components/Markdown.tsx +60 -0
- package/dist/sdk/src/components/MarkdownEditor.tsx +121 -0
- package/dist/sdk/src/components/MultiSelectActionBar.tsx +285 -0
- package/dist/sdk/src/components/MultiSelectCheckbox.tsx +97 -0
- package/dist/sdk/src/components/MultiSelectDragOverlay.tsx +39 -0
- package/dist/sdk/src/components/NotisCommentBoundary.tsx +172 -0
- package/dist/sdk/src/components/NotisSelectionBoundary.tsx +59 -0
- package/dist/sdk/src/components/RecordComponents.tsx +33 -0
- package/dist/sdk/src/components/RenderChartData.tsx +19 -0
- package/dist/sdk/src/components/ShortcutHints.tsx +56 -0
- package/dist/sdk/src/components/Skeleton.tsx +24 -0
- package/dist/sdk/src/config.ts +239 -0
- package/dist/sdk/src/documents.ts +258 -0
- package/dist/sdk/src/hooks/useAction.ts +28 -0
- package/dist/sdk/src/hooks/useActiveResource.ts +19 -0
- package/dist/sdk/src/hooks/useAgentContext.ts +23 -0
- package/dist/sdk/src/hooks/useCloudComputer.ts +95 -0
- package/dist/sdk/src/hooks/useCloudComputerShell.ts +114 -0
- package/dist/sdk/src/hooks/useCollectionInteractions.ts +838 -0
- package/dist/sdk/src/hooks/useDatabaseSchema.ts +49 -0
- package/dist/sdk/src/hooks/useDatabaseSubscription.ts +76 -0
- package/dist/sdk/src/hooks/useDocument.ts +43 -0
- package/dist/sdk/src/hooks/useDocuments.ts +84 -0
- package/dist/sdk/src/hooks/useHandover.ts +78 -0
- package/dist/sdk/src/hooks/useLongPressSelection.ts +79 -0
- package/dist/sdk/src/hooks/useMultiSelect.ts +95 -0
- package/dist/sdk/src/hooks/useNativeDocumentBody.ts +50 -0
- package/dist/sdk/src/hooks/useNativeDocuments.ts +83 -0
- package/dist/sdk/src/hooks/useNotis.ts +42 -0
- package/dist/sdk/src/hooks/useNotisNavigation.ts +67 -0
- package/dist/sdk/src/hooks/useQuery.ts +74 -0
- package/dist/sdk/src/hooks/useTool.ts +65 -0
- package/dist/sdk/src/hooks/useToolQuery.ts +12 -0
- package/dist/sdk/src/hooks/useTopBarSearch.ts +81 -0
- package/dist/sdk/src/hooks/useUpsertDocument.ts +95 -0
- package/dist/sdk/src/hooks/useViewParams.ts +121 -0
- package/dist/sdk/src/hooks/useViewerReads.ts +129 -0
- package/dist/sdk/src/index.ts +200 -0
- package/dist/sdk/src/interactions/actions.ts +59 -0
- package/dist/sdk/src/interactions/shortcuts.tsx +740 -0
- package/dist/sdk/src/interactions/visibility.ts +13 -0
- package/dist/sdk/src/interactions.ts +45 -0
- package/dist/sdk/src/nativeData.ts +39 -0
- package/dist/sdk/src/presentation.tsx +82 -0
- package/dist/sdk/src/provider.tsx +49 -0
- package/dist/sdk/src/queryCache.ts +172 -0
- package/dist/sdk/src/queryErrors.ts +51 -0
- package/dist/sdk/src/runtime.ts +603 -0
- package/dist/sdk/src/space.ts +425 -0
- package/dist/sdk/src/styles.css +290 -0
- package/dist/sdk/src/tailwind.ts +66 -0
- package/dist/sdk/src/vite.ts +83 -0
- package/dist/skill-sync/index.js +2143 -0
- package/dist/skill-sync/index.js.map +7 -0
- package/dist/skill-sync-worker.mjs +3487 -0
- package/dist/space-harness/host.js +66 -0
- package/dist/view-renderer/dist/host.css +1 -0
- package/dist/view-renderer/dist/host.js +1042 -0
- package/dist/view-renderer/dist/theme.css +73 -0
- package/dist/view-renderer/src/dom.js +180 -0
- package/dist/view-renderer/src/errors.js +8 -0
- package/dist/view-renderer/src/host.tsx +173 -0
- package/dist/view-renderer/src/index.js +4 -0
- package/dist/view-renderer/src/pool.js +76 -0
- package/dist/view-renderer/src/remote-broker.js +59 -0
- package/dist/view-renderer/src/renderer.js +201 -0
- package/package.json +25 -8
- package/skills/notis-apps/cli.md +406 -0
- package/skills/notis-cli/AGENT_INSTRUCTIONS.md +39 -0
- package/skills/notis-onboarding/BRIEF.md +133 -0
- package/skills/notis-query/cli.md +39 -0
- package/src/agent-hook-entry.js +5 -0
- package/src/cli.js +294 -25
- package/src/command-specs/agents.js +392 -0
- package/src/command-specs/apps.js +1476 -203
- package/src/command-specs/auth.js +114 -137
- package/src/command-specs/diagnostics.js +730 -0
- package/src/command-specs/files.js +29 -0
- package/src/command-specs/handover.js +374 -0
- package/src/command-specs/helpers.js +86 -82
- package/src/command-specs/index.js +31 -6
- package/src/command-specs/meta.js +150 -18
- package/src/command-specs/onboarding.js +290 -0
- package/src/command-specs/profile.js +358 -0
- package/src/command-specs/skills.js +304 -0
- package/src/command-specs/smoke.js +386 -0
- package/src/command-specs/spaces.js +572 -0
- package/src/command-specs/tools.js +455 -139
- package/src/command-specs/views.js +69 -0
- package/src/runtime/agent-browser.js +700 -0
- package/src/runtime/agent-memory-state.js +126 -0
- package/src/runtime/agent-setup.js +383 -0
- package/src/runtime/app-boundary-validator.js +407 -0
- package/src/runtime/app-changelog.js +79 -0
- package/src/runtime/app-data-literals.js +82 -0
- package/src/runtime/app-platform.js +2752 -237
- package/src/runtime/app-registry-scaffolds.js +367 -0
- package/src/runtime/app-test-server.js +293 -0
- package/src/runtime/assets/store-screenshot-dark.png +0 -0
- package/src/runtime/auth-recovery.js +110 -0
- package/src/runtime/base-skills.d.ts +20 -0
- package/src/runtime/base-skills.js +167 -0
- package/src/runtime/channel.js +133 -0
- package/src/runtime/confined-space-build.js +74 -0
- package/src/runtime/confined-space-verify.js +49 -0
- package/src/runtime/delegated-context.js +68 -0
- package/src/runtime/errors.js +1 -0
- package/src/runtime/git.js +233 -0
- package/src/runtime/login-listener.js +15 -0
- package/src/runtime/native-record-files.js +47 -0
- package/src/runtime/oauth.js +2622 -0
- package/src/runtime/output.js +37 -5
- package/src/runtime/ports.js +31 -0
- package/src/runtime/profiles.js +906 -55
- package/src/runtime/retired-sdk-files.js +20 -0
- package/src/runtime/screenshot-framing.js +85 -0
- package/src/runtime/skill-sync/cloud-client.ts +121 -0
- package/src/runtime/skill-sync/index.ts +905 -0
- package/src/runtime/skill-sync/local-scanner.ts +1259 -0
- package/src/runtime/skill-sync/native-identity.ts +102 -0
- package/src/runtime/skill-sync/native-plan.ts +59 -0
- package/src/runtime/skill-sync/symlink-manager.ts +433 -0
- package/src/runtime/skill-sync/sync-plan.ts +54 -0
- package/src/runtime/skill-sync/types.ts +157 -0
- package/src/runtime/skill-sync/write-cloud-skill.ts +50 -0
- package/src/runtime/skill-sync-service.js +109 -0
- package/src/runtime/source-root.js +32 -0
- package/src/runtime/space-collection-scaffold.js +66 -0
- package/src/runtime/space-database-structures.js +229 -0
- package/src/runtime/space-fixture-broker.js +50 -0
- package/src/runtime/space-lock.js +337 -0
- package/src/runtime/space-platform.js +105 -0
- package/src/runtime/space-source.js +281 -0
- package/src/runtime/space-test-server.js +327 -0
- package/src/runtime/space-verification.js +89 -0
- package/src/runtime/space-verify-browser-worker.js +51 -0
- package/src/runtime/space-view-manifest.js +254 -0
- package/src/runtime/store-screenshot.js +146 -0
- package/src/runtime/sync-skills.d.ts +37 -0
- package/src/runtime/sync-skills.js +231 -0
- package/src/runtime/telemetry.js +92 -0
- package/src/runtime/transport.js +331 -45
- package/src/runtime/view-render.js +49 -0
- package/src/skill-sync-worker-entry.js +2 -0
- package/src/skill-sync-worker.js +50 -0
- package/src/space-harness-host.ts +103 -0
- package/template/.harness/index.html.tmpl +435 -0
- package/template/CHANGELOG.md +5 -0
- package/template/app/globals.css +29 -3
- package/template/app/layout.tsx +6 -3
- package/template/app/page.tsx +49 -42
- package/template/components/page-heading.tsx +23 -0
- package/template/components/ui/badge.tsx +7 -4
- package/template/components/ui/button.tsx +1 -1
- package/template/components/ui/card.tsx +24 -11
- package/template/components/ui/native-select.tsx +24 -0
- package/template/notis.config.ts +24 -6
- package/template/package-lock.json +3642 -0
- package/template/package.json +19 -16
- package/template/postcss.config.mjs +1 -1
- package/template/tailwind.config.ts +1 -6
- package/template/tsconfig.json +1 -0
- package/src/command-specs/db.js +0 -163
- package/src/runtime/app-preview-server.js +0 -312
- package/template/packages/notis-sdk/src/config.ts +0 -48
- package/template/packages/notis-sdk/src/helpers.ts +0 -131
- package/template/packages/notis-sdk/src/hooks/useAppState.ts +0 -50
- package/template/packages/notis-sdk/src/hooks/useCollectionItem.ts +0 -58
- package/template/packages/notis-sdk/src/hooks/useDatabase.ts +0 -87
- package/template/packages/notis-sdk/src/hooks/useDocument.ts +0 -61
- package/template/packages/notis-sdk/src/hooks/useNotis.ts +0 -31
- package/template/packages/notis-sdk/src/hooks/useNotisNavigation.ts +0 -49
- package/template/packages/notis-sdk/src/hooks/useTool.ts +0 -49
- package/template/packages/notis-sdk/src/hooks/useUpsertDocument.ts +0 -57
- package/template/packages/notis-sdk/src/index.ts +0 -47
- package/template/packages/notis-sdk/src/provider.tsx +0 -44
- package/template/packages/notis-sdk/src/runtime.ts +0 -159
- package/template/packages/notis-sdk/src/styles.css +0 -123
- package/template/packages/notis-sdk/src/vite.ts +0 -54
- package/template/packages/notis-sdk/tsconfig.json +0 -15
- /package/{template/packages/notis-sdk → dist/sdk}/src/hooks/useBackend.ts +0 -0
- /package/{template/packages/notis-sdk → dist/sdk}/src/hooks/useTools.ts +0 -0
- /package/{template/packages/notis-sdk → dist/sdk}/src/ui.ts +0 -0
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import type { CloudSkill, NativeSkillReference, SyncedSkill, SyncPullResponse } from './types';
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
|
|
4
|
+
export const NATIVE_IDENTITY_FILE = '.notis-native-skill.json';
|
|
5
|
+
export interface NativeSkillIdentity {
|
|
6
|
+
schema: 1;
|
|
7
|
+
cloudId: string;
|
|
8
|
+
reference: NativeSkillReference;
|
|
9
|
+
displayName: string;
|
|
10
|
+
frontmatterName: string;
|
|
11
|
+
scope: string;
|
|
12
|
+
folderHash: string;
|
|
13
|
+
files: Record<string, string>;
|
|
14
|
+
}
|
|
15
|
+
const UUID = /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/;
|
|
16
|
+
|
|
17
|
+
export function checkedNativeReference(value: unknown, cloudId?: string): NativeSkillReference {
|
|
18
|
+
const reference = value as NativeSkillReference;
|
|
19
|
+
if (!reference || typeof reference !== 'object' || Array.isArray(reference)
|
|
20
|
+
|| Object.keys(reference).sort().join(',') !== 'access_revision,kind,skill_id,version'
|
|
21
|
+
|| reference.kind !== 'native_standalone_skill' || !UUID.test(reference.skill_id)
|
|
22
|
+
|| (cloudId !== undefined && reference.skill_id !== cloudId)
|
|
23
|
+
|| !Number.isSafeInteger(reference.access_revision) || reference.access_revision < 0
|
|
24
|
+
|| !reference.version || Object.keys(reference.version).sort().join(',') !== 'digest,revision'
|
|
25
|
+
|| !Number.isSafeInteger(reference.version.revision) || reference.version.revision < 1
|
|
26
|
+
|| !/^[a-f0-9]{64}$/.test(reference.version.digest)) {
|
|
27
|
+
throw new Error('Native Skill identity is unavailable. Refresh before syncing.');
|
|
28
|
+
}
|
|
29
|
+
return structuredClone(reference);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function nativeDirectory(reference: NativeSkillReference): string {
|
|
33
|
+
return 'native-' + checkedNativeReference(reference).skill_id;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function sameNativeReference(left?: NativeSkillReference, right?: NativeSkillReference): boolean {
|
|
37
|
+
return Boolean(left && right && left.skill_id === right.skill_id
|
|
38
|
+
&& left.access_revision === right.access_revision && left.version.revision === right.version.revision
|
|
39
|
+
&& left.version.digest === right.version.digest);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function isNativeSkill(skill: CloudSkill): boolean {
|
|
43
|
+
return skill.source === 'native' || skill.native_reference !== undefined;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function assertNativePull(response: SyncPullResponse): void {
|
|
47
|
+
const ids = new Set<string>();
|
|
48
|
+
for (const skill of response.skills) {
|
|
49
|
+
if (!isNativeSkill(skill)) continue;
|
|
50
|
+
const reference = checkedNativeReference(skill.native_reference, skill.id);
|
|
51
|
+
if (response.skill_sync_protocol !== 2 || skill.name !== nativeDirectory(reference) || ids.has(skill.id)
|
|
52
|
+
|| !/^[a-f0-9]{64}$/.test(response.sync_scope || '') || skill.native_scope !== response.sync_scope
|
|
53
|
+
|| !Array.isArray(skill.bundle_files) || skill.bundle_hydration_failed || typeof skill.skill_md !== 'string') {
|
|
54
|
+
throw new Error('Native Skill inventory is incomplete. Existing local files were retained.');
|
|
55
|
+
}
|
|
56
|
+
const paths = new Set<string>();
|
|
57
|
+
let entrypoint = false;
|
|
58
|
+
for (const file of skill.bundle_files) {
|
|
59
|
+
if (typeof file.path !== 'string' || typeof file.content_b64 !== 'string'
|
|
60
|
+
|| file.path.startsWith('/') || file.path.includes('\\') || file.path.includes('\0')
|
|
61
|
+
|| file.path.split('/').some(part => !part || part === '.' || part === '..')
|
|
62
|
+
|| paths.has(file.path.toLowerCase()) || file.path.toLowerCase() === NATIVE_IDENTITY_FILE) {
|
|
63
|
+
throw new Error('Native Skill bundle has a conflicting managed path.');
|
|
64
|
+
}
|
|
65
|
+
paths.add(file.path.toLowerCase());
|
|
66
|
+
if (file.path === 'SKILL.md') {
|
|
67
|
+
entrypoint = Buffer.from(file.content_b64, 'base64').equals(Buffer.from(skill.skill_md, 'utf8'));
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
if (!entrypoint) throw new Error('Native Skill instructions and files do not match.');
|
|
71
|
+
ids.add(skill.id);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function nativeFileDigest(bytes: Buffer): string {
|
|
76
|
+
return createHash('sha256').update(bytes).digest('hex');
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function nativeFrontmatterName(markdown: string): string {
|
|
80
|
+
const header = markdown.match(/^---\s*\n([\s\S]*?)\n---/);
|
|
81
|
+
return header?.[1].match(/^name\s*:\s*(.+)$/m)?.[1].trim().replace(/^['"]|['"]$/g, '') || '';
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function checkedNativeIdentity(value: unknown): NativeSkillIdentity {
|
|
85
|
+
const identity = value as NativeSkillIdentity;
|
|
86
|
+
if (!identity || identity.schema !== 1 || typeof identity.cloudId !== 'string'
|
|
87
|
+
|| typeof identity.displayName !== 'string' || typeof identity.frontmatterName !== 'string'
|
|
88
|
+
|| !/^[a-f0-9]{64}$/.test(identity.scope) || !/^[a-f0-9]{64}$/.test(identity.folderHash)
|
|
89
|
+
|| !identity.files || !Object.keys(identity.files).includes('SKILL.md')
|
|
90
|
+
|| Object.entries(identity.files).some(([name, hash]) => name.startsWith('/') || name.includes('\\')
|
|
91
|
+
|| name.split('/').some(part => !part || part === '.' || part === '..') || !/^[a-f0-9]{64}$/.test(hash))) {
|
|
92
|
+
throw new Error('Native Skill provenance is incomplete. Existing files were retained.');
|
|
93
|
+
}
|
|
94
|
+
return { ...identity, reference: checkedNativeReference(identity.reference, identity.cloudId) };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function nativeState(skill: CloudSkill): Partial<SyncedSkill> {
|
|
98
|
+
if (!isNativeSkill(skill)) return {};
|
|
99
|
+
return { nativeReference: checkedNativeReference(skill.native_reference, skill.id),
|
|
100
|
+
...(skill.native_settings ? { nativeSettingsRevision: skill.native_settings.revision } : {}),
|
|
101
|
+
nativeDisplayName: skill.display_name || skill.name, nativeScope: skill.native_scope, nativeUnavailable: false };
|
|
102
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { CloudSkill, LocalSkill, NotisSyncState, SyncPullResponse } from './types';
|
|
2
|
+
import { checkedNativeReference, isNativeSkill, sameNativeReference } from './native-identity';
|
|
3
|
+
|
|
4
|
+
/** Folder names may change; existing managed cloud identity may not. */
|
|
5
|
+
export function bindNativeFolders(response: SyncPullResponse, locals: LocalSkill[], state: NotisSyncState): SyncPullResponse {
|
|
6
|
+
const nativeIds = new Set(response.skills.filter(isNativeSkill).map(skill => skill.id));
|
|
7
|
+
for (const entry of Object.values(state.skills)) if (entry.nativeReference || entry.nativeUnavailable) nativeIds.add(entry.cloudId);
|
|
8
|
+
const byId = new Map<string, LocalSkill>();
|
|
9
|
+
for (const local of locals) {
|
|
10
|
+
const id = local.cloudId || state.skills[local.name]?.cloudId;
|
|
11
|
+
if (!id || (!nativeIds.has(id) && !local.nativeReference)) continue;
|
|
12
|
+
if (local.nativeReference) checkedNativeReference(local.nativeReference, id);
|
|
13
|
+
if (byId.has(id)) throw new Error('Two local folders claim the same native Skill. Keep both copies and resolve the conflict.');
|
|
14
|
+
byId.set(id, local);
|
|
15
|
+
}
|
|
16
|
+
const skills = response.skills.map(skill => isNativeSkill(skill) && byId.has(skill.id)
|
|
17
|
+
? { ...skill, name: byId.get(skill.id)!.name } : skill);
|
|
18
|
+
for (const skill of skills.filter(isNativeSkill)) {
|
|
19
|
+
if (skills.some(other => other.id !== skill.id && other.name === skill.name)) {
|
|
20
|
+
throw new Error('A native Skill and another Skill share a local folder. Existing files were retained.');
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
return { ...response, skills };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function previousNativeState(state: NotisSyncState, skill: CloudSkill) {
|
|
27
|
+
const named = state.skills[skill.name];
|
|
28
|
+
return named?.cloudId === skill.id ? named : Object.values(state.skills).find(entry => entry.cloudId === skill.id);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function nativeLocalReady(skill: CloudSkill, local?: LocalSkill): boolean {
|
|
32
|
+
return Boolean(local?.nativeFilesCurrent && local.folderHash === local.nativeBaseFolderHash
|
|
33
|
+
&& local.cloudId === skill.id && local.nativeScope === skill.native_scope
|
|
34
|
+
&& sameNativeReference(local.nativeReference, skill.native_reference));
|
|
35
|
+
}
|
|
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);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function retainMissingNativeState(previous: NotisSyncState, next: NotisSyncState): NotisSyncState {
|
|
43
|
+
const present = new Set(Object.values(next.skills).map(row => row.cloudId));
|
|
44
|
+
const retained = Object.fromEntries(Object.entries(previous.skills)
|
|
45
|
+
.filter(([, row]) => (row.nativeReference || row.nativeUnavailable) && !present.has(row.cloudId))
|
|
46
|
+
.map(([name, row]) => [name, { ...row, nativeUnavailable: true, verifiedAgentLinks: {} }]));
|
|
47
|
+
const history = structuredClone(previous.nativeHistory || {});
|
|
48
|
+
for (const [name, row] of [...Object.entries(previous.skills), ...Object.entries(next.skills)]) {
|
|
49
|
+
if (!row.nativeReference && !row.nativeUnavailable) continue;
|
|
50
|
+
const names = history[row.cloudId]?.names || [];
|
|
51
|
+
history[row.cloudId] = { names: [...new Set([...names, name])] };
|
|
52
|
+
}
|
|
53
|
+
return { ...next, skills: { ...retained, ...next.skills },
|
|
54
|
+
...(Object.keys(history).length ? { nativeHistory: history } : {}) };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function hasNativeFolderHistory(state: NotisSyncState, name: string): boolean {
|
|
58
|
+
return Object.values(state.nativeHistory || {}).some(row => row.names.includes(name));
|
|
59
|
+
}
|
|
@@ -0,0 +1,433 @@
|
|
|
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 AgentTargets, type SkillSyncFailure, 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
|
+
const AGENT_FAILURE_LABELS: Record<string, string> = {
|
|
26
|
+
claude_code: 'Claude Code',
|
|
27
|
+
cursor: 'Cursor',
|
|
28
|
+
codex: 'Codex',
|
|
29
|
+
legacy: 'legacy ~/.agents/skills',
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/** Failures are shown next to skill names in the Portal and CLI; never leak raw agent ids. */
|
|
33
|
+
function agentFailureLabel(agent: string): string {
|
|
34
|
+
return AGENT_FAILURE_LABELS[agent] ?? agent;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function agentFolderFailureName(agent: string): string {
|
|
38
|
+
return `${agentFailureLabel(agent)} skills folder`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface DeletedAgentSymlink {
|
|
42
|
+
skillId: string;
|
|
43
|
+
skillName: string;
|
|
44
|
+
agent: ExternalAgent;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
type AgentSkillDirs = {
|
|
48
|
+
notis: string;
|
|
49
|
+
} & typeof EXTERNAL_AGENT_SKILL_DIRS;
|
|
50
|
+
|
|
51
|
+
export interface SymlinkSyncOptions {
|
|
52
|
+
agentSkillDirs?: Partial<AgentSkillDirs>;
|
|
53
|
+
legacyGlobalSkillsDir?: string;
|
|
54
|
+
removeUndesired?: boolean;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface RemoveSkillSymlinkOptions extends SymlinkSyncOptions {
|
|
58
|
+
ownership?: 'any-managed' | 'exact-skill-dir';
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface SymlinkSyncResult {
|
|
62
|
+
linked: number;
|
|
63
|
+
removed: number;
|
|
64
|
+
skipped: number;
|
|
65
|
+
verifiedAgentLinks?: Record<string, Partial<AgentTargets>>;
|
|
66
|
+
failures?: SkillSyncFailure[];
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Remove only links into another account's managed mirror. This is safe even
|
|
70
|
+
* when automatic sync is disabled: it never changes either account mirror and
|
|
71
|
+
* preserves links owned by the currently authenticated account. */
|
|
72
|
+
export async function removeForeignAccountSymlinks(
|
|
73
|
+
skillsDir: string,
|
|
74
|
+
options: SymlinkSyncOptions = {},
|
|
75
|
+
): Promise<number> {
|
|
76
|
+
const agentSkillDirs = { ...defaultAgentSkillDirs(skillsDir), ...options.agentSkillDirs };
|
|
77
|
+
const currentRoot = path.resolve(skillsDir);
|
|
78
|
+
const foreignCapableRoots = [
|
|
79
|
+
path.join(path.resolve(NOTIS_SKILL_SYNC_ROOT), 'users'),
|
|
80
|
+
path.join(path.resolve(AGENTS_DIR), 'notis', 'users'),
|
|
81
|
+
path.dirname(path.dirname(currentRoot)),
|
|
82
|
+
];
|
|
83
|
+
let removed = 0;
|
|
84
|
+
const legacyGlobalSkillsDir = options.legacyGlobalSkillsDir || LEGACY_AGENTS_SKILLS_DIR;
|
|
85
|
+
for (const agentDir of new Set([...Object.values(agentSkillDirs), legacyGlobalSkillsDir])) {
|
|
86
|
+
let entries;
|
|
87
|
+
try {
|
|
88
|
+
entries = await fs.readdir(agentDir, { withFileTypes: true });
|
|
89
|
+
} catch (error) {
|
|
90
|
+
if ((error as NodeJS.ErrnoException)?.code === 'ENOENT') continue;
|
|
91
|
+
throw error;
|
|
92
|
+
}
|
|
93
|
+
for (const entry of entries) {
|
|
94
|
+
if (!entry.isSymbolicLink()) continue;
|
|
95
|
+
const entryPath = path.join(agentDir, entry.name);
|
|
96
|
+
try {
|
|
97
|
+
const target = await fs.readlink(entryPath);
|
|
98
|
+
const resolvedTarget = path.resolve(path.dirname(entryPath), target);
|
|
99
|
+
const belongsToAnyAccount = foreignCapableRoots.some((root) => (
|
|
100
|
+
resolvedTarget.startsWith(`${root}${path.sep}`)
|
|
101
|
+
));
|
|
102
|
+
const belongsToCurrentAccount = resolvedTarget === currentRoot
|
|
103
|
+
|| resolvedTarget.startsWith(`${currentRoot}${path.sep}`);
|
|
104
|
+
if (belongsToAnyAccount && !belongsToCurrentAccount) {
|
|
105
|
+
await fs.unlink(entryPath);
|
|
106
|
+
removed += 1;
|
|
107
|
+
}
|
|
108
|
+
} catch (error) {
|
|
109
|
+
// A disappearing link is already safe. Permission and I/O failures
|
|
110
|
+
// must fail closed: otherwise a previous account's link can remain
|
|
111
|
+
// exposed while the repeating sync reports success.
|
|
112
|
+
if ((error as NodeJS.ErrnoException)?.code !== 'ENOENT') throw error;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return removed;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function managedSkillRoots(
|
|
120
|
+
skillsDir: string,
|
|
121
|
+
legacyGlobalSkillsDir: string = LEGACY_AGENTS_SKILLS_DIR,
|
|
122
|
+
): string[] {
|
|
123
|
+
const resolvedSkillsDir = path.resolve(skillsDir);
|
|
124
|
+
const scopedUsersRoot = path.dirname(path.dirname(resolvedSkillsDir));
|
|
125
|
+
const roots = [
|
|
126
|
+
resolvedSkillsDir,
|
|
127
|
+
path.resolve(legacyGlobalSkillsDir),
|
|
128
|
+
path.join(path.resolve(NOTIS_SKILL_SYNC_ROOT), 'users'),
|
|
129
|
+
path.join(path.resolve(AGENTS_DIR), 'notis', 'users'),
|
|
130
|
+
];
|
|
131
|
+
if (path.basename(scopedUsersRoot) === 'users') {
|
|
132
|
+
roots.push(scopedUsersRoot);
|
|
133
|
+
}
|
|
134
|
+
return roots;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async function isManagedSymlink(linkPath: string, managedRoots: string[]): Promise<boolean> {
|
|
138
|
+
try {
|
|
139
|
+
const stats = await fs.lstat(linkPath);
|
|
140
|
+
if (!stats.isSymbolicLink()) {
|
|
141
|
+
return false;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const target = await fs.readlink(linkPath);
|
|
145
|
+
const resolvedTarget = path.resolve(path.dirname(linkPath), target);
|
|
146
|
+
return managedRoots.some((root) => (
|
|
147
|
+
resolvedTarget === root || resolvedTarget.startsWith(`${root}${path.sep}`)
|
|
148
|
+
));
|
|
149
|
+
} catch (error) {
|
|
150
|
+
if ((error as NodeJS.ErrnoException)?.code === 'ENOENT') return false;
|
|
151
|
+
throw error;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async function ensureCorrectSymlink(linkPath: string, targetPath: string): Promise<'linked' | 'skipped' | 'blocked'> {
|
|
156
|
+
try {
|
|
157
|
+
const stats = await fs.lstat(linkPath);
|
|
158
|
+
if (stats.isSymbolicLink()) {
|
|
159
|
+
const currentTarget = await fs.readlink(linkPath);
|
|
160
|
+
const resolvedTarget = path.resolve(path.dirname(linkPath), currentTarget);
|
|
161
|
+
if (resolvedTarget === targetPath) {
|
|
162
|
+
return 'skipped';
|
|
163
|
+
}
|
|
164
|
+
await fs.unlink(linkPath);
|
|
165
|
+
} else {
|
|
166
|
+
return 'blocked';
|
|
167
|
+
}
|
|
168
|
+
} catch (error) {
|
|
169
|
+
if ((error as NodeJS.ErrnoException)?.code !== 'ENOENT') throw error;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const relativePath = path.relative(path.dirname(linkPath), targetPath);
|
|
173
|
+
await fs.symlink(relativePath, linkPath);
|
|
174
|
+
return 'linked';
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function defaultAgentSkillDirs(skillsDir: string): AgentSkillDirs {
|
|
178
|
+
return {
|
|
179
|
+
notis: skillsDir,
|
|
180
|
+
...EXTERNAL_AGENT_SKILL_DIRS,
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
async function removeUndesiredManagedSymlinks(
|
|
185
|
+
agentDir: string,
|
|
186
|
+
desiredSkills: Set<string>,
|
|
187
|
+
managedRoots: string[],
|
|
188
|
+
failures: SkillSyncFailure[],
|
|
189
|
+
agent: string,
|
|
190
|
+
): Promise<number> {
|
|
191
|
+
let removed = 0;
|
|
192
|
+
try {
|
|
193
|
+
await fs.mkdir(agentDir, { recursive: true });
|
|
194
|
+
const existingEntries = await fs.readdir(agentDir, { withFileTypes: true });
|
|
195
|
+
for (const entry of existingEntries) {
|
|
196
|
+
const entryPath = path.join(agentDir, entry.name);
|
|
197
|
+
try {
|
|
198
|
+
if (!desiredSkills.has(entry.name) && await isManagedSymlink(entryPath, managedRoots)) {
|
|
199
|
+
await fs.unlink(entryPath);
|
|
200
|
+
removed += 1;
|
|
201
|
+
}
|
|
202
|
+
} catch (error) {
|
|
203
|
+
if ((error as NodeJS.ErrnoException)?.code !== 'ENOENT') {
|
|
204
|
+
failures.push({ name: entry.name, error: `${agentFailureLabel(agent)}: could not remove skill link (${(error as Error).message})` });
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
} catch (error) {
|
|
209
|
+
failures.push({ name: agentFolderFailureName(agent), error: `Could not read agent skills directory (${(error as Error).message})` });
|
|
210
|
+
}
|
|
211
|
+
return removed;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
export async function removeAllSymlinksForSkill(
|
|
215
|
+
skillName: string,
|
|
216
|
+
skillsDir: string = LEGACY_AGENTS_SKILLS_DIR,
|
|
217
|
+
options: RemoveSkillSymlinkOptions = {},
|
|
218
|
+
): Promise<number> {
|
|
219
|
+
let removed = 0;
|
|
220
|
+
const agentSkillDirs = { ...defaultAgentSkillDirs(skillsDir), ...options.agentSkillDirs };
|
|
221
|
+
const legacyGlobalSkillsDir = options.legacyGlobalSkillsDir || LEGACY_AGENTS_SKILLS_DIR;
|
|
222
|
+
const managedRoots = managedSkillRoots(skillsDir, legacyGlobalSkillsDir);
|
|
223
|
+
const agentDirs = new Set([...Object.values(agentSkillDirs), legacyGlobalSkillsDir]);
|
|
224
|
+
const expectedTarget = path.resolve(skillsDir, safeName(skillName, skillsDir));
|
|
225
|
+
for (const agentDir of agentDirs) {
|
|
226
|
+
const linkPath = path.join(agentDir, safeName(skillName, agentDir));
|
|
227
|
+
const owned = options.ownership === 'exact-skill-dir'
|
|
228
|
+
? await isManagedSymlink(linkPath, [expectedTarget])
|
|
229
|
+
: await isManagedSymlink(linkPath, managedRoots);
|
|
230
|
+
if (owned) {
|
|
231
|
+
await fs.unlink(linkPath);
|
|
232
|
+
removed += 1;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
return removed;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Detect per-agent skill symlinks that we created on a prior sync but that the user has since
|
|
240
|
+
* deleted locally (e.g. `rm ~/.claude/skills/<skill>`). The caller turns each result into a
|
|
241
|
+
* per-agent deactivation so the deletion sticks instead of being recreated on the next pass.
|
|
242
|
+
*
|
|
243
|
+
* A pair is flagged only when the skill is cloud-active for that agent, the previous sync state
|
|
244
|
+
* recorded it active for that agent (so we know we created the link), and the link is now truly
|
|
245
|
+
* absent (lstat ENOENT). Stale/wrong-target links and real files blocking the path are NOT
|
|
246
|
+
* deletions — those are repaired/skipped by syncSymlinks as before. Only external agents are
|
|
247
|
+
* considered; `notis` has no symlink (its dir is the source skills folder).
|
|
248
|
+
*/
|
|
249
|
+
export async function detectDeletedAgentSymlinks(
|
|
250
|
+
cloudSkills: CloudSkill[],
|
|
251
|
+
previousState: NotisSyncState,
|
|
252
|
+
skillsDir: string = LEGACY_AGENTS_SKILLS_DIR,
|
|
253
|
+
options: SymlinkSyncOptions = {},
|
|
254
|
+
): Promise<DeletedAgentSymlink[]> {
|
|
255
|
+
const agentSkillDirs = { ...defaultAgentSkillDirs(skillsDir), ...options.agentSkillDirs };
|
|
256
|
+
const deletions: DeletedAgentSymlink[] = [];
|
|
257
|
+
|
|
258
|
+
for (const agent of EXTERNAL_AGENTS) {
|
|
259
|
+
const agentDir = agentSkillDirs[agent];
|
|
260
|
+
if (path.resolve(agentDir) === path.resolve(skillsDir)) {
|
|
261
|
+
continue;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// A missing (or non-directory) agent skills directory is NOT a per-skill deletion: removing
|
|
265
|
+
// ~/.claude/skills wholesale would otherwise make every entry's link path ENOENT and
|
|
266
|
+
// deactivate every skill for that agent. syncSymlinks recreates the directory and its links,
|
|
267
|
+
// so only absent entries INSIDE an existing agent directory count as explicit user deletions.
|
|
268
|
+
try {
|
|
269
|
+
const dirStat = await fs.stat(agentDir);
|
|
270
|
+
if (!dirStat.isDirectory()) {
|
|
271
|
+
continue;
|
|
272
|
+
}
|
|
273
|
+
} catch {
|
|
274
|
+
continue;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
for (const skill of cloudSkills) {
|
|
278
|
+
if (skill.status !== 'active') {
|
|
279
|
+
continue;
|
|
280
|
+
}
|
|
281
|
+
if (!skill.id || skill.id.startsWith('local-')) {
|
|
282
|
+
continue;
|
|
283
|
+
}
|
|
284
|
+
const previous = previousState.skills[skill.name];
|
|
285
|
+
if (!previous || previous.cloudId !== skill.id
|
|
286
|
+
|| previous.verifiedAgentLinks?.[agent] !== true
|
|
287
|
+
|| !skill.updated_at || previous.cloudUpdatedAt !== skill.updated_at) {
|
|
288
|
+
continue;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
const cloudTargets = normalizeAgentTargets(skill.agent_targets);
|
|
292
|
+
const previousTargets = normalizeAgentTargets(previous.agentTargets);
|
|
293
|
+
if (!cloudTargets[agent] || !previousTargets[agent]) {
|
|
294
|
+
continue;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
let safeSkillName: string;
|
|
298
|
+
try {
|
|
299
|
+
safeSkillName = safeName(skill.name, skillsDir);
|
|
300
|
+
} catch {
|
|
301
|
+
continue;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
const linkPath = path.join(agentDir, safeName(safeSkillName, agentDir));
|
|
305
|
+
try {
|
|
306
|
+
await fs.lstat(linkPath);
|
|
307
|
+
} catch (error) {
|
|
308
|
+
if ((error as NodeJS.ErrnoException)?.code === 'ENOENT') {
|
|
309
|
+
deletions.push({ skillId: skill.id, skillName: skill.name, agent });
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
return deletions;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
export async function syncSymlinks(
|
|
319
|
+
skills: CloudSkill[],
|
|
320
|
+
skillsDir: string = LEGACY_AGENTS_SKILLS_DIR,
|
|
321
|
+
options: SymlinkSyncOptions = {},
|
|
322
|
+
): Promise<SymlinkSyncResult> {
|
|
323
|
+
await fs.mkdir(skillsDir, { recursive: true });
|
|
324
|
+
|
|
325
|
+
const result: Required<SymlinkSyncResult> = {
|
|
326
|
+
linked: 0,
|
|
327
|
+
removed: 0,
|
|
328
|
+
skipped: 0,
|
|
329
|
+
verifiedAgentLinks: {},
|
|
330
|
+
failures: [],
|
|
331
|
+
};
|
|
332
|
+
const agentSkillDirs = { ...defaultAgentSkillDirs(skillsDir), ...options.agentSkillDirs };
|
|
333
|
+
const legacyGlobalSkillsDir = options.legacyGlobalSkillsDir || LEGACY_AGENTS_SKILLS_DIR;
|
|
334
|
+
const managedRoots = managedSkillRoots(skillsDir, legacyGlobalSkillsDir);
|
|
335
|
+
|
|
336
|
+
const desiredByAgent = {
|
|
337
|
+
notis: new Set<string>(),
|
|
338
|
+
claude_code: new Set<string>(),
|
|
339
|
+
cursor: new Set<string>(),
|
|
340
|
+
codex: new Set<string>(),
|
|
341
|
+
};
|
|
342
|
+
|
|
343
|
+
for (const skill of skills) {
|
|
344
|
+
if (skill.status !== 'active') {
|
|
345
|
+
continue;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
const safeSkillName = safeName(skill.name, skillsDir);
|
|
349
|
+
const targets = normalizeAgentTargets(skill.agent_targets);
|
|
350
|
+
if (targets.notis) {
|
|
351
|
+
desiredByAgent.notis.add(safeSkillName);
|
|
352
|
+
}
|
|
353
|
+
if (targets.claude_code) {
|
|
354
|
+
desiredByAgent.claude_code.add(safeSkillName);
|
|
355
|
+
}
|
|
356
|
+
if (targets.cursor) {
|
|
357
|
+
desiredByAgent.cursor.add(safeSkillName);
|
|
358
|
+
}
|
|
359
|
+
if (targets.codex) {
|
|
360
|
+
desiredByAgent.codex.add(safeSkillName);
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
for (const [agent, agentDir] of Object.entries(agentSkillDirs) as Array<[keyof AgentSkillDirs, string]>) {
|
|
365
|
+
if (path.resolve(agentDir) === path.resolve(skillsDir)) {
|
|
366
|
+
result.skipped += desiredByAgent[agent].size;
|
|
367
|
+
continue;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
try {
|
|
371
|
+
await fs.mkdir(agentDir, { recursive: true });
|
|
372
|
+
} catch (error) {
|
|
373
|
+
result.failures.push({ name: agentFolderFailureName(agent), error: `Could not create agent skills directory (${(error as Error).message})` });
|
|
374
|
+
continue;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
const desiredSkills = desiredByAgent[agent];
|
|
378
|
+
if (options.removeUndesired !== false) {
|
|
379
|
+
result.removed += await removeUndesiredManagedSymlinks(agentDir, desiredSkills, managedRoots, result.failures, agent);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
for (const skillName of desiredSkills) {
|
|
383
|
+
const targetPath = path.join(skillsDir, skillName);
|
|
384
|
+
const linkPath = path.join(agentDir, safeName(skillName, agentDir));
|
|
385
|
+
try {
|
|
386
|
+
if (!(await fs.stat(path.join(targetPath, "SKILL.md"))).isFile()) throw new Error("Missing SKILL.md");
|
|
387
|
+
} catch {
|
|
388
|
+
result.skipped += 1;
|
|
389
|
+
result.failures.push({ name: skillName, error: `${agentFailureLabel(agent)}: SKILL.md is missing or unreadable` });
|
|
390
|
+
continue;
|
|
391
|
+
}
|
|
392
|
+
let syncOutcome;
|
|
393
|
+
try {
|
|
394
|
+
syncOutcome = await ensureCorrectSymlink(linkPath, targetPath);
|
|
395
|
+
} catch (error) {
|
|
396
|
+
result.skipped += 1;
|
|
397
|
+
result.failures.push({ name: skillName, error: `${agentFailureLabel(agent)}: could not create skill link (${(error as Error).message})` });
|
|
398
|
+
continue;
|
|
399
|
+
}
|
|
400
|
+
if (syncOutcome === 'linked') {
|
|
401
|
+
result.linked += 1;
|
|
402
|
+
} else if (syncOutcome === 'blocked') {
|
|
403
|
+
console.warn(
|
|
404
|
+
`[skill-sync] Could not link "${skillName}" for ${agent}: non-symlink entry blocks ${linkPath}`,
|
|
405
|
+
);
|
|
406
|
+
result.skipped += 1;
|
|
407
|
+
result.failures.push({ name: skillName, error: `${agentFailureLabel(agent)}: an existing file or folder blocks the skill link` });
|
|
408
|
+
} else {
|
|
409
|
+
result.skipped += 1;
|
|
410
|
+
}
|
|
411
|
+
if (syncOutcome !== "blocked") {
|
|
412
|
+
result.verifiedAgentLinks[skillName] = { ...result.verifiedAgentLinks[skillName], [agent]: true };
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
if (
|
|
418
|
+
options.removeUndesired !== false
|
|
419
|
+
&& !Object.values(agentSkillDirs).some(
|
|
420
|
+
(agentDir) => path.resolve(agentDir) === path.resolve(legacyGlobalSkillsDir),
|
|
421
|
+
)
|
|
422
|
+
) {
|
|
423
|
+
result.removed += await removeUndesiredManagedSymlinks(
|
|
424
|
+
legacyGlobalSkillsDir,
|
|
425
|
+
new Set(),
|
|
426
|
+
managedRoots,
|
|
427
|
+
result.failures,
|
|
428
|
+
'legacy',
|
|
429
|
+
);
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
return result;
|
|
433
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { CloudSkill, LocalSkill, NotisSyncState } from "./types";
|
|
2
|
+
import { isNativeSkill } from './native-identity';
|
|
3
|
+
import { hasNativeFolderHistory } from './native-plan';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* One cloud row per local folder. Several active rows can carry the same skill name
|
|
7
|
+
* (duplicates created by earlier pushes), and every one of them writes to the same
|
|
8
|
+
* directory. Applying them all in
|
|
9
|
+
* one pass replaced that directory several times per sync and left whichever row came
|
|
10
|
+
* last on disk. The newest revision wins, deterministically.
|
|
11
|
+
*/
|
|
12
|
+
export function selectCloudSkillsToApply(skills: CloudSkill[]): CloudSkill[] {
|
|
13
|
+
const winners = new Map<string, CloudSkill>();
|
|
14
|
+
for (const skill of skills) {
|
|
15
|
+
const current = winners.get(skill.name);
|
|
16
|
+
if (!current || (skill.updated_at || "") >= (current.updated_at || "")) {
|
|
17
|
+
winners.set(skill.name, skill);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
return [...winners.values()];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function getPushCandidates(
|
|
24
|
+
localSkills: LocalSkill[],
|
|
25
|
+
syncState: NotisSyncState,
|
|
26
|
+
cloudCuratedSkillNames: ReadonlySet<string> = new Set(),
|
|
27
|
+
cloudSkillNames?: ReadonlySet<string>,
|
|
28
|
+
cloudSkills: CloudSkill[] = [],
|
|
29
|
+
): LocalSkill[] {
|
|
30
|
+
return localSkills.filter((skill) => {
|
|
31
|
+
if (cloudCuratedSkillNames.has(skill.name)) {
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
const previous = syncState.skills[skill.name]
|
|
35
|
+
|| (skill.cloudId ? Object.values(syncState.skills).find(row => row.cloudId === skill.cloudId) : undefined);
|
|
36
|
+
const native = cloudSkills.find(row => isNativeSkill(row) && row.id === (skill.cloudId || previous?.cloudId));
|
|
37
|
+
if (native || skill.nativeReference || previous?.nativeReference || previous?.nativeUnavailable) {
|
|
38
|
+
return Boolean(native && skill.nativeReference && skill.nativeScope === native.native_scope
|
|
39
|
+
&& (previous ? previous.folderHash !== skill.folderHash : skill.nativeBaseFolderHash !== skill.folderHash));
|
|
40
|
+
}
|
|
41
|
+
if (!previous) {
|
|
42
|
+
if (hasNativeFolderHistory(syncState, skill.name) || /^native-[a-f0-9-]{36}$/.test(skill.name)) return false;
|
|
43
|
+
return true;
|
|
44
|
+
}
|
|
45
|
+
if (cloudSkillNames && !cloudSkillNames.has(skill.name)) {
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
return previous.folderHash !== skill.folderHash;
|
|
49
|
+
}).map(skill => {
|
|
50
|
+
const previous = syncState.skills[skill.name]
|
|
51
|
+
|| Object.values(syncState.skills).find(row => row.cloudId === skill.cloudId);
|
|
52
|
+
return { ...skill, ...(skill.cloudId || previous?.cloudId ? { cloudId: skill.cloudId || previous!.cloudId } : {}) };
|
|
53
|
+
});
|
|
54
|
+
}
|