sfora-cli 0.15.0 → 0.17.0

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.
Files changed (94) hide show
  1. package/README.md +80 -1
  2. package/dist/agent-webhook.d.ts +20 -0
  3. package/dist/agent-webhook.js +42 -0
  4. package/dist/api-client.d.ts +97 -0
  5. package/dist/api-client.js +68 -0
  6. package/dist/ask.d.ts +51 -0
  7. package/dist/ask.js +70 -0
  8. package/dist/attachments-node.d.ts +7 -0
  9. package/dist/attachments-node.js +15 -0
  10. package/dist/attachments.d.ts +112 -0
  11. package/dist/attachments.js +254 -0
  12. package/dist/block-commands.d.ts +10 -0
  13. package/dist/block-commands.js +28 -0
  14. package/dist/chat.d.ts +15 -0
  15. package/dist/chat.js +7 -0
  16. package/dist/cli-args.d.ts +7 -0
  17. package/dist/cli-args.js +28 -1
  18. package/dist/cli.d.ts +12 -1
  19. package/dist/cli.js +186 -19
  20. package/dist/format/linkUrls.d.ts +2 -0
  21. package/dist/format/linkUrls.js +48 -0
  22. package/dist/format/postMarkdown.d.ts +12 -1
  23. package/dist/format/postMarkdown.js +9 -2
  24. package/dist/index.d.ts +23 -1
  25. package/dist/index.js +17 -1
  26. package/dist/local-core/files.d.ts +1 -1
  27. package/dist/local-core/files.js +2 -2
  28. package/dist/local-core/index.d.ts +12 -0
  29. package/dist/local-core/index.js +11 -0
  30. package/dist/local-core/skill-adapters.d.ts +21 -0
  31. package/dist/local-core/skill-adapters.js +19 -0
  32. package/dist/local-core/skill-discovery.d.ts +22 -0
  33. package/dist/local-core/skill-discovery.js +79 -0
  34. package/dist/local-core/skill-domain.d.ts +74 -0
  35. package/dist/local-core/skill-domain.js +1 -0
  36. package/dist/local-core/skill-executor.d.ts +23 -0
  37. package/dist/local-core/skill-executor.js +51 -0
  38. package/dist/local-core/skill-index.d.ts +54 -0
  39. package/dist/local-core/skill-index.js +115 -0
  40. package/dist/local-core/skill-local-executor.d.ts +18 -0
  41. package/dist/local-core/skill-local-executor.js +249 -0
  42. package/dist/local-core/skill-operations.d.ts +61 -0
  43. package/dist/local-core/skill-operations.js +268 -0
  44. package/dist/local-core/skill-review.d.ts +46 -0
  45. package/dist/local-core/skill-review.js +132 -0
  46. package/dist/local-core/skill-service.d.ts +96 -0
  47. package/dist/local-core/skill-service.js +157 -0
  48. package/dist/local-core/skill-store.d.ts +34 -0
  49. package/dist/local-core/skill-store.js +187 -0
  50. package/dist/local-core/skill-sync.d.ts +132 -0
  51. package/dist/local-core/skill-sync.js +111 -0
  52. package/dist/local-core/skills.d.ts +35 -0
  53. package/dist/local-core/skills.js +142 -37
  54. package/dist/mcp-description.d.ts +11 -0
  55. package/dist/mcp-description.js +29 -0
  56. package/dist/mcp-server.d.ts +5 -1
  57. package/dist/mcp-server.js +28 -18
  58. package/dist/shell-commands.d.ts +7 -1
  59. package/dist/shell-commands.js +49 -3
  60. package/dist/skills-client.d.ts +13 -2
  61. package/dist/skills-client.js +57 -5
  62. package/dist/skills-command.d.ts +1 -1
  63. package/dist/skills-command.js +171 -4
  64. package/dist/skills-packet/sfora-asks/SKILL.md +49 -0
  65. package/dist/skills-packet/sfora-asks/references/asks.md +41 -0
  66. package/dist/skills-packet/sfora-board/SKILL.md +53 -0
  67. package/dist/skills-packet/sfora-board/references/board.md +60 -0
  68. package/dist/skills-packet/sfora-board/references/plan.md +25 -0
  69. package/dist/skills-packet/sfora-chat/SKILL.md +55 -0
  70. package/dist/skills-packet/sfora-chat/references/rooms.md +45 -0
  71. package/dist/skills-packet/sfora-chat/references/waiting.md +37 -0
  72. package/dist/skills-packet/sfora-live-edit/SKILL.md +62 -0
  73. package/dist/skills-packet/sfora-live-edit/references/collisions.md +54 -0
  74. package/dist/skills-packet/sfora-live-edit/references/http.md +63 -0
  75. package/dist/skills-packet/sfora-live-edit/references/live-editing.md +49 -0
  76. package/dist/skills-packet/sfora-setup/SKILL.md +37 -0
  77. package/dist/skills-packet/sfora-setup/references/sign-in.md +43 -0
  78. package/dist/skills-packet/sfora-skills/SKILL.md +54 -0
  79. package/dist/skills-packet/sfora-skills/references/skills.md +78 -0
  80. package/dist/skills-packet/sfora-troubleshoot/SKILL.md +39 -0
  81. package/dist/skills-packet/sfora-troubleshoot/references/sharp-edges.md +80 -0
  82. package/dist/skills-packet/sfora-write/SKILL.md +53 -0
  83. package/dist/skills-packet/sfora-write/references/attachments.md +15 -0
  84. package/dist/skills-packet/sfora-write/references/blocks.md +36 -0
  85. package/dist/skills-packet/sfora-write/references/posts-and-docs.md +56 -0
  86. package/dist/skills-packet.d.ts +63 -0
  87. package/dist/skills-packet.js +166 -0
  88. package/dist/typing.d.ts +23 -0
  89. package/dist/typing.js +62 -0
  90. package/dist/version.d.ts +1 -1
  91. package/dist/version.js +1 -1
  92. package/dist/watch.d.ts +78 -1
  93. package/dist/watch.js +109 -0
  94. package/package.json +4 -4
