// // main.js — extension entry point. // // Chat happens in a terminal running `claude`. This extension is everything // around that: the project's sessions, the commands that launch or resume one, // and the account state behind them. // const { conf, log, warn, oneLine, relativize } = require("./util.js"); const cli = require("./cli.js"); const auth = require("./auth.js"); const launch = require("./launch.js"); const store = require("./sessions-store.js"); const { Sidebar } = require("./sidebar.js"); const MODELS = [ ["", "Claude Code default"], ["opus", "Opus"], ["sonnet", "Sonnet"], ["haiku", "Haiku"], ["fable", "Fable"], ]; const PERMISSION_MODES = [ ["", "Claude Code default"], ["manual", "Manual — ask before each tool"], ["acceptEdits", "Accept Edits — auto-approve file edits"], ["plan", "Plan — research only, no changes"], ["auto", "Auto — Claude decides what needs asking"], ["bypassPermissions", "Bypass — approve everything (dangerous)"], ]; class Controller { constructor() { this.sessions = []; this.authState = null; this.sidebar = null; } // -- lifecycle --------------------------------------------------------- activate() { const controller = this; this.sidebar = new Sidebar({ get sessions() { return controller.sessions; }, get authState() { return controller.authState; }, get workspacePath() { return nova.workspace.path; }, describeAuth: () => auth.describe(this.authState), configuredModel: () => this.labelFor(MODELS, conf("claudenova.model", "")), }); // Sessions live in ~/.claude, outside the workspace, so a file watcher // would never fire for them. Rescanning when the section becomes // visible keeps the list fresh at the moment it is looked at. this.sidebar.onDidBecomeVisible(() => this.refreshSessions()); this.registerCommands(); this.watchConfig(); this.refreshSessions(); this.refreshAuth().catch((err) => log("initial auth check failed:", err)); } dispose() { if (this.sidebar) this.sidebar.dispose(); } labelFor(table, value) { const row = table.find((entry) => entry[0] === (value || "")); return row ? row[1] : value; } watchConfig() { const reload = () => this.sidebar.reload(); for (const key of ["claudenova.model", "claudenova.permissionMode"]) { nova.subscriptions.add(nova.config.onDidChange(key, reload)); nova.subscriptions.add(nova.workspace.config.onDidChange(key, reload)); } nova.subscriptions.add( nova.config.onDidChange("claudenova.binaryPath", () => cli.forgetBinary()) ); } // -- data -------------------------------------------------------------- refreshSessions() { this.sessions = store.recentSessions(nova.workspace.path); if (this.sidebar) this.sidebar.reload(); return this.sessions; } async refreshAuth() { this.authState = await auth.status(); if (this.sidebar) this.sidebar.reload(); return this.authState; } // -- commands ---------------------------------------------------------- registerCommands() { const register = (name, callback) => { nova.subscriptions.add(nova.commands.register(name, callback)); }; register("claudenova.newSession", () => this.commandNewSession()); register("claudenova.resumeSession", () => this.commandResumeSession()); register("claudenova.copyResume", () => this.sessionCommand("resume")); register("claudenova.sessionAction", () => this.commandSessionAction()); register("claudenova.refreshSessions", () => this.refreshSessions()); register("claudenova.copyFileReference", (editor) => this.commandCopyFileReference(editor)); register("claudenova.setModel", () => this.commandSetModel()); register("claudenova.setPermissionMode", () => this.commandSetPermissionMode()); register("claudenova.signIn", () => this.commandSignIn()); register("claudenova.signOut", () => this.commandSignOut()); register("claudenova.authStatus", () => this.commandAuthStatus()); register("claudenova.refreshStatus", () => this.refreshAuth()); register("claudenova.setApiKey", () => this.commandSetApiKey()); register("claudenova.clearApiKey", () => this.commandClearApiKey()); } async commandNewSession() { try { await launch.copyCommand({}); } catch (err) { nova.workspace.showErrorMessage(String(err.message || err)); } } /** Pick a session from a palette, then copy its resume command. */ commandResumeSession() { const sessions = this.refreshSessions(); if (sessions.length === 0) { nova.workspace.showInformativeMessage( "No previous Claude sessions were found for this project." ); return; } const choices = sessions.map( (session) => `${oneLine(session.label, 60)} — ${session.relative}` ); nova.workspace.showChoicePalette( choices, { placeholder: "Resume a Claude session" }, (choice, index) => { if (index === null || index === undefined) return; this.copyFor(sessions[index], "resume"); } ); } /** An action row under a session was activated. */ commandSessionAction() { const selection = this.sidebar.sessionsView.selection; const row = selection && selection[0]; if (!row || !row.session) return; this.copyFor(row.session, row.id); } /** A session row (not an action row) was activated. */ sessionCommand(action) { const selection = this.sidebar.sessionsView.selection; const selected = selection && selection[0]; const session = selected && (selected.session || (selected.path ? selected : null)); if (!session) return; this.copyFor(session, action); } async copyFor(session, action) { if (action === "reveal") { try { nova.fs.reveal(session.path); } catch (err) { nova.workspace.showErrorMessage("Could not reveal the session file: " + err); } return; } try { await launch.copyCommand({ resume: session.id, remoteControl: action === "resume-remote", }); } catch (err) { nova.workspace.showErrorMessage(String(err.message || err)); } } /** * Copy an @-mention for the active file, for pasting into a terminal * session. Claude Code resolves `@path` against the project directory. */ commandCopyFileReference(editor) { const target = TextEditor.isTextEditor(editor) ? editor : nova.workspace.activeTextEditor; const path = target && target.document && target.document.path; if (!path) { nova.workspace.showWarningMessage("Save the file first so Claude can reference it."); return; } const reference = "@" + relativize(path); nova.clipboard.writeText(reference); nova.workspace.showInformativeMessage( `Copied ${reference} — paste it into your Claude session.` ); } commandSetModel() { const labels = MODELS.map((entry) => entry[1]); nova.workspace.showChoicePalette(labels, { placeholder: "Model" }, (choice, index) => { if (index === null || index === undefined) return; nova.workspace.config.set("claudenova.model", MODELS[index][0]); this.sidebar.reload(); }); } commandSetPermissionMode() { const labels = PERMISSION_MODES.map((entry) => entry[1]); nova.workspace.showChoicePalette( labels, { placeholder: "Permission mode" }, (choice, index) => { if (index === null || index === undefined) return; nova.workspace.config.set("claudenova.permissionMode", PERMISSION_MODES[index][0]); this.sidebar.reload(); } ); } // -- account ----------------------------------------------------------- commandSignIn() { nova.workspace.showActionPanel( "How would you like to sign in to Claude?", { buttons: ["Claude Subscription", "Anthropic Console", "Open a Terminal", "Cancel"] }, (index) => { if (index === null || index === 3) return; if (index === 2) { auth.signInViaTerminal() .then(() => { nova.workspace.showInformativeMessage( "Finish signing in in the terminal window, then refresh the Claude sidebar." ); }) .catch((err) => nova.workspace.showErrorMessage(String(err.message || err))); return; } auth.signIn({ console: index === 1 }) .then(async (ok) => { if (!ok) return; await this.refreshAuth(); nova.workspace.showInformativeMessage( "Signed in to Claude as " + auth.describe(this.authState) ); }) .catch((err) => { nova.workspace.showErrorMessage( String(err.message || err) + "\n\nYou can also sign in by running `claude auth login` in a terminal." ); }); } ); } commandSignOut() { auth.signOut() .then(() => this.refreshAuth()) .then(() => nova.workspace.showInformativeMessage("Signed out of Claude.")) .catch((err) => nova.workspace.showErrorMessage(String(err.message || err))); } async commandAuthStatus() { const state = await this.refreshAuth(); const lines = []; if (!state.available) { lines.push("Claude Code was not found on this machine."); if (state.error) lines.push(state.error); } else if (state.loggedIn) { lines.push("Signed in to Claude."); if (state.email) lines.push("Account: " + state.email); if (state.subscriptionType) lines.push("Plan: " + state.subscriptionType); if (state.orgName) lines.push("Organization: " + state.orgName); if (state.authMethod) lines.push("Method: " + state.authMethod); } else { lines.push("Not signed in."); if (state.error) lines.push(state.error); } if (cli.storedApiKey()) { lines.push(""); lines.push("An API key stored in your keychain is passed to Claude Code."); } const version = await cli.version().catch(() => null); if (version) { lines.push(""); lines.push("Claude Code " + version); } nova.workspace.showInformativeMessage(lines.join("\n")); } commandSetApiKey() { nova.workspace.showInputPanel( "Store an Anthropic API key in your keychain. Claude Code will use it instead of your signed-in account.", { label: "API key", placeholder: "sk-ant-…", secure: true, prompt: "Save" }, (value) => { if (value === null) return; const key = value.trim(); if (!key) return; try { cli.setStoredApiKey(key); nova.workspace.showInformativeMessage( "Saved. It applies to sessions you start from now on." ); this.refreshAuth(); } catch (err) { nova.workspace.showErrorMessage("Could not save the key: " + (err.message || err)); } } ); } commandClearApiKey() { cli.clearStoredApiKey(); nova.workspace.showInformativeMessage("Removed the stored API key."); this.refreshAuth(); } } const controller = new Controller(); exports.activate = function () { log("activating"); try { controller.activate(); } catch (err) { warn("activation failed:", err); nova.workspace.showErrorMessage("The Claude extension failed to start: " + (err.message || err)); } }; exports.deactivate = function () { controller.dispose(); };