Skip to content

Commit 03187ab

Browse files
feat(cli): add path-based resource directories
1 parent 3b7684a commit 03187ab

16 files changed

Lines changed: 1235 additions & 463 deletions

File tree

‎packages/sim-cli/README.md‎

Lines changed: 37 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -113,46 +113,72 @@ also accepts its singular form: for example, `sim table list`,
113113
spellings.
114114

115115
```bash
116-
sim workflows list [--folder <id>] [--deployed] [--limit <n>]
116+
sim workflows ls [--folder </path>] [--search <text>] [--limit <n>]
117+
sim workflows list [--folder </path>] [--deployed-only] [--limit <n>]
117118
sim workflows get <id>
119+
sim workflows mv <id> --folder </path>
118120
sim workflows deploy|undeploy|rollback <id>
119121

120122
sim logs list [--level error] [--workflow <id>…] [--trigger <name>…] [--start <date>]
121123
sim logs get <id>
122124
sim logs execution <executionId>
123125

124-
sim tables list
126+
sim tables ls [--folder </path>] [--search <text>] [--limit <n>]
127+
sim tables list [--folder </path>]
125128
sim tables get <tableId>
129+
sim tables mv <tableId> --folder </path>
126130
sim tables columns <tableId>
127131
sim tables rows list <tableId> [--limit <n>]
128132
sim tables rows query <tableId> [--filter <json>] [--sort <json>] [--limit <n>]
129133
sim tables upsert <tableId> --data <json>
130134
sim tables rows batch-delete <tableId> (--row <id>… | --filter <json>) --yes
131135

132-
sim files list
133-
sim files create --name <name> [--content <value>] [--encoding utf-8|base64]
134-
sim files upload <path> [--name <name>] [--folder-id <id>]
136+
sim files ls [--folder </path>] [--search <text>] [--limit <n>]
137+
sim files list [--folder </path>]
138+
sim files get <fileId>
139+
sim files create --name <name> [--folder </path>] [--content <value>] [--encoding utf-8|base64]
140+
sim files upload <path> [--name <name>] [--folder </path>]
135141
sim files download <fileId> [-o <path>]
136-
sim files move [--file-ids <id>…] [--folder-ids <id>…] [--target-folder-id <id>]
137-
sim files batch-archive [--file-ids <id>…] [--folder-ids <id>…] --yes
142+
sim files mv --file-ids <id>… [--to </path>]
143+
sim files batch-delete --file-ids <id>… --yes
138144
sim files delete <fileId>
139145

140-
sim knowledge list
146+
sim knowledge ls [--folder </path>] [--search <text>] [--limit <n>]
147+
sim knowledge list [--folder </path>]
141148
sim knowledge get <id>
149+
sim knowledge mv <id> --folder </path>
142150
sim knowledge documents <id> [--search <text>]
143151
sim knowledge documents upload <id> <path> [--tag <value>...]
144152
sim knowledge search --query <text> --kb <id>… [--search-mode vector|hybrid]
145153
```
146154

155+
`ls` is a directory view: it combines the resources at `--folder` with that
156+
folder's direct child folders. Its `ref` column is the resource ID or canonical
157+
folder path to pass to the next command. Use `list` when you want resources only,
158+
or `folders ls` when you want folders only.
159+
160+
Each folder-backed resource has the same path commands:
161+
162+
```bash
163+
sim tables folders ls --parent /Reports
164+
sim tables folders create /Reports/Quarterly
165+
sim tables folders mv /Reports/Quarterly /Archive/Quarterly
166+
sim tables folders delete /Archive/Quarterly --recursive false --yes
167+
```
168+
169+
Replace `tables` with `files`, `workflows`, or `knowledge`. Paths are canonical,
170+
start with `/`, and use `/` for root. A slash that belongs to a folder name is
171+
percent-encoded as `%2F` rather than treated as a separator.
172+
147173
### List inputs
148174

149175
Primitive lists take space-separated values. Prefix a path with `@` to read
150176
one value per line, or use `@-` to read the list from stdin.
151177

152178
```bash
153-
sim files move --file-ids file_1 file_2 --target-folder-id folder_1
154-
sim files move --file-ids @file-ids.txt --target-folder-id folder_1
155-
printf 'file_1\nfile_2\n' | sim files move --file-ids @- --target-folder-id folder_1
179+
sim files mv --file-ids file_1 file_2 --to /Archive
180+
sim files mv --file-ids @file-ids.txt --to /Archive
181+
printf 'file_1\nfile_2\n' | sim files mv --file-ids @- --to /Archive
156182
```
157183

158184
Arrays of objects remain JSON inputs because they cannot be represented as a

