sh3-core 0.23.2 → 0.25.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/dist/BrandSlot.svelte +62 -3
- package/dist/BrandSlot.test.js +52 -0
- package/dist/Sh3.svelte +4 -4
- package/dist/actions/listActive.js +1 -0
- package/dist/actions/listActive.test.js +13 -0
- package/dist/actions/types.d.ts +12 -0
- package/dist/api.d.ts +2 -0
- package/dist/api.js +2 -0
- package/dist/app/store/StoreView.svelte +1 -1
- package/dist/apps/types.d.ts +8 -0
- package/dist/chrome/MenuSheet.svelte +19 -6
- package/dist/contributions/contextSource.d.ts +48 -0
- package/dist/contributions/contextSource.js +21 -0
- package/dist/documents/picker-primitive.d.ts +0 -9
- package/dist/documents/picker-primitive.js +0 -9
- package/dist/layout/store.svelte.js +1 -1
- package/dist/overlays/presets.d.ts +17 -2
- package/dist/overlays/presets.js +28 -2
- package/dist/overlays/presets.test.js +29 -0
- package/dist/primitives/widgets/DocumentFilePicker.svelte +9 -7
- package/dist/primitives/widgets/DocumentFilePicker.svelte.d.ts +44 -27
- package/dist/primitives/widgets/_DocumentBrowser.svelte +4 -4
- package/dist/registry/installer.js +50 -10
- package/dist/registry/installer.test.d.ts +1 -0
- package/dist/registry/installer.test.js +146 -0
- package/dist/registry/types.d.ts +19 -0
- package/dist/runtime/runVerb.test.js +87 -0
- package/dist/sh3core-shard/Sh3Home.svelte +0 -1
- package/dist/shards/lifecycle.svelte.d.ts +8 -0
- package/dist/shards/lifecycle.svelte.js +17 -0
- package/dist/shell-shard/verbs/xfer.js +66 -4
- package/dist/shell-shard/verbs/xfer.test.js +74 -0
- package/dist/transport/apiFetch.js +21 -3
- package/dist/transport/apiFetch.test.js +63 -0
- package/dist/verbs/types.d.ts +49 -12
- package/dist/verbs/types.test.d.ts +1 -0
- package/dist/verbs/types.test.js +43 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
|
@@ -1,4 +1,38 @@
|
|
|
1
1
|
import { parseScopePath, resolveScope } from './scope-parse';
|
|
2
|
+
// ---------------------------------------------------------------------------
|
|
3
|
+
// Path helpers
|
|
4
|
+
//
|
|
5
|
+
// xfer follows cp-style semantics for the destination:
|
|
6
|
+
// - dst path ending with `/` (or empty) is a directory; the file is placed
|
|
7
|
+
// inside with the source's filename (or, for -R, with the source-relative
|
|
8
|
+
// path rebased under the dst directory).
|
|
9
|
+
// - dst path without trailing slash is treated literally.
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
function basename(p) {
|
|
12
|
+
const i = p.lastIndexOf('/');
|
|
13
|
+
return i >= 0 ? p.slice(i + 1) : p;
|
|
14
|
+
}
|
|
15
|
+
/** Compose a final dst path from a (possibly-directory) dst dir plus a relative segment. */
|
|
16
|
+
function joinDst(dstDir, relative) {
|
|
17
|
+
if (!dstDir)
|
|
18
|
+
return relative;
|
|
19
|
+
if (dstDir.endsWith('/'))
|
|
20
|
+
return dstDir + relative;
|
|
21
|
+
return `${dstDir}/${relative}`;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Strip a folder prefix off a doc path, returning the remainder. For the
|
|
25
|
+
* single-file case (doc.path equals prefix), returns the filename. The
|
|
26
|
+
* caller's filter guarantees doc.path is either exactly `prefix` or starts
|
|
27
|
+
* with `prefix + '/'`, so this never produces a stray leading slash.
|
|
28
|
+
*/
|
|
29
|
+
function rebaseFromPrefix(prefix, docPath) {
|
|
30
|
+
if (!prefix)
|
|
31
|
+
return docPath;
|
|
32
|
+
if (docPath === prefix)
|
|
33
|
+
return basename(docPath);
|
|
34
|
+
return docPath.slice(prefix.length + 1); // skip prefix and the separating '/'
|
|
35
|
+
}
|
|
2
36
|
export const xferVerb = {
|
|
3
37
|
name: 'xfer',
|
|
4
38
|
summary: [
|
|
@@ -7,6 +41,7 @@ export const xferVerb = {
|
|
|
7
41
|
' Either side may be @me or @project-<slug>; bare paths resolve to the active scope.',
|
|
8
42
|
' -R recursive (src is a folder prefix)',
|
|
9
43
|
' -C copy only, do not delete source',
|
|
44
|
+
' Trailing `/` on dst means "into directory" (cp-style); without it dst is a literal path.',
|
|
10
45
|
].join('\n'),
|
|
11
46
|
programmatic: true,
|
|
12
47
|
async run(ctx, args) {
|
|
@@ -60,25 +95,52 @@ export const xferVerb = {
|
|
|
60
95
|
ctx.scrollback.push({ kind: 'status', text: 'xfer: path required (use -R for folder recursion)', level: 'error', ts });
|
|
61
96
|
return;
|
|
62
97
|
}
|
|
63
|
-
|
|
98
|
+
// cp-style: trailing slash (or empty path) means "into this directory" —
|
|
99
|
+
// append the source filename so the file lands inside instead of trying
|
|
100
|
+
// to overwrite the directory itself.
|
|
101
|
+
const dstIsDir = !dstParsed.path || dstParsed.path.endsWith('/');
|
|
102
|
+
const dstFinalPath = dstIsDir
|
|
103
|
+
? joinDst(dstParsed.path, basename(srcParsed.path))
|
|
104
|
+
: dstParsed.path;
|
|
105
|
+
if (srcTenant === dstTenant && srcParsed.shardId === dstParsed.shardId && srcParsed.path === dstFinalPath) {
|
|
64
106
|
ctx.scrollback.push({ kind: 'status', text: 'xfer: source and destination are the same', level: 'error', ts });
|
|
65
107
|
return;
|
|
66
108
|
}
|
|
67
|
-
await ctx.sh3.docs.transferBetweenScopes(srcTenant, srcParsed.shardId, srcParsed.path, dstTenant, dstParsed.shardId,
|
|
109
|
+
await ctx.sh3.docs.transferBetweenScopes(srcTenant, srcParsed.shardId, srcParsed.path, dstTenant, dstParsed.shardId, dstFinalPath, moveOpts);
|
|
68
110
|
const verb = copy ? 'copied' : 'moved';
|
|
69
111
|
ctx.scrollback.push({ kind: 'status', text: `xfer: ${verb} ${positional[0]} → ${positional[1]}`, level: 'info', ts });
|
|
70
112
|
return;
|
|
71
113
|
}
|
|
72
114
|
const prefix = srcParsed.path;
|
|
73
115
|
const allDocs = await ctx.sh3.docs.listDocumentsIn(srcTenant);
|
|
74
|
-
|
|
116
|
+
// Folder-boundary filter: when `prefix` is set, a doc matches only if its
|
|
117
|
+
// path equals the prefix (single-file case) or sits inside the prefix
|
|
118
|
+
// folder (`prefix + '/'` ...). Plain `startsWith(prefix)` would also
|
|
119
|
+
// match sibling files whose names happen to share the prefix string
|
|
120
|
+
// (e.g. prefix `notes` matching `notesheet.md`).
|
|
121
|
+
const matching = allDocs.filter((d) => {
|
|
122
|
+
if (d.shardId !== srcParsed.shardId)
|
|
123
|
+
return false;
|
|
124
|
+
if (!prefix)
|
|
125
|
+
return true;
|
|
126
|
+
if (d.path === prefix)
|
|
127
|
+
return true;
|
|
128
|
+
return d.path.startsWith(`${prefix}/`);
|
|
129
|
+
});
|
|
75
130
|
if (matching.length === 0) {
|
|
76
131
|
ctx.scrollback.push({ kind: 'status', text: `xfer: no documents found under ${positional[0]}`, level: 'info', ts });
|
|
77
132
|
return;
|
|
78
133
|
}
|
|
79
134
|
let count = 0;
|
|
80
135
|
for (const doc of matching) {
|
|
81
|
-
|
|
136
|
+
// Per-doc dst: rebase the doc's path relative to the src prefix, then
|
|
137
|
+
// place it under the dst directory. Without this the dst directory is
|
|
138
|
+
// ignored and the file lands at its source path in dst shard, which
|
|
139
|
+
// (a) silently misplaces the file and (b) blows up on mounts where
|
|
140
|
+
// `mounts/<unknown-segment>/...` fails to resolve to a real mount.
|
|
141
|
+
const relative = rebaseFromPrefix(prefix, doc.path);
|
|
142
|
+
const dstFinalPath = joinDst(dstParsed.path, relative);
|
|
143
|
+
await ctx.sh3.docs.transferBetweenScopes(srcTenant, doc.shardId, doc.path, dstTenant, dstParsed.shardId, dstFinalPath, moveOpts);
|
|
82
144
|
count++;
|
|
83
145
|
}
|
|
84
146
|
const verb = copy ? 'copied' : 'moved';
|
|
@@ -104,4 +104,78 @@ describe('xfer verb', () => {
|
|
|
104
104
|
expect(transferBetweenScopes).toHaveBeenCalledTimes(2);
|
|
105
105
|
expect(transferBetweenScopes).toHaveBeenCalledWith('proj-abc', 'notes', 'ideas/a.md', 'user-me', 'notes', 'ideas/a.md', expect.objectContaining({ delete: true }));
|
|
106
106
|
});
|
|
107
|
+
// ---------------------------------------------------------------------------
|
|
108
|
+
// Bug repro — xfer -R discards dst directory; dst with trailing slash should
|
|
109
|
+
// preserve it; substring-prefix filter false-matches.
|
|
110
|
+
// ---------------------------------------------------------------------------
|
|
111
|
+
it('-R preserves dst directory when transferring into a different folder', async () => {
|
|
112
|
+
// User's exact scenario: a single file under svg-designer goes into a
|
|
113
|
+
// mount subdirectory. Previously dst.path was discarded and the file
|
|
114
|
+
// landed at `mounts/sh3_dirt.svg`, which the mount resolver interpreted
|
|
115
|
+
// as `mountId=sh3_dirt.svg` → 500.
|
|
116
|
+
const transferBetweenScopes = vi.fn(async () => { });
|
|
117
|
+
const allDocs = [
|
|
118
|
+
{ shardId: 'svg-designer', path: 'sh3_dirt.svg', size: 1, lastModified: 0 },
|
|
119
|
+
];
|
|
120
|
+
const docs = makeDocs({
|
|
121
|
+
transferBetweenScopes,
|
|
122
|
+
listDocumentsIn: vi.fn(async () => allDocs),
|
|
123
|
+
});
|
|
124
|
+
const sh3 = makeSh3(personalScope);
|
|
125
|
+
const { ctx } = makeCtx(docs, sh3);
|
|
126
|
+
await xferVerb.run(ctx, ['-R', '-C', '@me:svg-designer/sh3_dirt.svg', 'mounts/square-survivor/']);
|
|
127
|
+
expect(transferBetweenScopes).toHaveBeenCalledTimes(1);
|
|
128
|
+
expect(transferBetweenScopes).toHaveBeenCalledWith('user-me', 'svg-designer', 'sh3_dirt.svg', 'user-me', 'mounts', 'square-survivor/sh3_dirt.svg', expect.objectContaining({ delete: false }));
|
|
129
|
+
});
|
|
130
|
+
it('-R rebases doc paths under a folder prefix into the dst directory', async () => {
|
|
131
|
+
const transferBetweenScopes = vi.fn(async () => { });
|
|
132
|
+
const allDocs = [
|
|
133
|
+
{ shardId: 'notes', path: 'ideas/a.md', size: 1, lastModified: 0 },
|
|
134
|
+
{ shardId: 'notes', path: 'ideas/sub/b.md', size: 1, lastModified: 0 },
|
|
135
|
+
];
|
|
136
|
+
const docs = makeDocs({
|
|
137
|
+
transferBetweenScopes,
|
|
138
|
+
listDocumentsIn: vi.fn(async () => allDocs),
|
|
139
|
+
});
|
|
140
|
+
const sh3 = makeSh3(personalScope);
|
|
141
|
+
const { ctx } = makeCtx(docs, sh3);
|
|
142
|
+
await xferVerb.run(ctx, ['-R', '@me:notes/ideas', '@me:notes/archived/']);
|
|
143
|
+
expect(transferBetweenScopes).toHaveBeenCalledTimes(2);
|
|
144
|
+
expect(transferBetweenScopes).toHaveBeenCalledWith('user-me', 'notes', 'ideas/a.md', 'user-me', 'notes', 'archived/a.md', expect.anything());
|
|
145
|
+
expect(transferBetweenScopes).toHaveBeenCalledWith('user-me', 'notes', 'ideas/sub/b.md', 'user-me', 'notes', 'archived/sub/b.md', expect.anything());
|
|
146
|
+
});
|
|
147
|
+
it('-R filter respects folder boundary — does not match substring prefixes', async () => {
|
|
148
|
+
const transferBetweenScopes = vi.fn(async () => { });
|
|
149
|
+
const allDocs = [
|
|
150
|
+
{ shardId: 'notes', path: 'notes/draft.md', size: 1, lastModified: 0 },
|
|
151
|
+
{ shardId: 'notes', path: 'notesheet.md', size: 1, lastModified: 0 },
|
|
152
|
+
];
|
|
153
|
+
const docs = makeDocs({
|
|
154
|
+
transferBetweenScopes,
|
|
155
|
+
listDocumentsIn: vi.fn(async () => allDocs),
|
|
156
|
+
});
|
|
157
|
+
const sh3 = makeSh3(personalScope);
|
|
158
|
+
const { ctx } = makeCtx(docs, sh3);
|
|
159
|
+
await xferVerb.run(ctx, ['-R', '@me:notes/notes', '@me:notes/archived/']);
|
|
160
|
+
// Only notes/draft.md matches the folder prefix `notes`. notesheet.md
|
|
161
|
+
// shares the substring but is a sibling file, not a child.
|
|
162
|
+
expect(transferBetweenScopes).toHaveBeenCalledTimes(1);
|
|
163
|
+
expect(transferBetweenScopes).toHaveBeenCalledWith('user-me', 'notes', 'notes/draft.md', 'user-me', 'notes', 'archived/draft.md', expect.anything());
|
|
164
|
+
});
|
|
165
|
+
it('non-recursive: trailing-slash dst appends source filename', async () => {
|
|
166
|
+
const transferBetweenScopes = vi.fn(async () => { });
|
|
167
|
+
const docs = makeDocs({ transferBetweenScopes });
|
|
168
|
+
const sh3 = makeSh3(personalScope);
|
|
169
|
+
const { ctx } = makeCtx(docs, sh3);
|
|
170
|
+
await xferVerb.run(ctx, ['@me:notes/foo.md', '@me:notes/archived/']);
|
|
171
|
+
expect(transferBetweenScopes).toHaveBeenCalledWith('user-me', 'notes', 'foo.md', 'user-me', 'notes', 'archived/foo.md', expect.anything());
|
|
172
|
+
});
|
|
173
|
+
it('non-recursive: literal dst path is used verbatim (no trailing slash)', async () => {
|
|
174
|
+
const transferBetweenScopes = vi.fn(async () => { });
|
|
175
|
+
const docs = makeDocs({ transferBetweenScopes });
|
|
176
|
+
const sh3 = makeSh3(personalScope);
|
|
177
|
+
const { ctx } = makeCtx(docs, sh3);
|
|
178
|
+
await xferVerb.run(ctx, ['@me:notes/foo.md', '@me:notes/archived/renamed.md']);
|
|
179
|
+
expect(transferBetweenScopes).toHaveBeenCalledWith('user-me', 'notes', 'foo.md', 'user-me', 'notes', 'archived/renamed.md', expect.anything());
|
|
180
|
+
});
|
|
107
181
|
});
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
* defensive pattern as `platform/index.ts`. Vite code-splits it
|
|
15
15
|
* into a Tauri-only chunk that never loads in web builds.
|
|
16
16
|
*/
|
|
17
|
+
import { getEnvServerUrl } from '../env/serverUrl';
|
|
17
18
|
import { getAuthToken } from './authToken';
|
|
18
19
|
let tauriFetch = null;
|
|
19
20
|
let tauriProbed = false;
|
|
@@ -43,8 +44,25 @@ async function getTauriFetch() {
|
|
|
43
44
|
}
|
|
44
45
|
return tauriFetch;
|
|
45
46
|
}
|
|
47
|
+
// Resolve relative paths against the configured server URL so callers can
|
|
48
|
+
// use bare `/api/...` paths and have them hit the configured sh3-server
|
|
49
|
+
// regardless of the webview's origin. Mirrors ctx.fetch's resolveUrl —
|
|
50
|
+
// absolute URLs pass through, relatives get prefixed when a serverUrl is
|
|
51
|
+
// set. When no serverUrl is configured (e.g. early bootstrap, web builds
|
|
52
|
+
// hosted same-origin), the path is left untouched so `fetch` falls back
|
|
53
|
+
// to `window.location.origin` as before.
|
|
54
|
+
function resolveApiUrl(url) {
|
|
55
|
+
if (url.startsWith('http://') || url.startsWith('https://'))
|
|
56
|
+
return url;
|
|
57
|
+
const base = getEnvServerUrl();
|
|
58
|
+
if (!base)
|
|
59
|
+
return url;
|
|
60
|
+
const sep = url.startsWith('/') ? '' : '/';
|
|
61
|
+
return `${base}${sep}${url}`;
|
|
62
|
+
}
|
|
46
63
|
export function apiFetch(url, init) {
|
|
47
64
|
var _a;
|
|
65
|
+
const resolved = resolveApiUrl(url);
|
|
48
66
|
// Inject Authorization: Bearer <session-token> if a session is active
|
|
49
67
|
// and the caller didn't already supply one. Cookies don't survive the
|
|
50
68
|
// cross-origin hop (SameSite=Lax + plugin-http has no cookie store),
|
|
@@ -62,11 +80,11 @@ export function apiFetch(url, init) {
|
|
|
62
80
|
// call-timing assertions in tests still hold and web builds skip the
|
|
63
81
|
// dynamic-import probe entirely.
|
|
64
82
|
if (!inTauriRuntime()) {
|
|
65
|
-
return fetch(
|
|
83
|
+
return fetch(resolved, Object.assign({ credentials: 'include' }, finalInit));
|
|
66
84
|
}
|
|
67
85
|
return getTauriFetch().then((tf) => {
|
|
68
86
|
if (tf)
|
|
69
|
-
return tf(
|
|
70
|
-
return fetch(
|
|
87
|
+
return tf(resolved, finalInit);
|
|
88
|
+
return fetch(resolved, Object.assign({ credentials: 'include' }, finalInit));
|
|
71
89
|
});
|
|
72
90
|
}
|
|
@@ -34,4 +34,67 @@ describe('apiFetch', () => {
|
|
|
34
34
|
const [, init] = calls[0];
|
|
35
35
|
expect(init.credentials).toBe('omit');
|
|
36
36
|
});
|
|
37
|
+
it('resolves relative paths against the configured serverUrl', async () => {
|
|
38
|
+
const calls = [];
|
|
39
|
+
globalThis.fetch = vi.fn(async (input) => {
|
|
40
|
+
calls.push(String(input));
|
|
41
|
+
return new Response('ok');
|
|
42
|
+
});
|
|
43
|
+
const { __setEnvServerUrl } = await import('../env/serverUrl');
|
|
44
|
+
__setEnvServerUrl('https://remote.example.com');
|
|
45
|
+
try {
|
|
46
|
+
const { apiFetch } = await import('./apiFetch');
|
|
47
|
+
await apiFetch('/api/admin/users');
|
|
48
|
+
expect(calls[0]).toBe('https://remote.example.com/api/admin/users');
|
|
49
|
+
}
|
|
50
|
+
finally {
|
|
51
|
+
__setEnvServerUrl('');
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
it('prepends a slash to bare relative paths when serverUrl is set', async () => {
|
|
55
|
+
const calls = [];
|
|
56
|
+
globalThis.fetch = vi.fn(async (input) => {
|
|
57
|
+
calls.push(String(input));
|
|
58
|
+
return new Response('ok');
|
|
59
|
+
});
|
|
60
|
+
const { __setEnvServerUrl } = await import('../env/serverUrl');
|
|
61
|
+
__setEnvServerUrl('https://remote.example.com');
|
|
62
|
+
try {
|
|
63
|
+
const { apiFetch } = await import('./apiFetch');
|
|
64
|
+
await apiFetch('api/admin/users');
|
|
65
|
+
expect(calls[0]).toBe('https://remote.example.com/api/admin/users');
|
|
66
|
+
}
|
|
67
|
+
finally {
|
|
68
|
+
__setEnvServerUrl('');
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
it('passes absolute URLs through unchanged regardless of serverUrl', async () => {
|
|
72
|
+
const calls = [];
|
|
73
|
+
globalThis.fetch = vi.fn(async (input) => {
|
|
74
|
+
calls.push(String(input));
|
|
75
|
+
return new Response('ok');
|
|
76
|
+
});
|
|
77
|
+
const { __setEnvServerUrl } = await import('../env/serverUrl');
|
|
78
|
+
__setEnvServerUrl('https://remote.example.com');
|
|
79
|
+
try {
|
|
80
|
+
const { apiFetch } = await import('./apiFetch');
|
|
81
|
+
await apiFetch('https://other.example.com/api/bar');
|
|
82
|
+
expect(calls[0]).toBe('https://other.example.com/api/bar');
|
|
83
|
+
}
|
|
84
|
+
finally {
|
|
85
|
+
__setEnvServerUrl('');
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
it('leaves relative paths alone when no serverUrl is configured', async () => {
|
|
89
|
+
const calls = [];
|
|
90
|
+
globalThis.fetch = vi.fn(async (input) => {
|
|
91
|
+
calls.push(String(input));
|
|
92
|
+
return new Response('ok');
|
|
93
|
+
});
|
|
94
|
+
const { __setEnvServerUrl } = await import('../env/serverUrl');
|
|
95
|
+
__setEnvServerUrl('');
|
|
96
|
+
const { apiFetch } = await import('./apiFetch');
|
|
97
|
+
await apiFetch('/api/admin/users');
|
|
98
|
+
expect(calls[0]).toBe('/api/admin/users');
|
|
99
|
+
});
|
|
37
100
|
});
|
package/dist/verbs/types.d.ts
CHANGED
|
@@ -209,14 +209,14 @@ export interface VerbContext {
|
|
|
209
209
|
signal?: AbortSignal;
|
|
210
210
|
}
|
|
211
211
|
/**
|
|
212
|
-
* Portable JSON Schema subset accepted by sh3-core for `Verb.schema.input
|
|
213
|
-
* Documented as the intersection of what
|
|
214
|
-
* tool-call APIs accept natively. sh3-core
|
|
215
|
-
* actual schema stays within this subset —
|
|
216
|
-
* `$ref`, or other Draft 2020-12 features
|
|
217
|
-
* risk. The type is intentionally
|
|
218
|
-
* and `items` so authors can express
|
|
219
|
-
* fighting the type system.
|
|
212
|
+
* Portable JSON Schema subset accepted by sh3-core for `Verb.schema.input`
|
|
213
|
+
* and `Verb.schema.output`. Documented as the intersection of what
|
|
214
|
+
* Anthropic, OpenAI, and Gemini tool-call APIs accept natively. sh3-core
|
|
215
|
+
* does NOT validate that the actual schema stays within this subset —
|
|
216
|
+
* authors who reach for `oneOf`, `$ref`, or other Draft 2020-12 features
|
|
217
|
+
* do so at their own portability risk. The type is intentionally
|
|
218
|
+
* `unknown`-permissive on `properties` and `items` so authors can express
|
|
219
|
+
* object/array shapes without fighting the type system.
|
|
220
220
|
*/
|
|
221
221
|
export interface PortableJSONSchema {
|
|
222
222
|
type?: 'string' | 'number' | 'integer' | 'boolean' | 'object' | 'array' | 'null';
|
|
@@ -229,14 +229,39 @@ export interface PortableJSONSchema {
|
|
|
229
229
|
required?: string[];
|
|
230
230
|
/** Item schema for `type: 'array'`. */
|
|
231
231
|
items?: PortableJSONSchema;
|
|
232
|
+
/**
|
|
233
|
+
* JSON Schema `format` hint. sh3-core blesses one value:
|
|
234
|
+
* - 'sh3-document' — runtime payload is an SH3 document handle
|
|
235
|
+
* ({ shardId: string; path: string }). Combine with type: 'object'.
|
|
236
|
+
* Other `format` values pass through untouched (portability with
|
|
237
|
+
* JSON Schema validators and tool-call APIs is preserved).
|
|
238
|
+
*/
|
|
239
|
+
format?: string;
|
|
232
240
|
}
|
|
233
241
|
/**
|
|
234
|
-
* Optional schema attached to a verb.
|
|
235
|
-
*
|
|
236
|
-
*
|
|
242
|
+
* Optional schema attached to a verb. `input` is consumed by sh3-ai's
|
|
243
|
+
* tool-call dispatcher and by sh3-pipeline's verb-adapter. `output`
|
|
244
|
+
* describes the shape of the verb's return value (the `result` field
|
|
245
|
+
* of `Sh3Api.runVerb`'s Promise resolution) and is consumed by
|
|
246
|
+
* sh3-pipeline to derive output ports.
|
|
237
247
|
*/
|
|
238
248
|
export interface VerbSchema {
|
|
239
249
|
input: PortableJSONSchema;
|
|
250
|
+
/**
|
|
251
|
+
* Shape of the verb's return value. When omitted, callers treat the
|
|
252
|
+
* verb as side-effect-only and read the scrollback instead.
|
|
253
|
+
*
|
|
254
|
+
* - {type:'object', properties:{…}} → result is an object whose
|
|
255
|
+
* own properties match the schema. Each top-level property is
|
|
256
|
+
* one logical output (one port for sh3-pipeline).
|
|
257
|
+
* - any other type → result IS that value.
|
|
258
|
+
*
|
|
259
|
+
* Document outputs are declared as
|
|
260
|
+
* { type: 'object', format: 'sh3-document',
|
|
261
|
+
* properties: { shardId: {type:'string'}, path: {type:'string'} } }
|
|
262
|
+
* or inlined inside a parent object's properties.
|
|
263
|
+
*/
|
|
264
|
+
output?: PortableJSONSchema;
|
|
240
265
|
}
|
|
241
266
|
export interface Verb {
|
|
242
267
|
name: string;
|
|
@@ -271,7 +296,19 @@ export interface Verb {
|
|
|
271
296
|
* whether to read it or fall back to `args[]`.
|
|
272
297
|
*/
|
|
273
298
|
schema?: VerbSchema;
|
|
274
|
-
|
|
299
|
+
/**
|
|
300
|
+
* Returns the verb's structured result. The return value MUST match
|
|
301
|
+
* the shape declared by `schema.output` when both are present:
|
|
302
|
+
* - schema.output {type:'object', properties:{…}} → return object with those keys
|
|
303
|
+
* - schema.output primitive → return that primitive
|
|
304
|
+
* - schema.output undefined → return value is ignored
|
|
305
|
+
*
|
|
306
|
+
* Verbs that don't declare schema.output may return undefined; the
|
|
307
|
+
* Sh3Api.runVerb resolution surfaces it as `result: undefined`.
|
|
308
|
+
* Existing verbs that returned Promise<void> remain assignable —
|
|
309
|
+
* `void` is a subtype of `unknown` for return-type widening.
|
|
310
|
+
*/
|
|
311
|
+
run(ctx: VerbContext, args: string[]): Promise<unknown>;
|
|
275
312
|
}
|
|
276
313
|
export type Resolution = {
|
|
277
314
|
kind: 'local';
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { describe, it, expectTypeOf } from 'vitest';
|
|
2
|
+
describe('VerbSchema with output', () => {
|
|
3
|
+
it('accepts output as PortableJSONSchema', () => {
|
|
4
|
+
const schema = {
|
|
5
|
+
input: { type: 'object', properties: { topic: { type: 'string' } } },
|
|
6
|
+
output: { type: 'object', properties: { answer: { type: 'string' } } },
|
|
7
|
+
};
|
|
8
|
+
expectTypeOf(schema.output).toEqualTypeOf();
|
|
9
|
+
});
|
|
10
|
+
it('accepts format on PortableJSONSchema', () => {
|
|
11
|
+
const docSchema = {
|
|
12
|
+
type: 'object',
|
|
13
|
+
format: 'sh3-document',
|
|
14
|
+
properties: {
|
|
15
|
+
shardId: { type: 'string' },
|
|
16
|
+
path: { type: 'string' },
|
|
17
|
+
},
|
|
18
|
+
};
|
|
19
|
+
expectTypeOf(docSchema.format).toEqualTypeOf();
|
|
20
|
+
});
|
|
21
|
+
});
|
|
22
|
+
describe('Verb.run return type', () => {
|
|
23
|
+
it('accepts a verb returning Promise<unknown>', () => {
|
|
24
|
+
const v = {
|
|
25
|
+
name: 'demo',
|
|
26
|
+
summary: 's',
|
|
27
|
+
async run() {
|
|
28
|
+
return { answer: 'ok' };
|
|
29
|
+
},
|
|
30
|
+
};
|
|
31
|
+
expectTypeOf(v.run).returns.toEqualTypeOf();
|
|
32
|
+
});
|
|
33
|
+
it('accepts a legacy verb returning Promise<void>', () => {
|
|
34
|
+
const v = {
|
|
35
|
+
name: 'legacy',
|
|
36
|
+
summary: 's',
|
|
37
|
+
async run() {
|
|
38
|
+
// returns nothing
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
expectTypeOf(v.run).returns.toEqualTypeOf();
|
|
42
|
+
});
|
|
43
|
+
});
|
package/dist/version.d.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
/** Auto-generated from package.json — do not edit manually. */
|
|
2
|
-
export declare const VERSION = "0.
|
|
2
|
+
export declare const VERSION = "0.25.0";
|
package/dist/version.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
/** Auto-generated from package.json — do not edit manually. */
|
|
2
|
-
export const VERSION = '0.
|
|
2
|
+
export const VERSION = '0.25.0';
|