init claude-code
This commit is contained in:
@@ -0,0 +1,204 @@
|
||||
import { feature } from 'bun:bundle'
|
||||
import { z } from 'zod/v4'
|
||||
import { getKairosActive, getUserMsgOptIn } from '../../bootstrap/state.js'
|
||||
import { getFeatureValue_CACHED_WITH_REFRESH } from '../../services/analytics/growthbook.js'
|
||||
import { logEvent } from '../../services/analytics/index.js'
|
||||
import type { ValidationResult } from '../../Tool.js'
|
||||
import { buildTool, type ToolDef } from '../../Tool.js'
|
||||
import { isEnvTruthy } from '../../utils/envUtils.js'
|
||||
import { lazySchema } from '../../utils/lazySchema.js'
|
||||
import { plural } from '../../utils/stringUtils.js'
|
||||
import { resolveAttachments, validateAttachmentPaths } from './attachments.js'
|
||||
import {
|
||||
BRIEF_TOOL_NAME,
|
||||
BRIEF_TOOL_PROMPT,
|
||||
DESCRIPTION,
|
||||
LEGACY_BRIEF_TOOL_NAME,
|
||||
} from './prompt.js'
|
||||
import { renderToolResultMessage, renderToolUseMessage } from './UI.js'
|
||||
|
||||
const inputSchema = lazySchema(() =>
|
||||
z.strictObject({
|
||||
message: z
|
||||
.string()
|
||||
.describe('The message for the user. Supports markdown formatting.'),
|
||||
attachments: z
|
||||
.array(z.string())
|
||||
.optional()
|
||||
.describe(
|
||||
'Optional file paths (absolute or relative to cwd) to attach. Use for photos, screenshots, diffs, logs, or any file the user should see alongside your message.',
|
||||
),
|
||||
status: z
|
||||
.enum(['normal', 'proactive'])
|
||||
.describe(
|
||||
"Use 'proactive' when you're surfacing something the user hasn't asked for and needs to see now — task completion while they're away, a blocker you hit, an unsolicited status update. Use 'normal' when replying to something the user just said.",
|
||||
),
|
||||
}),
|
||||
)
|
||||
type InputSchema = ReturnType<typeof inputSchema>
|
||||
|
||||
// attachments MUST remain optional — resumed sessions replay pre-attachment
|
||||
// outputs verbatim and a required field would crash the UI renderer on resume.
|
||||
const outputSchema = lazySchema(() =>
|
||||
z.object({
|
||||
message: z.string().describe('The message'),
|
||||
attachments: z
|
||||
.array(
|
||||
z.object({
|
||||
path: z.string(),
|
||||
size: z.number(),
|
||||
isImage: z.boolean(),
|
||||
file_uuid: z.string().optional(),
|
||||
}),
|
||||
)
|
||||
.optional()
|
||||
.describe('Resolved attachment metadata'),
|
||||
sentAt: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
'ISO timestamp captured at tool execution on the emitting process. Optional — resumed sessions replay pre-sentAt outputs verbatim.',
|
||||
),
|
||||
}),
|
||||
)
|
||||
type OutputSchema = ReturnType<typeof outputSchema>
|
||||
export type Output = z.infer<OutputSchema>
|
||||
|
||||
const KAIROS_BRIEF_REFRESH_MS = 5 * 60 * 1000
|
||||
|
||||
/**
|
||||
* Entitlement check — is the user ALLOWED to use Brief? Combines build-time
|
||||
* flags with runtime GB gate + assistant-mode passthrough. No opt-in check
|
||||
* here — this decides whether opt-in should be HONORED, not whether the user
|
||||
* has opted in.
|
||||
*
|
||||
* Build-time OR-gated on KAIROS || KAIROS_BRIEF (same pattern as
|
||||
* PROACTIVE || KAIROS): assistant mode depends on Brief, so KAIROS alone
|
||||
* must bundle it. KAIROS_BRIEF lets Brief ship independently.
|
||||
*
|
||||
* Use this to decide whether `--brief` / `defaultView: 'chat'` / `--tools`
|
||||
* listing should be honored. Use `isBriefEnabled()` to decide whether the
|
||||
* tool is actually active in the current session.
|
||||
*
|
||||
* CLAUDE_CODE_BRIEF env var force-grants entitlement for dev/testing —
|
||||
* bypasses the GB gate so you can test without being enrolled. Still
|
||||
* requires an opt-in action to activate (--brief, defaultView, etc.), but
|
||||
* the env var alone also sets userMsgOptIn via maybeActivateBrief().
|
||||
*/
|
||||
export function isBriefEntitled(): boolean {
|
||||
// Positive ternary — see docs/feature-gating.md. Negative early-return
|
||||
// would not eliminate the GB gate string from external builds.
|
||||
return feature('KAIROS') || feature('KAIROS_BRIEF')
|
||||
? getKairosActive() ||
|
||||
isEnvTruthy(process.env.CLAUDE_CODE_BRIEF) ||
|
||||
getFeatureValue_CACHED_WITH_REFRESH(
|
||||
'tengu_kairos_brief',
|
||||
false,
|
||||
KAIROS_BRIEF_REFRESH_MS,
|
||||
)
|
||||
: false
|
||||
}
|
||||
|
||||
/**
|
||||
* Unified activation gate for the Brief tool. Governs model-facing behavior
|
||||
* as a unit: tool availability, system prompt section (getBriefSection),
|
||||
* tool-deferral bypass (isDeferredTool), and todo-nag suppression.
|
||||
*
|
||||
* Activation requires explicit opt-in (userMsgOptIn) set by one of:
|
||||
* - `--brief` CLI flag (maybeActivateBrief in main.tsx)
|
||||
* - `defaultView: 'chat'` in settings (main.tsx init)
|
||||
* - `/brief` slash command (brief.ts)
|
||||
* - `/config` defaultView picker (Config.tsx)
|
||||
* - SendUserMessage in `--tools` / SDK `tools` option (main.tsx)
|
||||
* - CLAUDE_CODE_BRIEF env var (maybeActivateBrief — dev/testing bypass)
|
||||
* Assistant mode (kairosActive) bypasses opt-in since its system prompt
|
||||
* hard-codes "you MUST use SendUserMessage" (systemPrompt.md:14).
|
||||
*
|
||||
* The GB gate is re-checked here as a kill-switch AND — flipping
|
||||
* tengu_kairos_brief off mid-session disables the tool on the next 5-min
|
||||
* refresh even for opted-in sessions. No opt-in → always false regardless
|
||||
* of GB (this is the fix for "brief defaults on for enrolled ants").
|
||||
*
|
||||
* Called from Tool.isEnabled() (lazy, post-init), never at module scope.
|
||||
* getKairosActive() and getUserMsgOptIn() are set in main.tsx before any
|
||||
* caller reaches here.
|
||||
*/
|
||||
export function isBriefEnabled(): boolean {
|
||||
// Top-level feature() guard is load-bearing for DCE: Bun can constant-fold
|
||||
// the ternary to `false` in external builds and then dead-code the BriefTool
|
||||
// object. Composing isBriefEntitled() alone (which has its own guard) is
|
||||
// semantically equivalent but defeats constant-folding across the boundary.
|
||||
return feature('KAIROS') || feature('KAIROS_BRIEF')
|
||||
? (getKairosActive() || getUserMsgOptIn()) && isBriefEntitled()
|
||||
: false
|
||||
}
|
||||
|
||||
export const BriefTool = buildTool({
|
||||
name: BRIEF_TOOL_NAME,
|
||||
aliases: [LEGACY_BRIEF_TOOL_NAME],
|
||||
searchHint:
|
||||
'send a message to the user — your primary visible output channel',
|
||||
maxResultSizeChars: 100_000,
|
||||
userFacingName() {
|
||||
return ''
|
||||
},
|
||||
get inputSchema(): InputSchema {
|
||||
return inputSchema()
|
||||
},
|
||||
get outputSchema(): OutputSchema {
|
||||
return outputSchema()
|
||||
},
|
||||
isEnabled() {
|
||||
return isBriefEnabled()
|
||||
},
|
||||
isConcurrencySafe() {
|
||||
return true
|
||||
},
|
||||
isReadOnly() {
|
||||
return true
|
||||
},
|
||||
toAutoClassifierInput(input) {
|
||||
return input.message
|
||||
},
|
||||
async validateInput({ attachments }, _context): Promise<ValidationResult> {
|
||||
if (!attachments || attachments.length === 0) {
|
||||
return { result: true }
|
||||
}
|
||||
return validateAttachmentPaths(attachments)
|
||||
},
|
||||
async description() {
|
||||
return DESCRIPTION
|
||||
},
|
||||
async prompt() {
|
||||
return BRIEF_TOOL_PROMPT
|
||||
},
|
||||
mapToolResultToToolResultBlockParam(output, toolUseID) {
|
||||
const n = output.attachments?.length ?? 0
|
||||
const suffix = n === 0 ? '' : ` (${n} ${plural(n, 'attachment')} included)`
|
||||
return {
|
||||
tool_use_id: toolUseID,
|
||||
type: 'tool_result',
|
||||
content: `Message delivered to user.${suffix}`,
|
||||
}
|
||||
},
|
||||
renderToolUseMessage,
|
||||
renderToolResultMessage,
|
||||
async call({ message, attachments, status }, context) {
|
||||
const sentAt = new Date().toISOString()
|
||||
logEvent('tengu_brief_send', {
|
||||
proactive: status === 'proactive',
|
||||
attachment_count: attachments?.length ?? 0,
|
||||
})
|
||||
if (!attachments || attachments.length === 0) {
|
||||
return { data: { message, sentAt } }
|
||||
}
|
||||
const appState = context.getAppState()
|
||||
const resolved = await resolveAttachments(attachments, {
|
||||
replBridgeEnabled: appState.replBridgeEnabled,
|
||||
signal: context.abortController.signal,
|
||||
})
|
||||
return {
|
||||
data: { message, attachments: resolved, sentAt },
|
||||
}
|
||||
},
|
||||
} satisfies ToolDef<InputSchema, Output>)
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* Shared attachment validation + resolution for SendUserMessage and
|
||||
* SendUserFile. Lives in BriefTool/ so the dynamic `./upload.js` import
|
||||
* inside the feature('BRIDGE_MODE') guard stays relative and upload.ts
|
||||
* (axios, crypto, auth utils) remains tree-shakeable from non-bridge builds.
|
||||
*/
|
||||
|
||||
import { feature } from 'bun:bundle'
|
||||
import { stat } from 'fs/promises'
|
||||
|
||||
import type { ValidationResult } from '../../Tool.js'
|
||||
|
||||
import { getCwd } from '../../utils/cwd.js'
|
||||
import { isEnvTruthy } from '../../utils/envUtils.js'
|
||||
import { getErrnoCode } from '../../utils/errors.js'
|
||||
import { IMAGE_EXTENSION_REGEX } from '../../utils/imagePaste.js'
|
||||
import { expandPath } from '../../utils/path.js'
|
||||
|
||||
export type ResolvedAttachment = {
|
||||
path: string
|
||||
size: number
|
||||
isImage: boolean
|
||||
file_uuid?: string
|
||||
}
|
||||
|
||||
export async function validateAttachmentPaths(
|
||||
rawPaths: string[],
|
||||
): Promise<ValidationResult> {
|
||||
const cwd = getCwd()
|
||||
for (const rawPath of rawPaths) {
|
||||
const fullPath = expandPath(rawPath)
|
||||
try {
|
||||
const stats = await stat(fullPath)
|
||||
if (!stats.isFile()) {
|
||||
return {
|
||||
result: false,
|
||||
message: `Attachment "${rawPath}" is not a regular file.`,
|
||||
errorCode: 1,
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
const code = getErrnoCode(e)
|
||||
if (code === 'ENOENT') {
|
||||
return {
|
||||
result: false,
|
||||
message: `Attachment "${rawPath}" does not exist. Current working directory: ${cwd}.`,
|
||||
errorCode: 1,
|
||||
}
|
||||
}
|
||||
if (code === 'EACCES' || code === 'EPERM') {
|
||||
return {
|
||||
result: false,
|
||||
message: `Attachment "${rawPath}" is not accessible (permission denied).`,
|
||||
errorCode: 1,
|
||||
}
|
||||
}
|
||||
throw e
|
||||
}
|
||||
}
|
||||
return { result: true }
|
||||
}
|
||||
|
||||
export async function resolveAttachments(
|
||||
rawPaths: string[],
|
||||
uploadCtx: { replBridgeEnabled: boolean; signal?: AbortSignal },
|
||||
): Promise<ResolvedAttachment[]> {
|
||||
// Stat serially (local, fast) to keep ordering deterministic, then upload
|
||||
// in parallel (network, slow). Upload failures resolve undefined — the
|
||||
// attachment still carries {path, size, isImage} for local renderers.
|
||||
const stated: ResolvedAttachment[] = []
|
||||
for (const rawPath of rawPaths) {
|
||||
const fullPath = expandPath(rawPath)
|
||||
// Single stat — we need size, so this is the operation, not a guard.
|
||||
// validateInput ran before us, but the file could have moved since
|
||||
// (TOCTOU); if it did, let the error propagate so the model sees it.
|
||||
const stats = await stat(fullPath)
|
||||
stated.push({
|
||||
path: fullPath,
|
||||
size: stats.size,
|
||||
isImage: IMAGE_EXTENSION_REGEX.test(fullPath),
|
||||
})
|
||||
}
|
||||
// Dynamic import inside the feature() guard so upload.ts (axios, crypto,
|
||||
// zod, auth utils, MIME map) is fully eliminated from non-BRIDGE_MODE
|
||||
// builds. A static import would force module-scope evaluation regardless
|
||||
// of the guard inside uploadBriefAttachment — CLAUDE.md: "helpers defined
|
||||
// outside remain in the build even if never called".
|
||||
if (feature('BRIDGE_MODE')) {
|
||||
// Headless/SDK callers never set appState.replBridgeEnabled (only the TTY
|
||||
// REPL does, at main.tsx init). CLAUDE_CODE_BRIEF_UPLOAD lets a host that
|
||||
// runs the CLI as a subprocess opt in — e.g. the cowork desktop bridge,
|
||||
// which already passes CLAUDE_CODE_OAUTH_TOKEN for auth.
|
||||
const shouldUpload =
|
||||
uploadCtx.replBridgeEnabled ||
|
||||
isEnvTruthy(process.env.CLAUDE_CODE_BRIEF_UPLOAD)
|
||||
const { uploadBriefAttachment } = await import('./upload.js')
|
||||
const uuids = await Promise.all(
|
||||
stated.map(a =>
|
||||
uploadBriefAttachment(a.path, a.size, {
|
||||
replBridgeEnabled: shouldUpload,
|
||||
signal: uploadCtx.signal,
|
||||
}),
|
||||
),
|
||||
)
|
||||
return stated.map((a, i) =>
|
||||
uuids[i] === undefined ? a : { ...a, file_uuid: uuids[i] },
|
||||
)
|
||||
}
|
||||
return stated
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
export const BRIEF_TOOL_NAME = 'SendUserMessage'
|
||||
export const LEGACY_BRIEF_TOOL_NAME = 'Brief'
|
||||
|
||||
export const DESCRIPTION = 'Send a message to the user'
|
||||
|
||||
export const BRIEF_TOOL_PROMPT = `Send a message the user will read. Text outside this tool is visible in the detail view, but most won't open it — the answer lives here.
|
||||
|
||||
\`message\` supports markdown. \`attachments\` takes file paths (absolute or cwd-relative) for images, diffs, logs.
|
||||
|
||||
\`status\` labels intent: 'normal' when replying to what they just asked; 'proactive' when you're initiating — a scheduled task finished, a blocker surfaced during background work, you need input on something they haven't asked about. Set it honestly; downstream routing uses it.`
|
||||
|
||||
export const BRIEF_PROACTIVE_SECTION = `## Talking to the user
|
||||
|
||||
${BRIEF_TOOL_NAME} is where your replies go. Text outside it is visible if the user expands the detail view, but most won't — assume unread. Anything you want them to actually see goes through ${BRIEF_TOOL_NAME}. The failure mode: the real answer lives in plain text while ${BRIEF_TOOL_NAME} just says "done!" — they see "done!" and miss everything.
|
||||
|
||||
So: every time the user says something, the reply they actually read comes through ${BRIEF_TOOL_NAME}. Even for "hi". Even for "thanks".
|
||||
|
||||
If you can answer right away, send the answer. If you need to go look — run a command, read files, check something — ack first in one line ("On it — checking the test output"), then work, then send the result. Without the ack they're staring at a spinner.
|
||||
|
||||
For longer work: ack → work → result. Between those, send a checkpoint when something useful happened — a decision you made, a surprise you hit, a phase boundary. Skip the filler ("running tests...") — a checkpoint earns its place by carrying information.
|
||||
|
||||
Keep messages tight — the decision, the file:line, the PR number. Second person always ("your config"), never third.`
|
||||
@@ -0,0 +1,174 @@
|
||||
/**
|
||||
* Upload BriefTool attachments to private_api so web viewers can preview them.
|
||||
*
|
||||
* When the repl bridge is active, attachment paths are meaningless to a web
|
||||
* viewer (they're on Claude's machine). We upload to /api/oauth/file_upload —
|
||||
* the same store MessageComposer/SpaceMessage render from — and stash the
|
||||
* returned file_uuid alongside the path. Web resolves file_uuid → preview;
|
||||
* desktop/local try path first.
|
||||
*
|
||||
* Best-effort: any failure (no token, bridge off, network error, 4xx) logs
|
||||
* debug and returns undefined. The attachment still carries {path, size,
|
||||
* isImage}, so local-terminal and same-machine-desktop render unaffected.
|
||||
*/
|
||||
|
||||
import { feature } from 'bun:bundle'
|
||||
import axios from 'axios'
|
||||
import { randomUUID } from 'crypto'
|
||||
import { readFile } from 'fs/promises'
|
||||
import { basename, extname } from 'path'
|
||||
import { z } from 'zod/v4'
|
||||
|
||||
import {
|
||||
getBridgeAccessToken,
|
||||
getBridgeBaseUrlOverride,
|
||||
} from '../../bridge/bridgeConfig.js'
|
||||
import { getOauthConfig } from '../../constants/oauth.js'
|
||||
import { logForDebugging } from '../../utils/debug.js'
|
||||
import { lazySchema } from '../../utils/lazySchema.js'
|
||||
import { jsonStringify } from '../../utils/slowOperations.js'
|
||||
|
||||
// Matches the private_api backend limit
|
||||
const MAX_UPLOAD_BYTES = 30 * 1024 * 1024
|
||||
|
||||
const UPLOAD_TIMEOUT_MS = 30_000
|
||||
|
||||
// Backend dispatches on mime: image/* → upload_image_wrapped (writes
|
||||
// PREVIEW/THUMBNAIL, no ORIGINAL), everything else → upload_generic_file
|
||||
// (ORIGINAL only, no preview). Only whitelist raster formats the
|
||||
// transcoder reliably handles — svg/bmp/ico risk a 400, and pdf routes
|
||||
// to upload_pdf_file_wrapped which also skips ORIGINAL. Dispatch
|
||||
// viewers use /preview for images and /contents for everything else,
|
||||
// so images go image/* and the rest go octet-stream.
|
||||
const MIME_BY_EXT: Record<string, string> = {
|
||||
'.png': 'image/png',
|
||||
'.jpg': 'image/jpeg',
|
||||
'.jpeg': 'image/jpeg',
|
||||
'.gif': 'image/gif',
|
||||
'.webp': 'image/webp',
|
||||
}
|
||||
|
||||
function guessMimeType(filename: string): string {
|
||||
const ext = extname(filename).toLowerCase()
|
||||
return MIME_BY_EXT[ext] ?? 'application/octet-stream'
|
||||
}
|
||||
|
||||
function debug(msg: string): void {
|
||||
logForDebugging(`[brief:upload] ${msg}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Base URL for uploads. Must match the host the token is valid for.
|
||||
*
|
||||
* Subprocess hosts (cowork) pass ANTHROPIC_BASE_URL alongside
|
||||
* CLAUDE_CODE_OAUTH_TOKEN — prefer that since getOauthConfig() only
|
||||
* returns staging when USE_STAGING_OAUTH is set, which such hosts don't
|
||||
* set. Without this a staging token hits api.anthropic.com → 401 → silent
|
||||
* skip → web viewer sees inert cards with no file_uuid.
|
||||
*/
|
||||
function getBridgeBaseUrl(): string {
|
||||
return (
|
||||
getBridgeBaseUrlOverride() ??
|
||||
process.env.ANTHROPIC_BASE_URL ??
|
||||
getOauthConfig().BASE_API_URL
|
||||
)
|
||||
}
|
||||
|
||||
// /api/oauth/file_upload returns one of ChatMessage{Image,Blob,Document}FileSchema.
|
||||
// All share file_uuid; that's the only field we need.
|
||||
const uploadResponseSchema = lazySchema(() =>
|
||||
z.object({ file_uuid: z.string() }),
|
||||
)
|
||||
|
||||
export type BriefUploadContext = {
|
||||
replBridgeEnabled: boolean
|
||||
signal?: AbortSignal
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload a single attachment. Returns file_uuid on success, undefined otherwise.
|
||||
* Every early-return is intentional graceful degradation.
|
||||
*/
|
||||
export async function uploadBriefAttachment(
|
||||
fullPath: string,
|
||||
size: number,
|
||||
ctx: BriefUploadContext,
|
||||
): Promise<string | undefined> {
|
||||
// Positive pattern so bun:bundle eliminates the entire body from
|
||||
// non-BRIDGE_MODE builds (negative `if (!feature(...)) return` does not).
|
||||
if (feature('BRIDGE_MODE')) {
|
||||
if (!ctx.replBridgeEnabled) return undefined
|
||||
|
||||
if (size > MAX_UPLOAD_BYTES) {
|
||||
debug(`skip ${fullPath}: ${size} bytes exceeds ${MAX_UPLOAD_BYTES} limit`)
|
||||
return undefined
|
||||
}
|
||||
|
||||
const token = getBridgeAccessToken()
|
||||
if (!token) {
|
||||
debug('skip: no oauth token')
|
||||
return undefined
|
||||
}
|
||||
|
||||
let content: Buffer
|
||||
try {
|
||||
content = await readFile(fullPath)
|
||||
} catch (e) {
|
||||
debug(`read failed for ${fullPath}: ${e}`)
|
||||
return undefined
|
||||
}
|
||||
|
||||
const baseUrl = getBridgeBaseUrl()
|
||||
const url = `${baseUrl}/api/oauth/file_upload`
|
||||
const filename = basename(fullPath)
|
||||
const mimeType = guessMimeType(filename)
|
||||
const boundary = `----FormBoundary${randomUUID()}`
|
||||
|
||||
// Manual multipart — same pattern as filesApi.ts. The oauth endpoint takes
|
||||
// a single "file" part (no "purpose" field like the public Files API).
|
||||
const body = Buffer.concat([
|
||||
Buffer.from(
|
||||
`--${boundary}\r\n` +
|
||||
`Content-Disposition: form-data; name="file"; filename="${filename}"\r\n` +
|
||||
`Content-Type: ${mimeType}\r\n\r\n`,
|
||||
),
|
||||
content,
|
||||
Buffer.from(`\r\n--${boundary}--\r\n`),
|
||||
])
|
||||
|
||||
try {
|
||||
const response = await axios.post(url, body, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'Content-Type': `multipart/form-data; boundary=${boundary}`,
|
||||
'Content-Length': body.length.toString(),
|
||||
},
|
||||
timeout: UPLOAD_TIMEOUT_MS,
|
||||
signal: ctx.signal,
|
||||
validateStatus: () => true,
|
||||
})
|
||||
|
||||
if (response.status !== 201) {
|
||||
debug(
|
||||
`upload failed for ${fullPath}: status=${response.status} body=${jsonStringify(response.data).slice(0, 200)}`,
|
||||
)
|
||||
return undefined
|
||||
}
|
||||
|
||||
const parsed = uploadResponseSchema().safeParse(response.data)
|
||||
if (!parsed.success) {
|
||||
debug(
|
||||
`unexpected response shape for ${fullPath}: ${parsed.error.message}`,
|
||||
)
|
||||
return undefined
|
||||
}
|
||||
|
||||
debug(`uploaded ${fullPath} → ${parsed.data.file_uuid} (${size} bytes)`)
|
||||
return parsed.data.file_uuid
|
||||
} catch (e) {
|
||||
debug(`upload threw for ${fullPath}: ${e}`)
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
Reference in New Issue
Block a user