‎packages/sim-cli/src/commands/protocol/files-upload.test.ts‎

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -83,8 +83,7 @@ describe('files upload', () => {
8383
size: 5,
8484
type: 'text/plain',
8585
key: 'workspace/ws_local/notes.txt',
86-
folderId: null,
87-
folderPath: null,
86+
folderPath: '/',
8887
uploadedBy: 'user_1',
8988
uploadedAt: '2026-08-04T19:00:00.000Z',
9089
updatedAt: '2026-08-04T19:00:00.000Z',
@@ -96,7 +95,7 @@ describe('files upload', () => {
9695
const logged: string[] = []
9796
vi.spyOn(console, 'log').mockImplementation((line: string) => logged.push(line))
9897

99-
await program().parseAsync(['node', 'sim', 'file', 'upload', path])
98+
await program().parseAsync(['node', 'sim', 'file', 'upload', path, '--folder', '/Reports'])
10099

101100
expect(fetchMock).toHaveBeenCalledWith(
102101
'https://storage.example/file',
@@ -106,6 +105,19 @@ describe('files upload', () => {
106105
body: expect.any(Blob),
107106
})
108107
)
108+
expect(mockRequest.mock.calls[0]).toEqual([
109+
'/api/v2/files/uploads',
110+
{
111+
method: 'POST',
112+
body: {
113+
workspaceId: 'ws_local',
114+
name: 'notes.txt',
115+
contentType: 'text/plain',
116+
size: 5,
117+
folderPath: '/Reports',
118+
},
119+
},
120+
])
109121
expect(mockRequest.mock.calls[1]).toEqual([
110122
'/api/v2/files/uploads/upload_1/complete',
111123
{

‎packages/sim-cli/src/commands/protocol/files-upload.ts‎

Lines changed: 32 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -12,43 +12,41 @@ export function attachFileUpload(files: Command): void {
1212
files
1313
.command('upload <path>')
1414
.description('Upload a file to the workspace')
15-
.option('--folder-id <id>', 'Target folder (defaults to the workspace root)')
15+
.option('--folder <path>', 'Canonical destination folder path (defaults to /)')
1616
.option('--name <name>', 'Store it under a different name')
17-
.action(
18-
async (path: string, options: { folderId?: string; name?: string }, command: Command) => {
19-
const { client, profile } = clientFrom(command)
20-
const workspaceId = client.requireWorkspace()
21-
const { name, size } = await localFile(path, options.name)
17+
.action(async (path: string, options: { folder?: string; name?: string }, command: Command) => {
18+
const { client, profile } = clientFrom(command)
19+
const workspaceId = client.requireWorkspace()
20+
const { name, size } = await localFile(path, options.name)
2221

23-
const created = await client.request<CreateFileUploadResponse>('/api/v2/files/uploads', {
24-
method: 'POST',
25-
body: {
26-
workspaceId,
27-
name,
28-
contentType: contentTypeFor(name),
29-
size,
30-
...(options.folderId ? { folderId: options.folderId } : {}),
31-
},
32-
})
33-
const { session, uploadToken, transfer } = created.data
34-
const completed = await finishUploadSession<CompleteFileUploadResponse['data']>(
35-
client,
22+
const created = await client.request<CreateFileUploadResponse>('/api/v2/files/uploads', {
23+
method: 'POST',
24+
body: {
3625
workspaceId,
37-
{
38-
basePath: `/api/v2/files/uploads/${encodeURIComponent(session.id)}`,
39-
uploadToken,
40-
transfer,
41-
size,
42-
},
43-
path
44-
)
45-
46-
printProtocolResult(profile.output, {
47-
id: completed.file?.id ?? session.id,
4826
name,
27+
contentType: contentTypeFor(name),
28+
size,
29+
...(options.folder ? { folderPath: options.folder } : {}),
30+
},
31+
})
32+
const { session, uploadToken, transfer } = created.data
33+
const completed = await finishUploadSession<CompleteFileUploadResponse['data']>(
34+
client,
35+
workspaceId,
36+
{
37+
basePath: `/api/v2/files/uploads/${encodeURIComponent(session.id)}`,
38+
uploadToken,
39+
transfer,
4940
size,
50-
status: 'uploaded',
51-
})
52-
}
53-
)
41+
},
42+
path
43+
)
44+
45+
printProtocolResult(profile.output, {
46+
id: completed.file?.id ?? session.id,
47+
name,
48+
size,
49+
status: 'uploaded',
50+
})
51+
})
5452
}

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

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { Command } from 'commander'
22
import { attachFileDownload } from './files-download.js'
33
import { attachFileUpload } from './files-upload.js'
44
import { attachKnowledgeDocumentUpload } from './knowledge-document-upload.js'
5+
import { attachResourceList } from './resource-ls.js'
56
import { attachTableImport } from './tables-import.js'
67

78
function group(program: Command, name: string): Command {
@@ -17,6 +18,31 @@ export function attachProtocolCommands(program: Command): void {
1718
const files = group(program, 'files')
1819
attachFileUpload(files)
1920
attachFileDownload(files)
20-
attachKnowledgeDocumentUpload(group(group(program, 'knowledge'), 'documents'))
21-
attachTableImport(group(program, 'tables'))
21+
attachResourceList(files, {
22+
kind: 'file',
23+
resources: 'listFiles',
24+
folders: 'listFileFolders',
25+
})
26+
27+
const knowledge = group(program, 'knowledge')
28+
attachKnowledgeDocumentUpload(group(knowledge, 'documents'))
29+
attachResourceList(knowledge, {
30+
kind: 'knowledge',
31+
resources: 'listKnowledgeBases',
32+
folders: 'listKnowledgeFolders',
33+
})
34+
35+
const tables = group(program, 'tables')
36+
attachTableImport(tables)
37+
attachResourceList(tables, {
38+
kind: 'table',
39+
resources: 'listTables',
40+
folders: 'listTableFolders',
41+
})
42+
43+
attachResourceList(group(program, 'workflows'), {
44+
kind: 'workflow',
45+
resources: 'listWorkflows',
46+
folders: 'listWorkflowFolders',
47+
})
2248
}
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
import { Command } from 'commander'
2+
import { beforeEach, describe, expect, it, vi } from 'vitest'
3+
import { buildGeneratedCommands } from '../../runtime/build.js'
4+
import { attachProtocolCommands } from './index.js'
5+
6+
const { mockRequest, output } = vi.hoisted(() => ({
7+
mockRequest: vi.fn(),
8+
output: { format: 'json' },
9+
}))
10+
11+
vi.mock('../../context.js', () => ({
12+
clientFrom: () => ({
13+
client: { request: mockRequest, requireWorkspace: () => 'ws_local' },
14+
profile: {
15+
workspaceId: 'ws_local',
16+
output: output.format,
17+
name: 'default',
18+
apiKey: 'k',
19+
endpoint: 'https://sim.example',
20+
},
21+
}),
22+
}))
23+
24+
function program(): Command {
25+
const root = new Command('sim').exitOverride()
26+
for (const group of buildGeneratedCommands()) root.addCommand(group)
27+
attachProtocolCommands(root)
28+
return root
29+
}
30+
31+
beforeEach(() => {
32+
vi.restoreAllMocks()
33+
mockRequest.mockReset()
34+
output.format = 'json'
35+
})
36+
37+
describe('resource ls', () => {
38+
it('is available for every folder-backed resource', () => {
39+
for (const resource of ['files', 'knowledge', 'tables', 'workflows']) {
40+
const group = program().commands.find((command) => command.name() === resource)
41+
expect(group?.commands.some((command) => command.name() === 'ls')).toBe(true)
42+
}
43+
})
44+
45+
it('combines child folders and resources in one directory listing', async () => {
46+
mockRequest.mockImplementation(async (path: string) => {
47+
if (path === '/api/v2/tables/folders') {
48+
return {
49+
data: [
50+
{
51+
name: 'Archive',
52+
path: '/Reports/Archive',
53+
parentPath: '/Reports',
54+
createdAt: '2026-08-01T00:00:00.000Z',
55+
updatedAt: '2026-08-02T00:00:00.000Z',
56+
},
57+
],
58+
nextCursor: null,
59+
}
60+
}
61+
if (path === '/api/v2/tables') {
62+
return {
63+
data: [
64+
{
65+
id: 'tbl_1',
66+
name: 'Revenue',
67+
folderPath: '/Reports',
68+
updatedAt: '2026-08-03T00:00:00.000Z',
69+
},
70+
],
71+
nextCursor: null,
72+
}
73+
}
74+
throw new Error(`Unexpected path: ${path}`)
75+
})
76+
const logged: string[] = []
77+
vi.spyOn(console, 'log').mockImplementation((line: string) => logged.push(line))
78+
79+
await program().parseAsync([
80+
'node',
81+
'sim',
82+
'table',
83+
'ls',
84+
'--folder',
85+
'/Reports',
86+
'--search',
87+
'r',
88+
])
89+
90+
expect(mockRequest).toHaveBeenCalledWith('/api/v2/tables/folders', {
91+
query: {
92+
workspaceId: 'ws_local',
93+
parentPath: '/Reports',
94+
search: 'r',
95+
sortBy: 'name',
96+
sortOrder: 'asc',
97+
},
98+
})
99+
expect(mockRequest).toHaveBeenCalledWith('/api/v2/tables', {
100+
query: {
101+
workspaceId: 'ws_local',
102+
folderPath: '/Reports',
103+
search: 'r',
104+
sortBy: 'name',
105+
sortOrder: 'asc',
106+
limit: 100,
107+
cursor: null,
108+
},
109+
})
110+
expect(JSON.parse(logged[0])).toEqual([
111+
{
112+
kind: 'folder',
113+
name: 'Archive',
114+
ref: '/Reports/Archive',
115+
folderPath: '/Reports',
116+
updatedAt: '2026-08-02T00:00:00.000Z',
117+
},
118+
{
119+
kind: 'table',
120+
name: 'Revenue',
121+
ref: 'tbl_1',
122+
folderPath: '/Reports',
123+
updatedAt: '2026-08-03T00:00:00.000Z',
124+
},
125+
])
126+
})
127+
})

0 commit comments

Comments
 (0)