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.
- package/README.md +80 -1
- package/dist/agent-webhook.d.ts +20 -0
- package/dist/agent-webhook.js +42 -0
- package/dist/api-client.d.ts +97 -0
- package/dist/api-client.js +68 -0
- package/dist/ask.d.ts +51 -0
- package/dist/ask.js +70 -0
- package/dist/attachments-node.d.ts +7 -0
- package/dist/attachments-node.js +15 -0
- package/dist/attachments.d.ts +112 -0
- package/dist/attachments.js +254 -0
- package/dist/block-commands.d.ts +10 -0
- package/dist/block-commands.js +28 -0
- package/dist/chat.d.ts +15 -0
- package/dist/chat.js +7 -0
- package/dist/cli-args.d.ts +7 -0
- package/dist/cli-args.js +28 -1
- package/dist/cli.d.ts +12 -1
- package/dist/cli.js +186 -19
- package/dist/format/linkUrls.d.ts +2 -0
- package/dist/format/linkUrls.js +48 -0
- package/dist/format/postMarkdown.d.ts +12 -1
- package/dist/format/postMarkdown.js +9 -2
- package/dist/index.d.ts +23 -1
- package/dist/index.js +17 -1
- package/dist/local-core/files.d.ts +1 -1
- package/dist/local-core/files.js +2 -2
- package/dist/local-core/index.d.ts +12 -0
- package/dist/local-core/index.js +11 -0
- package/dist/local-core/skill-adapters.d.ts +21 -0
- package/dist/local-core/skill-adapters.js +19 -0
- package/dist/local-core/skill-discovery.d.ts +22 -0
- package/dist/local-core/skill-discovery.js +79 -0
- package/dist/local-core/skill-domain.d.ts +74 -0
- package/dist/local-core/skill-domain.js +1 -0
- package/dist/local-core/skill-executor.d.ts +23 -0
- package/dist/local-core/skill-executor.js +51 -0
- package/dist/local-core/skill-index.d.ts +54 -0
- package/dist/local-core/skill-index.js +115 -0
- package/dist/local-core/skill-local-executor.d.ts +18 -0
- package/dist/local-core/skill-local-executor.js +249 -0
- package/dist/local-core/skill-operations.d.ts +61 -0
- package/dist/local-core/skill-operations.js +268 -0
- package/dist/local-core/skill-review.d.ts +46 -0
- package/dist/local-core/skill-review.js +132 -0
- package/dist/local-core/skill-service.d.ts +96 -0
- package/dist/local-core/skill-service.js +157 -0
- package/dist/local-core/skill-store.d.ts +34 -0
- package/dist/local-core/skill-store.js +187 -0
- package/dist/local-core/skill-sync.d.ts +132 -0
- package/dist/local-core/skill-sync.js +111 -0
- package/dist/local-core/skills.d.ts +35 -0
- package/dist/local-core/skills.js +142 -37
- package/dist/mcp-description.d.ts +11 -0
- package/dist/mcp-description.js +29 -0
- package/dist/mcp-server.d.ts +5 -1
- package/dist/mcp-server.js +28 -18
- package/dist/shell-commands.d.ts +7 -1
- package/dist/shell-commands.js +49 -3
- package/dist/skills-client.d.ts +13 -2
- package/dist/skills-client.js +57 -5
- package/dist/skills-command.d.ts +1 -1
- package/dist/skills-command.js +171 -4
- package/dist/skills-packet/sfora-asks/SKILL.md +49 -0
- package/dist/skills-packet/sfora-asks/references/asks.md +41 -0
- package/dist/skills-packet/sfora-board/SKILL.md +53 -0
- package/dist/skills-packet/sfora-board/references/board.md +60 -0
- package/dist/skills-packet/sfora-board/references/plan.md +25 -0
- package/dist/skills-packet/sfora-chat/SKILL.md +55 -0
- package/dist/skills-packet/sfora-chat/references/rooms.md +45 -0
- package/dist/skills-packet/sfora-chat/references/waiting.md +37 -0
- package/dist/skills-packet/sfora-live-edit/SKILL.md +62 -0
- package/dist/skills-packet/sfora-live-edit/references/collisions.md +54 -0
- package/dist/skills-packet/sfora-live-edit/references/http.md +63 -0
- package/dist/skills-packet/sfora-live-edit/references/live-editing.md +49 -0
- package/dist/skills-packet/sfora-setup/SKILL.md +37 -0
- package/dist/skills-packet/sfora-setup/references/sign-in.md +43 -0
- package/dist/skills-packet/sfora-skills/SKILL.md +54 -0
- package/dist/skills-packet/sfora-skills/references/skills.md +78 -0
- package/dist/skills-packet/sfora-troubleshoot/SKILL.md +39 -0
- package/dist/skills-packet/sfora-troubleshoot/references/sharp-edges.md +80 -0
- package/dist/skills-packet/sfora-write/SKILL.md +53 -0
- package/dist/skills-packet/sfora-write/references/attachments.md +15 -0
- package/dist/skills-packet/sfora-write/references/blocks.md +36 -0
- package/dist/skills-packet/sfora-write/references/posts-and-docs.md +56 -0
- package/dist/skills-packet.d.ts +63 -0
- package/dist/skills-packet.js +166 -0
- package/dist/typing.d.ts +23 -0
- package/dist/typing.js +62 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/watch.d.ts +78 -1
- package/dist/watch.js +109 -0
- package/package.json +4 -4
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import type { SkillCloudBinding, SkillRevision } from './skill-domain.js';
|
|
2
|
+
import type { SkillStore } from './skill-store.js';
|
|
3
|
+
import { type SkillBundle, type inspectSkillTarget } from './skills.js';
|
|
4
|
+
export interface SkillCloudScope {
|
|
5
|
+
deployment: string;
|
|
6
|
+
accountId: string;
|
|
7
|
+
organizationId: string;
|
|
8
|
+
}
|
|
9
|
+
export interface PublishedSkillSnapshot {
|
|
10
|
+
remote: SkillCloudBinding['remote'];
|
|
11
|
+
accountId: string;
|
|
12
|
+
locator: SkillCloudBinding['locator'];
|
|
13
|
+
revision: SkillRevision;
|
|
14
|
+
draftRevision: number;
|
|
15
|
+
hasDraft: boolean;
|
|
16
|
+
draftHash?: string;
|
|
17
|
+
supportsIdentityGuards?: boolean;
|
|
18
|
+
bundle: SkillBundle;
|
|
19
|
+
}
|
|
20
|
+
export type SkillTransferPlan = ReturnType<typeof planSkillTransfer>;
|
|
21
|
+
/** A new installation needs an explicitly selected empty destination, not a guessed name match. */
|
|
22
|
+
export declare function planSkillInstallation(remote: PublishedSkillSnapshot, destination: string, target: Awaited<ReturnType<typeof inspectSkillTarget>>): {
|
|
23
|
+
schemaVersion: 1;
|
|
24
|
+
kind: "skill-install-preview";
|
|
25
|
+
source: {
|
|
26
|
+
accountId: string;
|
|
27
|
+
deployment: string;
|
|
28
|
+
organizationId: string;
|
|
29
|
+
skillId: string;
|
|
30
|
+
projectId: string;
|
|
31
|
+
};
|
|
32
|
+
locator: {
|
|
33
|
+
project: string;
|
|
34
|
+
name: string;
|
|
35
|
+
};
|
|
36
|
+
destination: string;
|
|
37
|
+
expected: {
|
|
38
|
+
remote: {
|
|
39
|
+
hash: string;
|
|
40
|
+
contentPolicy: "bundle-v1";
|
|
41
|
+
cloudVersion?: number;
|
|
42
|
+
};
|
|
43
|
+
targetState: "file" | "link" | "unavailable" | "managed" | "unmanaged" | "modified" | "missing";
|
|
44
|
+
localHash: string | null;
|
|
45
|
+
};
|
|
46
|
+
ownership: "unknown" | "managed" | "unmanaged";
|
|
47
|
+
outcome: string;
|
|
48
|
+
blockers: string[];
|
|
49
|
+
files: {
|
|
50
|
+
path: string;
|
|
51
|
+
kind: "added";
|
|
52
|
+
after: {
|
|
53
|
+
hash: string;
|
|
54
|
+
size: number;
|
|
55
|
+
executable: boolean;
|
|
56
|
+
};
|
|
57
|
+
}[];
|
|
58
|
+
};
|
|
59
|
+
export type SkillContentState = {
|
|
60
|
+
state: 'available';
|
|
61
|
+
bundle: SkillBundle;
|
|
62
|
+
} | {
|
|
63
|
+
state: 'missing';
|
|
64
|
+
reason: string;
|
|
65
|
+
} | {
|
|
66
|
+
state: 'unavailable';
|
|
67
|
+
reason: string;
|
|
68
|
+
};
|
|
69
|
+
export type SkillSyncStatus = 'local-only' | 'synced' | 'local-changed' | 'remote-changed' | 'conflict' | 'missing' | 'unknown' | 'unavailable';
|
|
70
|
+
export declare function assertSkillBindingScope(binding: SkillCloudBinding, scope: SkillCloudScope): void;
|
|
71
|
+
export declare function validatePublishedSkillSnapshot(snapshot: PublishedSkillSnapshot): void;
|
|
72
|
+
export declare function assertSkillBindingSnapshot(binding: SkillCloudBinding, snapshot: PublishedSkillSnapshot): void;
|
|
73
|
+
/** Adoption is explicit. Matching names, install receipts and stale observations cannot establish a baseline. */
|
|
74
|
+
export declare class SkillBindingService {
|
|
75
|
+
readonly store: SkillStore;
|
|
76
|
+
constructor(store: SkillStore);
|
|
77
|
+
list(): Promise<SkillCloudBinding[]>;
|
|
78
|
+
adopt(localSkillId: string, snapshot: PublishedSkillSnapshot, verifiedLocal: SkillBundle): Promise<SkillCloudBinding>;
|
|
79
|
+
}
|
|
80
|
+
/** Pure three-way comparison. Unavailable input is never interpreted as deletion or synchronization. */
|
|
81
|
+
export declare function compareSkillStates(local: SkillContentState, remote: SkillContentState | undefined, baseline?: SkillRevision): SkillSyncStatus;
|
|
82
|
+
/** Review artifact only. A future executor must revalidate every token and record a durable operation. */
|
|
83
|
+
export declare function planSkillTransfer(input: {
|
|
84
|
+
direction: 'push' | 'pull';
|
|
85
|
+
binding: SkillCloudBinding;
|
|
86
|
+
location: {
|
|
87
|
+
id: string;
|
|
88
|
+
path: string;
|
|
89
|
+
skillId: string;
|
|
90
|
+
};
|
|
91
|
+
local: SkillContentState;
|
|
92
|
+
remote: PublishedSkillSnapshot;
|
|
93
|
+
ownership: 'managed' | 'unmanaged' | 'unknown';
|
|
94
|
+
}): {
|
|
95
|
+
schemaVersion: 1;
|
|
96
|
+
kind: "skill-transfer-preview";
|
|
97
|
+
direction: "push" | "pull";
|
|
98
|
+
status: SkillSyncStatus;
|
|
99
|
+
outcome: string;
|
|
100
|
+
blockers: string[];
|
|
101
|
+
binding: SkillCloudBinding;
|
|
102
|
+
location: {
|
|
103
|
+
id: string;
|
|
104
|
+
path: string;
|
|
105
|
+
skillId: string;
|
|
106
|
+
};
|
|
107
|
+
ownership: "unknown" | "managed" | "unmanaged";
|
|
108
|
+
expected: {
|
|
109
|
+
localHash: string | null;
|
|
110
|
+
remote: {
|
|
111
|
+
hash: string;
|
|
112
|
+
contentPolicy: "bundle-v1";
|
|
113
|
+
cloudVersion?: number;
|
|
114
|
+
};
|
|
115
|
+
draftRevision: number;
|
|
116
|
+
};
|
|
117
|
+
files: {
|
|
118
|
+
before: {
|
|
119
|
+
hash: string;
|
|
120
|
+
size: number;
|
|
121
|
+
executable: boolean;
|
|
122
|
+
} | null;
|
|
123
|
+
after: {
|
|
124
|
+
hash: string;
|
|
125
|
+
size: number;
|
|
126
|
+
executable: boolean;
|
|
127
|
+
} | null;
|
|
128
|
+
path: string;
|
|
129
|
+
kind: "added" | "removed" | "modified";
|
|
130
|
+
}[];
|
|
131
|
+
};
|
|
132
|
+
export type SkillInstallPlan = ReturnType<typeof planSkillInstallation>;
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { isAbsolute } from 'node:path';
|
|
3
|
+
import { diffSkillBundles, validateSkillBundle } from './skills.js';
|
|
4
|
+
/** A new installation needs an explicitly selected empty destination, not a guessed name match. */
|
|
5
|
+
export function planSkillInstallation(remote, destination, target) {
|
|
6
|
+
validatePublishedSkillSnapshot(remote);
|
|
7
|
+
if (!isAbsolute(destination))
|
|
8
|
+
throw new Error('Installation destination must be absolute.');
|
|
9
|
+
const blockers = target.state === 'missing' ? [] : ['Destination is occupied or unavailable. Choose a new folder, or explicitly bind and review an update.'];
|
|
10
|
+
return { schemaVersion: 1, kind: 'skill-install-preview', source: { ...remote.remote, accountId: remote.accountId }, locator: { ...remote.locator }, destination,
|
|
11
|
+
expected: { remote: { ...remote.revision }, targetState: target.state, localHash: target.hash ?? null }, ownership: target.ownership,
|
|
12
|
+
outcome: blockers.length ? 'blocked' : 'reviewable', blockers,
|
|
13
|
+
files: remote.bundle.files.map(f => ({ path: f.path, kind: 'added', after: { hash: f.sha256, size: f.size, executable: f.executable } })) };
|
|
14
|
+
}
|
|
15
|
+
export function assertSkillBindingScope(binding, scope) {
|
|
16
|
+
if (binding.accountId !== scope.accountId || binding.remote.deployment !== scope.deployment || binding.remote.organizationId !== scope.organizationId) {
|
|
17
|
+
throw new Error('Skill binding belongs to a different account, organization or deployment. Explicitly link the intended destination.');
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
export function validatePublishedSkillSnapshot(snapshot) {
|
|
21
|
+
validateSkillBundle(snapshot.bundle);
|
|
22
|
+
if (snapshot.revision.contentPolicy !== 'bundle-v1' || snapshot.revision.hash !== snapshot.bundle.hash
|
|
23
|
+
|| !Number.isSafeInteger(snapshot.revision.cloudVersion) || Number(snapshot.revision.cloudVersion) < 1
|
|
24
|
+
|| !Number.isSafeInteger(snapshot.draftRevision) || snapshot.draftRevision < 0 || typeof snapshot.hasDraft !== 'boolean') {
|
|
25
|
+
throw new Error('Published skill revision does not match its verified bundle.');
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
export function assertSkillBindingSnapshot(binding, snapshot) {
|
|
29
|
+
validatePublishedSkillSnapshot(snapshot);
|
|
30
|
+
assertSkillBindingScope(binding, { ...snapshot.remote, accountId: snapshot.accountId });
|
|
31
|
+
if (binding.remote.skillId !== snapshot.remote.skillId || binding.remote.projectId !== snapshot.remote.projectId) {
|
|
32
|
+
throw new Error('Cloud lookup resolved to a different skill identity. No binding was changed.');
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
/** Adoption is explicit. Matching names, install receipts and stale observations cannot establish a baseline. */
|
|
36
|
+
export class SkillBindingService {
|
|
37
|
+
store;
|
|
38
|
+
constructor(store) {
|
|
39
|
+
this.store = store;
|
|
40
|
+
}
|
|
41
|
+
async list() { return (await this.store.read()).bindings; }
|
|
42
|
+
async adopt(localSkillId, snapshot, verifiedLocal) {
|
|
43
|
+
validatePublishedSkillSnapshot(snapshot);
|
|
44
|
+
validateSkillBundle(verifiedLocal);
|
|
45
|
+
return this.store.transaction(c => {
|
|
46
|
+
if (!c.identities.some(i => i.id === localSkillId))
|
|
47
|
+
throw new Error('Local skill identity not found. Refresh the inventory first.');
|
|
48
|
+
const existing = c.bindings.find(b => b.localSkillId === localSkillId && b.accountId === snapshot.accountId
|
|
49
|
+
&& b.remote.deployment === snapshot.remote.deployment && b.remote.organizationId === snapshot.remote.organizationId && b.remote.projectId === snapshot.remote.projectId);
|
|
50
|
+
if (existing)
|
|
51
|
+
assertSkillBindingSnapshot(existing, snapshot);
|
|
52
|
+
const binding = existing ?? { id: randomUUID(), localSkillId, remote: { ...snapshot.remote }, accountId: snapshot.accountId, locator: { ...snapshot.locator } };
|
|
53
|
+
binding.locator = { ...snapshot.locator };
|
|
54
|
+
// Differing bytes retain the last confirmed baseline, or remain unknown.
|
|
55
|
+
if (verifiedLocal.hash === snapshot.revision.hash)
|
|
56
|
+
binding.baseline = { ...snapshot.revision };
|
|
57
|
+
if (!existing)
|
|
58
|
+
c.bindings.push(binding);
|
|
59
|
+
return structuredClone(binding);
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
/** Pure three-way comparison. Unavailable input is never interpreted as deletion or synchronization. */
|
|
64
|
+
export function compareSkillStates(local, remote, baseline) {
|
|
65
|
+
if (local.state === 'available')
|
|
66
|
+
validateSkillBundle(local.bundle);
|
|
67
|
+
if (remote?.state === 'available')
|
|
68
|
+
validateSkillBundle(remote.bundle);
|
|
69
|
+
if (local.state === 'unavailable' || remote?.state === 'unavailable')
|
|
70
|
+
return 'unavailable';
|
|
71
|
+
if (local.state === 'missing' || remote?.state === 'missing')
|
|
72
|
+
return 'missing';
|
|
73
|
+
if (!remote)
|
|
74
|
+
return 'local-only';
|
|
75
|
+
if (!baseline || baseline.contentPolicy !== 'bundle-v1' || !/^[a-f0-9]{64}$/.test(baseline.hash))
|
|
76
|
+
return 'unknown';
|
|
77
|
+
if (local.bundle.hash === remote.bundle.hash)
|
|
78
|
+
return 'synced';
|
|
79
|
+
if (local.bundle.hash === baseline.hash)
|
|
80
|
+
return 'remote-changed';
|
|
81
|
+
if (remote.bundle.hash === baseline.hash)
|
|
82
|
+
return 'local-changed';
|
|
83
|
+
return 'conflict';
|
|
84
|
+
}
|
|
85
|
+
/** Review artifact only. A future executor must revalidate every token and record a durable operation. */
|
|
86
|
+
export function planSkillTransfer(input) {
|
|
87
|
+
const { binding, local, remote, direction, location, ownership } = input;
|
|
88
|
+
assertSkillBindingSnapshot(binding, remote);
|
|
89
|
+
if (location.skillId !== binding.localSkillId)
|
|
90
|
+
throw new Error('Location does not belong to the bound local skill.');
|
|
91
|
+
const status = compareSkillStates(local, { state: 'available', bundle: remote.bundle }, binding.baseline);
|
|
92
|
+
const blockers = [];
|
|
93
|
+
if (!['synced', direction === 'push' ? 'local-changed' : 'remote-changed'].includes(status))
|
|
94
|
+
blockers.push(`Cannot ${direction} automatically from ${status}; review the divergence first.`);
|
|
95
|
+
if (status !== 'synced' && direction === 'pull' && ownership !== 'managed')
|
|
96
|
+
blockers.push('Replacing local files requires verified Sfora ownership or an explicitly chosen new destination.');
|
|
97
|
+
if (status !== 'synced' && direction === 'push' && remote.hasDraft)
|
|
98
|
+
blockers.push('The cloud contains an unpublished draft; review it before replacing it.');
|
|
99
|
+
const before = direction === 'push' ? remote.bundle : local.state === 'available' ? local.bundle : undefined;
|
|
100
|
+
const after = direction === 'pull' ? remote.bundle : local.state === 'available' ? local.bundle : undefined;
|
|
101
|
+
const files = before && after ? diffSkillBundles(before, after).map(change => {
|
|
102
|
+
const metadata = (bundle) => { const f = bundle.files.find(f => f.path === change.path); return f ? { hash: f.sha256, size: f.size, executable: f.executable } : null; };
|
|
103
|
+
return { ...change, before: metadata(before), after: metadata(after) };
|
|
104
|
+
}) : [];
|
|
105
|
+
return {
|
|
106
|
+
schemaVersion: 1, kind: 'skill-transfer-preview', direction, status,
|
|
107
|
+
outcome: blockers.length ? 'blocked' : status === 'synced' ? 'no-op' : 'reviewable', blockers,
|
|
108
|
+
binding: structuredClone(binding), location: { ...location }, ownership,
|
|
109
|
+
expected: { localHash: local.state === 'available' ? local.bundle.hash : null, remote: { ...remote.revision }, draftRevision: remote.draftRevision }, files,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
@@ -33,7 +33,33 @@ export declare function validateSkillName(name: string): void;
|
|
|
33
33
|
export declare function validateSkillPath(path: string): void;
|
|
34
34
|
export declare function skillBundleHash(files: SkillFile[]): string;
|
|
35
35
|
export declare function validateSkillBundle(bundle: SkillBundle): void;
|
|
36
|
+
/** The longest `description` a SKILL.md may carry (the Agent Skills format's limit). */
|
|
37
|
+
export declare const SKILL_DESCRIPTION_MAX = 1024;
|
|
38
|
+
/**
|
|
39
|
+
* The top-level scalar keys of a SKILL.md frontmatter block, or `null` when the
|
|
40
|
+
* file does not open with one. A deliberately small YAML reader: plain, quoted
|
|
41
|
+
* and block (`|`, `>`) scalars, plus indented continuation lines. Nested maps
|
|
42
|
+
* (`metadata:`) are skipped. It never throws; a value it cannot read is absent.
|
|
43
|
+
*/
|
|
44
|
+
export declare function readSkillFrontmatter(markdown: string): Record<string, string> | null;
|
|
45
|
+
/**
|
|
46
|
+
* What is wrong with a bundle's SKILL.md frontmatter, as sentences; empty when
|
|
47
|
+
* nothing is. `name` must be present and equal the folder name, `description`
|
|
48
|
+
* present, non-empty and at most {@link SKILL_DESCRIPTION_MAX} characters.
|
|
49
|
+
* Kept apart from {@link validateSkillBundle} on purpose: that one gates every
|
|
50
|
+
* read (scan, inventory, uninstall, cloud download), and many existing skills
|
|
51
|
+
* have no frontmatter at all. This one gates what Sfora itself publishes or
|
|
52
|
+
* ships: `skills push` and `skills packet install`.
|
|
53
|
+
*/
|
|
54
|
+
export declare function skillFrontmatterProblems(bundle: SkillBundle): string[];
|
|
55
|
+
/**
|
|
56
|
+
* Throws every {@link skillFrontmatterProblems} finding, followed by the exact
|
|
57
|
+
* block to write, with the folder name filled in: a skill that pushed fine
|
|
58
|
+
* before this check existed is fixed with one edit.
|
|
59
|
+
*/
|
|
60
|
+
export declare function validateSkillFrontmatter(bundle: SkillBundle): void;
|
|
36
61
|
export declare function readSkillBundle(directory: string, name?: string): Promise<SkillBundle>;
|
|
62
|
+
/** Legacy array API; the shared discovery engine owns all traversal. */
|
|
37
63
|
export declare function scanLocalSkills(roots?: string[]): Promise<Array<{
|
|
38
64
|
path: string;
|
|
39
65
|
name: string;
|
|
@@ -41,5 +67,14 @@ export declare function scanLocalSkills(roots?: string[]): Promise<Array<{
|
|
|
41
67
|
error?: string;
|
|
42
68
|
}>>;
|
|
43
69
|
export declare function diffSkillBundles(local: SkillBundle, incoming: SkillBundle): SkillDifference[];
|
|
70
|
+
export declare function readInstallation(destination: string): Promise<SkillInstallation | undefined>;
|
|
71
|
+
/** A receipt is historical intent. Classify today's filesystem object before trusting it. */
|
|
72
|
+
export declare function inspectSkillTarget(directory: string): Promise<{
|
|
73
|
+
state: 'missing' | 'link' | 'file' | 'managed' | 'modified' | 'unmanaged' | 'unavailable';
|
|
74
|
+
ownership: 'managed' | 'unmanaged' | 'unknown';
|
|
75
|
+
hash?: string;
|
|
76
|
+
receipt?: SkillInstallation;
|
|
77
|
+
reason?: string;
|
|
78
|
+
}>;
|
|
44
79
|
export declare function installSkillBundle(bundle: SkillBundle, targetRoot: string, source?: string, sourceVersion?: number): Promise<SkillInstallation>;
|
|
45
80
|
export declare function uninstallSkill(directory: string): Promise<void>;
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { constants } from "node:fs";
|
|
2
|
-
import { chmod, lstat, mkdir, open, readdir,
|
|
2
|
+
import { chmod, lstat, mkdir, open, readdir, realpath, rename, rm, writeFile } from "node:fs/promises";
|
|
3
3
|
import { basename, dirname, join, resolve } from "node:path";
|
|
4
|
-
import { homedir } from "node:os";
|
|
5
4
|
import { randomUUID } from "node:crypto";
|
|
6
5
|
import { atomicWrite, LocalConflictError, sha256, withLocalLock } from "./files.js";
|
|
7
6
|
export const SKILL_LIMITS = { files: 1000, fileBytes: 5 * 1024 * 1024, totalBytes: 20 * 1024 * 1024 };
|
|
@@ -52,6 +51,99 @@ export function validateSkillBundle(bundle) {
|
|
|
52
51
|
if (skillBundleHash(bundle.files) !== bundle.hash)
|
|
53
52
|
throw new Error("Invalid skill bundle hash.");
|
|
54
53
|
}
|
|
54
|
+
/** The longest `description` a SKILL.md may carry (the Agent Skills format's limit). */
|
|
55
|
+
export const SKILL_DESCRIPTION_MAX = 1024;
|
|
56
|
+
/**
|
|
57
|
+
* The top-level scalar keys of a SKILL.md frontmatter block, or `null` when the
|
|
58
|
+
* file does not open with one. A deliberately small YAML reader: plain, quoted
|
|
59
|
+
* and block (`|`, `>`) scalars, plus indented continuation lines. Nested maps
|
|
60
|
+
* (`metadata:`) are skipped. It never throws; a value it cannot read is absent.
|
|
61
|
+
*/
|
|
62
|
+
export function readSkillFrontmatter(markdown) {
|
|
63
|
+
const m = /^?---[ \t]*\r?\n([\s\S]*?)\r?\n---[ \t]*(?:\r?\n|$)/.exec(markdown);
|
|
64
|
+
if (!m)
|
|
65
|
+
return null;
|
|
66
|
+
const lines = m[1].split(/\r?\n/);
|
|
67
|
+
const out = {};
|
|
68
|
+
for (let i = 0; i < lines.length; i++) {
|
|
69
|
+
const key = /^([A-Za-z_][\w-]*):(?:[ \t]+(.*))?$/.exec(lines[i]);
|
|
70
|
+
if (!key)
|
|
71
|
+
continue;
|
|
72
|
+
const given = (key[2] ?? "").trim();
|
|
73
|
+
const raw = /^["']/.test(given) ? given : given.replace(/[ \t]+#.*$/, "").trim();
|
|
74
|
+
const body = [];
|
|
75
|
+
while (i + 1 < lines.length && (/^[ \t]+\S/.test(lines[i + 1]) || (lines[i + 1].trim() === "" && /^[|>]/.test(raw))))
|
|
76
|
+
body.push(lines[++i]);
|
|
77
|
+
let value;
|
|
78
|
+
if (/^[|>][+-]?$/.test(raw)) {
|
|
79
|
+
const indent = Math.min(...body.filter(l => l.trim()).map(l => /^[ \t]*/.exec(l)[0].length));
|
|
80
|
+
const text = body.map(l => l.slice(Number.isFinite(indent) ? indent : 0));
|
|
81
|
+
value = raw.startsWith("|") ? text.join("\n") : text.map(l => l.trim()).join(" ").replace(/ {2,}/g, " ");
|
|
82
|
+
}
|
|
83
|
+
else if (!raw && body.length)
|
|
84
|
+
continue; // a nested map, not a scalar
|
|
85
|
+
else {
|
|
86
|
+
value = [raw, ...body.map(l => l.trim())].filter(Boolean).join(" ");
|
|
87
|
+
const q = /^(["'])([\s\S]*)\1$/.exec(value);
|
|
88
|
+
if (q)
|
|
89
|
+
value = q[1] === "'" ? q[2].replace(/''/g, "'") : q[2].replace(/\\"/g, '"');
|
|
90
|
+
}
|
|
91
|
+
out[key[1]] = value.trim();
|
|
92
|
+
}
|
|
93
|
+
return out;
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* What is wrong with a bundle's SKILL.md frontmatter, as sentences; empty when
|
|
97
|
+
* nothing is. `name` must be present and equal the folder name, `description`
|
|
98
|
+
* present, non-empty and at most {@link SKILL_DESCRIPTION_MAX} characters.
|
|
99
|
+
* Kept apart from {@link validateSkillBundle} on purpose: that one gates every
|
|
100
|
+
* read (scan, inventory, uninstall, cloud download), and many existing skills
|
|
101
|
+
* have no frontmatter at all. This one gates what Sfora itself publishes or
|
|
102
|
+
* ships: `skills push` and `skills packet install`.
|
|
103
|
+
*/
|
|
104
|
+
export function skillFrontmatterProblems(bundle) {
|
|
105
|
+
const file = bundle.files.find(f => f.path === "SKILL.md");
|
|
106
|
+
if (!file)
|
|
107
|
+
return ["A skill must contain SKILL.md."];
|
|
108
|
+
const fm = readSkillFrontmatter(Buffer.from(file.contentBase64, "base64").toString("utf8"));
|
|
109
|
+
if (!fm)
|
|
110
|
+
return [`${bundle.name}/SKILL.md has no frontmatter: open it with a --- block carrying name and description.`];
|
|
111
|
+
const problems = [];
|
|
112
|
+
if (!fm.name)
|
|
113
|
+
problems.push(`${bundle.name}/SKILL.md frontmatter has no name.`);
|
|
114
|
+
else if (fm.name !== bundle.name)
|
|
115
|
+
problems.push(`${bundle.name}/SKILL.md frontmatter name '${fm.name}' differs from its folder name '${bundle.name}'.`);
|
|
116
|
+
if (!fm.description)
|
|
117
|
+
problems.push(`${bundle.name}/SKILL.md frontmatter has no description.`);
|
|
118
|
+
else if (fm.description.length > SKILL_DESCRIPTION_MAX)
|
|
119
|
+
problems.push(`${bundle.name}/SKILL.md description is ${fm.description.length} characters; the limit is ${SKILL_DESCRIPTION_MAX}.`);
|
|
120
|
+
return problems;
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* Throws every {@link skillFrontmatterProblems} finding, followed by the exact
|
|
124
|
+
* block to write, with the folder name filled in: a skill that pushed fine
|
|
125
|
+
* before this check existed is fixed with one edit.
|
|
126
|
+
*/
|
|
127
|
+
export function validateSkillFrontmatter(bundle) {
|
|
128
|
+
const problems = skillFrontmatterProblems(bundle);
|
|
129
|
+
if (!problems.length)
|
|
130
|
+
return;
|
|
131
|
+
const file = bundle.files.find(f => f.path === "SKILL.md");
|
|
132
|
+
const hasBlock = !!file && readSkillFrontmatter(Buffer.from(file.contentBase64, "base64").toString("utf8")) !== null;
|
|
133
|
+
throw new Error([
|
|
134
|
+
...problems,
|
|
135
|
+
hasBlock
|
|
136
|
+
? `Its frontmatter block must carry these two fields (keep any others):`
|
|
137
|
+
: `Add this at the very top of ${bundle.name}/SKILL.md:`,
|
|
138
|
+
"",
|
|
139
|
+
...(hasBlock ? [] : ["---"]),
|
|
140
|
+
`name: ${bundle.name}`,
|
|
141
|
+
`description: Use when <the situation this skill is for>. Skip when <when it is not>.`,
|
|
142
|
+
...(hasBlock ? [] : ["---"]),
|
|
143
|
+
"",
|
|
144
|
+
`name must equal the folder name; description is one line of at most ${SKILL_DESCRIPTION_MAX} characters.`,
|
|
145
|
+
].join("\n"));
|
|
146
|
+
}
|
|
55
147
|
export async function readSkillBundle(directory, name = basename(resolve(directory))) {
|
|
56
148
|
validateSkillName(name);
|
|
57
149
|
if ((await lstat(directory)).isSymbolicLink())
|
|
@@ -99,38 +191,13 @@ export async function readSkillBundle(directory, name = basename(resolve(directo
|
|
|
99
191
|
validateSkillBundle(bundle);
|
|
100
192
|
return bundle;
|
|
101
193
|
}
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
catch (e) {
|
|
110
|
-
if (e.code === "ENOENT")
|
|
111
|
-
continue;
|
|
112
|
-
throw e;
|
|
113
|
-
}
|
|
114
|
-
for (const entry of entries) {
|
|
115
|
-
if (!entry.isDirectory() || entry.name.startsWith("."))
|
|
116
|
-
continue;
|
|
117
|
-
const path = join(root, entry.name);
|
|
118
|
-
try {
|
|
119
|
-
await lstat(join(path, "SKILL.md"));
|
|
120
|
-
}
|
|
121
|
-
catch {
|
|
122
|
-
continue;
|
|
123
|
-
}
|
|
124
|
-
try {
|
|
125
|
-
const bundle = await readSkillBundle(path);
|
|
126
|
-
results.push({ path, name: bundle.name, hash: bundle.hash });
|
|
127
|
-
}
|
|
128
|
-
catch (e) {
|
|
129
|
-
results.push({ path, name: entry.name, error: e instanceof Error ? e.message : String(e) });
|
|
130
|
-
}
|
|
131
|
-
}
|
|
132
|
-
}
|
|
133
|
-
return results;
|
|
194
|
+
/** Legacy array API; the shared discovery engine owns all traversal. */
|
|
195
|
+
export async function scanLocalSkills(roots) {
|
|
196
|
+
const { discoverSkills } = await import("./skill-discovery.js");
|
|
197
|
+
const result = await discoverSkills(roots?.map(path => ({ path, label: "Chosen folder", depth: 4 })));
|
|
198
|
+
if (result.warnings.length)
|
|
199
|
+
throw new Error(result.warnings.join("\n"));
|
|
200
|
+
return result.skills;
|
|
134
201
|
}
|
|
135
202
|
export function diffSkillBundles(local, incoming) {
|
|
136
203
|
validateSkillBundle(local);
|
|
@@ -147,10 +214,24 @@ export function diffSkillBundles(local, incoming) {
|
|
|
147
214
|
});
|
|
148
215
|
}
|
|
149
216
|
function recordPath(destination) { return join(dirname(destination), `.${basename(destination)}.sfora-install.json`); }
|
|
150
|
-
async function readInstallation(destination) {
|
|
217
|
+
export async function readInstallation(destination) {
|
|
151
218
|
try {
|
|
152
|
-
const
|
|
153
|
-
|
|
219
|
+
const handle = await open(recordPath(destination), constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
220
|
+
let raw;
|
|
221
|
+
try {
|
|
222
|
+
const info = await handle.stat();
|
|
223
|
+
if (!info.isFile() || info.size > 65536)
|
|
224
|
+
throw new Error('Invalid installation record file.');
|
|
225
|
+
raw = await handle.readFile('utf8');
|
|
226
|
+
}
|
|
227
|
+
finally {
|
|
228
|
+
await handle.close();
|
|
229
|
+
}
|
|
230
|
+
const record = JSON.parse(raw);
|
|
231
|
+
if (record.schemaVersion !== 1 || record.path !== destination || record.name !== basename(destination)
|
|
232
|
+
|| !/^[a-f0-9]{64}$/.test(record.hash) || !Number.isSafeInteger(record.installedAt) || record.installedAt < 0
|
|
233
|
+
|| (record.source !== undefined && (typeof record.source !== 'string' || record.source.length > 8192))
|
|
234
|
+
|| (record.sourceVersion !== undefined && (!Number.isSafeInteger(record.sourceVersion) || record.sourceVersion < 1)))
|
|
154
235
|
throw new Error("Unsupported or mismatched installation record.");
|
|
155
236
|
return record;
|
|
156
237
|
}
|
|
@@ -160,6 +241,30 @@ async function readInstallation(destination) {
|
|
|
160
241
|
throw e;
|
|
161
242
|
}
|
|
162
243
|
}
|
|
244
|
+
/** A receipt is historical intent. Classify today's filesystem object before trusting it. */
|
|
245
|
+
export async function inspectSkillTarget(directory) {
|
|
246
|
+
try {
|
|
247
|
+
const destination = join(await realpath(dirname(resolve(directory))), basename(directory));
|
|
248
|
+
const info = await lstat(destination).catch(e => { if (e.code === 'ENOENT')
|
|
249
|
+
return null; throw e; });
|
|
250
|
+
if (!info)
|
|
251
|
+
return { state: 'missing', ownership: 'unknown' };
|
|
252
|
+
if (info.isSymbolicLink())
|
|
253
|
+
return { state: 'link', ownership: 'unmanaged', reason: 'A link cannot be replaced using a copy installation receipt.' };
|
|
254
|
+
if (!info.isDirectory())
|
|
255
|
+
return { state: 'file', ownership: 'unmanaged', reason: 'The target is no longer a skill directory.' };
|
|
256
|
+
const bundle = await readSkillBundle(destination);
|
|
257
|
+
const receipt = await readInstallation(destination);
|
|
258
|
+
if (!receipt)
|
|
259
|
+
return { state: 'unmanaged', ownership: 'unmanaged', hash: bundle.hash };
|
|
260
|
+
if (receipt.hash !== bundle.hash)
|
|
261
|
+
return { state: 'modified', ownership: 'unmanaged', hash: bundle.hash, receipt, reason: 'The managed copy has local changes.' };
|
|
262
|
+
return { state: 'managed', ownership: 'managed', hash: bundle.hash, receipt };
|
|
263
|
+
}
|
|
264
|
+
catch (error) {
|
|
265
|
+
return { state: 'unavailable', ownership: 'unknown', reason: error.message };
|
|
266
|
+
}
|
|
267
|
+
}
|
|
163
268
|
export async function installSkillBundle(bundle, targetRoot, source, sourceVersion) {
|
|
164
269
|
validateSkillBundle(bundle);
|
|
165
270
|
if (sourceVersion !== undefined && (!Number.isSafeInteger(sourceVersion) || sourceVersion < 1))
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `bash` tool's description for a cloud workspace, shared by the npm stdio
|
|
3
|
+
* server (`sfora --mcp`) and the hosted one (the app's POST /mcp), which run
|
|
4
|
+
* the same shell (card #811: the two had drifted, and the hosted text taught a
|
|
5
|
+
* board column that does not exist). Only what persists differs: the stdio
|
|
6
|
+
* server keeps one shell, so cwd and env carry over; each hosted call is a
|
|
7
|
+
* fresh shell at `/`.
|
|
8
|
+
*/
|
|
9
|
+
export declare function cloudToolDescription({ stateless }: {
|
|
10
|
+
stateless: boolean;
|
|
11
|
+
}): string;
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `bash` tool's description for a cloud workspace, shared by the npm stdio
|
|
3
|
+
* server (`sfora --mcp`) and the hosted one (the app's POST /mcp), which run
|
|
4
|
+
* the same shell (card #811: the two had drifted, and the hosted text taught a
|
|
5
|
+
* board column that does not exist). Only what persists differs: the stdio
|
|
6
|
+
* server keeps one shell, so cwd and env carry over; each hosted call is a
|
|
7
|
+
* fresh shell at `/`.
|
|
8
|
+
*/
|
|
9
|
+
export function cloudToolDescription({ stateless }) {
|
|
10
|
+
const persistence = stateless
|
|
11
|
+
? "Calls are stateless — always use absolute paths."
|
|
12
|
+
: "cwd and environment persist across calls.";
|
|
13
|
+
return `Run a bash command against the sfora workspace — a Unix-style view where every post, task, and doc is a markdown file:
|
|
14
|
+
- /projects/<slug>/posts/<file>.md published posts
|
|
15
|
+
- /projects/<slug>/drafts/<file>.md your drafts
|
|
16
|
+
- /projects/<slug>/board/<NN-stage>/<NNNN>.md tasks (kanban cards), by stage
|
|
17
|
+
- /projects/<slug>/library/documents/<file>.md workspace documents (writable)
|
|
18
|
+
- /projects/<slug>/library/files/<file> uploaded files (read-only)
|
|
19
|
+
- /projects/<slug>/library/repositories/<repo> project source trees (read-only)
|
|
20
|
+
- /projects/<slug>/pulls/<number>.md pull requests (diff + linked work), read-only
|
|
21
|
+
- /projects/<slug>/plan.md the goal + open questions (write to set the goal)
|
|
22
|
+
- /projects/<slug>/asks.md coordination asks, read-only
|
|
23
|
+
- /inbox/mentions.md unread mentions
|
|
24
|
+
- /me/api-key your identity
|
|
25
|
+
Every board is the same four fixed columns — 01-triage / 02-todo / 03-in-progress / 04-done. There is no column management; move a card into 04-done to close it. Question cards (kind: question) map their stage to the plan: triage = fuzzy, todo = up for grabs, in-progress = claimed, done = decided.
|
|
26
|
+
Examples: 'ls /projects', 'cat /projects/web/board/02-todo/*.md', 'grep -ri TODO /projects', 'echo "# Fix login\\nstatus: active" > /projects/web/board/02-todo/fix.md', 'mv /projects/web/board/02-todo/0003-*.md /projects/web/board/04-done/'.
|
|
27
|
+
Write a file to create or update the entity (frontmatter sets fields like status/priority/assignees/due). ${persistence}
|
|
28
|
+
Chat: 'typing <room> [--for <secs>]' shows the room you are working on a reply (agents; 30s by default, run again to extend); it ends when you send there, or with 'typing <room> --stop'.`;
|
|
29
|
+
}
|
package/dist/mcp-server.d.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* MCP stdio server exposing a
|
|
2
|
+
* MCP stdio server exposing a `bash` tool — plus, against the cloud, the
|
|
3
|
+
* `attachments` list and `attachment` fetch (card #822), which return a post's
|
|
4
|
+
* images as image content a vision client can see. Each bash call runs the
|
|
3
5
|
* command through one persistent {@link createSforaShell} instance, with cwd/env
|
|
4
6
|
* carried across calls (just-bash doesn't persist them itself), so an agent's
|
|
5
7
|
* `cd` and `export` survive between tool invocations.
|
|
@@ -15,4 +17,6 @@ export interface RunMcpServerOptions {
|
|
|
15
17
|
/** Terminal client slug for write attribution (`X-Sfora-Client`). */
|
|
16
18
|
clientLabel?: string;
|
|
17
19
|
}
|
|
20
|
+
/** The shell the server runs commands in. Exported for the transport-header test (card #815). */
|
|
21
|
+
export declare function createMcpShell(options: RunMcpServerOptions): import("./index.js").SforaShell | import("./index.js").LocalShell;
|
|
18
22
|
export declare function runMcpServer(options: RunMcpServerOptions): Promise<void>;
|
package/dist/mcp-server.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* MCP stdio server exposing a
|
|
2
|
+
* MCP stdio server exposing a `bash` tool — plus, against the cloud, the
|
|
3
|
+
* `attachments` list and `attachment` fetch (card #822), which return a post's
|
|
4
|
+
* images as image content a vision client can see. Each bash call runs the
|
|
3
5
|
* command through one persistent {@link createSforaShell} instance, with cwd/env
|
|
4
6
|
* carried across calls (just-bash doesn't persist them itself), so an agent's
|
|
5
7
|
* `cd` and `export` survive between tool invocations.
|
|
@@ -10,21 +12,10 @@ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
|
10
12
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
11
13
|
import { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
|
|
12
14
|
import { createSforaShell, createLocalShell } from "./index.js";
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
- /projects/<slug>/library/documents/<file>.md workspace documents (writable)
|
|
18
|
-
- /projects/<slug>/library/files/<file> uploaded files (read-only)
|
|
19
|
-
- /projects/<slug>/library/repositories/<repo> project source trees (read-only)
|
|
20
|
-
- /projects/<slug>/pulls/<number>.md pull requests (diff + linked work), read-only
|
|
21
|
-
- /projects/<slug>/plan.md the goal + open questions (write to set the goal)
|
|
22
|
-
- /projects/<slug>/asks.md coordination asks, read-only
|
|
23
|
-
- /inbox/mentions.md unread mentions
|
|
24
|
-
- /me/api-key your identity
|
|
25
|
-
Every board is the same four fixed columns — 01-triage / 02-todo / 03-in-progress / 04-done. There is no column management; move a card into 04-done to close it. Question cards (kind: question) map their stage to the plan: triage = fuzzy, todo = up for grabs, in-progress = claimed, done = decided.
|
|
26
|
-
Examples: 'ls /projects', 'cat /projects/web/board/02-todo/*.md', 'grep -ri TODO /projects', 'echo "# Fix login\\nstatus: active" > /projects/web/board/02-todo/fix.md', 'mv /projects/web/board/02-todo/0003-*.md /projects/web/board/04-done/'.
|
|
27
|
-
Write a file to create or update the entity (frontmatter sets fields like status/priority/assignees/due). cwd and environment persist across calls.`;
|
|
15
|
+
import { ATTACHMENT_TOOLS, attachmentToolResult, isAttachmentTool } from "./attachments.js";
|
|
16
|
+
import { cloudToolDescription } from "./mcp-description.js";
|
|
17
|
+
// The cloud description is shared with the hosted /mcp route (card #811).
|
|
18
|
+
const TOOL_DESCRIPTION = cloudToolDescription({ stateless: false });
|
|
28
19
|
const LOCAL_TOOL_DESCRIPTION = `Run a bash command against the local sfora workspace (a .sfora/ directory of plain markdown files, git-versioned with the repo):
|
|
29
20
|
- /board/<NN-stage>/<NNNN>-<slug>.md tasks (kanban cards), by stage — 'mv' between stage dirs moves a task
|
|
30
21
|
- /posts/<YYYY-MM-DD>-<slug>.md posts
|
|
@@ -32,15 +23,25 @@ const LOCAL_TOOL_DESCRIPTION = `Run a bash command against the local sfora works
|
|
|
32
23
|
Every board is the same four fixed columns — 01-triage / 02-todo / 03-in-progress / 04-done. There is no column management; moving a card into 04-done marks it done.
|
|
33
24
|
Examples: 'ls /board/02-todo', 'cat /board/02-todo/*.md', 'grep -ri TODO /', 'echo "# Fix login\\nstatus: active" > /board/02-todo/fix-login.md', 'mv /board/02-todo/0003-*.md /board/04-done/'.
|
|
34
25
|
Frontmatter sets task fields (status/priority/labels/assignees/due). cwd and environment persist across calls.`;
|
|
35
|
-
|
|
36
|
-
|
|
26
|
+
/** The shell the server runs commands in. Exported for the transport-header test (card #815). */
|
|
27
|
+
export function createMcpShell(options) {
|
|
28
|
+
return options.localRoot
|
|
37
29
|
? createLocalShell(options.localRoot)
|
|
38
30
|
: createSforaShell({
|
|
39
31
|
baseUrl: options.baseUrl,
|
|
40
32
|
apiKey: options.apiKey,
|
|
41
33
|
org: options.org,
|
|
42
34
|
clientLabel: options.clientLabel,
|
|
35
|
+
// Card #815 (D11): this server's calls are MCP, not terminal commands.
|
|
36
|
+
transport: "mcp",
|
|
43
37
|
});
|
|
38
|
+
}
|
|
39
|
+
export async function runMcpServer(options) {
|
|
40
|
+
// A local workspace has no attachments, so only the cloud shell's client
|
|
41
|
+
// answers the attachment tools.
|
|
42
|
+
const shell = createMcpShell(options);
|
|
43
|
+
const { bash } = shell;
|
|
44
|
+
const client = "client" in shell ? shell.client : null;
|
|
44
45
|
// Persistent shell state across tool calls.
|
|
45
46
|
let cwd = "/";
|
|
46
47
|
let env;
|
|
@@ -61,9 +62,18 @@ export async function runMcpServer(options) {
|
|
|
61
62
|
required: ["command"],
|
|
62
63
|
},
|
|
63
64
|
},
|
|
65
|
+
...(client ? ATTACHMENT_TOOLS : []),
|
|
64
66
|
],
|
|
65
67
|
}));
|
|
66
68
|
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
69
|
+
if (client && isAttachmentTool(request.params.name)) {
|
|
70
|
+
const args = (request.params.arguments ?? {});
|
|
71
|
+
return attachmentToolResult(client, {
|
|
72
|
+
path: args.path,
|
|
73
|
+
// `attachments` lists; only `attachment` fetches by id.
|
|
74
|
+
id: request.params.name === "attachment" ? (args.id ?? "") : undefined,
|
|
75
|
+
});
|
|
76
|
+
}
|
|
67
77
|
if (request.params.name !== "bash") {
|
|
68
78
|
return {
|
|
69
79
|
content: [
|