Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ pnpm release
## Architecture

### Monorepo Structure
- Uses pnpm workspaces (v9.0.0 strictly enforced) and Turbo for task orchestration
- Uses pnpm workspaces (v10.15.0 strictly enforced) and Turbo for task orchestration
- Packages in `packages/*` directory
- Node.js 20+ required
- TypeScript 5.8+ with CommonJS module system for CLI package
Expand Down Expand Up @@ -104,4 +104,4 @@ export MXBAI_API_KEY="your-api-key"
- File upload with processing strategies (high_quality, fast, auto)
- Git-based and hash-based sync capabilities
- Manifest-based bulk uploads via YAML configuration
- Support for aliases and configuration management
- Support for aliases and configuration management
1 change: 1 addition & 0 deletions packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@
"cli-table3": "^0.6.5",
"commander": "^12.0.0",
"dotenv": "^16.4.5",
"fast-deep-equal": "^3.1.3",
"glob": "^10.4.5",
"inquirer": "^9.2.23",
"mime-types": "^3.0.1",
Expand Down
24 changes: 23 additions & 1 deletion packages/cli/src/commands/store/sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
parseOptions,
} from "../../utils/global-options";
import { validateMetadata } from "../../utils/metadata";
import { loadMetadataMapping } from "../../utils/metadata-file";
import { formatBytes, formatCountWithSuffix } from "../../utils/output";
import { resolveStore } from "../../utils/store";
import {
Expand All @@ -37,6 +38,7 @@ const SyncStoreSchema = extendGlobalOptions({
yes: z.boolean().optional(),
force: z.boolean().optional(),
metadata: z.string().optional(),
metadataFile: z.string().optional(),
parallel: z.coerce
.number({ error: '"parallel" must be a number' })
.int({ error: '"parallel" must be an integer' })
Expand All @@ -54,6 +56,7 @@ interface SyncOptions extends GlobalOptions {
yes?: boolean;
force?: boolean;
metadata?: string;
metadataFile?: string;
parallel?: number;
}

Expand All @@ -79,6 +82,7 @@ export function createSyncCommand(): Command {
"Force re-upload all files, ignoring change detection"
)
.option("--metadata <json>", "Additional metadata for files")
.option("--metadata-file <file>", "Per-file metadata mapping (JSON/YAML)")
.option("--parallel <n>", "Number of concurrent operations (1-200)")
);

Expand Down Expand Up @@ -106,7 +110,23 @@ export function createSyncCommand(): Command {
// Parse metadata if provided
const additionalMetadata = validateMetadata(parsedOptions.metadata);

// Get git info
let metadataMap: Map<string, Record<string, unknown>> | undefined;
if (parsedOptions.metadataFile) {
try {
metadataMap = loadMetadataMapping(parsedOptions.metadataFile);
console.log(
chalk.green("✓"),
`Loaded metadata for ${metadataMap.size} file${metadataMap.size === 1 ? "" : "s"} from ${parsedOptions.metadataFile}`
);
} catch (error) {
console.error(
chalk.red("✗"),
`Failed to load metadata file: ${error instanceof Error ? error.message : "Unknown error"}`
);
process.exit(1);
}
}

const gitInfo = await getGitInfo();

const spinner = ora("Loading existing files from store...").start();
Expand Down Expand Up @@ -154,6 +174,7 @@ export function createSyncCommand(): Command {
gitInfo,
fromGit,
forceUpload: parsedOptions.force,
metadataMap,
});

analyzeSpinner.succeed("Change analysis complete");
Expand Down Expand Up @@ -221,6 +242,7 @@ export function createSyncCommand(): Command {
strategy: parsedOptions.strategy,
contextualization: parsedOptions.contextualization,
metadata: additionalMetadata,
metadataMap,
gitInfo: gitInfo.isRepo ? gitInfo : undefined,
parallel: parsedOptions.parallel,
}
Expand Down
56 changes: 49 additions & 7 deletions packages/cli/src/commands/store/upload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@ import {
} from "../../utils/global-options";
import { uploadFromManifest } from "../../utils/manifest";
import { validateMetadata } from "../../utils/metadata";
import {
loadMetadataMapping,
normalizePathForMetadata,
} from "../../utils/metadata-file";
import { formatBytes, formatCountWithSuffix } from "../../utils/output";
import { getStoreFiles, resolveStore } from "../../utils/store";
import { type FileToUpload, uploadFilesInBatch } from "../../utils/upload";
Expand All @@ -32,6 +36,7 @@ const UploadStoreSchema = extendGlobalOptions({
.boolean({ error: '"contextualization" must be a boolean' })
.optional(),
metadata: z.string().optional(),
metadataFile: z.string().optional(),
dryRun: z.boolean().optional(),
parallel: z.coerce
.number({ error: '"parallel" must be a number' })
Expand All @@ -47,6 +52,7 @@ export interface UploadOptions extends GlobalOptions {
strategy?: FileCreateParams.Experimental["parsing_strategy"];
contextualization?: boolean;
metadata?: string;
metadataFile?: string;
dryRun?: boolean;
parallel?: number;
unique?: boolean;
Expand All @@ -65,6 +71,7 @@ export function createUploadCommand(): Command {
.option("--strategy <strategy>", "Processing strategy")
.option("--contextualization", "Enable context preservation")
.option("--metadata <json>", "Additional metadata as JSON string")
.option("--metadata-file <file>", "Per-file metadata mapping (JSON/YAML)")
.option("--dry-run", "Preview what would be uploaded", false)
.option("--parallel <n>", "Number of concurrent uploads (1-200)")
.option(
Expand Down Expand Up @@ -93,6 +100,15 @@ export function createUploadCommand(): Command {

spinner.succeed("Upload initialized");

// Validate mutually exclusive options
if (parsedOptions.manifest && parsedOptions.metadataFile) {
console.error(
chalk.red("✗"),
"Cannot use both --manifest and --metadata-file. Use --manifest with per-file metadata entries instead."
);
process.exit(1);
}

// Handle manifest file upload
if (parsedOptions.manifest) {
return await uploadFromManifest(
Expand Down Expand Up @@ -123,6 +139,23 @@ export function createUploadCommand(): Command {

const metadata = validateMetadata(parsedOptions.metadata);

let metadataMap: Map<string, Record<string, unknown>> | undefined;
if (parsedOptions.metadataFile) {
try {
metadataMap = loadMetadataMapping(parsedOptions.metadataFile);
console.log(
chalk.green("✓"),
`Loaded metadata for ${metadataMap.size} file${metadataMap.size === 1 ? "" : "s"} from ${parsedOptions.metadataFile}`
);
} catch (error) {
console.error(
chalk.red("✗"),
`Failed to load metadata file: ${error instanceof Error ? error.message : "Unknown error"}`
);
process.exit(1);
}
}

// Collect all files matching patterns
const files: string[] = [];
for (const pattern of parsedOptions.patterns) {
Expand Down Expand Up @@ -150,6 +183,7 @@ export function createUploadCommand(): Command {
}, 0);

console.log(
chalk.green("✓"),
`Found ${formatCountWithSuffix(uniqueFiles.length, "file")} matching the ${
patterns.length > 1 ? "patterns" : "pattern"
} (${formatBytes(totalSize)})`
Expand Down Expand Up @@ -198,7 +232,7 @@ export function createUploadCommand(): Command {
])
);
spinner.succeed(
`Found ${formatCountWithSuffix(existingFiles.size, "existing file")}`
`Found ${formatCountWithSuffix(existingFiles.size, "existing file")} in store`
);
} catch (error) {
spinner.fail("Failed to check existing files");
Expand All @@ -207,12 +241,20 @@ export function createUploadCommand(): Command {
}

// Transform files to shared format
const filesToUpload: FileToUpload[] = uniqueFiles.map((filePath) => ({
path: filePath,
strategy,
contextualization,
metadata,
}));
const filesToUpload: FileToUpload[] = uniqueFiles.map((filePath) => {
const normalizedPath = normalizePathForMetadata(filePath);
const perFileMetadata = metadataMap?.get(normalizedPath) || {};

return {
path: filePath,
strategy,
contextualization,
metadata: {
...metadata, // CLI --metadata (base for all files)
...perFileMetadata, // Per-file from mapping (overrides)
},
};
});

// Upload files with progress tracking
await uploadFilesInBatch(client, store.id, filesToUpload, {
Expand Down
9 changes: 0 additions & 9 deletions packages/cli/src/utils/hash.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,15 +14,6 @@ export async function calculateFileHash(filePath: string): Promise<string> {
return `sha256:${hash.digest("hex")}`;
}

/**
* Calculate SHA-256 hash of a string or buffer
*/
export function calculateHash(content: string | Buffer): string {
const hash = createHash("sha256");
hash.update(content);
return `sha256:${hash.digest("hex")}`;
}

/**
* Compare two hashes
*/
Expand Down
106 changes: 106 additions & 0 deletions packages/cli/src/utils/metadata-file.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import { readFileSync } from "node:fs";
import { normalize, relative } from "node:path";
import equal from "fast-deep-equal";
import { parse } from "yaml";
import type { FileSyncMetadata } from "./sync-state";

type SyncMetadataFields = keyof FileSyncMetadata;

const SYNC_METADATA_FIELDS = new Set<SyncMetadataFields>([
"file_path",
"file_hash",
"git_commit",
"git_branch",
"uploaded_at",
"synced",
]);

/**
* Normalize a file path for consistent metadata map lookups across platforms
* Converts absolute path to relative-to-CWD and removes leading ./
*/
export function normalizePathForMetadata(filePath: string): string {
const relativePath = relative(process.cwd(), filePath);
return normalize(relativePath).replace(/^\.[\\/]/, "");
}

/**
* Load metadata mapping from JSON/YAML file
* Returns a Map with paths normalized relative to CWD
*
* Paths in the metadata file should be relative to CWD.
* They will be normalized to ensure consistent lookups across platforms.
*/
export function loadMetadataMapping(
filePath: string
): Map<string, Record<string, unknown>> {
const content = readFileSync(filePath, "utf-8");

// Try JSON first, then YAML
let data: Record<string, Record<string, unknown>>;
try {
data = JSON.parse(content);
} catch {
try {
data = parse(content);
} catch {
throw new Error(
"Metadata file must be valid JSON or YAML and contain an object"
);
}
}

if (typeof data !== "object" || data === null || Array.isArray(data)) {
throw new Error(
"Metadata file must contain an object mapping paths to metadata"
);
}

const map = new Map<string, Record<string, unknown>>();
for (const [key, value] of Object.entries(data)) {
if (typeof value !== "object" || value === null || Array.isArray(value)) {
throw new Error(
`Metadata for "${key}" must be an object, got ${typeof value}`
);
}

// Normalize path and remove leading ./
const normalizedKey = normalize(key).replace(/^\.[\\/]/, "");

map.set(normalizedKey, value);
}

return map;
}

/**
* Extract user-provided metadata by removing sync-specific fields
*/
export function extractUserMetadata(
metadata: Record<string, unknown>
): Record<string, unknown> {
const userMetadata: Record<string, unknown> = {};

for (const [key, value] of Object.entries(metadata)) {
if (!SYNC_METADATA_FIELDS.has(key as SyncMetadataFields)) {
userMetadata[key] = value;
}
}

return userMetadata;
}

/**
* Deep equality check for metadata objects
* Uses fast-deep-equal for reliable comparison including edge cases like:
* - Date objects
* - undefined values
* - NaN, Infinity
* - Nested objects and arrays
*/
export function metadataEquals(
a: Record<string, unknown>,
b: Record<string, unknown>
): boolean {
return equal(a, b);
}
1 change: 1 addition & 0 deletions packages/cli/src/utils/sync-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export interface FileSyncMetadata {
git_branch?: string;
uploaded_at: string;
synced: boolean;
[key: string]: unknown;
}

/**
Expand Down
Loading