Skip to content

Commit 4e132f0

Browse files
feat(cli): support knowledge document uploads
1 parent 12f11ca commit 4e132f0

8 files changed

Lines changed: 550 additions & 2 deletions

File tree

‎packages/sim-cli/README.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,7 @@ sim files delete <fileId>
136136
sim knowledge list
137137
sim knowledge get <id>
138138
sim knowledge documents <id> [--search <text>]
139+
sim knowledge documents upload <id> <path> [--tag <value>...]
139140
sim knowledge search --query <text> --kb <id>… [--search-mode vector|hybrid]
140141
```
141142

‎packages/sim-cli/src/commands/protocol/index.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { Command } from 'commander'
22
import { attachFileDownload } from './files-download.js'
33
import { attachFileUpload } from './files-upload.js'
4+
import { attachKnowledgeDocumentUpload } from './knowledge-document-upload.js'
45
import { attachTableImport } from './tables-import.js'
56

67
function group(program: Command, name: string): Command {
@@ -16,5 +17,6 @@ export function attachProtocolCommands(program: Command): void {
1617
const files = group(program, 'files')
1718
attachFileUpload(files)
1819
attachFileDownload(files)
20+
attachKnowledgeDocumentUpload(group(group(program, 'knowledge'), 'documents'))
1921
attachTableImport(group(program, 'tables'))
2022
}
Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,205 @@
1+
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
2+
import { tmpdir } from 'node:os'
3+
import { join } from 'node:path'
4+
import { Command } from 'commander'
5+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
6+
import { buildGeneratedCommands } from '../../runtime/build.js'
7+
import { attachProtocolCommands } from './index.js'
8+
9+
const { mockRequest } = vi.hoisted(() => ({
10+
mockRequest: vi.fn(),
11+
}))
12+
13+
vi.mock('../../context.js', () => ({
14+
clientFrom: () => ({
15+
client: { request: mockRequest, requireWorkspace: () => 'ws_local' },
16+
profile: {
17+
workspaceId: 'ws_local',
18+
output: 'json',
19+
name: 'default',
20+
apiKey: 'k',
21+
endpoint: 'https://sim.example',
22+
},
23+
}),
24+
}))
25+
26+
let dir: string
27+
28+
beforeEach(() => {
29+
dir = mkdtempSync(join(tmpdir(), 'sim-kb-upload-'))
30+
mockRequest.mockReset()
31+
})
32+
33+
afterEach(() => {
34+
vi.restoreAllMocks()
35+
vi.unstubAllGlobals()
36+
rmSync(dir, { recursive: true, force: true })
37+
})
38+
39+
function program(): Command {
40+
const root = new Command('sim').exitOverride()
41+
for (const group of buildGeneratedCommands()) root.addCommand(group)
42+
attachProtocolCommands(root)
43+
const override = (command: Command) => {
44+
command.exitOverride()
45+
command.commands.forEach(override)
46+
}
47+
override(root)
48+
return root
49+
}
50+
51+
function uploadSession() {
52+
return {
53+
id: 'upload_1',
54+
knowledgeBaseId: 'kb_1',
55+
status: 'uploading',
56+
name: 'notes.doc',
57+
contentType: 'application/msword',
58+
size: 5,
59+
partSize: 10,
60+
partCount: 1,
61+
uploadToken: 'secret-token',
62+
expiresAt: '2026-08-04T20:00:00.000Z',
63+
error: null,
64+
document: null,
65+
}
66+
}
67+
68+
describe('knowledge documents upload', () => {
69+
it('owns the multipart protocol while hiding its low-level operations', () => {
70+
const knowledge = program().commands.find((command) => command.name() === 'knowledge')
71+
expect(knowledge?.commands.map((command) => command.name())).not.toEqual(
72+
expect.arrayContaining(['uploads', 'parts', 'complete'])
73+
)
74+
75+
const documents = knowledge?.commands.find((command) => command.name() === 'documents')
76+
expect(documents?.commands.map((command) => command.name())).toContain('upload')
77+
})
78+
79+
it('uploads a local document and prints the created document without transfer secrets', async () => {
80+
const path = join(dir, 'notes.doc')
81+
writeFileSync(path, 'hello')
82+
const session = uploadSession()
83+
mockRequest
84+
.mockResolvedValueOnce({ data: session })
85+
.mockResolvedValueOnce({
86+
data: {
87+
parts: [
88+
{
89+
partNumber: 1,
90+
url: 'https://storage.example/part',
91+
headers: { 'content-type': 'application/octet-stream' },
92+
expiresAt: '2026-08-04T20:00:00.000Z',
93+
},
94+
],
95+
},
96+
})
97+
.mockResolvedValueOnce({
98+
data: {
99+
...session,
100+
status: 'completed',
101+
document: {
102+
id: 'doc_1',
103+
knowledgeBaseId: 'kb_1',
104+
filename: 'notes.doc',
105+
fileSize: 5,
106+
mimeType: 'application/msword',
107+
processingStatus: 'pending',
108+
chunkCount: 0,
109+
tokenCount: 0,
110+
characterCount: 0,
111+
enabled: true,
112+
createdAt: '2026-08-04T19:00:00.000Z',
113+
},
114+
},
115+
})
116+
vi.stubGlobal(
117+
'fetch',
118+
vi.fn().mockResolvedValue(
119+
new Response(null, {
120+
status: 200,
121+
headers: { etag: '"etag-1"' },
122+
})
123+
)
124+
)
125+
const logged: string[] = []
126+
vi.spyOn(console, 'log').mockImplementation((line: string) => logged.push(line))
127+
128+
await program().parseAsync([
129+
'node',
130+
'sim',
131+
'knowledge',
132+
'documents',
133+
'upload',
134+
'kb_1',
135+
path,
136+
'--tag',
137+
'customer',
138+
'priority',
139+
'--recipe',
140+
'default',
141+
'--lang',
142+
'en',
143+
])
144+
145+
expect(mockRequest.mock.calls[0]).toEqual([
146+
'/api/v2/knowledge/kb_1/documents/uploads',
147+
{
148+
method: 'POST',
149+
body: {
150+
workspaceId: 'ws_local',
151+
name: 'notes.doc',
152+
contentType: 'application/msword',
153+
size: 5,
154+
tag1: 'customer',
155+
tag2: 'priority',
156+
processingOptions: { recipe: 'default', lang: 'en' },
157+
},
158+
},
159+
])
160+
expect(mockRequest.mock.calls[1][0]).toBe(
161+
'/api/v2/knowledge/kb_1/documents/uploads/upload_1/parts'
162+
)
163+
expect(mockRequest.mock.calls[2][0]).toBe(
164+
'/api/v2/knowledge/kb_1/documents/uploads/upload_1/complete'
165+
)
166+
expect(mockRequest.mock.calls[2][1].body).toEqual({
167+
parts: [{ partNumber: 1, etag: 'etag-1' }],
168+
})
169+
expect(JSON.parse(logged[0])).toEqual({
170+
id: 'doc_1',
171+
knowledgeBaseId: 'kb_1',
172+
name: 'notes.doc',
173+
size: 5,
174+
status: 'pending',
175+
})
176+
expect(logged[0]).not.toContain('secret-token')
177+
})
178+
179+
it('rejects more tags than the protocol supports before making a request', async () => {
180+
const path = join(dir, 'notes.txt')
181+
writeFileSync(path, 'hello')
182+
183+
await expect(
184+
program().parseAsync([
185+
'node',
186+
'sim',
187+
'knowledge',
188+
'documents',
189+
'upload',
190+
'kb_1',
191+
path,
192+
'--tag',
193+
'1',
194+
'2',
195+
'3',
196+
'4',
197+
'5',
198+
'6',
199+
'7',
200+
'8',
201+
])
202+
).rejects.toThrow(/at most seven/)
203+
expect(mockRequest).not.toHaveBeenCalled()
204+
})
205+
})
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import type { Command } from 'commander'
2+
import { clientFrom } from '../../context.js'
3+
import type { CreateKnowledgeDocumentUploadResponse } from '../../generated/v2-api.js'
4+
import { SimApiError } from '../../http/client.js'
5+
import { contentTypeFor, localFile } from '../../transfer/local-file.js'
6+
import { finishTransfer } from '../../transfer/multipart.js'
7+
import { printProtocolResult } from './result.js'
8+
9+
type KnowledgeDocumentUpload = CreateKnowledgeDocumentUploadResponse['data']
10+
11+
interface KnowledgeDocumentUploadOptions {
12+
name?: string
13+
tag?: string[]
14+
recipe?: string
15+
lang?: string
16+
}
17+
18+
function uploadMetadata(options: KnowledgeDocumentUploadOptions): Record<string, unknown> {
19+
if (options.tag && options.tag.length > 7) {
20+
throw new SimApiError('--tag accepts at most seven values', 0)
21+
}
22+
23+
const metadata: Record<string, unknown> = {}
24+
options.tag?.forEach((value, index) => {
25+
metadata[`tag${index + 1}`] = value
26+
})
27+
28+
if (options.recipe || options.lang) {
29+
metadata.processingOptions = {
30+
...(options.recipe ? { recipe: options.recipe } : {}),
31+
...(options.lang ? { lang: options.lang } : {}),
32+
}
33+
}
34+
return metadata
35+
}
36+
37+
export function attachKnowledgeDocumentUpload(documents: Command): void {
38+
documents
39+
.command('upload <knowledgeBaseId> <path>')
40+
.description('Upload a document to a knowledge base')
41+
.option('--name <name>', 'Store it under a different name')
42+
.option('--tag <value...>', 'Document tags, in tag1 through tag7 order')
43+
.option('--recipe <name>', 'Document processing recipe')
44+
.option('--lang <code>', 'Document language code')
45+
.action(
46+
async (
47+
knowledgeBaseId: string,
48+
path: string,
49+
options: KnowledgeDocumentUploadOptions,
50+
command: Command
51+
) => {
52+
const { client, profile } = clientFrom(command)
53+
const workspaceId = client.requireWorkspace()
54+
const { name, size } = await localFile(path, options.name)
55+
const created = await client.request<CreateKnowledgeDocumentUploadResponse>(
56+
`/api/v2/knowledge/${encodeURIComponent(knowledgeBaseId)}/documents/uploads`,
57+
{
58+
method: 'POST',
59+
body: {
60+
workspaceId,
61+
name,
62+
contentType: contentTypeFor(name),
63+
size,
64+
...uploadMetadata(options),
65+
},
66+
}
67+
)
68+
const upload = created.data
69+
const completed = await finishTransfer<KnowledgeDocumentUpload>(
70+
client,
71+
workspaceId,
72+
{
73+
basePath: `/api/v2/knowledge/${encodeURIComponent(
74+
knowledgeBaseId
75+
)}/documents/uploads/${encodeURIComponent(upload.id)}`,
76+
uploadToken: upload.uploadToken,
77+
partSize: upload.partSize,
78+
partCount: upload.partCount,
79+
size,
80+
},
81+
path
82+
)
83+
84+
if (!completed.document) {
85+
throw new Error(`Knowledge upload ${completed.id} completed without a document`)
86+
}
87+
printProtocolResult(profile.output, {
88+
id: completed.document.id,
89+
knowledgeBaseId: completed.document.knowledgeBaseId,
90+
name: completed.document.filename,
91+
size: completed.document.fileSize,
92+
status: completed.document.processingStatus,
93+
})
94+
}
95+
)
96+
}

‎packages/sim-cli/src/contract/commands.ts‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -395,9 +395,13 @@ export const CLI_CONTRACT: CliContract = {
395395
},
396396

397397
// ─── Not a terminal-shaped operation ──────────────────────────────────────
398-
// Multipart upload; `sim knowledge documents upload <path>` would need its own
399-
// file-reading command rather than a generated flag surface.
398+
// Multipart upload; `sim knowledge documents upload <id> <path>` needs its
399+
// own file-reading command rather than a generated flag surface.
400400
uploadKnowledgeDocument: { hidden: true },
401+
createKnowledgeDocumentUpload: { hidden: true },
402+
createKnowledgeDocumentUploadPartUrls: { hidden: true },
403+
completeKnowledgeDocumentUpload: { hidden: true },
404+
abortKnowledgeDocumentUpload: { hidden: true },
401405

402406
// ─── Steps of a transfer, not commands ────────────────────────────────────
403407
// Uploading is now a presigned multipart handshake: create the upload, ask for

0 commit comments

Comments
 (0)