@@ -0,0 +1,132 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { readSkillBundle, inspectSkillTarget } from './skills.js';
3
+ import { SkillBindingService, planSkillInstallation, planSkillTransfer } from './skill-sync.js';
4
+ import { SkillOperationExecutor } from './skill-executor.js';
5
+ /** Read-only previews become durable operations only when the person confirms.
6
+ * Ephemeral review IDs are capability tokens owned by the desktop main process. */
7
+ export class SkillReviewService {
8
+ store;
9
+ journal;
10
+ connect;
11
+ now;
12
+ reviews = new Map();
13
+ constructor(store, journal, connect, now = Date.now) {
14
+ this.store = store;
15
+ this.journal = journal;
16
+ this.connect = connect;
17
+ this.now = now;
18
+ }
19
+ remember(entry) {
20
+ for (const [id, item] of this.reviews)
21
+ if (item.expiresAt < this.now() && (!item.operationId || item.result) && !item.running)
22
+ this.reviews.delete(id);
23
+ if (this.reviews.size >= 50)
24
+ throw new Error('Too many open reviews. Finish existing transfers or reopen the app.');
25
+ this.reviews.set(entry.view.id, { ...entry, expiresAt: this.now() + 10 * 60_000 });
26
+ return structuredClone(entry.view);
27
+ }
28
+ view(remote, path, action) {
29
+ return { id: randomUUID(), action, outcome: 'reviewable', name: remote.locator.name, project: remote.locator.project,
30
+ scope: `${remote.remote.deployment} · ${remote.remote.organizationId} · ${remote.accountId}`, path, version: remote.revision.cloudVersion, files: [], blockers: [] };
31
+ }
32
+ async transfer(input) {
33
+ if (!['push', 'pull'].includes(input.direction))
34
+ throw new Error('Choose Push to Sfora or Update local copy.');
35
+ const catalog = await this.store.read();
36
+ const location = catalog.locations.find(l => l.id === input.locationId);
37
+ if (!location)
38
+ throw new Error('This local skill is no longer tracked. Refresh and choose it again.');
39
+ const local = await readSkillBundle(location.path);
40
+ const cloud = await this.connect(input.connection);
41
+ const names = [...new Set(catalog.bindings.filter(b => b.localSkillId === location.skillId && b.remote.projectId === input.projectId).map(b => b.locator.name))];
42
+ let remote;
43
+ try {
44
+ remote = await cloud.snapshot(input.projectId, names.length === 1 ? names[0] : local.name);
45
+ }
46
+ catch (error) {
47
+ const code = error?.code;
48
+ if (names.length || !['SKILL_NOT_FOUND', 'SKILL_NOT_PUBLISHED'].includes(code ?? ''))
49
+ throw error;
50
+ if (input.direction === 'pull')
51
+ throw new Error('This skill has no published version to install. Open its project to review or publish a draft.');
52
+ return this.remember({ connection: input.connection, view: { id: randomUUID(), action: 'push', outcome: 'draft-needed', name: local.name, project: input.projectId, scope: input.connection, path: location.path,
53
+ files: local.files.map(file => ({ path: file.path, kind: 'added' })), blockers: ['For the first publication, review these local files in the project editor, save a draft, then publish it for your team.'] } });
54
+ }
55
+ if (remote.remote.projectId !== input.projectId)
56
+ throw new Error('The selected project resolved to a different identity.');
57
+ const binding = catalog.bindings.find(b => b.localSkillId === location.skillId && b.accountId === remote.accountId && b.remote.deployment === remote.remote.deployment && b.remote.organizationId === remote.remote.organizationId && b.remote.projectId === remote.remote.projectId);
58
+ const view = this.view(remote, location.path, input.direction);
59
+ if (!binding) {
60
+ if (local.hash !== remote.revision.hash) {
61
+ view.outcome = 'blocked';
62
+ view.blockers = ['These copies have not been linked and their contents differ. Open the project to review its existing skill, or install it into a new folder.'];
63
+ return this.remember({ view, connection: input.connection });
64
+ }
65
+ view.action = 'link';
66
+ return this.remember({ view, connection: input.connection, link: { localSkillId: location.skillId, locationId: location.id, snapshot: remote } });
67
+ }
68
+ const target = await inspectSkillTarget(location.path);
69
+ const plan = planSkillTransfer({ direction: input.direction, binding, location, local: { state: 'available', bundle: local }, remote, ownership: target.ownership });
70
+ view.outcome = plan.outcome;
71
+ view.files = plan.files.map(f => ({ path: f.path, kind: f.kind }));
72
+ view.blockers = plan.blockers;
73
+ return this.remember({ view, connection: input.connection, plan });
74
+ }
75
+ async install(input) {
76
+ const remote = await (await this.connect(input.connection)).snapshot(input.projectId, input.name);
77
+ if (remote.remote.projectId !== input.projectId)
78
+ throw new Error('The selected project resolved to a different identity.');
79
+ const plan = planSkillInstallation(remote, input.destination, await inspectSkillTarget(input.destination));
80
+ const view = this.view(remote, input.destination, 'install');
81
+ view.outcome = plan.outcome;
82
+ view.files = plan.files.map(f => ({ path: f.path, kind: f.kind }));
83
+ view.blockers = plan.blockers;
84
+ return this.remember({ view, connection: input.connection, plan });
85
+ }
86
+ async confirm(id) {
87
+ const entry = this.reviews.get(id);
88
+ if (!entry || (!entry.operationId && entry.expiresAt < this.now()))
89
+ throw new Error('This review expired. Review the current changes again.');
90
+ if (entry.view.outcome !== 'reviewable')
91
+ throw new Error('There are no applicable changes in this review.');
92
+ if (entry.result)
93
+ return entry.result;
94
+ if (entry.running)
95
+ return entry.running;
96
+ entry.running = this.apply(entry).then(result => { if (result.status === 'succeeded') {
97
+ entry.result = result;
98
+ delete entry.plan;
99
+ delete entry.link;
100
+ } return result; }).finally(() => { entry.running = undefined; });
101
+ return entry.running;
102
+ }
103
+ async apply(entry) {
104
+ const cloud = await this.connect(entry.connection);
105
+ if (entry.link) {
106
+ const { snapshot, locationId, localSkillId } = entry.link;
107
+ const location = (await this.store.read()).locations.find(l => l.id === locationId && l.skillId === localSkillId);
108
+ if (!location || location.path !== entry.view.path)
109
+ throw new Error('Local location changed. Review the copies again.');
110
+ const remote = await cloud.snapshot(snapshot.locator.project, snapshot.locator.name);
111
+ if (JSON.stringify(remote.remote) !== JSON.stringify(snapshot.remote) || remote.accountId !== snapshot.accountId || JSON.stringify(remote.revision) !== JSON.stringify(snapshot.revision))
112
+ throw new Error('Cloud identity or revision changed. Review the copies again.');
113
+ const local = await readSkillBundle(location.path);
114
+ if (local.hash !== remote.revision.hash)
115
+ throw new Error('Local files changed. Review the copies again.');
116
+ await new SkillBindingService(this.store).adopt(localSkillId, remote, local);
117
+ return { status: 'succeeded' };
118
+ }
119
+ if (!entry.plan)
120
+ throw new Error('Review has no applicable changes.');
121
+ const recovering = !!entry.operationId;
122
+ if (!entry.operationId)
123
+ entry.operationId = (await this.journal.create(entry.plan)).id;
124
+ try {
125
+ const operation = await new SkillOperationExecutor(this.store, this.journal, cloud).apply(entry.operationId, recovering);
126
+ return { status: operation.status, operationId: operation.id };
127
+ }
128
+ catch (error) {
129
+ throw new Error(`${error.message} The result is saved in Transfers for recovery.`);
130
+ }
131
+ }
132
+ }
@@ -0,0 +1,96 @@
1
+ import { discoverSkills, type SkillSource } from './skill-discovery.js';
2
+ import { type RegisteredSkillRoot, type SkillStore } from './skill-store.js';
3
+ import { readSkillBundle } from './skills.js';
4
+ export declare const pathWithin: (root: string, path: string) => boolean;
5
+ export interface SkillServicePorts {
6
+ discover: typeof discoverSkills;
7
+ resolveDirectory(path: string): Promise<string>;
8
+ inspect: typeof readSkillBundle;
9
+ }
10
+ /** Node service shared by CLI and desktop. Its IDs are identity, not renderer grants. */
11
+ export declare class SkillService {
12
+ readonly store: SkillStore;
13
+ readonly sources: () => SkillSource[];
14
+ readonly ports: SkillServicePorts;
15
+ constructor(store?: SkillStore, sources?: () => SkillSource[], ports?: SkillServicePorts);
16
+ registerRoot(path: string, label?: string, scope?: "custom" | "project"): Promise<{
17
+ id: string;
18
+ path: string;
19
+ label: string;
20
+ depth: number;
21
+ scope?: "custom" | "project";
22
+ }>;
23
+ roots(): Promise<RegisteredSkillRoot[]>;
24
+ removeRoot(id: string): Promise<void>;
25
+ inventory(): Promise<{
26
+ skills: {
27
+ skillId: string;
28
+ locationId: string;
29
+ path: string;
30
+ name: string;
31
+ sources: string[];
32
+ aliases: import("./skill-domain.js").SkillAlias[];
33
+ hash: string | undefined;
34
+ fileCount: number | undefined;
35
+ error: string | undefined;
36
+ availability: "available" | "unavailable";
37
+ }[];
38
+ warnings: string[];
39
+ }>;
40
+ /** Warm reads use persisted observations; explicit inventory remains a full reconciliation. */
41
+ cachedInventory(): Promise<{
42
+ skills: {
43
+ skillId: string;
44
+ locationId: string;
45
+ path: string;
46
+ name: string;
47
+ sources: string[];
48
+ aliases: import("./skill-domain.js").SkillAlias[];
49
+ hash: string | undefined;
50
+ fileCount: number | undefined;
51
+ error: string | undefined;
52
+ availability: "available" | "unavailable";
53
+ }[];
54
+ warnings: string[];
55
+ }>;
56
+ discoverySources(): Promise<SkillSource[]>;
57
+ /** Re-read only affected known locations, preserving identity and last known bytes on failure. */
58
+ refreshLocations(ids: string[]): Promise<{
59
+ skills: {
60
+ skillId: string;
61
+ locationId: string;
62
+ path: string;
63
+ name: string;
64
+ sources: string[];
65
+ aliases: import("./skill-domain.js").SkillAlias[];
66
+ hash: string | undefined;
67
+ fileCount: number | undefined;
68
+ error: string | undefined;
69
+ availability: "available" | "unavailable";
70
+ }[];
71
+ warnings: string[];
72
+ }>;
73
+ /** Metadata-only explicit relocation. Never guesses identity from a name/hash or moves files. */
74
+ relocateLocation(id: string, path: string): Promise<{
75
+ id: string;
76
+ skillId: string;
77
+ path: string;
78
+ aliases: import("./skill-domain.js").SkillAlias[];
79
+ createdAt: number;
80
+ }>;
81
+ rebuild(): Promise<{
82
+ skills: {
83
+ skillId: string;
84
+ locationId: string;
85
+ path: string;
86
+ name: string;
87
+ sources: string[];
88
+ aliases: import("./skill-domain.js").SkillAlias[];
89
+ hash: string | undefined;
90
+ fileCount: number | undefined;
91
+ error: string | undefined;
92
+ availability: "available" | "unavailable";
93
+ }[];
94
+ warnings: string[];
95
+ }>;
96
+ }
@@ -0,0 +1,157 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { realpath, stat } from 'node:fs/promises';
3
+ import { basename, relative, isAbsolute } from 'node:path';
4
+ import { discoverSkills, defaultSkillSources } from './skill-discovery.js';
5
+ import { projectSkillSources } from './skill-adapters.js';
6
+ import { SQLiteSkillStore } from './skill-store.js';
7
+ import { readSkillBundle } from './skills.js';
8
+ import { LocalConflictError } from './files.js';
9
+ export const pathWithin = (root, path) => { const rel = relative(root, path); return rel === '' || (rel !== '..' && !rel.startsWith('../') && !rel.startsWith('..\\') && !isAbsolute(rel)); };
10
+ const filesystemPorts = {
11
+ discover: discoverSkills,
12
+ resolveDirectory: async (path) => { const canonical = await realpath(path); if (!(await stat(canonical)).isDirectory())
13
+ throw new Error('Choose a directory for skill discovery.'); return canonical; },
14
+ inspect: readSkillBundle,
15
+ };
16
+ function rootSources(roots) {
17
+ return roots.flatMap(root => root.scope === 'project'
18
+ ? projectSkillSources(root.path).map(source => ({ ...source, id: `${root.id}:${source.id}`, label: `${root.label} · ${source.label}` }))
19
+ : [{ ...root, kind: 'custom' }]);
20
+ }
21
+ /** Node service shared by CLI and desktop. Its IDs are identity, not renderer grants. */
22
+ export class SkillService {
23
+ store;
24
+ sources;
25
+ ports;
26
+ constructor(store = new SQLiteSkillStore(), sources = defaultSkillSources, ports = filesystemPorts) {
27
+ this.store = store;
28
+ this.sources = sources;
29
+ this.ports = ports;
30
+ }
31
+ async registerRoot(path, label, scope = "custom") {
32
+ const canonical = await this.ports.resolveDirectory(path);
33
+ const name = label?.trim() || basename(canonical) || canonical;
34
+ if (name.length > 200)
35
+ throw new Error('Folder label must be at most 200 characters.');
36
+ return this.store.transaction(c => {
37
+ const found = c.roots.find(r => r.path === canonical);
38
+ if (found) {
39
+ found.label = name;
40
+ found.scope = scope;
41
+ return { ...found };
42
+ }
43
+ const root = { id: randomUUID(), path: canonical, label: name, depth: 4, scope };
44
+ c.roots.push(root);
45
+ return root;
46
+ });
47
+ }
48
+ async roots() { return (await this.store.read()).roots; }
49
+ async removeRoot(id) {
50
+ return this.store.transaction(c => { if (!c.roots.some(r => r.id === id))
51
+ throw new Error('Registered folder not found.'); c.roots = c.roots.filter(r => r.id !== id); });
52
+ }
53
+ async inventory() {
54
+ const before = await this.store.read();
55
+ const sources = [...this.sources(), ...rootSources(before.roots)];
56
+ const result = await this.ports.discover(sources);
57
+ const now = Date.now();
58
+ return this.store.transaction(c => {
59
+ // Scanning happens outside the lock. Do not commit results for a changed root set,
60
+ // or let an older scan overwrite observations from a concurrent newer transaction.
61
+ if (c.revision !== before.revision)
62
+ throw new LocalConflictError('Skill catalog changed during discovery. Refresh to use the latest inventory.');
63
+ const active = new Set(sources.map(s => s.id ?? s.path));
64
+ for (const found of result.skills) {
65
+ let location = c.locations.find(l => l.path === found.path);
66
+ if (!location) {
67
+ const identity = { id: randomUUID(), createdAt: now };
68
+ c.identities.push(identity);
69
+ location = { id: randomUUID(), skillId: identity.id, path: found.path, aliases: [], createdAt: now };
70
+ c.locations.push(location);
71
+ }
72
+ // Retain provenance aliases, but only active roots contribute to the displayed view.
73
+ for (const alias of found.aliases) {
74
+ const old = location.aliases.find(a => a.sourceId === alias.sourceId && a.path === alias.path);
75
+ if (old)
76
+ Object.assign(old, alias);
77
+ else
78
+ location.aliases.push(alias);
79
+ }
80
+ c.observations = c.observations.filter(o => o.locationId !== location.id);
81
+ c.observations.push({ locationId: location.id, name: found.name, observedAt: now, hash: found.hash, fileCount: found.fileCount, error: found.error, availability: 'available' });
82
+ }
83
+ const observed = new Set(result.skills.map(s => s.path));
84
+ const locations = c.locations.filter(l => l.aliases.some(a => active.has(a.sourceId)));
85
+ for (const location of locations) {
86
+ if (observed.has(location.path))
87
+ continue;
88
+ const old = c.observations.find(o => o.locationId === location.id);
89
+ if (old) {
90
+ old.availability = 'unavailable';
91
+ old.error = 'Not observed in this scan. The folder may be missing or inaccessible; its last known metadata is retained.';
92
+ }
93
+ }
94
+ const skills = locations.map(l => {
95
+ const o = c.observations.find(o => o.locationId === l.id);
96
+ const aliases = l.aliases.filter(a => active.has(a.sourceId));
97
+ return { skillId: l.skillId, locationId: l.id, path: l.path, name: o?.name ?? basename(l.path), sources: [...new Set(aliases.map(a => a.label))], aliases, hash: o?.hash, fileCount: o?.fileCount, error: o?.error ?? (!o ? 'Observation needs refresh.' : undefined), availability: o?.availability ?? 'unavailable' };
98
+ }).sort((a, b) => a.name.localeCompare(b.name) || a.path.localeCompare(b.path));
99
+ return { skills, warnings: result.warnings };
100
+ });
101
+ }
102
+ /** Warm reads use persisted observations; explicit inventory remains a full reconciliation. */
103
+ async cachedInventory() {
104
+ const c = await this.store.read();
105
+ const active = new Set([...this.sources(), ...rootSources(c.roots)].map(s => s.id ?? s.path));
106
+ const skills = c.locations.filter(l => l.aliases.some(a => active.has(a.sourceId))).map(l => {
107
+ const o = c.observations.find(o => o.locationId === l.id);
108
+ const aliases = l.aliases.filter(a => active.has(a.sourceId));
109
+ return { skillId: l.skillId, locationId: l.id, path: l.path, name: o?.name ?? basename(l.path), sources: [...new Set(aliases.map(a => a.label))], aliases, hash: o?.hash, fileCount: o?.fileCount, error: o?.error ?? (!o ? 'Observation needs refresh.' : undefined), availability: o?.availability ?? 'unavailable' };
110
+ }).sort((a, b) => a.name.localeCompare(b.name) || a.path.localeCompare(b.path));
111
+ return { skills, warnings: [] };
112
+ }
113
+ async discoverySources() { return [...this.sources(), ...rootSources((await this.store.read()).roots)]; }
114
+ /** Re-read only affected known locations, preserving identity and last known bytes on failure. */
115
+ async refreshLocations(ids) {
116
+ const before = await this.store.read();
117
+ const updates = await Promise.all(before.locations.filter(l => ids.includes(l.id)).map(async (l) => {
118
+ try {
119
+ const bundle = await this.ports.inspect(l.path);
120
+ return { locationId: l.id, name: bundle.name, hash: bundle.hash, fileCount: bundle.files.length, observedAt: Date.now(), availability: 'available' };
121
+ }
122
+ catch (error) {
123
+ return { ...before.observations.find(o => o.locationId === l.id), locationId: l.id, name: basename(l.path), observedAt: Date.now(), availability: 'unavailable', error: error.message };
124
+ }
125
+ }));
126
+ await this.store.transaction(c => {
127
+ if (c.revision !== before.revision)
128
+ throw new LocalConflictError('Skill catalog changed during refresh.');
129
+ for (const update of updates) {
130
+ c.observations = c.observations.filter(o => o.locationId !== update.locationId);
131
+ c.observations.push(update);
132
+ }
133
+ });
134
+ return this.cachedInventory();
135
+ }
136
+ /** Metadata-only explicit relocation. Never guesses identity from a name/hash or moves files. */
137
+ async relocateLocation(id, path) {
138
+ const canonical = await this.ports.resolveDirectory(path);
139
+ await this.ports.inspect(canonical);
140
+ return this.store.transaction(c => {
141
+ const location = c.locations.find(l => l.id === id);
142
+ if (!location)
143
+ throw new Error('Skill location not found.');
144
+ if (c.locations.some(l => l.path === canonical && l.id !== id))
145
+ throw new Error('Destination is already associated with another skill location.');
146
+ const sources = [...this.sources(), ...rootSources(c.roots)];
147
+ const scopes = sources.filter(s => pathWithin(s.path, canonical));
148
+ if (!scopes.length)
149
+ throw new Error('Register the destination folder before relocating this skill.');
150
+ location.path = canonical;
151
+ location.aliases = scopes.map(s => ({ sourceId: ('id' in s && s.id) || s.path, path: canonical, label: s.label, kind: ('kind' in s && s.kind) || 'custom' }));
152
+ c.observations = c.observations.filter(o => o.locationId !== id);
153
+ return { ...location };
154
+ });
155
+ }
156
+ async rebuild() { await this.store.transaction(c => { c.observations = []; }); return this.inventory(); }
157
+ }
@@ -0,0 +1,34 @@
1
+ import type { SkillCloudBinding, SkillIdentity, SkillLocation, SkillObservation } from './skill-domain.js';
2
+ export interface RegisteredSkillRoot {
3
+ id: string;
4
+ path: string;
5
+ label: string;
6
+ depth: number;
7
+ scope?: "custom" | "project";
8
+ }
9
+ export interface SkillCatalog {
10
+ schemaVersion: 2;
11
+ revision: number;
12
+ roots: RegisteredSkillRoot[];
13
+ identities: SkillIdentity[];
14
+ locations: SkillLocation[];
15
+ observations: SkillObservation[];
16
+ bindings: SkillCloudBinding[];
17
+ }
18
+ export interface SkillStore {
19
+ read(): Promise<SkillCatalog>;
20
+ transaction<T>(change: (catalog: SkillCatalog) => T): Promise<T>;
21
+ }
22
+ export declare const defaultSkillCatalogPath: () => string;
23
+ /** Fail closed rather than silently dropping durable records from a malformed/future store. */
24
+ export declare function validateSkillCatalog(value: unknown): asserts value is SkillCatalog;
25
+ /** SQLite owns cross-process locking and crash rollback; no persistent lock directories. */
26
+ export declare class SQLiteSkillStore implements SkillStore {
27
+ readonly path: string;
28
+ constructor(path?: string);
29
+ private exists;
30
+ private version;
31
+ private readDatabase;
32
+ read(): Promise<SkillCatalog>;
33
+ transaction<T>(change: (catalog: SkillCatalog) => T): Promise<T>;
34
+ }
@@ -0,0 +1,187 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { setTimeout as delay } from 'node:timers/promises';
3
+ import { DatabaseSync } from 'node:sqlite';
4
+ import { lstat, mkdir, open } from 'node:fs/promises';
5
+ import { dirname, join, isAbsolute } from 'node:path';
6
+ import { homedir } from 'node:os';
7
+ export const defaultSkillCatalogPath = () => join(homedir(), '.sfora/skills/catalog.sqlite');
8
+ const empty = () => ({ schemaVersion: 2, revision: 0, roots: [], identities: [], locations: [], observations: [], bindings: [] });
9
+ const string = (value) => typeof value === 'string' && value.length > 0 && value.length <= 8192;
10
+ const time = (value) => Number.isSafeInteger(value) && Number(value) >= 0;
11
+ /** Fail closed rather than silently dropping durable records from a malformed/future store. */
12
+ export function validateSkillCatalog(value) {
13
+ const c = value;
14
+ if (!c || c.schemaVersion !== 2)
15
+ throw new Error('Unsupported skill catalog schema. Keep this file and use a compatible Sfora version.');
16
+ if (!time(c.revision) || ![c.roots, c.identities, c.locations, c.observations, c.bindings].every(v => Array.isArray(v) && v.length <= 10000))
17
+ throw new Error('Invalid skill catalog structure. Original catalog was preserved.');
18
+ const unique = (values) => new Set(values).size === values.length;
19
+ if (!c.roots.every(r => r && string(r.id) && string(r.path) && isAbsolute(r.path) && string(r.label) && Number.isInteger(r.depth) && r.depth >= 0 && r.depth <= 6 && (r.scope === undefined || ["custom", "project"].includes(r.scope))) || !unique(c.roots.map(r => r.id)) || !unique(c.roots.map(r => r.path)))
20
+ throw new Error('Invalid registered skill roots.');
21
+ if (!c.identities.every(i => i && string(i.id) && time(i.createdAt)) || !unique(c.identities.map(i => i.id)))
22
+ throw new Error('Invalid skill identities.');
23
+ const identities = new Set(c.identities.map(i => i.id));
24
+ if (!c.locations.every(l => l && string(l.id) && identities.has(l.skillId) && string(l.path) && isAbsolute(l.path) && time(l.createdAt) && Array.isArray(l.aliases) && l.aliases.length <= 1000 && l.aliases.every(a => a && string(a.sourceId) && string(a.path) && isAbsolute(a.path) && string(a.label) && ['agent', 'cache', 'custom'].includes(a.kind))) || !unique(c.locations.map(l => l.id)) || !unique(c.locations.map(l => l.path)))
25
+ throw new Error('Invalid skill locations.');
26
+ const deployment = (value) => {
27
+ if (!string(value))
28
+ return false;
29
+ try {
30
+ const url = new URL(value);
31
+ return ['https:', 'http:'].includes(url.protocol) && !url.username && !url.password && url.origin === value;
32
+ }
33
+ catch {
34
+ return false;
35
+ }
36
+ };
37
+ if (!c.bindings.every(b => b && string(b.id) && identities.has(b.localSkillId) && string(b.accountId)
38
+ && b.remote && deployment(b.remote.deployment) && string(b.remote.organizationId) && string(b.remote.projectId) && string(b.remote.skillId)
39
+ && b.locator && string(b.locator.project) && string(b.locator.name)
40
+ && (b.baseline === undefined || (b.baseline && b.baseline.contentPolicy === 'bundle-v1' && /^[a-f0-9]{64}$/.test(b.baseline.hash)
41
+ && Number.isSafeInteger(b.baseline.cloudVersion) && Number(b.baseline.cloudVersion) > 0)))
42
+ || !unique(c.bindings.map(b => b.id))
43
+ || !unique(c.bindings.map(b => JSON.stringify([b.localSkillId, b.accountId, b.remote.deployment, b.remote.organizationId, b.remote.projectId]))))
44
+ throw new Error('Invalid skill cloud bindings.');
45
+ const locations = new Set(c.locations.map(l => l.id));
46
+ if (!c.observations.every(o => o && locations.has(o.locationId) && string(o.name) && time(o.observedAt) && ['available', 'unavailable'].includes(o.availability) && (o.hash === undefined || /^[a-f0-9]{64}$/.test(o.hash)) && (o.fileCount === undefined || time(o.fileCount)) && (o.error === undefined || typeof o.error === 'string')) || !unique(c.observations.map(o => o.locationId)))
47
+ throw new Error('Invalid skill observations.');
48
+ }
49
+ /** SQLite owns cross-process locking and crash rollback; no persistent lock directories. */
50
+ export class SQLiteSkillStore {
51
+ path;
52
+ constructor(path = defaultSkillCatalogPath()) {
53
+ this.path = path;
54
+ }
55
+ async exists() {
56
+ const info = await lstat(this.path).catch(error => { if (error.code === 'ENOENT')
57
+ return null; throw error; });
58
+ if (!info)
59
+ return false;
60
+ if (!info.isFile() || info.isSymbolicLink())
61
+ throw new Error('Skill catalog must be a regular file, not a symbolic link.');
62
+ if (info.size > 64 * 1024 * 1024)
63
+ throw new Error('Skill catalog exceeds supported size.');
64
+ return true;
65
+ }
66
+ version(db) { return Number(db.prepare('PRAGMA user_version').get().user_version); }
67
+ readDatabase(db) {
68
+ if (this.version(db) === 0 && db.prepare("SELECT name FROM sqlite_master WHERE type='table'").all().length === 0)
69
+ return empty();
70
+ if (![1, 2].includes(this.version(db)))
71
+ throw new Error('Unsupported skill catalog schema. Keep this file and use a compatible Sfora version.');
72
+ const rows = (table) => db.prepare(`SELECT data FROM ${table} ORDER BY rowid`).all().map(row => JSON.parse(String(row.data)));
73
+ const catalog = { schemaVersion: 2, revision: Number(db.prepare("SELECT value FROM metadata WHERE key='revision'").get().value), roots: rows('roots'), identities: rows('identities'), locations: rows('locations'), observations: rows('observations'), bindings: this.version(db) === 1 ? [] : rows('bindings') };
74
+ validateSkillCatalog(catalog);
75
+ return catalog;
76
+ }
77
+ async read() {
78
+ if (!await this.exists())
79
+ return empty();
80
+ const db = new DatabaseSync(this.path, { readOnly: true });
81
+ try {
82
+ db.exec('PRAGMA busy_timeout=5000; BEGIN');
83
+ const catalog = this.readDatabase(db);
84
+ db.exec('COMMIT');
85
+ return catalog;
86
+ }
87
+ finally {
88
+ db.close();
89
+ }
90
+ }
91
+ async transaction(change) {
92
+ await mkdir(dirname(this.path), { recursive: true, mode: 0o700 });
93
+ const exists = await this.exists();
94
+ if (!exists) {
95
+ const handle = await open(this.path, 'ax', 0o600).catch(error => { if (error.code === 'EEXIST')
96
+ return null; throw error; });
97
+ await handle?.close();
98
+ }
99
+ await this.exists();
100
+ const db = new DatabaseSync(this.path);
101
+ try {
102
+ db.exec('PRAGMA busy_timeout=5000; PRAGMA foreign_keys=ON;');
103
+ // Changing journal mode on first open can return SQLITE_BUSY without using
104
+ // busy_timeout. Retry only before any user mutation or transaction has begun.
105
+ const deadline = Date.now() + 5000;
106
+ for (;;) {
107
+ try {
108
+ const version = this.version(db);
109
+ if (![0, 1, 2].includes(version))
110
+ throw new Error('Unsupported skill catalog schema. Keep this file and use a compatible Sfora version.');
111
+ // VACUUM INTO includes committed WAL pages. Reserve private permissions
112
+ // before SQLite writes the backup; never copy a live database file.
113
+ if (version === 1) {
114
+ this.readDatabase(db);
115
+ const backupPath = `${this.path}.v1-${randomUUID()}.sqlite`;
116
+ const backup = await open(backupPath, 'ax', 0o600);
117
+ await backup.close();
118
+ db.prepare('VACUUM INTO ?').run(backupPath);
119
+ }
120
+ db.exec('PRAGMA journal_mode=WAL; PRAGMA synchronous=FULL; BEGIN IMMEDIATE;');
121
+ break;
122
+ }
123
+ catch (error) {
124
+ if (![5, 6].includes(Number(error.errcode)) || Date.now() >= deadline)
125
+ throw error;
126
+ await delay(25);
127
+ }
128
+ }
129
+ if (this.version(db) === 0) {
130
+ if (db.prepare("SELECT name FROM sqlite_master WHERE type='table'").all().length)
131
+ throw new Error('Unrecognized skill catalog; existing tables were preserved.');
132
+ db.exec(`CREATE TABLE metadata (key TEXT PRIMARY KEY, value INTEGER NOT NULL);
133
+ INSERT INTO metadata VALUES ('revision',0);
134
+ CREATE TABLE roots (id TEXT PRIMARY KEY, data TEXT NOT NULL);
135
+ CREATE TABLE identities (id TEXT PRIMARY KEY, data TEXT NOT NULL);
136
+ CREATE TABLE locations (id TEXT PRIMARY KEY, skill_id TEXT NOT NULL REFERENCES identities(id), data TEXT NOT NULL);
137
+ CREATE TABLE observations (id TEXT PRIMARY KEY REFERENCES locations(id), data TEXT NOT NULL);
138
+ PRAGMA user_version=1;`);
139
+ }
140
+ if (this.version(db) === 1) {
141
+ db.exec(`CREATE TABLE bindings (id TEXT PRIMARY KEY, skill_id TEXT NOT NULL REFERENCES identities(id), data TEXT NOT NULL);
142
+ PRAGMA user_version=2;`);
143
+ }
144
+ const catalog = this.readDatabase(db);
145
+ const result = change(catalog);
146
+ if (result && typeof result.then === "function")
147
+ throw new Error("Skill catalog transactions must be synchronous.");
148
+ catalog.revision++;
149
+ validateSkillCatalog(catalog);
150
+ // Metadata tables are small; synchronize changed rows under one transaction.
151
+ // The service never removes durable identities during observation rebuilds.
152
+ const sync = (table, values) => {
153
+ const ids = new Set(values.map(v => v.id));
154
+ const remove = db.prepare(`DELETE FROM ${table} WHERE id=?`);
155
+ for (const row of db.prepare(`SELECT id FROM ${table}`).all())
156
+ if (!ids.has(String(row.id)))
157
+ remove.run(String(row.id));
158
+ const statement = db.prepare((table === 'locations' || table === 'bindings')
159
+ ? `INSERT INTO ${table}(id,skill_id,data) VALUES (?,?,?) ON CONFLICT(id) DO UPDATE SET skill_id=excluded.skill_id,data=excluded.data WHERE data!=excluded.data`
160
+ : `INSERT INTO ${table}(id,data) VALUES (?,?) ON CONFLICT(id) DO UPDATE SET data=excluded.data WHERE data!=excluded.data`);
161
+ for (const row of values)
162
+ if ((table === 'locations' || table === 'bindings'))
163
+ statement.run(row.id, row.skillId, row.data);
164
+ else
165
+ statement.run(row.id, row.data);
166
+ };
167
+ sync('roots', catalog.roots.map(r => ({ id: r.id, data: JSON.stringify(r) })));
168
+ sync('identities', catalog.identities.map(r => ({ id: r.id, data: JSON.stringify(r) })));
169
+ sync('locations', catalog.locations.map(r => ({ id: r.id, skillId: r.skillId, data: JSON.stringify(r) })));
170
+ sync('observations', catalog.observations.map(r => ({ id: r.locationId, data: JSON.stringify(r) })));
171
+ sync('bindings', catalog.bindings.map(r => ({ id: r.id, skillId: r.localSkillId, data: JSON.stringify(r) })));
172
+ db.prepare("UPDATE metadata SET value=? WHERE key='revision'").run(catalog.revision);
173
+ db.exec('COMMIT');
174
+ return result;
175
+ }
176
+ catch (error) {
177
+ try {
178
+ db.exec('ROLLBACK');
179
+ }
180
+ catch { }
181
+ throw error;
182
+ }
183
+ finally {
184
+ db.close();
185
+ }
186
+ }
187
+ }