@tuskydp/cli 0.2.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (104) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/dist/src/commands/decrypt.d.ts.map +1 -1
  3. package/dist/src/commands/decrypt.js +53 -21
  4. package/dist/src/commands/decrypt.js.map +1 -1
  5. package/dist/src/commands/download.d.ts +1 -0
  6. package/dist/src/commands/download.d.ts.map +1 -1
  7. package/dist/src/commands/download.js +81 -11
  8. package/dist/src/commands/download.js.map +1 -1
  9. package/dist/src/commands/export.d.ts +6 -0
  10. package/dist/src/commands/export.d.ts.map +1 -1
  11. package/dist/src/commands/export.js +29 -17
  12. package/dist/src/commands/export.js.map +1 -1
  13. package/dist/src/commands/files.d.ts.map +1 -1
  14. package/dist/src/commands/files.js +89 -10
  15. package/dist/src/commands/files.js.map +1 -1
  16. package/dist/src/commands/folder.d.ts +3 -0
  17. package/dist/src/commands/folder.d.ts.map +1 -0
  18. package/dist/src/commands/folder.js +151 -0
  19. package/dist/src/commands/folder.js.map +1 -0
  20. package/dist/src/commands/rehydrate.d.ts +1 -0
  21. package/dist/src/commands/rehydrate.d.ts.map +1 -1
  22. package/dist/src/commands/rehydrate.js +15 -7
  23. package/dist/src/commands/rehydrate.js.map +1 -1
  24. package/dist/src/commands/trash.d.ts +3 -0
  25. package/dist/src/commands/trash.d.ts.map +1 -0
  26. package/dist/src/commands/trash.js +109 -0
  27. package/dist/src/commands/trash.js.map +1 -0
  28. package/dist/src/commands/upload.d.ts +4 -0
  29. package/dist/src/commands/upload.d.ts.map +1 -1
  30. package/dist/src/commands/upload.js +104 -3
  31. package/dist/src/commands/upload.js.map +1 -1
  32. package/dist/src/commands/wallet.d.ts +3 -0
  33. package/dist/src/commands/wallet.d.ts.map +1 -0
  34. package/dist/src/commands/wallet.js +126 -0
  35. package/dist/src/commands/wallet.js.map +1 -0
  36. package/dist/src/commands/webhook.d.ts +3 -0
  37. package/dist/src/commands/webhook.d.ts.map +1 -0
  38. package/dist/src/commands/webhook.js +172 -0
  39. package/dist/src/commands/webhook.js.map +1 -0
  40. package/dist/src/crypto.d.ts +16 -0
  41. package/dist/src/crypto.d.ts.map +1 -1
  42. package/dist/src/crypto.js +26 -0
  43. package/dist/src/crypto.js.map +1 -1
  44. package/dist/src/index.js +17 -5
  45. package/dist/src/index.js.map +1 -1
  46. package/dist/src/mcp/server.d.ts.map +1 -1
  47. package/dist/src/mcp/server.js +2 -1
  48. package/dist/src/mcp/server.js.map +1 -1
  49. package/dist/src/mcp/tools/files.d.ts.map +1 -1
  50. package/dist/src/mcp/tools/files.js +40 -5
  51. package/dist/src/mcp/tools/files.js.map +1 -1
  52. package/dist/src/seal.d.ts +16 -0
  53. package/dist/src/seal.d.ts.map +1 -1
  54. package/dist/src/seal.js +23 -0
  55. package/dist/src/seal.js.map +1 -1
  56. package/dist/src/tui/files-panel.d.ts +31 -1
  57. package/dist/src/tui/files-panel.d.ts.map +1 -1
  58. package/dist/src/tui/files-panel.js +118 -11
  59. package/dist/src/tui/files-panel.js.map +1 -1
  60. package/dist/src/tui/index.d.ts.map +1 -1
  61. package/dist/src/tui/index.js +272 -33
  62. package/dist/src/tui/index.js.map +1 -1
  63. package/dist/src/tui/overview.d.ts.map +1 -1
  64. package/dist/src/tui/overview.js +24 -8
  65. package/dist/src/tui/overview.js.map +1 -1
  66. package/dist/src/tui/trash-screen.d.ts +4 -0
  67. package/dist/src/tui/trash-screen.d.ts.map +1 -0
  68. package/dist/src/tui/trash-screen.js +190 -0
  69. package/dist/src/tui/trash-screen.js.map +1 -0
  70. package/dist/src/tui/vaults-panel.d.ts +8 -0
  71. package/dist/src/tui/vaults-panel.d.ts.map +1 -1
  72. package/dist/src/tui/vaults-panel.js +45 -6
  73. package/dist/src/tui/vaults-panel.js.map +1 -1
  74. package/dist/src/version.d.ts +2 -0
  75. package/dist/src/version.d.ts.map +1 -0
  76. package/dist/src/version.js +21 -0
  77. package/dist/src/version.js.map +1 -0
  78. package/package.json +3 -3
  79. package/src/commands/decrypt.ts +56 -23
  80. package/src/commands/download.ts +82 -11
  81. package/src/commands/export.ts +35 -19
  82. package/src/commands/files.ts +93 -9
  83. package/src/commands/folder.ts +169 -0
  84. package/src/commands/rehydrate.ts +15 -8
  85. package/src/commands/trash.ts +121 -0
  86. package/src/commands/upload.ts +126 -3
  87. package/src/commands/wallet.ts +183 -0
  88. package/src/commands/webhook.ts +193 -0
  89. package/src/crypto.ts +35 -0
  90. package/src/index.ts +17 -6
  91. package/src/mcp/server.ts +2 -1
  92. package/src/mcp/tools/files.ts +43 -5
  93. package/src/seal.ts +34 -1
  94. package/src/tui/files-panel.ts +139 -11
  95. package/src/tui/index.ts +289 -33
  96. package/src/tui/overview.ts +22 -8
  97. package/src/tui/trash-screen.ts +203 -0
  98. package/src/tui/vaults-panel.ts +55 -6
  99. package/src/version.ts +21 -0
  100. package/vitest.config.ts +1 -0
  101. package/dist/src/client.d.ts +0 -120
  102. package/dist/src/client.d.ts.map +0 -1
  103. package/dist/src/client.js +0 -152
  104. package/dist/src/client.js.map +0 -1
