749 lines
21 KiB
TypeScript
749 lines
21 KiB
TypeScript
/**
|
|
* Files API client for managing files
|
|
*
|
|
* This module provides functionality to download and upload files to Anthropic Public Files API.
|
|
* Used by the Claude Code agent to download file attachments at session startup.
|
|
*
|
|
* API Reference: https://docs.anthropic.com/en/api/files-content
|
|
*/
|
|
|
|
import axios from 'axios'
|
|
import { randomUUID } from 'crypto'
|
|
import * as fs from 'fs/promises'
|
|
import * as path from 'path'
|
|
import { count } from '../../utils/array.js'
|
|
import { getCwd } from '../../utils/cwd.js'
|
|
import { logForDebugging } from '../../utils/debug.js'
|
|
import { errorMessage } from '../../utils/errors.js'
|
|
import { logError } from '../../utils/log.js'
|
|
import { sleep } from '../../utils/sleep.js'
|
|
import {
|
|
type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
|
logEvent,
|
|
} from '../analytics/index.js'
|
|
|
|
// Files API is currently in beta. oauth-2025-04-20 enables Bearer OAuth
|
|
// on public-api routes (auth.py: "oauth_auth" not in beta_versions → 404).
|
|
const FILES_API_BETA_HEADER = 'files-api-2025-04-14,oauth-2025-04-20'
|
|
const ANTHROPIC_VERSION = '2023-06-01'
|
|
|
|
// API base URL - uses ANTHROPIC_BASE_URL set by env-manager for the appropriate environment
|
|
// Falls back to public API for standalone usage
|
|
function getDefaultApiBaseUrl(): string {
|
|
return (
|
|
process.env.ANTHROPIC_BASE_URL ||
|
|
process.env.CLAUDE_CODE_API_BASE_URL ||
|
|
'https://api.anthropic.com'
|
|
)
|
|
}
|
|
|
|
function logDebugError(message: string): void {
|
|
logForDebugging(`[files-api] ${message}`, { level: 'error' })
|
|
}
|
|
|
|
function logDebug(message: string): void {
|
|
logForDebugging(`[files-api] ${message}`)
|
|
}
|
|
|
|
/**
|
|
* File specification parsed from CLI args
|
|
* Format: --file=<file_id>:<relative_path>
|
|
*/
|
|
export type File = {
|
|
fileId: string
|
|
relativePath: string
|
|
}
|
|
|
|
/**
|
|
* Configuration for the files API client
|
|
*/
|
|
export type FilesApiConfig = {
|
|
/** OAuth token for authentication (from session JWT) */
|
|
oauthToken: string
|
|
/** Base URL for the API (default: https://api.anthropic.com) */
|
|
baseUrl?: string
|
|
/** Session ID for creating session-specific directories */
|
|
sessionId: string
|
|
}
|
|
|
|
/**
|
|
* Result of a file download operation
|
|
*/
|
|
export type DownloadResult = {
|
|
fileId: string
|
|
path: string
|
|
success: boolean
|
|
error?: string
|
|
bytesWritten?: number
|
|
}
|
|
|
|
const MAX_RETRIES = 3
|
|
const BASE_DELAY_MS = 500
|
|
const MAX_FILE_SIZE_BYTES = 500 * 1024 * 1024 // 500MB
|
|
|
|
/**
|
|
* Result type for retry operations - signals whether to continue retrying
|
|
*/
|
|
type RetryResult<T> = { done: true; value: T } | { done: false; error?: string }
|
|
|
|
/**
|
|
* Executes an operation with exponential backoff retry logic
|
|
*
|
|
* @param operation - Operation name for logging
|
|
* @param attemptFn - Function to execute on each attempt, returns RetryResult
|
|
* @returns The successful result value
|
|
* @throws Error if all retries exhausted
|
|
*/
|
|
async function retryWithBackoff<T>(
|
|
operation: string,
|
|
attemptFn: (attempt: number) => Promise<RetryResult<T>>,
|
|
): Promise<T> {
|
|
let lastError = ''
|
|
|
|
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
|
|
const result = await attemptFn(attempt)
|
|
|
|
if (result.done) {
|
|
return result.value
|
|
}
|
|
|
|
lastError = result.error || `${operation} failed`
|
|
logDebug(
|
|
`${operation} attempt ${attempt}/${MAX_RETRIES} failed: ${lastError}`,
|
|
)
|
|
|
|
if (attempt < MAX_RETRIES) {
|
|
const delayMs = BASE_DELAY_MS * Math.pow(2, attempt - 1)
|
|
logDebug(`Retrying ${operation} in ${delayMs}ms...`)
|
|
await sleep(delayMs)
|
|
}
|
|
}
|
|
|
|
throw new Error(`${lastError} after ${MAX_RETRIES} attempts`)
|
|
}
|
|
|
|
/**
|
|
* Downloads a single file from the Anthropic Public Files API
|
|
*
|
|
* @param fileId - The file ID (e.g., "file_011CNha8iCJcU1wXNR6q4V8w")
|
|
* @param config - Files API configuration
|
|
* @returns The file content as a Buffer
|
|
*/
|
|
export async function downloadFile(
|
|
fileId: string,
|
|
config: FilesApiConfig,
|
|
): Promise<Buffer> {
|
|
const baseUrl = config.baseUrl || getDefaultApiBaseUrl()
|
|
const url = `${baseUrl}/v1/files/${fileId}/content`
|
|
|
|
const headers = {
|
|
Authorization: `Bearer ${config.oauthToken}`,
|
|
'anthropic-version': ANTHROPIC_VERSION,
|
|
'anthropic-beta': FILES_API_BETA_HEADER,
|
|
}
|
|
|
|
logDebug(`Downloading file ${fileId} from ${url}`)
|
|
|
|
return retryWithBackoff(`Download file ${fileId}`, async () => {
|
|
try {
|
|
const response = await axios.get(url, {
|
|
headers,
|
|
responseType: 'arraybuffer',
|
|
timeout: 60000, // 60 second timeout for large files
|
|
validateStatus: status => status < 500,
|
|
})
|
|
|
|
if (response.status === 200) {
|
|
logDebug(`Downloaded file ${fileId} (${response.data.length} bytes)`)
|
|
return { done: true, value: Buffer.from(response.data) }
|
|
}
|
|
|
|
// Non-retriable errors - throw immediately
|
|
if (response.status === 404) {
|
|
throw new Error(`File not found: ${fileId}`)
|
|
}
|
|
if (response.status === 401) {
|
|
throw new Error('Authentication failed: invalid or missing API key')
|
|
}
|
|
if (response.status === 403) {
|
|
throw new Error(`Access denied to file: ${fileId}`)
|
|
}
|
|
|
|
return { done: false, error: `status ${response.status}` }
|
|
} catch (error) {
|
|
if (!axios.isAxiosError(error)) {
|
|
throw error
|
|
}
|
|
return { done: false, error: error.message }
|
|
}
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Normalizes a relative path, strips redundant prefixes, and builds the full
|
|
* download path under {basePath}/{session_id}/uploads/.
|
|
* Returns null if the path is invalid (e.g., path traversal).
|
|
*/
|
|
export function buildDownloadPath(
|
|
basePath: string,
|
|
sessionId: string,
|
|
relativePath: string,
|
|
): string | null {
|
|
const normalized = path.normalize(relativePath)
|
|
if (normalized.startsWith('..')) {
|
|
logDebugError(
|
|
`Invalid file path: ${relativePath}. Path must not traverse above workspace`,
|
|
)
|
|
return null
|
|
}
|
|
|
|
const uploadsBase = path.join(basePath, sessionId, 'uploads')
|
|
const redundantPrefixes = [
|
|
path.join(basePath, sessionId, 'uploads') + path.sep,
|
|
path.sep + 'uploads' + path.sep,
|
|
]
|
|
const matchedPrefix = redundantPrefixes.find(p => normalized.startsWith(p))
|
|
const cleanPath = matchedPrefix
|
|
? normalized.slice(matchedPrefix.length)
|
|
: normalized
|
|
return path.join(uploadsBase, cleanPath)
|
|
}
|
|
|
|
/**
|
|
* Downloads a file and saves it to the session-specific workspace directory
|
|
*
|
|
* @param attachment - The file attachment to download
|
|
* @param config - Files API configuration
|
|
* @returns Download result with success/failure status
|
|
*/
|
|
export async function downloadAndSaveFile(
|
|
attachment: File,
|
|
config: FilesApiConfig,
|
|
): Promise<DownloadResult> {
|
|
const { fileId, relativePath } = attachment
|
|
const fullPath = buildDownloadPath(getCwd(), config.sessionId, relativePath)
|
|
|
|
if (!fullPath) {
|
|
return {
|
|
fileId,
|
|
path: '',
|
|
success: false,
|
|
error: `Invalid file path: ${relativePath}`,
|
|
}
|
|
}
|
|
|
|
try {
|
|
// Download the file content
|
|
const content = await downloadFile(fileId, config)
|
|
|
|
// Ensure the parent directory exists
|
|
const parentDir = path.dirname(fullPath)
|
|
await fs.mkdir(parentDir, { recursive: true })
|
|
|
|
// Write the file
|
|
await fs.writeFile(fullPath, content)
|
|
|
|
logDebug(`Saved file ${fileId} to ${fullPath} (${content.length} bytes)`)
|
|
|
|
return {
|
|
fileId,
|
|
path: fullPath,
|
|
success: true,
|
|
bytesWritten: content.length,
|
|
}
|
|
} catch (error) {
|
|
logDebugError(`Failed to download file ${fileId}: ${errorMessage(error)}`)
|
|
if (error instanceof Error) {
|
|
logError(error)
|
|
}
|
|
|
|
return {
|
|
fileId,
|
|
path: fullPath,
|
|
success: false,
|
|
error: errorMessage(error),
|
|
}
|
|
}
|
|
}
|
|
|
|
// Default concurrency limit for parallel downloads
|
|
const DEFAULT_CONCURRENCY = 5
|
|
|
|
/**
|
|
* Execute promises with limited concurrency
|
|
*
|
|
* @param items - Items to process
|
|
* @param fn - Async function to apply to each item
|
|
* @param concurrency - Maximum concurrent operations
|
|
* @returns Results in the same order as input items
|
|
*/
|
|
async function parallelWithLimit<T, R>(
|
|
items: T[],
|
|
fn: (item: T, index: number) => Promise<R>,
|
|
concurrency: number,
|
|
): Promise<R[]> {
|
|
const results: R[] = new Array(items.length)
|
|
let currentIndex = 0
|
|
|
|
async function worker(): Promise<void> {
|
|
while (currentIndex < items.length) {
|
|
const index = currentIndex++
|
|
const item = items[index]
|
|
if (item !== undefined) {
|
|
results[index] = await fn(item, index)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Start workers up to the concurrency limit
|
|
const workers: Promise<void>[] = []
|
|
const workerCount = Math.min(concurrency, items.length)
|
|
for (let i = 0; i < workerCount; i++) {
|
|
workers.push(worker())
|
|
}
|
|
|
|
await Promise.all(workers)
|
|
return results
|
|
}
|
|
|
|
/**
|
|
* Downloads all file attachments for a session in parallel
|
|
*
|
|
* @param attachments - List of file attachments to download
|
|
* @param config - Files API configuration
|
|
* @param concurrency - Maximum concurrent downloads (default: 5)
|
|
* @returns Array of download results in the same order as input
|
|
*/
|
|
export async function downloadSessionFiles(
|
|
files: File[],
|
|
config: FilesApiConfig,
|
|
concurrency: number = DEFAULT_CONCURRENCY,
|
|
): Promise<DownloadResult[]> {
|
|
if (files.length === 0) {
|
|
return []
|
|
}
|
|
|
|
logDebug(
|
|
`Downloading ${files.length} file(s) for session ${config.sessionId}`,
|
|
)
|
|
const startTime = Date.now()
|
|
|
|
// Download files in parallel with concurrency limit
|
|
const results = await parallelWithLimit(
|
|
files,
|
|
file => downloadAndSaveFile(file, config),
|
|
concurrency,
|
|
)
|
|
|
|
const elapsedMs = Date.now() - startTime
|
|
const successCount = count(results, r => r.success)
|
|
logDebug(
|
|
`Downloaded ${successCount}/${files.length} file(s) in ${elapsedMs}ms`,
|
|
)
|
|
|
|
return results
|
|
}
|
|
|
|
// ============================================================================
|
|
// Upload Functions (BYOC mode)
|
|
// ============================================================================
|
|
|
|
/**
|
|
* Result of a file upload operation
|
|
*/
|
|
export type UploadResult =
|
|
| {
|
|
path: string
|
|
fileId: string
|
|
size: number
|
|
success: true
|
|
}
|
|
| {
|
|
path: string
|
|
error: string
|
|
success: false
|
|
}
|
|
|
|
/**
|
|
* Upload a single file to the Files API (BYOC mode)
|
|
*
|
|
* Size validation is performed after reading the file to avoid TOCTOU race
|
|
* conditions where the file size could change between initial check and upload.
|
|
*
|
|
* @param filePath - Absolute path to the file to upload
|
|
* @param relativePath - Relative path for the file (used as filename in API)
|
|
* @param config - Files API configuration
|
|
* @returns Upload result with success/failure status
|
|
*/
|
|
export async function uploadFile(
|
|
filePath: string,
|
|
relativePath: string,
|
|
config: FilesApiConfig,
|
|
opts?: { signal?: AbortSignal },
|
|
): Promise<UploadResult> {
|
|
const baseUrl = config.baseUrl || getDefaultApiBaseUrl()
|
|
const url = `${baseUrl}/v1/files`
|
|
|
|
const headers = {
|
|
Authorization: `Bearer ${config.oauthToken}`,
|
|
'anthropic-version': ANTHROPIC_VERSION,
|
|
'anthropic-beta': FILES_API_BETA_HEADER,
|
|
}
|
|
|
|
logDebug(`Uploading file ${filePath} as ${relativePath}`)
|
|
|
|
// Read file content first (outside retry loop since it's not a network operation)
|
|
let content: Buffer
|
|
try {
|
|
content = await fs.readFile(filePath)
|
|
} catch (error) {
|
|
logEvent('tengu_file_upload_failed', {
|
|
error_type:
|
|
'file_read' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
|
})
|
|
return {
|
|
path: relativePath,
|
|
error: errorMessage(error),
|
|
success: false,
|
|
}
|
|
}
|
|
|
|
const fileSize = content.length
|
|
|
|
if (fileSize > MAX_FILE_SIZE_BYTES) {
|
|
logEvent('tengu_file_upload_failed', {
|
|
error_type:
|
|
'file_too_large' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
|
})
|
|
return {
|
|
path: relativePath,
|
|
error: `File exceeds maximum size of ${MAX_FILE_SIZE_BYTES} bytes (actual: ${fileSize})`,
|
|
success: false,
|
|
}
|
|
}
|
|
|
|
// Use crypto.randomUUID for boundary to avoid collisions when uploads start same millisecond
|
|
const boundary = `----FormBoundary${randomUUID()}`
|
|
const filename = path.basename(relativePath)
|
|
|
|
// Build the multipart body
|
|
const bodyParts: Buffer[] = []
|
|
|
|
// File part
|
|
bodyParts.push(
|
|
Buffer.from(
|
|
`--${boundary}\r\n` +
|
|
`Content-Disposition: form-data; name="file"; filename="${filename}"\r\n` +
|
|
`Content-Type: application/octet-stream\r\n\r\n`,
|
|
),
|
|
)
|
|
bodyParts.push(content)
|
|
bodyParts.push(Buffer.from('\r\n'))
|
|
|
|
// Purpose part
|
|
bodyParts.push(
|
|
Buffer.from(
|
|
`--${boundary}\r\n` +
|
|
`Content-Disposition: form-data; name="purpose"\r\n\r\n` +
|
|
`user_data\r\n`,
|
|
),
|
|
)
|
|
|
|
// End boundary
|
|
bodyParts.push(Buffer.from(`--${boundary}--\r\n`))
|
|
|
|
const body = Buffer.concat(bodyParts)
|
|
|
|
try {
|
|
return await retryWithBackoff(`Upload file ${relativePath}`, async () => {
|
|
try {
|
|
const response = await axios.post(url, body, {
|
|
headers: {
|
|
...headers,
|
|
'Content-Type': `multipart/form-data; boundary=${boundary}`,
|
|
'Content-Length': body.length.toString(),
|
|
},
|
|
timeout: 120000, // 2 minute timeout for uploads
|
|
signal: opts?.signal,
|
|
validateStatus: status => status < 500,
|
|
})
|
|
|
|
if (response.status === 200 || response.status === 201) {
|
|
const fileId = response.data?.id
|
|
if (!fileId) {
|
|
return {
|
|
done: false,
|
|
error: 'Upload succeeded but no file ID returned',
|
|
}
|
|
}
|
|
logDebug(`Uploaded file ${filePath} -> ${fileId} (${fileSize} bytes)`)
|
|
return {
|
|
done: true,
|
|
value: {
|
|
path: relativePath,
|
|
fileId,
|
|
size: fileSize,
|
|
success: true as const,
|
|
},
|
|
}
|
|
}
|
|
|
|
// Non-retriable errors - throw to exit retry loop
|
|
if (response.status === 401) {
|
|
logEvent('tengu_file_upload_failed', {
|
|
error_type:
|
|
'auth' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
|
})
|
|
throw new UploadNonRetriableError(
|
|
'Authentication failed: invalid or missing API key',
|
|
)
|
|
}
|
|
|
|
if (response.status === 403) {
|
|
logEvent('tengu_file_upload_failed', {
|
|
error_type:
|
|
'forbidden' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
|
})
|
|
throw new UploadNonRetriableError('Access denied for upload')
|
|
}
|
|
|
|
if (response.status === 413) {
|
|
logEvent('tengu_file_upload_failed', {
|
|
error_type:
|
|
'size' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
|
})
|
|
throw new UploadNonRetriableError('File too large for upload')
|
|
}
|
|
|
|
return { done: false, error: `status ${response.status}` }
|
|
} catch (error) {
|
|
// Non-retriable errors propagate up
|
|
if (error instanceof UploadNonRetriableError) {
|
|
throw error
|
|
}
|
|
if (axios.isCancel(error)) {
|
|
throw new UploadNonRetriableError('Upload canceled')
|
|
}
|
|
// Network errors are retriable
|
|
if (axios.isAxiosError(error)) {
|
|
return { done: false, error: error.message }
|
|
}
|
|
throw error
|
|
}
|
|
})
|
|
} catch (error) {
|
|
if (error instanceof UploadNonRetriableError) {
|
|
return {
|
|
path: relativePath,
|
|
error: error.message,
|
|
success: false,
|
|
}
|
|
}
|
|
logEvent('tengu_file_upload_failed', {
|
|
error_type:
|
|
'network' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
|
})
|
|
return {
|
|
path: relativePath,
|
|
error: errorMessage(error),
|
|
success: false,
|
|
}
|
|
}
|
|
}
|
|
|
|
/** Error class for non-retriable upload failures */
|
|
class UploadNonRetriableError extends Error {
|
|
constructor(message: string) {
|
|
super(message)
|
|
this.name = 'UploadNonRetriableError'
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Upload multiple files in parallel with concurrency limit (BYOC mode)
|
|
*
|
|
* @param files - Array of files to upload (path and relativePath)
|
|
* @param config - Files API configuration
|
|
* @param concurrency - Maximum concurrent uploads (default: 5)
|
|
* @returns Array of upload results in the same order as input
|
|
*/
|
|
export async function uploadSessionFiles(
|
|
files: Array<{ path: string; relativePath: string }>,
|
|
config: FilesApiConfig,
|
|
concurrency: number = DEFAULT_CONCURRENCY,
|
|
): Promise<UploadResult[]> {
|
|
if (files.length === 0) {
|
|
return []
|
|
}
|
|
|
|
logDebug(`Uploading ${files.length} file(s) for session ${config.sessionId}`)
|
|
const startTime = Date.now()
|
|
|
|
const results = await parallelWithLimit(
|
|
files,
|
|
file => uploadFile(file.path, file.relativePath, config),
|
|
concurrency,
|
|
)
|
|
|
|
const elapsedMs = Date.now() - startTime
|
|
const successCount = count(results, r => r.success)
|
|
logDebug(`Uploaded ${successCount}/${files.length} file(s) in ${elapsedMs}ms`)
|
|
|
|
return results
|
|
}
|
|
|
|
// ============================================================================
|
|
// List Files Functions (1P/Cloud mode)
|
|
// ============================================================================
|
|
|
|
/**
|
|
* File metadata returned from listFilesCreatedAfter
|
|
*/
|
|
export type FileMetadata = {
|
|
filename: string
|
|
fileId: string
|
|
size: number
|
|
}
|
|
|
|
/**
|
|
* List files created after a given timestamp (1P/Cloud mode).
|
|
* Uses the public GET /v1/files endpoint with after_created_at query param.
|
|
* Handles pagination via after_id cursor when has_more is true.
|
|
*
|
|
* @param afterCreatedAt - ISO 8601 timestamp to filter files created after
|
|
* @param config - Files API configuration
|
|
* @returns Array of file metadata for files created after the timestamp
|
|
*/
|
|
export async function listFilesCreatedAfter(
|
|
afterCreatedAt: string,
|
|
config: FilesApiConfig,
|
|
): Promise<FileMetadata[]> {
|
|
const baseUrl = config.baseUrl || getDefaultApiBaseUrl()
|
|
const headers = {
|
|
Authorization: `Bearer ${config.oauthToken}`,
|
|
'anthropic-version': ANTHROPIC_VERSION,
|
|
'anthropic-beta': FILES_API_BETA_HEADER,
|
|
}
|
|
|
|
logDebug(`Listing files created after ${afterCreatedAt}`)
|
|
|
|
const allFiles: FileMetadata[] = []
|
|
let afterId: string | undefined
|
|
|
|
// Paginate through results
|
|
while (true) {
|
|
const params: Record<string, string> = {
|
|
after_created_at: afterCreatedAt,
|
|
}
|
|
if (afterId) {
|
|
params.after_id = afterId
|
|
}
|
|
|
|
const page = await retryWithBackoff(
|
|
`List files after ${afterCreatedAt}`,
|
|
async () => {
|
|
try {
|
|
const response = await axios.get(`${baseUrl}/v1/files`, {
|
|
headers,
|
|
params,
|
|
timeout: 60000,
|
|
validateStatus: status => status < 500,
|
|
})
|
|
|
|
if (response.status === 200) {
|
|
return { done: true, value: response.data }
|
|
}
|
|
|
|
if (response.status === 401) {
|
|
logEvent('tengu_file_list_failed', {
|
|
error_type:
|
|
'auth' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
|
})
|
|
throw new Error('Authentication failed: invalid or missing API key')
|
|
}
|
|
if (response.status === 403) {
|
|
logEvent('tengu_file_list_failed', {
|
|
error_type:
|
|
'forbidden' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
|
})
|
|
throw new Error('Access denied to list files')
|
|
}
|
|
|
|
return { done: false, error: `status ${response.status}` }
|
|
} catch (error) {
|
|
if (!axios.isAxiosError(error)) {
|
|
throw error
|
|
}
|
|
logEvent('tengu_file_list_failed', {
|
|
error_type:
|
|
'network' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
|
})
|
|
return { done: false, error: error.message }
|
|
}
|
|
},
|
|
)
|
|
|
|
const files = page.data || []
|
|
for (const f of files) {
|
|
allFiles.push({
|
|
filename: f.filename,
|
|
fileId: f.id,
|
|
size: f.size_bytes,
|
|
})
|
|
}
|
|
|
|
if (!page.has_more) {
|
|
break
|
|
}
|
|
|
|
// Use the last file's ID as cursor for next page
|
|
const lastFile = files.at(-1)
|
|
if (!lastFile?.id) {
|
|
break
|
|
}
|
|
afterId = lastFile.id
|
|
}
|
|
|
|
logDebug(`Listed ${allFiles.length} files created after ${afterCreatedAt}`)
|
|
return allFiles
|
|
}
|
|
|
|
// ============================================================================
|
|
// Parse Functions
|
|
// ============================================================================
|
|
|
|
/**
|
|
* Parse file attachment specs from CLI arguments
|
|
* Format: <file_id>:<relative_path>
|
|
*
|
|
* @param fileSpecs - Array of file spec strings
|
|
* @returns Parsed file attachments
|
|
*/
|
|
export function parseFileSpecs(fileSpecs: string[]): File[] {
|
|
const files: File[] = []
|
|
|
|
// Sandbox-gateway may pass multiple specs as a single space-separated string
|
|
const expandedSpecs = fileSpecs.flatMap(s => s.split(' ').filter(Boolean))
|
|
|
|
for (const spec of expandedSpecs) {
|
|
const colonIndex = spec.indexOf(':')
|
|
if (colonIndex === -1) {
|
|
continue
|
|
}
|
|
|
|
const fileId = spec.substring(0, colonIndex)
|
|
const relativePath = spec.substring(colonIndex + 1)
|
|
|
|
if (!fileId || !relativePath) {
|
|
logDebugError(
|
|
`Invalid file spec: ${spec}. Both file_id and path are required`,
|
|
)
|
|
continue
|
|
}
|
|
|
|
files.push({ fileId, relativePath })
|
|
}
|
|
|
|
return files
|
|
}
|