diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 192def5..6b6df87 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -46,20 +46,20 @@ jobs: - name: Lint run: bun run lint - - name: Build binary + - name: Build bundle run: bun run build - name: CLI help smoke test - run: ./bin/letta --help + run: ./letta.js --help - name: CLI version smoke test - run: ./bin/letta --version || true + run: ./letta.js --version || true - name: Headless smoke test (API) if: ${{ github.event_name == 'push' }} env: LETTA_API_KEY: ${{ secrets.LETTA_API_KEY }} - run: ./bin/letta --prompt "ping" --tools "" --permission-mode plan + run: ./letta.js --prompt "ping" --tools "" --permission-mode plan - name: Publish dry-run if: ${{ github.event_name == 'push' }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index acf0000..f00d33c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -11,8 +11,11 @@ on: - "v*" jobs: - verify-tag: + publish: runs-on: ubuntu-latest + environment: npm-publish + permissions: + contents: write steps: - name: Checkout uses: actions/checkout@v4 @@ -27,33 +30,6 @@ jobs: exit 1 fi - build: - needs: verify-tag - name: ${{ matrix.name }} - runs-on: ${{ matrix.runner }} - strategy: - fail-fast: false - matrix: - include: - - name: macOS arm64 - runner: macos-14 - binary: letta-macos-arm64 - - name: macOS x64 - runner: macos-13 - binary: letta-macos-x64 - - name: Linux x64 - runner: ubuntu-24.04 - binary: letta-linux-x64 - - name: Linux arm64 - runner: ubuntu-24.04-arm - binary: letta-linux-arm64 - - name: Windows x64 - runner: windows-latest - binary: letta-windows-x64.exe - steps: - - name: Checkout - uses: actions/checkout@v4 - - name: Setup Bun uses: oven-sh/setup-bun@v1 with: @@ -62,71 +38,25 @@ jobs: - name: Install dependencies run: bun install - - name: Build binary + - name: Build bundle run: bun run build - - name: Rename binary - shell: bash - run: | - if [[ "${{ matrix.runner }}" == windows* ]]; then - mv bin/letta.exe bin/${{ matrix.binary }} - else - mv bin/letta bin/${{ matrix.binary }} - fi - - - name: Upload binary artifact - uses: actions/upload-artifact@v4 - with: - name: ${{ matrix.binary }} - path: bin/${{ matrix.binary }} - if-no-files-found: error - - publish: - needs: build - runs-on: ubuntu-latest - environment: npm-publish - permissions: - contents: write - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Setup Bun - uses: oven-sh/setup-bun@v1 - with: - bun-version: 1.3.0 - - - name: Install dependencies - run: bun install - - - name: Download all binary artifacts - uses: actions/download-artifact@v4 - with: - path: bin-artifacts - - - name: Move binaries to bin directory - run: | - mkdir -p bin - mv bin-artifacts/*/* bin/ - ls -lah bin/ - chmod +x bin/letta-* - - name: Smoke test - help - run: bun bin/letta.js --help + run: ./letta.js --help - name: Smoke test - version - run: bun bin/letta.js --version || echo "Version flag not implemented yet" + run: ./letta.js --version || echo "Version flag not implemented yet" - name: Integration smoke test (real API) env: LETTA_API_KEY: ${{ secrets.LETTA_API_KEY }} - run: bun bin/letta.js --prompt "ping" --tools "" --permission-mode plan + run: ./letta.js --prompt "ping" --tools "" --permission-mode plan - name: Create GitHub Release uses: softprops/action-gh-release@v2 with: tag_name: ${{ github.ref_name }} - files: bin/letta-* + files: letta.js fail_on_unmatched_files: true - name: Publish to npm diff --git a/.gitignore b/.gitignore index 3527d9e..cac67e7 100644 --- a/.gitignore +++ b/.gitignore @@ -1,8 +1,8 @@ node_modules bun.lockb -dist bin/ -!bin/letta.js +letta.js +letta.js.map .DS_Store # Logs diff --git a/build.js b/build.js new file mode 100644 index 0000000..2b524f7 --- /dev/null +++ b/build.js @@ -0,0 +1,59 @@ +#!/usr/bin/env bun + +/** + * Build script for Letta Code CLI + * Bundles TypeScript source into a single JavaScript file + */ + +import { readFileSync } from "node:fs"; +import { join, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +// Read version from package.json +const pkg = JSON.parse(readFileSync(join(__dirname, "package.json"), "utf-8")); +const version = pkg.version; + +console.log(`📦 Building Letta Code v${version}...`); + +await Bun.build({ + entrypoints: ["./src/index.ts"], + outdir: ".", + target: "node", + format: "esm", + minify: false, // Keep readable for debugging + sourcemap: "external", + naming: { + entry: "letta.js", + }, + define: { + "process.env.LETTA_VERSION": JSON.stringify(version), + }, + // Load text files as strings (for markdown, etc.) + loader: { + ".md": "text", + ".mdx": "text", + ".txt": "text", + }, +}); + +// Add shebang to output file +const outputPath = join(__dirname, "letta.js"); +let content = readFileSync(outputPath, "utf-8"); + +// Remove any existing shebang first +if (content.startsWith("#!")) { + content = content.slice(content.indexOf("\n") + 1); +} + +const withShebang = `#!/usr/bin/env node\n${content}`; +await Bun.write(outputPath, withShebang); + +// Make executable +await Bun.$`chmod +x letta.js`; + +console.log("✅ Build complete!"); +console.log(` Output: letta.js`); +console.log(` Size: ${(await Bun.file(outputPath).size / 1024).toFixed(0)}KB`); diff --git a/package.json b/package.json index ce60f09..0715945 100644 --- a/package.json +++ b/package.json @@ -4,12 +4,12 @@ "description": "Letta Code is a CLI tool for interacting with stateful Letta agents from the terminal.", "type": "module", "bin": { - "letta": "bin/letta.js" + "letta": "letta.js" }, "files": [ "LICENSE", "README.md", - "bin", + "letta.js", "scripts", "vendor" ], @@ -40,9 +40,9 @@ "scripts": { "lint": "bunx --bun @biomejs/biome@2.2.5 check src", "fix": "bunx --bun @biomejs/biome@2.2.5 check --write src", - "dev:ui": "bun --loader:.md=text --loader:.mdx=text --loader:.txt=text run src/index.ts", - "build": "bun build src/index.ts --compile --loader:.md=text --loader:.mdx=text --loader:.txt=text --outfile bin/letta", - "prepublishOnly": "echo 'Binaries should be built in CI. Run bun run build for local development.'", + "dev": "bun --loader:.md=text --loader:.mdx=text --loader:.txt=text run src/index.ts", + "build": "bun run build.js", + "prepare": "bun run build", "postinstall": "bun scripts/postinstall-patches.js || true" }, "lint-staged": { diff --git a/src/cli/helpers/diff.ts b/src/cli/helpers/diff.ts index 442f19b..408ad2f 100644 --- a/src/cli/helpers/diff.ts +++ b/src/cli/helpers/diff.ts @@ -70,8 +70,6 @@ export type AdvancedDiffResult = function readFileOrNull(p: string): string | null { try { - const _file = Bun.file(p); - // Note: Bun.file().text() is async, but we need sync for diff preview // Fall back to node:fs for sync reading return require("node:fs").readFileSync(p, "utf-8"); } catch { diff --git a/src/index.ts b/src/index.ts index 8af859c..0dbf3d7 100755 --- a/src/index.ts +++ b/src/index.ts @@ -46,7 +46,7 @@ async function main() { let values: Record; try { const parsed = parseArgs({ - args: Bun.argv, + args: process.argv, options: { help: { type: "boolean", short: "h" }, continue: { type: "boolean", short: "c" }, @@ -149,7 +149,7 @@ async function main() { await upsertToolsToServer(client); const { handleHeadlessCommand } = await import("./headless"); - await handleHeadlessCommand(Bun.argv); + await handleHeadlessCommand(process.argv); return; } diff --git a/src/permissions/loader.ts b/src/permissions/loader.ts index 30a7ed2..d34075e 100644 --- a/src/permissions/loader.ts +++ b/src/permissions/loader.ts @@ -1,3 +1,4 @@ +import { exists, readFile, writeFile } from "../utils/fs.js"; // src/permissions/loader.ts // Load and merge permission settings from hierarchical sources @@ -38,10 +39,10 @@ export async function loadPermissions( ]; for (const settingsPath of sources) { - const file = Bun.file(settingsPath); try { - if (await file.exists()) { - const settings = (await file.json()) as SettingsFile; + if (exists(settingsPath)) { + const content = await readFile(settingsPath); + const settings = JSON.parse(content) as SettingsFile; if (settings.permissions) { mergePermissions(merged, settings.permissions as PermissionRules); } @@ -103,11 +104,11 @@ export async function savePermissionRule( } // Load existing settings - const file = Bun.file(settingsPath); let settings: SettingsFile = {}; try { - if (await file.exists()) { - settings = (await file.json()) as SettingsFile; + if (exists(settingsPath)) { + const content = await readFile(settingsPath); + settings = JSON.parse(content) as SettingsFile; } } catch (_error) { // Start with empty settings if file doesn't exist or is invalid @@ -126,8 +127,8 @@ export async function savePermissionRule( settings.permissions[ruleType].push(rule); } - // Save settings (Bun.write creates parent directories automatically) - await Bun.write(settingsPath, JSON.stringify(settings, null, 2)); + // Save settings + await writeFile(settingsPath, JSON.stringify(settings, null, 2)); // If saving to .letta/settings.local.json, ensure it's gitignored if (scope === "local") { @@ -142,14 +143,12 @@ async function ensureLocalSettingsIgnored( workingDirectory: string, ): Promise { const gitignorePath = join(workingDirectory, ".gitignore"); - const gitignoreFile = Bun.file(gitignorePath); - const pattern = ".letta/settings.local.json"; try { let content = ""; - if (await gitignoreFile.exists()) { - content = await gitignoreFile.text(); + if (exists(gitignorePath)) { + content = await readFile(gitignorePath); } // Check if pattern already exists @@ -158,7 +157,7 @@ async function ensureLocalSettingsIgnored( const newContent = `${ content + (content.endsWith("\n") ? "" : "\n") + pattern }\n`; - await Bun.write(gitignorePath, newContent); + await writeFile(gitignorePath, newContent); } } catch (_error) { // Silently fail if we can't update .gitignore diff --git a/src/project-settings.ts b/src/project-settings.ts index 7ae7278..0f2bacc 100644 --- a/src/project-settings.ts +++ b/src/project-settings.ts @@ -2,6 +2,7 @@ // Manages project-level settings stored in ./.letta/settings.json import { join } from "node:path"; +import { exists, readFile, writeFile } from "./utils/fs.js"; export interface ProjectSettings { localSharedBlockIds: Record; // label -> blockId mapping for project-local blocks @@ -28,14 +29,14 @@ export async function loadProjectSettings( workingDirectory: string = process.cwd(), ): Promise { const settingsPath = getProjectSettingsPath(workingDirectory); - const file = Bun.file(settingsPath); try { - if (!(await file.exists())) { + if (!exists(settingsPath)) { return DEFAULT_PROJECT_SETTINGS; } - const settings = (await file.json()) as RawProjectSettings; + const content = await readFile(settingsPath); + const settings = JSON.parse(content) as RawProjectSettings; // Extract only localSharedBlockIds (permissions and other fields handled elsewhere) return { @@ -56,13 +57,13 @@ export async function saveProjectSettings( updates: Partial, ): Promise { const settingsPath = getProjectSettingsPath(workingDirectory); - const file = Bun.file(settingsPath); try { // Read existing settings (might have permissions, etc.) let existingSettings: RawProjectSettings = {}; - if (await file.exists()) { - existingSettings = (await file.json()) as RawProjectSettings; + if (exists(settingsPath)) { + const content = await readFile(settingsPath); + existingSettings = JSON.parse(content) as RawProjectSettings; } // Merge updates with existing settings @@ -71,8 +72,7 @@ export async function saveProjectSettings( ...updates, }; - // Bun.write automatically creates parent directories (.letta/) - await Bun.write(settingsPath, JSON.stringify(newSettings, null, 2)); + await writeFile(settingsPath, JSON.stringify(newSettings, null, 2)); } catch (error) { console.error("Error saving project settings:", error); throw error; diff --git a/src/settings.ts b/src/settings.ts index 3cce5f6..67c2639 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -4,6 +4,7 @@ import { homedir } from "node:os"; import { join } from "node:path"; import type { PermissionRules } from "./permissions/types"; +import { exists, readFile, writeFile } from "./utils/fs.js"; export type UIMode = "simple" | "rich"; @@ -32,18 +33,18 @@ function getSettingsPath(): string { */ export async function loadSettings(): Promise { const settingsPath = getSettingsPath(); - const file = Bun.file(settingsPath); try { // Check if settings file exists - if (!(await file.exists())) { - // Create default settings file (Bun.write auto-creates parent directories) + if (!exists(settingsPath)) { + // Create default settings file await saveSettings(DEFAULT_SETTINGS); return DEFAULT_SETTINGS; } - // Read and parse settings using Bun's built-in JSON parser - const settings = (await file.json()) as Settings; + // Read and parse settings + const content = await readFile(settingsPath); + const settings = JSON.parse(content) as Settings; // Merge with defaults in case new fields were added return { ...DEFAULT_SETTINGS, ...settings }; @@ -60,8 +61,7 @@ export async function saveSettings(settings: Settings): Promise { const settingsPath = getSettingsPath(); try { - // Bun.write automatically creates parent directories - await Bun.write(settingsPath, JSON.stringify(settings, null, 2)); + await writeFile(settingsPath, JSON.stringify(settings, null, 2)); } catch (error) { console.error("Error saving settings:", error); throw error; diff --git a/src/utils/fs.ts b/src/utils/fs.ts new file mode 100644 index 0000000..53868f9 --- /dev/null +++ b/src/utils/fs.ts @@ -0,0 +1,37 @@ +/** + * File system utilities using Node.js APIs + * Compatible with both Node.js and Bun + */ + +import { + existsSync, + readFileSync as fsReadFileSync, + writeFileSync as fsWriteFileSync, + mkdirSync, +} from "node:fs"; +import { dirname } from "node:path"; + +/** + * Read a file and return its contents as text + */ +export async function readFile(path: string): Promise { + return fsReadFileSync(path, { encoding: "utf-8" }); +} + +/** + * Write content to a file, creating parent directories if needed + */ +export async function writeFile(path: string, content: string): Promise { + const dir = dirname(path); + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }); + } + fsWriteFileSync(path, content, { encoding: "utf-8", flush: true }); +} + +/** + * Check if a file exists + */ +export function exists(path: string): boolean { + return existsSync(path); +}