package/src/crypto.ts CHANGED
@@ -7,6 +7,13 @@ import {
7
7
  pbkdf2Sync,
8
8
  } from 'crypto';
9
9
 
10
+ export interface FileMeta {
11
+ /** Original filename */
12
+ n: string;
13
+ /** Original MIME type */
14
+ m: string;
15
+ }
16
+
10
17
  const PBKDF2_ITERATIONS = 600_000;
11
18
  const AES_ALGO = 'aes-256-gcm' as const;
12
19
  const VERIFIER_CONTEXT = 'tuskydp-key-verifier-v1';
@@ -92,6 +99,34 @@ export function encryptBuffer(plaintext: Buffer, masterKey: Buffer): {
92
99
  };
93
100
  }
94
101
 
102
+ /**
103
+ * Encrypt filename + MIME type together using master key (AES-256-GCM).
104
+ * Format: base64([iv(12) | ciphertext | authTag(16)])
105
+ */
106
+ export function encryptMetadata(name: string, mimeType: string, masterKey: Buffer): string {
107
+ const plaintext = Buffer.from(JSON.stringify({ n: name, m: mimeType }), 'utf8');
108
+ const iv = randomBytes(12);
109
+ const cipher = createCipheriv(AES_ALGO, masterKey, iv);
110
+ const encrypted = Buffer.concat([cipher.update(plaintext), cipher.final()]);
111
+ const tag = cipher.getAuthTag();
112
+ return Buffer.concat([iv, encrypted, tag]).toString('base64');
113
+ }
114
+
115
+ /**
116
+ * Decrypt an encryptedName blob produced by encryptMetadata.
117
+ * Returns the original { name, mimeType }.
118
+ */
119
+ export function decryptMetadata(encryptedBase64: string, masterKey: Buffer): FileMeta {
120
+ const buf = Buffer.from(encryptedBase64, 'base64');
121
+ const iv = buf.subarray(0, 12);
122
+ const tag = buf.subarray(buf.length - 16);
123
+ const ciphertext = buf.subarray(12, buf.length - 16);
124
+ const decipher = createDecipheriv(AES_ALGO, masterKey, iv);
125
+ decipher.setAuthTag(tag);
126
+ const plaintext = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
127
+ return JSON.parse(plaintext.toString('utf8')) as FileMeta;
128
+ }
129
+
95
130
  export function decryptBuffer(
96
131
  ciphertext: Buffer,
97
132
  wrappedKeyBase64: string,
package/src/index.ts CHANGED
@@ -5,6 +5,10 @@ import { registerEncryptionCommands } from './commands/encryption.js';
5
5
  import { registerVaultCommands } from './commands/vault.js';
6
6
  import { registerFileCommands } from './commands/files.js';
7
7
  import { registerAccountCommands } from './commands/account.js';
8
+ import { registerFolderCommands } from './commands/folder.js';
9
+ import { registerTrashCommands } from './commands/trash.js';
10
+ import { registerWebhookCommands } from './commands/webhook.js';
11
+ import { registerWalletCommands } from './commands/wallet.js';
8
12
  import { uploadCommand } from './commands/upload.js';
9
13
  import { downloadCommand } from './commands/download.js';
10
14
  import { rehydrateCommand } from './commands/rehydrate.js';
@@ -12,17 +16,14 @@ import { registerTuiCommand } from './commands/tui.js';
12
16
  import { registerMcpCommands } from './commands/mcp.js';
13
17
  import { registerExportCommand } from './commands/export.js';
14
18
  import { registerDecryptCommand } from './commands/decrypt.js';
15
-
16
- import { createRequire } from 'module';
17
- const __require = createRequire(import.meta.url);
18
- const { version: VERSION } = __require('../../package.json');
19
+ import { CLI_VERSION } from './version.js';
19
20
 
20
21
  const program = new Command();
21
22
 
22
23
  program
23
24
  .name('tusky')
24
25
  .description('Tusky — Encrypted decentralized storage for developers, apps, and agents')
25
- .version(VERSION)
26
+ .version(CLI_VERSION)
26
27
  .option('--api-key <key>', 'Override API key')
27
28
  .option('--api-url <url>', 'Override API URL')
28
29
  .option('--format <fmt>', 'Output format: table, json, plain', 'table')
@@ -41,6 +42,10 @@ registerAuthCommands(program);
41
42
  registerEncryptionCommands(program);
42
43
  registerVaultCommands(program);
43
44
  registerFileCommands(program);
45
+ registerFolderCommands(program);
46
+ registerTrashCommands(program);
47
+ registerWebhookCommands(program);
48
+ registerWalletCommands(program);
44
49
  registerAccountCommands(program);
45
50
  registerTuiCommand(program);
46
51
  registerMcpCommands(program);
@@ -48,10 +53,14 @@ registerExportCommand(program);
48
53
  registerDecryptCommand(program);
49
54
 
50
55
  // Direct shortcuts for common operations
51
- program.command('upload <paths...>')
56
+ program.command('upload [paths...]')
52
57
  .description('Upload files')
53
58
  .option('--vault <vault>', 'Target vault')
59
+ .option('--folder <folder-id>', 'Target folder ID within the vault')
54
60
  .option('--recursive', 'Upload directory contents')
61
+ .option('--content <text>', 'Upload inline text content instead of a file path')
62
+ .option('--stdin', 'Read file content from stdin')
63
+ .option('--name <filename>', 'File name to use (required with --content or --stdin)')
55
64
  .action(async (paths: string[], options) => {
56
65
  await uploadCommand(paths, options, program);
57
66
  });
@@ -59,6 +68,7 @@ program.command('upload <paths...>')
59
68
  program.command('download <file-id>')
60
69
  .description('Download a file')
61
70
  .option('--output <path>', 'Output path')
71
+ .option('--stdout', 'Write file content to stdout (for sandboxed agents without filesystem access)')
62
72
  .action(async (fileId: string, options) => {
63
73
  await downloadCommand(fileId, options, program);
64
74
  });
@@ -66,6 +76,7 @@ program.command('download <file-id>')
66
76
  program.command('rehydrate <blob-id>')
67
77
  .description('Download a file directly from Walrus by blob ID')
68
78
  .option('--output <path>', 'Output file path')
79
+ .option('--stdout', 'Write blob content to stdout (for sandboxed agents without filesystem access)')
69
80
  .action(async (blobId: string, options) => {
70
81
  await rehydrateCommand(blobId, options, program);
71
82
  });
package/src/mcp/server.ts CHANGED
@@ -25,6 +25,7 @@ import { registerFolderTools } from './tools/folders.js';
25
25
  import { registerFileTools } from './tools/files.js';
26
26
  import { registerTrashTools } from './tools/trash.js';
27
27
  import { registerSharedVaultTools } from './tools/sharedVaults.js';
28
+ import { CLI_VERSION } from '../version.js';
28
29
 
29
30
  // ---------------------------------------------------------------------------
30
31
  // Encryption bootstrap
@@ -135,7 +136,7 @@ export async function startMcpServer(options: McpServerOptions): Promise<void> {
135
136
  // Create MCP server
136
137
  const server = new McpServer({
137
138
  name: 'tusky',
138
- version: '0.1.0',
139
+ version: CLI_VERSION,
139
140
  });
140
141
 
141
142
  // Register all tools
@@ -13,8 +13,8 @@ import { basename, resolve, join } from 'path';
13
13
  import { lookup } from 'mime-types';
14
14
  import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
15
15
  import type { McpContext } from '../context.js';
16
- import { encryptBuffer, decryptBuffer } from '../../crypto.js';
17
- import { isSealConfigured, sealEncrypt, sealDecrypt } from '../../seal.js';
16
+ import { encryptBuffer, decryptBuffer, encryptMetadata, decryptMetadata } from '../../crypto.js';
17
+ import { isSealConfigured, sealEncrypt, sealDecrypt, sealEncryptMetadata, sealDecryptMetadata } from '../../seal.js';
18
18
  import { wrapToolError } from './helpers.js';
19
19
 
20
20
  // ---------------------------------------------------------------------------
@@ -117,6 +117,38 @@ async function fetchAndDecryptFile(fileId: string, ctx: McpContext) {
117
117
  return { fileBuffer, encryption };
118
118
  }
119
119
 
120
+ /**
121
+ * Resolve the real filename from a FileStatusResponse.
122
+ * Decrypts encryptedName (private vaults) or encryptedMetadata (shared vaults) when present.
123
+ * Falls back to the stored placeholder name on failure.
124
+ */
125
+ async function resolveFilename(
126
+ fileInfo: { name: string; nameEncrypted?: boolean; encryptedName?: string | null; encryptedMetadata?: string | null; fileId?: string },
127
+ ctx: McpContext,
128
+ ): Promise<string> {
129
+ if (!fileInfo.nameEncrypted) return fileInfo.name;
130
+ try {
131
+ if (fileInfo.encryptedName) {
132
+ const masterKey = ctx.getMasterKey();
133
+ if (masterKey) {
134
+ const meta = decryptMetadata(fileInfo.encryptedName, masterKey);
135
+ return meta.n;
136
+ }
137
+ } else if (fileInfo.encryptedMetadata && fileInfo.fileId) {
138
+ const keypair = ctx.getSuiKeypair();
139
+ if (keypair) {
140
+ const fileObj = await ctx.sdk.files.get(fileInfo.fileId);
141
+ const vault = await ctx.sdk.vaults.get(fileObj.vaultId);
142
+ const meta = await sealDecryptMetadata(fileInfo.encryptedMetadata, vault, keypair);
143
+ return meta.n;
144
+ }
145
+ }
146
+ } catch {
147
+ // Fall through to placeholder
148
+ }
149
+ return fileInfo.name;
150
+ }
151
+
120
152
  /**
121
153
  * Encrypt (if private vault) and upload a buffer to Tusky.
122
154
  * Shared by tusky_file_upload (from disk) and tusky_file_create (from content).
@@ -141,6 +173,8 @@ async function encryptAndUpload(
141
173
  plaintextChecksumSha256?: string;
142
174
  sealIdentity?: string;
143
175
  sealEncryptedObject?: string;
176
+ encryptedName?: string;
177
+ encryptedMetadata?: string;
144
178
  } = {};
145
179
 
146
180
  if (isPrivate) {
@@ -159,6 +193,7 @@ async function encryptAndUpload(
159
193
  encryptionIv: iv,
160
194
  plaintextSizeBytes: fileBuffer.length,
161
195
  plaintextChecksumSha256: plaintextChecksum,
196
+ encryptedName: encryptMetadata(fileName, mimeType, masterKey),
162
197
  };
163
198
  } else if (isShared) {
164
199
  const keypair = ctx.getSuiKeypair();
@@ -176,6 +211,7 @@ async function encryptAndUpload(
176
211
  sealIdentity: sealResult.sealIdentity,
177
212
  sealEncryptedObject: sealResult.sealEncryptedObject,
178
213
  plaintextSizeBytes: fileBuffer.length,
214
+ encryptedMetadata: await sealEncryptMetadata(fileName, mimeType, vault, fileNonce),
179
215
  };
180
216
  } else {
181
217
  uploadBody = fileBuffer;
@@ -313,13 +349,14 @@ export function registerFileTools(server: McpServer, ctx: McpContext) {
313
349
  const { fileBuffer, encryption } = await fetchAndDecryptFile(fileId, ctx);
314
350
 
315
351
  const fileInfo = await ctx.sdk.files.getStatus(fileId);
352
+ const realName = await resolveFilename({ ...fileInfo, fileId }, ctx);
316
353
  const finalPath = resolve(outputPath);
317
354
 
318
355
  writeFileSync(finalPath, fileBuffer);
319
356
 
320
357
  const result = {
321
358
  fileId,
322
- name: fileInfo.name,
359
+ name: realName,
323
360
  savedTo: finalPath,
324
361
  sizeBytes: fileBuffer.length,
325
362
  mimeType: fileInfo.mimeType,
@@ -346,6 +383,7 @@ export function registerFileTools(server: McpServer, ctx: McpContext) {
346
383
  try {
347
384
  const { fileBuffer, encryption } = await fetchAndDecryptFile(fileId, ctx);
348
385
  const fileInfo = await ctx.sdk.files.getStatus(fileId);
386
+ const realName = await resolveFilename({ ...fileInfo, fileId }, ctx);
349
387
 
350
388
  const isText = isTextMimeType(fileInfo.mimeType);
351
389
 
@@ -354,7 +392,7 @@ export function registerFileTools(server: McpServer, ctx: McpContext) {
354
392
  content: [
355
393
  {
356
394
  type: 'text' as const,
357
- text: `--- ${fileInfo.name} (${fileInfo.mimeType}, ${fileBuffer.length} bytes${encryption?.encrypted ? ', decrypted' : ''}) ---\n\n${fileBuffer.toString('utf-8')}`,
395
+ text: `--- ${realName} (${fileInfo.mimeType}, ${fileBuffer.length} bytes${encryption?.encrypted ? ', decrypted' : ''}) ---\n\n${fileBuffer.toString('utf-8')}`,
358
396
  },
359
397
  ],
360
398
  };
@@ -367,7 +405,7 @@ export function registerFileTools(server: McpServer, ctx: McpContext) {
367
405
  type: 'text' as const,
368
406
  text: JSON.stringify({
369
407
  fileId,
370
- name: fileInfo.name,
408
+ name: realName,
371
409
  mimeType: fileInfo.mimeType,
372
410
  sizeBytes: fileBuffer.length,
373
411
  wasEncrypted: encryption?.encrypted ?? false,
package/src/seal.ts CHANGED
@@ -18,7 +18,7 @@ import { Ed25519Keypair } from '@mysten/sui/keypairs/ed25519';
18
18
  import { SuiJsonRpcClient } from '@mysten/sui/jsonRpc';
19
19
  import { Transaction } from '@mysten/sui/transactions';
20
20
  import { fromHex } from '@mysten/sui/utils';
21
- import { SEAL_PACKAGE_ID, SEAL_DEFAULT_KEY_SERVER_CONFIGS } from '@tuskydp/shared/constants.js';
21
+ import { SEAL_PACKAGE_ID, SEAL_DEFAULT_KEY_SERVER_CONFIGS, METADATA_NONCE_PREFIX } from '@tuskydp/shared/constants.js';
22
22
  import type { VaultResponse } from '@tuskydp/sdk';
23
23
  import { getSuiPrivateKey } from './config.js';
24
24
 
@@ -193,6 +193,39 @@ export async function sealEncrypt(
193
193
  };
194
194
  }
195
195
 
196
+ /**
197
+ * Encrypt filename + MIME type using SEAL for a shared vault.
198
+ * Uses a distinct nonce (METADATA_NONCE_PREFIX + fileNonce) so the metadata
199
+ * identity differs from the file content identity.
200
+ *
201
+ * Returns base64-encoded full SEAL EncryptedObject (stored in encrypted_metadata column).
202
+ */
203
+ export async function sealEncryptMetadata(
204
+ name: string,
205
+ mimeType: string,
206
+ vault: VaultResponse,
207
+ fileNonce: string,
208
+ ): Promise<string> {
209
+ const plaintext = new TextEncoder().encode(JSON.stringify({ n: name, m: mimeType }));
210
+ const metaNonce = METADATA_NONCE_PREFIX + fileNonce;
211
+ const result = await sealEncrypt(plaintext, vault, metaNonce);
212
+ return Buffer.from(result.encryptedData).toString('base64');
213
+ }
214
+
215
+ /**
216
+ * Decrypt an encryptedMetadata blob produced by sealEncryptMetadata.
217
+ * Returns the original { n: name, m: mimeType }.
218
+ */
219
+ export async function sealDecryptMetadata(
220
+ encryptedBase64: string,
221
+ vault: VaultResponse,
222
+ keypair: import('@mysten/sui/keypairs/ed25519').Ed25519Keypair,
223
+ ): Promise<{ n: string; m: string }> {
224
+ const encryptedData = new Uint8Array(Buffer.from(encryptedBase64, 'base64'));
225
+ const plaintext = await sealDecrypt(encryptedData, vault, keypair);
226
+ return JSON.parse(new TextDecoder().decode(plaintext));
227
+ }
228
+
196
229
  // ---------------------------------------------------------------------------
197
230
  // Decrypt
198
231
  // ---------------------------------------------------------------------------
@@ -2,6 +2,12 @@ import blessed from 'blessed';
2
2
  import type { TuskyClient } from '@tuskydp/sdk';
3
3
  import { formatBytes, formatDate, formatRow, statusColor, statusColorClose, getCurrentTheme } from './helpers.js';
4
4
 
5
+ export interface FolderItem {
6
+ id: string;
7
+ name: string;
8
+ parentId: string | null;
9
+ }
10
+
5
11
  export interface FileItem {
6
12
  id: string;
7
13
  name: string;
@@ -13,6 +19,9 @@ export interface FileItem {
13
19
  walrusBlobId: string | null;
14
20
  walrusBlobObjectId: string | null;
15
21
  encrypted: boolean;
22
+ folderId: string | null;
23
+ autoRenew: boolean;
24
+ ppuEpochEnd: string | null;
16
25
  }
17
26
 
18
27
  export class FilesPanel {
@@ -20,9 +29,15 @@ export class FilesPanel {
20
29
  private screen: blessed.Widgets.Screen;
21
30
  private sdk: TuskyClient;
22
31
  private files: FileItem[] = [];
32
+ private folders: FolderItem[] = [];
33
+ /** Combined display items: folders first, then files */
34
+ private displayItems: Array<{ type: 'folder'; folder: FolderItem } | { type: 'file'; file: FileItem } | { type: 'parent' }> = [];
23
35
  private loading = false;
24
36
  private currentVaultId: string | null = null;
25
37
  private currentVaultName = '';
38
+ private currentFolderId: string | null = null;
39
+ private currentFolderName: string | null = null;
40
+ private folderStack: Array<{ id: string | null; name: string | null }> = [];
26
41
 
27
42
  constructor(
28
43
  screen: blessed.Widgets.Screen,
@@ -63,18 +78,43 @@ export class FilesPanel {
63
78
  });
64
79
  }
65
80
 
66
- async loadForVault(vaultId: string, vaultName: string) {
81
+ async loadForVault(vaultId: string, vaultName: string, folderId?: string | null) {
67
82
  if (this.loading) return;
68
83
  this.loading = true;
69
84
  this.currentVaultId = vaultId;
70
85
  this.currentVaultName = vaultName;
71
- this.list.setLabel(` Files — ${vaultName} `);
72
- this.list.setItems(['Loading files...'] as any);
86
+
87
+ if (folderId !== undefined) {
88
+ this.currentFolderId = folderId;
89
+ }
90
+
91
+ const folderPath = this.currentFolderName
92
+ ? `${vaultName} / ${this.currentFolderName}`
93
+ : vaultName;
94
+ this.list.setLabel(` Files — ${folderPath} `);
95
+ this.list.setItems(['Loading...'] as any);
73
96
  this.screen.render();
74
97
 
75
98
  try {
76
- const { files } = await this.sdk.files.list({ vaultId, limit: 100, sortBy: 'createdAt', order: 'desc' });
77
- this.files = files.map((f) => ({
99
+ // Load folders and files for current context
100
+ const [foldersResult, filesResult] = await Promise.all([
101
+ this.sdk.folders.list({ vaultId, parentId: this.currentFolderId || undefined }).catch(() => []),
102
+ this.sdk.files.list({
103
+ vaultId,
104
+ folderId: this.currentFolderId || undefined,
105
+ limit: 100,
106
+ sortBy: 'createdAt',
107
+ order: 'desc',
108
+ }),
109
+ ]);
110
+
111
+ this.folders = (foldersResult as any[]).map((f) => ({
112
+ id: f.id,
113
+ name: f.name,
114
+ parentId: f.parentId || null,
115
+ }));
116
+
117
+ this.files = filesResult.files.map((f) => ({
78
118
  id: f.id,
79
119
  name: f.name,
80
120
  sizeBytes: f.sizeBytes || 0,
@@ -85,10 +125,31 @@ export class FilesPanel {
85
125
  walrusBlobId: f.walrusBlobId || null,
86
126
  walrusBlobObjectId: f.walrusBlobObjectId || null,
87
127
  encrypted: f.encrypted || false,
128
+ folderId: f.folderId || null,
129
+ autoRenew: f.autoRenew || false,
130
+ ppuEpochEnd: f.ppuEpochEnd || null,
88
131
  }));
89
132
 
90
- if (this.files.length === 0) {
91
- this.list.setItems(['(no files — press u to upload)'] as any);
133
+ // Build combined display list
134
+ this.displayItems = [];
135
+
136
+ // Show ".." to go up if we're in a subfolder
137
+ if (this.currentFolderId) {
138
+ this.displayItems.push({ type: 'parent' });
139
+ }
140
+
141
+ // Folders first
142
+ for (const folder of this.folders) {
143
+ this.displayItems.push({ type: 'folder', folder });
144
+ }
145
+
146
+ // Then files
147
+ for (const file of this.files) {
148
+ this.displayItems.push({ type: 'file', file });
149
+ }
150
+
151
+ if (this.displayItems.length === 0) {
152
+ this.list.setItems(['(empty — press u to upload, f to create folder)'] as any);
92
153
  } else {
93
154
  const totalW = (this.list.width as number) - 4;
94
155
  const sizeW = 9;
@@ -97,13 +158,37 @@ export class FilesPanel {
97
158
  const encW = 3;
98
159
  const nameW = Math.max(10, totalW - sizeW - statusW - dateW - encW - 4);
99
160
 
100
- const items = this.files.map((f) => {
161
+ const items = this.displayItems.map((item) => {
162
+ if (item.type === 'parent') {
163
+ return formatRow([
164
+ { text: '../', tagged: '{yellow-fg}../{/yellow-fg}', width: nameW },
165
+ { text: '', width: sizeW, align: 'right' },
166
+ { text: '', width: statusW },
167
+ { text: '', width: encW },
168
+ { text: '', width: dateW, align: 'right' },
169
+ ], totalW);
170
+ }
171
+ if (item.type === 'folder') {
172
+ const name = item.folder.name;
173
+ const display = name.length > nameW - 2
174
+ ? name.slice(0, nameW - 3) + '...'
175
+ : name + '/';
176
+ return formatRow([
177
+ { text: display, tagged: `{cyan-fg}${display}{/cyan-fg}`, width: nameW },
178
+ { text: '', width: sizeW, align: 'right' },
179
+ { text: 'folder', tagged: '{cyan-fg}folder{/cyan-fg}', width: statusW },
180
+ { text: '', width: encW },
181
+ { text: '', width: dateW, align: 'right' },
182
+ ], totalW);
183
+ }
184
+ // file
185
+ const f = item.file;
101
186
  const size = formatBytes(f.plaintextSizeBytes ?? f.sizeBytes);
102
187
  const status = f.status;
103
188
  const statusTagged = `${statusColor(f.status)}${f.status}${statusColorClose(f.status)}`;
104
189
  const date = f.createdAt ? formatDate(f.createdAt) : '';
105
190
  const enc = f.encrypted ? '🔒' : '';
106
- const name = f.name.length > nameW ? f.name.slice(0, nameW - 1) + '' : f.name;
191
+ const name = f.name.length > nameW ? f.name.slice(0, nameW - 1) + '...' : f.name;
107
192
  return formatRow([
108
193
  { text: name, width: nameW },
109
194
  { text: size, width: sizeW, align: 'right' },
@@ -122,18 +207,57 @@ export class FilesPanel {
122
207
  this.screen.render();
123
208
  }
124
209
 
210
+ /** Navigate into a folder or back to parent */
211
+ async navigateToFolder(folderId: string | null, folderName: string | null) {
212
+ if (!this.currentVaultId) return;
213
+
214
+ // Push current location onto stack before navigating
215
+ this.folderStack.push({ id: this.currentFolderId, name: this.currentFolderName });
216
+ this.currentFolderId = folderId;
217
+ this.currentFolderName = folderName;
218
+ await this.loadForVault(this.currentVaultId, this.currentVaultName);
219
+ }
220
+
221
+ /** Navigate back to parent folder */
222
+ async navigateUp() {
223
+ if (!this.currentVaultId || !this.currentFolderId) return;
224
+
225
+ const prev = this.folderStack.pop();
226
+ this.currentFolderId = prev?.id || null;
227
+ this.currentFolderName = prev?.name || null;
228
+ await this.loadForVault(this.currentVaultId, this.currentVaultName);
229
+ }
230
+
231
+ /** Reset folder navigation when switching vaults */
232
+ resetFolderNav() {
233
+ this.currentFolderId = null;
234
+ this.currentFolderName = null;
235
+ this.folderStack = [];
236
+ }
237
+
238
+ getSelectedItem(): { type: 'parent' } | { type: 'folder'; folder: FolderItem } | { type: 'file'; file: FileItem } | null {
239
+ const idx = (this.list as any).selected as number;
240
+ return this.displayItems[idx] || null;
241
+ }
242
+
125
243
  clear() {
126
244
  this.files = [];
245
+ this.folders = [];
246
+ this.displayItems = [];
127
247
  this.currentVaultId = null;
128
248
  this.currentVaultName = '';
249
+ this.currentFolderId = null;
250
+ this.currentFolderName = null;
251
+ this.folderStack = [];
129
252
  this.list.setLabel(' Files ');
130
253
  this.list.setItems(['Select a vault'] as any);
131
254
  this.screen.render();
132
255
  }
133
256
 
134
257
  getSelected(): FileItem | null {
135
- const idx = (this.list as any).selected as number;
136
- return this.files[idx] || null;
258
+ const item = this.getSelectedItem();
259
+ if (item && item.type === 'file') return item.file;
260
+ return null;
137
261
  }
138
262
 
139
263
  getFiles(): FileItem[] {
@@ -148,6 +272,10 @@ export class FilesPanel {
148
272
  return this.currentVaultName;
149
273
  }
150
274
 
275
+ getCurrentFolderId(): string | null {
276
+ return this.currentFolderId;
277
+ }
278
+
151
279
  focus() {
152
280
  this.list.focus();
153
281
  this.list.style.border = { fg: getCurrentTheme().borderFocus } as any;