Replace fixed Claude/Codex switch with customizable runtime list

Settings now expose a list of CLI runtimes (each with a display name and
launch command) instead of two hardcoded entries. Users can add as many
runtimes as they want from the settings panel, remove unused ones (the
list always keeps at least one entry), and pick the default runtime
through a dropdown listing every configured entry.

The sidebar toolbar replaces the previous Claude/Codex toggle buttons
with a dropdown populated from the same configured list, so switching
runtime is a single action and reflects user-added entries.

A migration helper converts legacy `command`, `codexCommand`, and
`runtime` settings into the new `runtimes` + `selectedRuntimeId` shape
on first load, and the Codex-specific terminal workarounds (no-color
env, terminal reset) are now triggered by `isCodexLikeCommand` so they
keep working regardless of the entry id.

Adds vitest coverage for the migration and codex-like command helpers.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
blamouche 2026-04-26 07:27:45 +02:00
parent eb3f01a27a
commit eb05136a03
10 changed files with 810 additions and 202 deletions

View file

@ -44,3 +44,4 @@
| 2026-04-25 09:35:00 CEST | agent | Fixed crash when `node-pty` native dep is missing post-unzip: made `require('node-pty')` optional in `pty-proxy.js` and threaded a clear error through `spawnWithFallback`, so the existing Python/pipe/script fallback chain takes over instead of the proxy dying at top-level require. Validated by running the proxy in a dir without `node_modules` and confirming Python bridge fallback executed. | `pty-proxy.js`, `node --check pty-proxy.js`, `/tmp/proxy-smoke-no-pty` (no-node-pty smoke test), `.prompt-hub/version.md`, `.prompt-hub/releases.md`, `manifest.json`, `versions.json`, `package.json` | success | Build, sync lockfile, commit, push, tag 0.1.25. |
| 2026-04-25 09:45:00 CEST | agent | Reworked README install section around the release zip flow (no command required), demoted `npm install --omit=dev` to optional native-PTY upgrade, added a `Release` section documenting the workflow, and updated the release workflow body template accordingly. | `README.md`, `.github/workflows/release.yml`, `.prompt-hub/version.md`, `.prompt-hub/releases.md`, `npm test` | success | Commit and push to main (no new tag yet — template applies to next release). |
| 2026-04-26 07:15:00 CEST | agent | Added project banner to the top of the README and stored the asset under a new `img/` folder. | `img/banner.png` (copied from `~/Downloads/image.png`), `README.md`, `.prompt-hub/version.md`, `.prompt-hub/releases.md` | success | Commit and push to main. |
| 2026-04-26 07:30:00 CEST | agent | Replaced the hardcoded Claude/Codex runtime pair with a customizable runtime list: settings now expose a name+command list with add/remove controls, sidebar toolbar uses a dropdown populated from settings, codex-specific workarounds gated by `isCodexLikeCommand`, and a migration helper converts legacy `command`/`codexCommand`/`runtime` settings on first load. Added vitest coverage (22 tests). | `runtime-utils.ts`, `tests/runtime-utils.test.ts`, `main.ts`, `styles.css`, `README.md`, `npm run build`, `npm test`, `.prompt-hub/version.md`, `.prompt-hub/releases.md` | success | Commit and push to main; ask user before tagging. |

View file

@ -1,5 +1,14 @@
# Releases
## 0.1.28 - 2026-04-26
- Replaced the hardcoded Claude/Codex runtime switch with a customizable, unbounded list of CLI runtimes configurable from the settings panel.
- Each runtime entry stores a display name and a launch command; users can add new runtimes with `Add runtime` and remove unused ones (a single runtime is always kept).
- The sidebar toolbar now exposes a dropdown populated with the configured runtimes (replacing the previous Claude/Codex toggle buttons).
- Added a migration path that converts legacy `command`, `codexCommand`, and `runtime` settings into the new `runtimes` + `selectedRuntimeId` shape on first load.
- The Codex-specific terminal workarounds (no-color env, terminal reset) now trigger by command shape (`isCodexLikeCommand`) instead of a hardcoded id, so they keep working for any user-renamed Codex entry.
- README updated to document the new settings shape and runtime dropdown behavior.
- Added unit tests for the new migration helper and codex-like command detection (22 tests total).
## 0.1.27 - 2026-04-26
- Added a project banner image at the top of the README (`img/banner.png`).

View file

@ -1 +1 @@
0.1.27
0.1.28

View file

@ -10,10 +10,11 @@ Use Claude Code directly inside your active Obsidian vault without leaving Obsid
## Features
- Dedicated `Claude Code` view in the right sidebar
- Dedicated CLI view in the right sidebar
- Embedded terminal (xterm)
- Quick actions: `Start`, `Stop`, `Restart`, `Clear`
- Launches Claude in the **current active vault folder**
- Quick actions: `Start`, `Stop`, `Restart`, `Clear`, `@Active file`
- **Customizable runtime list** — configure as many CLI runtimes as you want (Claude and Codex are pre-populated; add any others from settings) and switch between them via a dropdown in the sidebar
- Launches the selected runtime in the **current active vault folder**
- Visible UI status (`Status: ...`)
- Explicit runtime error messages in the panel
- Runtime fallbacks for macOS / Linux / Windows
@ -79,12 +80,21 @@ If you assemble the plugin folder by hand, make sure these are present:
## Plugin Settings
- `Command`: command to run (default: `claude`)
- `Auto-start`: starts automatically when panel opens
- `Default runtime`: which configured runtime is selected when the panel opens (and used by auto-start).
- `Auto-start`: starts the default runtime automatically when the panel opens.
- `Auto-restart on runtime switch`: when you change the runtime from the sidebar dropdown while a process is running, restart it immediately to apply the new selection.
- `Runtimes`: a customizable list of CLIs that show up in the sidebar dropdown. Each entry has:
- `Name`: label shown in the dropdown.
- `Command`: the launch command (e.g. `claude`, `codex --no-alt-screen ...`, or any other CLI).
- You can add as many runtimes as you want with `Add runtime`, and remove unused ones (the list must keep at least one entry). Claude and Codex are pre-populated on first install.
- `Node executable`:
- `auto` (recommended): automatic detection
- or explicit path (`/opt/homebrew/bin/node`, `C:\Program Files\nodejs\node.exe`, etc.)
### Switching runtime from the sidebar
The sidebar toolbar exposes a runtime dropdown listing every entry from settings. Pick another runtime to switch the panel target — if a process is already running and `Auto-restart on runtime switch` is enabled, the running process is stopped and the newly selected one launches automatically.
## Runtime Architecture (Fallback Chain)
The plugin tries multiple strategies to maximize startup success:

343
main.js
View file

@ -9289,6 +9289,66 @@ var o = class {
function formatActiveFileMention(fileName) {
return `@${fileName.trim()} `;
}
function isCodexLikeCommand(command) {
if (typeof command !== "string") {
return false;
}
const trimmed = command.trim();
if (!trimmed) {
return false;
}
return trimmed === "codex" || /^codex(\s|$)/.test(trimmed);
}
function migrateRuntimeSettings(raw, defaults, generateId = defaultGenerateRuntimeId) {
const fallbackDefaults = defaults.length > 0 ? defaults.map((d) => ({ ...d })) : [{ id: generateId(), name: "Default", command: "" }];
if (raw && Array.isArray(raw.runtimes)) {
const sanitized = sanitizeRuntimes(raw.runtimes, generateId);
if (sanitized.length > 0) {
const selected = typeof raw.selectedRuntimeId === "string" && sanitized.some((r) => r.id === raw.selectedRuntimeId) ? raw.selectedRuntimeId : sanitized[0].id;
return { runtimes: sanitized, selectedRuntimeId: selected };
}
}
const runtimes = fallbackDefaults;
if (raw && typeof raw.command === "string" && raw.command.trim()) {
const claude = runtimes.find((r) => r.id === "claude");
if (claude) {
claude.command = raw.command.trim();
}
}
if (raw && typeof raw.codexCommand === "string" && raw.codexCommand.trim()) {
const codex = runtimes.find((r) => r.id === "codex");
if (codex) {
codex.command = raw.codexCommand.trim();
}
}
const legacyRuntime = typeof (raw == null ? void 0 : raw.runtime) === "string" && (raw.runtime === "claude" || raw.runtime === "codex") ? raw.runtime : void 0;
const selectedRuntimeId = legacyRuntime && runtimes.some((r) => r.id === legacyRuntime) ? legacyRuntime : runtimes[0].id;
return { runtimes, selectedRuntimeId };
}
function sanitizeRuntimes(raw, generateId) {
const result = [];
const seenIds = /* @__PURE__ */ new Set();
for (const entry of raw) {
if (!entry || typeof entry !== "object") continue;
const candidate = entry;
const name = typeof candidate.name === "string" ? candidate.name : "";
const command = typeof candidate.command === "string" ? candidate.command : "";
let id = typeof candidate.id === "string" && candidate.id.trim() ? candidate.id : generateId();
while (seenIds.has(id)) {
id = generateId();
}
seenIds.add(id);
result.push({ id, name, command });
}
return result;
}
function defaultGenerateRuntimeId() {
const cryptoApi = globalThis.crypto;
if (cryptoApi && typeof cryptoApi.randomUUID === "function") {
return cryptoApi.randomUUID();
}
return `runtime-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
}
function resolvePluginDir(pluginDir, vaultBasePath, pathApi) {
if (!pluginDir) {
return void 0;
@ -9359,14 +9419,21 @@ function detectNodeExecutable(configuredValue, platform, env, existsSync, pathAp
// main.ts
var VIEW_TYPE_CLAUDE = "claude-cli-view";
var CODEX_DEFAULT_COMMAND = "codex --no-alt-screen -c check_for_update_on_startup=false -c hide_full_access_warning=true -c hide_world_writable_warning=true -c hide_rate_limit_model_nudge=true";
var DEFAULT_RUNTIMES = [
{ id: "claude", name: "Claude", command: "claude" },
{ id: "codex", name: "Codex", command: CODEX_DEFAULT_COMMAND }
];
var DEFAULT_SETTINGS = {
command: "claude",
codexCommand: "codex --no-alt-screen -c check_for_update_on_startup=false -c hide_full_access_warning=true -c hide_world_writable_warning=true -c hide_rate_limit_model_nudge=true",
runtimes: DEFAULT_RUNTIMES.map((runtime) => ({ ...runtime })),
selectedRuntimeId: "claude",
autoRestartOnRuntimeSwitch: true,
autoStart: true,
nodeExecutable: "auto",
runtime: "claude"
nodeExecutable: "auto"
};
function cloneDefaultRuntimes() {
return DEFAULT_RUNTIMES.map((runtime) => ({ ...runtime }));
}
var ClaudeCliView = class extends import_obsidian.ItemView {
constructor(leaf, plugin) {
super(leaf);
@ -9376,9 +9443,9 @@ var ClaudeCliView = class extends import_obsidian.ItemView {
this.terminalHostEl = null;
this.resizeObserver = null;
this.statusEl = null;
this.runtimeButtons = null;
this.runningRuntime = null;
this.pendingStartRuntime = null;
this.runtimeSelect = null;
this.runningRuntimeId = null;
this.pendingStartRuntimeId = null;
this.plugin = plugin;
}
getViewType() {
@ -9399,18 +9466,17 @@ var ClaudeCliView = class extends import_obsidian.ItemView {
const restartBtn = toolbarEl.createEl("button", { text: "Restart" });
const clearBtn = toolbarEl.createEl("button", { text: "Clear" });
const mentionBtn = toolbarEl.createEl("button", { text: "@Active file" });
const runtimeToggleEl = toolbarEl.createDiv({ cls: "claude-cli-runtime-toggle" });
const claudeBtn = runtimeToggleEl.createEl("button", { text: "Claude" });
const codexBtn = runtimeToggleEl.createEl("button", { text: "Codex" });
const runtimePickerEl = toolbarEl.createDiv({ cls: "claude-cli-runtime-picker" });
const runtimeIconEl = runtimePickerEl.createSpan({ cls: "claude-cli-runtime-picker-icon" });
(0, import_obsidian.setIcon)(runtimeIconEl, "terminal");
this.runtimeSelect = runtimePickerEl.createEl("select", { cls: "claude-cli-runtime-select" });
this.runtimeSelect.setAttribute("aria-label", "Select runtime");
this.refreshRuntimeSelect();
this.setButtonIcon(startBtn, "play", "Start");
this.setButtonIcon(stopBtn, "square", "Stop");
this.setButtonIcon(restartBtn, "refresh-cw", "Restart");
this.setButtonIcon(clearBtn, "eraser", "Clear");
this.setButtonIcon(mentionBtn, "file-plus", "@Active file");
this.setButtonIcon(claudeBtn, "bot", "Claude");
this.setButtonIcon(codexBtn, "code-2", "Codex");
this.runtimeButtons = { claude: claudeBtn, codex: codexBtn };
this.updateRuntimeButtons();
this.statusEl = this.contentEl.createDiv({ cls: "claude-cli-status" });
startBtn.addEventListener("click", () => this.startClaudeProcess());
stopBtn.addEventListener("click", () => this.stopClaudeProcess());
@ -9420,8 +9486,11 @@ var ClaudeCliView = class extends import_obsidian.ItemView {
return (_a5 = this.terminal) == null ? void 0 : _a5.clear();
});
mentionBtn.addEventListener("click", () => this.insertActiveFileMention());
claudeBtn.addEventListener("click", () => this.setRuntime("claude"));
codexBtn.addEventListener("click", () => this.setRuntime("codex"));
this.runtimeSelect.addEventListener("change", () => {
if (this.runtimeSelect) {
this.setRuntime(this.runtimeSelect.value);
}
});
this.terminalHostEl = this.contentEl.createDiv({ cls: "claude-cli-terminal" });
this.terminal = new Dl({
cursorBlink: true,
@ -9462,6 +9531,27 @@ var ClaudeCliView = class extends import_obsidian.ItemView {
this.setStatus("Idle");
}
}
refreshRuntimeSelect() {
if (!this.runtimeSelect) {
return;
}
this.runtimeSelect.empty();
const runtimes = this.plugin.settings.runtimes;
if (runtimes.length === 0) {
const placeholder = this.runtimeSelect.createEl("option", { text: "(no runtime configured)" });
placeholder.value = "";
this.runtimeSelect.value = "";
this.runtimeSelect.disabled = true;
return;
}
this.runtimeSelect.disabled = false;
for (const runtime of runtimes) {
const opt = this.runtimeSelect.createEl("option", { text: runtime.name || "(unnamed)" });
opt.value = runtime.id;
}
const validSelection = runtimes.some((r) => r.id === this.plugin.settings.selectedRuntimeId);
this.runtimeSelect.value = validSelection ? this.plugin.settings.selectedRuntimeId : runtimes[0].id;
}
async onClose() {
var _a5, _b;
this.stopClaudeProcess();
@ -9472,27 +9562,41 @@ var ClaudeCliView = class extends import_obsidian.ItemView {
this.fitAddon = null;
this.statusEl = null;
}
async startClaudeProcess(runtimeOverride) {
async startClaudeProcess(runtimeIdOverride) {
var _a5;
if (!this.terminal) {
return;
}
const targetRuntime = runtimeOverride != null ? runtimeOverride : this.plugin.settings.runtime;
const targetLabel = this.getRuntimeLabel(targetRuntime);
const targetRuntime = runtimeIdOverride ? this.plugin.settings.runtimes.find((r) => r.id === runtimeIdOverride) : this.getSelectedRuntime();
if (!targetRuntime) {
const message = "No runtime configured. Add one in plugin settings.";
this.writeSystemLine(`[${message}]`);
this.setStatus(message);
new import_obsidian.Notice(message, 6e3);
return;
}
const targetLabel = targetRuntime.name || "(unnamed runtime)";
if (this.processHandle) {
if (this.runningRuntime === targetRuntime) {
if (this.runningRuntimeId === targetRuntime.id) {
this.writeSystemLine(`[${targetLabel} process is already running]`);
this.setStatus("Already running");
} else {
this.pendingStartRuntime = targetRuntime;
this.pendingStartRuntimeId = targetRuntime.id;
this.writeSystemLine(`[Switch requested: ${targetLabel}. Stopping current process first...]`);
this.stopClaudeProcess(true);
}
return;
}
const runtimeLabel = this.getRuntimeLabel(targetRuntime);
const command = this.getRuntimeCommand(targetRuntime);
if (targetRuntime === "codex") {
const command = (targetRuntime.command || "").trim();
if (!command) {
const message = `Runtime "${targetLabel}" has an empty command. Set one in plugin settings.`;
this.writeSystemLine(`[${message}]`);
this.setStatus(message);
new import_obsidian.Notice(message, 6e3);
return;
}
const codexLike = isCodexLikeCommand(command);
if (codexLike) {
this.resetTerminalDisplay();
}
this.writeSystemLine(`[Starting: ${command}]`);
@ -9500,7 +9604,7 @@ var ClaudeCliView = class extends import_obsidian.ItemView {
try {
const vaultPath = getVaultBasePath(this.app);
if (!vaultPath) {
const message = `Unable to resolve current vault path. ${runtimeLabel} was not started.`;
const message = `Unable to resolve current vault path. ${targetLabel} was not started.`;
this.writeSystemLine(`[${message}]`);
this.setStatus(message);
new import_obsidian.Notice(message, 6e3);
@ -9515,7 +9619,7 @@ var ClaudeCliView = class extends import_obsidian.ItemView {
return;
}
const shellEnv = getShellEnv();
if (targetRuntime === "codex") {
if (codexLike) {
shellEnv.NO_COLOR = "1";
shellEnv.CLICOLOR = "0";
shellEnv.FORCE_COLOR = "0";
@ -9531,14 +9635,14 @@ var ClaudeCliView = class extends import_obsidian.ItemView {
vaultPath
});
this.processHandle = makeProxyAdapter(helperHandle);
this.runningRuntime = targetRuntime;
this.runningRuntimeId = targetRuntime.id;
} catch (error) {
const message = `Failed to start process: ${error.message}`;
this.writeSystemLine(`[${message}]`);
this.setStatus(message);
new import_obsidian.Notice(message, 7e3);
this.processHandle = null;
this.runningRuntime = null;
this.runningRuntimeId = null;
return;
}
this.setStatus("Running");
@ -9551,11 +9655,11 @@ var ClaudeCliView = class extends import_obsidian.ItemView {
this.writeSystemLine(`[${message}]`);
this.setStatus(message);
this.processHandle = null;
this.runningRuntime = null;
const nextRuntime = this.pendingStartRuntime;
this.pendingStartRuntime = null;
if (nextRuntime) {
void this.startClaudeProcess(nextRuntime);
this.runningRuntimeId = null;
const nextRuntimeId = this.pendingStartRuntimeId;
this.pendingStartRuntimeId = null;
if (nextRuntimeId) {
void this.startClaudeProcess(nextRuntimeId);
}
});
(_a5 = this.fitAddon) == null ? void 0 : _a5.fit();
@ -9565,7 +9669,7 @@ var ClaudeCliView = class extends import_obsidian.ItemView {
return;
}
if (!preservePendingStart) {
this.pendingStartRuntime = null;
this.pendingStartRuntimeId = null;
}
this.writeSystemLine(`[Stopping ${this.getRunningRuntimeLabel()} process...]`);
this.setStatus("Stopping...");
@ -9582,29 +9686,37 @@ var ClaudeCliView = class extends import_obsidian.ItemView {
var _a5;
(_a5 = this.statusEl) == null ? void 0 : _a5.setText(`Status: ${message}`);
}
getRuntimeLabel(runtime = this.plugin.settings.runtime) {
return runtime === "codex" ? "Codex" : "Claude";
getSelectedRuntime() {
var _a5, _b;
const { runtimes, selectedRuntimeId } = this.plugin.settings;
return (_b = (_a5 = runtimes.find((r) => r.id === selectedRuntimeId)) != null ? _a5 : runtimes[0]) != null ? _b : null;
}
getRuntimeLabel(runtimeId) {
var _a5;
if (runtimeId) {
const match = this.plugin.settings.runtimes.find((r) => r.id === runtimeId);
return (match == null ? void 0 : match.name) || "(unnamed runtime)";
}
return ((_a5 = this.getSelectedRuntime()) == null ? void 0 : _a5.name) || "(no runtime)";
}
getRunningRuntimeLabel() {
if (!this.runningRuntime) {
if (!this.runningRuntimeId) {
return this.getRuntimeLabel();
}
return this.getRuntimeLabel(this.runningRuntime);
}
getRuntimeCommand(runtime = this.plugin.settings.runtime) {
if (runtime === "codex") {
return this.plugin.settings.codexCommand.trim() || DEFAULT_SETTINGS.codexCommand;
}
return this.plugin.settings.command.trim();
return this.getRuntimeLabel(this.runningRuntimeId);
}
restartClaudeProcess() {
const targetRuntime = this.plugin.settings.runtime;
if (!this.processHandle) {
void this.startClaudeProcess(targetRuntime);
const target = this.getSelectedRuntime();
if (!target) {
void this.startClaudeProcess();
return;
}
this.pendingStartRuntime = targetRuntime;
this.writeSystemLine(`[Restart requested: ${this.getRuntimeLabel(targetRuntime)}]`);
if (!this.processHandle) {
void this.startClaudeProcess(target.id);
return;
}
this.pendingStartRuntimeId = target.id;
this.writeSystemLine(`[Restart requested: ${target.name}]`);
this.stopClaudeProcess(true);
}
resetTerminalDisplay() {
@ -9615,13 +9727,21 @@ var ClaudeCliView = class extends import_obsidian.ItemView {
this.terminal.reset();
(_a5 = this.fitAddon) == null ? void 0 : _a5.fit();
}
setRuntime(runtime) {
if (this.plugin.settings.runtime === runtime) {
setRuntime(runtimeId) {
if (!runtimeId) {
return;
}
this.plugin.settings.runtime = runtime;
const exists = this.plugin.settings.runtimes.some((r) => r.id === runtimeId);
if (!exists) {
this.refreshRuntimeSelect();
return;
}
if (this.plugin.settings.selectedRuntimeId === runtimeId) {
return;
}
this.plugin.settings.selectedRuntimeId = runtimeId;
void this.plugin.saveSettings();
this.updateRuntimeButtons();
this.refreshRuntimeSelect();
const selectedLabel = this.getRuntimeLabel();
this.writeSystemLine(`[Runtime selected: ${selectedLabel}]`);
if (this.processHandle) {
@ -9635,13 +9755,6 @@ var ClaudeCliView = class extends import_obsidian.ItemView {
}
this.setStatus(`${selectedLabel} selected`);
}
updateRuntimeButtons() {
if (!this.runtimeButtons) {
return;
}
this.runtimeButtons.claude.toggleClass("is-active", this.plugin.settings.runtime === "claude");
this.runtimeButtons.codex.toggleClass("is-active", this.plugin.settings.runtime === "codex");
}
insertActiveFileMention() {
var _a5;
const activeFile = this.app.workspace.getActiveFile();
@ -9711,11 +9824,28 @@ var ClaudeCliPlugin = class extends import_obsidian.Plugin {
workspace.revealLeaf(leaf);
}
async loadSettings() {
this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData());
var _a5;
const raw = (_a5 = await this.loadData()) != null ? _a5 : {};
const { runtimes, selectedRuntimeId } = migrateRuntimeSettings(raw, cloneDefaultRuntimes());
this.settings = {
runtimes,
selectedRuntimeId,
autoRestartOnRuntimeSwitch: typeof raw.autoRestartOnRuntimeSwitch === "boolean" ? raw.autoRestartOnRuntimeSwitch : DEFAULT_SETTINGS.autoRestartOnRuntimeSwitch,
autoStart: typeof raw.autoStart === "boolean" ? raw.autoStart : DEFAULT_SETTINGS.autoStart,
nodeExecutable: typeof raw.nodeExecutable === "string" && raw.nodeExecutable.trim() ? raw.nodeExecutable : DEFAULT_SETTINGS.nodeExecutable
};
}
async saveSettings() {
await this.saveData(this.settings);
}
notifyRuntimesChanged() {
this.app.workspace.getLeavesOfType(VIEW_TYPE_CLAUDE).forEach((leaf) => {
const view = leaf.view;
if (view instanceof ClaudeCliView) {
view.refreshRuntimeSelect();
}
});
}
};
var ClaudeCliSettingTab = class extends import_obsidian.PluginSettingTab {
constructor(app, plugin) {
@ -9731,35 +9861,94 @@ var ClaudeCliSettingTab = class extends import_obsidian.PluginSettingTab {
cls: "setting-item-description"
});
containerEl.createEl("h3", { text: "Runtime behavior" });
new import_obsidian.Setting(containerEl).setName("Default runtime").setDesc("Runtime selected by default when opening the panel (and used by auto-start).").addDropdown(
(dropdown) => dropdown.addOption("claude", "Claude").addOption("codex", "Codex").setValue(this.plugin.settings.runtime).onChange(async (value) => {
this.plugin.settings.runtime = value === "codex" ? "codex" : "claude";
new import_obsidian.Setting(containerEl).setName("Default runtime").setDesc("Runtime selected by default when opening the panel (and used by auto-start).").addDropdown((dropdown) => {
const runtimes = this.plugin.settings.runtimes;
if (runtimes.length === 0) {
dropdown.addOption("", "(no runtime configured)");
dropdown.setDisabled(true);
} else {
for (const runtime of runtimes) {
dropdown.addOption(runtime.id, runtime.name || "(unnamed)");
}
const validSelection = runtimes.some((r) => r.id === this.plugin.settings.selectedRuntimeId);
dropdown.setValue(
validSelection ? this.plugin.settings.selectedRuntimeId : runtimes[0].id
);
}
dropdown.onChange(async (value) => {
if (!value) {
return;
}
this.plugin.settings.selectedRuntimeId = value;
await this.plugin.saveSettings();
})
);
this.plugin.notifyRuntimesChanged();
});
});
new import_obsidian.Setting(containerEl).setName("Auto-start").setDesc("Automatically start the selected default runtime when the panel opens.").addToggle(
(toggle) => toggle.setValue(this.plugin.settings.autoStart).onChange(async (value) => {
this.plugin.settings.autoStart = value;
await this.plugin.saveSettings();
})
);
new import_obsidian.Setting(containerEl).setName("Auto-restart on runtime switch").setDesc("Automatically restart the running process when switching Claude/Codex from the toolbar.").addToggle(
new import_obsidian.Setting(containerEl).setName("Auto-restart on runtime switch").setDesc("Automatically restart the running process when changing the runtime from the sidebar dropdown.").addToggle(
(toggle) => toggle.setValue(this.plugin.settings.autoRestartOnRuntimeSwitch).onChange(async (value) => {
this.plugin.settings.autoRestartOnRuntimeSwitch = value;
await this.plugin.saveSettings();
})
);
containerEl.createEl("h3", { text: "Commands" });
new import_obsidian.Setting(containerEl).setName("Claude command").setDesc("Command used to launch Claude in the embedded terminal.").addText(
(text) => text.setPlaceholder("claude").setValue(this.plugin.settings.command).onChange(async (value) => {
this.plugin.settings.command = value.trim() || "claude";
await this.plugin.saveSettings();
})
);
new import_obsidian.Setting(containerEl).setName("Codex command").setDesc("Command used to launch Codex in the embedded terminal.").addText(
(text) => text.setPlaceholder(DEFAULT_SETTINGS.codexCommand).setValue(this.plugin.settings.codexCommand).onChange(async (value) => {
this.plugin.settings.codexCommand = value.trim() || DEFAULT_SETTINGS.codexCommand;
containerEl.createEl("h3", { text: "Runtimes" });
containerEl.createEl("p", {
text: "Configure the CLIs that show up in the sidebar dropdown. Each entry needs a display name and a launch command. Add as many as you want.",
cls: "setting-item-description"
});
const runtimesListEl = containerEl.createDiv({ cls: "claude-cli-runtimes-list" });
if (this.plugin.settings.runtimes.length === 0) {
runtimesListEl.createEl("p", {
text: "No runtimes configured yet. Click 'Add runtime' to create one.",
cls: "setting-item-description"
});
}
this.plugin.settings.runtimes.forEach((runtime, index) => {
const setting = new import_obsidian.Setting(runtimesListEl).setClass("claude-cli-runtime-item");
setting.infoEl.detach();
setting.addText(
(text) => text.setPlaceholder("Name").setValue(runtime.name).onChange(async (value) => {
this.plugin.settings.runtimes[index].name = value;
await this.plugin.saveSettings();
this.plugin.notifyRuntimesChanged();
})
).addText((text) => {
text.setPlaceholder("Command (e.g. claude, codex --no-alt-screen ...)").setValue(runtime.command).onChange(async (value) => {
this.plugin.settings.runtimes[index].command = value;
await this.plugin.saveSettings();
});
text.inputEl.addClass("claude-cli-runtime-command-input");
}).addExtraButton(
(btn) => btn.setIcon("trash-2").setTooltip("Remove runtime").onClick(async () => {
if (this.plugin.settings.runtimes.length <= 1) {
new import_obsidian.Notice("Keep at least one runtime configured.", 4e3);
return;
}
const removed = this.plugin.settings.runtimes.splice(index, 1)[0];
if (this.plugin.settings.selectedRuntimeId === removed.id) {
this.plugin.settings.selectedRuntimeId = this.plugin.settings.runtimes[0].id;
}
await this.plugin.saveSettings();
this.plugin.notifyRuntimesChanged();
this.display();
})
);
});
new import_obsidian.Setting(containerEl).addButton(
(btn) => btn.setButtonText("Add runtime").setIcon("plus").onClick(async () => {
this.plugin.settings.runtimes.push({
id: defaultGenerateRuntimeId(),
name: "New runtime",
command: ""
});
await this.plugin.saveSettings();
this.plugin.notifyRuntimesChanged();
this.display();
})
);
containerEl.createEl("h3", { text: "Advanced" });

361
main.ts
View file

@ -3,34 +3,46 @@ import type { ChildProcess } from "child_process";
import { Terminal } from "@xterm/xterm";
import { FitAddon } from "@xterm/addon-fit";
import {
defaultGenerateRuntimeId,
detectNodeExecutable,
formatActiveFileMention,
isCodexLikeCommand,
mergePathEntries as mergePathEntriesForPlatform,
resolvePluginDir as resolvePluginDirWithVault
migrateRuntimeSettings,
resolvePluginDir as resolvePluginDirWithVault,
type CliRuntimeConfig
} from "./runtime-utils";
const VIEW_TYPE_CLAUDE = "claude-cli-view";
type CliRuntime = "claude" | "codex";
const CODEX_DEFAULT_COMMAND =
"codex --no-alt-screen -c check_for_update_on_startup=false -c hide_full_access_warning=true -c hide_world_writable_warning=true -c hide_rate_limit_model_nudge=true";
const DEFAULT_RUNTIMES: CliRuntimeConfig[] = [
{ id: "claude", name: "Claude", command: "claude" },
{ id: "codex", name: "Codex", command: CODEX_DEFAULT_COMMAND }
];
interface ClaudeCliPluginSettings {
command: string;
codexCommand: string;
runtimes: CliRuntimeConfig[];
selectedRuntimeId: string;
autoRestartOnRuntimeSwitch: boolean;
autoStart: boolean;
nodeExecutable: string;
runtime: CliRuntime;
}
const DEFAULT_SETTINGS: ClaudeCliPluginSettings = {
command: "claude",
codexCommand:
"codex --no-alt-screen -c check_for_update_on_startup=false -c hide_full_access_warning=true -c hide_world_writable_warning=true -c hide_rate_limit_model_nudge=true",
runtimes: DEFAULT_RUNTIMES.map((runtime) => ({ ...runtime })),
selectedRuntimeId: "claude",
autoRestartOnRuntimeSwitch: true,
autoStart: true,
nodeExecutable: "auto",
runtime: "claude"
nodeExecutable: "auto"
};
function cloneDefaultRuntimes(): CliRuntimeConfig[] {
return DEFAULT_RUNTIMES.map((runtime) => ({ ...runtime }));
}
interface ProcessAdapter {
write(data: string): void;
resize?(cols: number, rows: number): void;
@ -47,9 +59,9 @@ class ClaudeCliView extends ItemView {
private terminalHostEl: HTMLDivElement | null = null;
private resizeObserver: ResizeObserver | null = null;
private statusEl: HTMLDivElement | null = null;
private runtimeButtons: Record<CliRuntime, HTMLButtonElement> | null = null;
private runningRuntime: CliRuntime | null = null;
private pendingStartRuntime: CliRuntime | null = null;
private runtimeSelect: HTMLSelectElement | null = null;
private runningRuntimeId: string | null = null;
private pendingStartRuntimeId: string | null = null;
constructor(leaf: WorkspaceLeaf, plugin: ClaudeCliPlugin) {
super(leaf);
@ -78,18 +90,17 @@ class ClaudeCliView extends ItemView {
const restartBtn = toolbarEl.createEl("button", { text: "Restart" });
const clearBtn = toolbarEl.createEl("button", { text: "Clear" });
const mentionBtn = toolbarEl.createEl("button", { text: "@Active file" });
const runtimeToggleEl = toolbarEl.createDiv({ cls: "claude-cli-runtime-toggle" });
const claudeBtn = runtimeToggleEl.createEl("button", { text: "Claude" });
const codexBtn = runtimeToggleEl.createEl("button", { text: "Codex" });
const runtimePickerEl = toolbarEl.createDiv({ cls: "claude-cli-runtime-picker" });
const runtimeIconEl = runtimePickerEl.createSpan({ cls: "claude-cli-runtime-picker-icon" });
setIcon(runtimeIconEl, "terminal");
this.runtimeSelect = runtimePickerEl.createEl("select", { cls: "claude-cli-runtime-select" });
this.runtimeSelect.setAttribute("aria-label", "Select runtime");
this.refreshRuntimeSelect();
this.setButtonIcon(startBtn, "play", "Start");
this.setButtonIcon(stopBtn, "square", "Stop");
this.setButtonIcon(restartBtn, "refresh-cw", "Restart");
this.setButtonIcon(clearBtn, "eraser", "Clear");
this.setButtonIcon(mentionBtn, "file-plus", "@Active file");
this.setButtonIcon(claudeBtn, "bot", "Claude");
this.setButtonIcon(codexBtn, "code-2", "Codex");
this.runtimeButtons = { claude: claudeBtn, codex: codexBtn };
this.updateRuntimeButtons();
this.statusEl = this.contentEl.createDiv({ cls: "claude-cli-status" });
startBtn.addEventListener("click", () => this.startClaudeProcess());
@ -97,8 +108,11 @@ class ClaudeCliView extends ItemView {
restartBtn.addEventListener("click", () => this.restartClaudeProcess());
clearBtn.addEventListener("click", () => this.terminal?.clear());
mentionBtn.addEventListener("click", () => this.insertActiveFileMention());
claudeBtn.addEventListener("click", () => this.setRuntime("claude"));
codexBtn.addEventListener("click", () => this.setRuntime("codex"));
this.runtimeSelect.addEventListener("change", () => {
if (this.runtimeSelect) {
this.setRuntime(this.runtimeSelect.value);
}
});
this.terminalHostEl = this.contentEl.createDiv({ cls: "claude-cli-terminal" });
@ -143,6 +157,30 @@ class ClaudeCliView extends ItemView {
}
}
refreshRuntimeSelect(): void {
if (!this.runtimeSelect) {
return;
}
this.runtimeSelect.empty();
const runtimes = this.plugin.settings.runtimes;
if (runtimes.length === 0) {
const placeholder = this.runtimeSelect.createEl("option", { text: "(no runtime configured)" });
placeholder.value = "";
this.runtimeSelect.value = "";
this.runtimeSelect.disabled = true;
return;
}
this.runtimeSelect.disabled = false;
for (const runtime of runtimes) {
const opt = this.runtimeSelect.createEl("option", { text: runtime.name || "(unnamed)" });
opt.value = runtime.id;
}
const validSelection = runtimes.some((r) => r.id === this.plugin.settings.selectedRuntimeId);
this.runtimeSelect.value = validSelection
? this.plugin.settings.selectedRuntimeId
: runtimes[0].id;
}
async onClose(): Promise<void> {
this.stopClaudeProcess();
this.resizeObserver?.disconnect();
@ -153,28 +191,46 @@ class ClaudeCliView extends ItemView {
this.statusEl = null;
}
async startClaudeProcess(runtimeOverride?: CliRuntime): Promise<void> {
async startClaudeProcess(runtimeIdOverride?: string): Promise<void> {
if (!this.terminal) {
return;
}
const targetRuntime = runtimeOverride ?? this.plugin.settings.runtime;
const targetLabel = this.getRuntimeLabel(targetRuntime);
const targetRuntime = runtimeIdOverride
? this.plugin.settings.runtimes.find((r) => r.id === runtimeIdOverride)
: this.getSelectedRuntime();
if (!targetRuntime) {
const message = "No runtime configured. Add one in plugin settings.";
this.writeSystemLine(`[${message}]`);
this.setStatus(message);
new Notice(message, 6000);
return;
}
const targetLabel = targetRuntime.name || "(unnamed runtime)";
if (this.processHandle) {
if (this.runningRuntime === targetRuntime) {
if (this.runningRuntimeId === targetRuntime.id) {
this.writeSystemLine(`[${targetLabel} process is already running]`);
this.setStatus("Already running");
} else {
this.pendingStartRuntime = targetRuntime;
this.pendingStartRuntimeId = targetRuntime.id;
this.writeSystemLine(`[Switch requested: ${targetLabel}. Stopping current process first...]`);
this.stopClaudeProcess(true);
}
return;
}
const runtimeLabel = this.getRuntimeLabel(targetRuntime);
const command = this.getRuntimeCommand(targetRuntime);
if (targetRuntime === "codex") {
const command = (targetRuntime.command || "").trim();
if (!command) {
const message = `Runtime "${targetLabel}" has an empty command. Set one in plugin settings.`;
this.writeSystemLine(`[${message}]`);
this.setStatus(message);
new Notice(message, 6000);
return;
}
const codexLike = isCodexLikeCommand(command);
if (codexLike) {
this.resetTerminalDisplay();
}
@ -184,7 +240,7 @@ class ClaudeCliView extends ItemView {
try {
const vaultPath = getVaultBasePath(this.app);
if (!vaultPath) {
const message = `Unable to resolve current vault path. ${runtimeLabel} was not started.`;
const message = `Unable to resolve current vault path. ${targetLabel} was not started.`;
this.writeSystemLine(`[${message}]`);
this.setStatus(message);
new Notice(message, 6000);
@ -200,7 +256,7 @@ class ClaudeCliView extends ItemView {
}
const shellEnv = getShellEnv();
if (targetRuntime === "codex") {
if (codexLike) {
// Keep Codex output readable in embedded terminals.
shellEnv.NO_COLOR = "1";
shellEnv.CLICOLOR = "0";
@ -218,14 +274,14 @@ class ClaudeCliView extends ItemView {
vaultPath
});
this.processHandle = makeProxyAdapter(helperHandle);
this.runningRuntime = targetRuntime;
this.runningRuntimeId = targetRuntime.id;
} catch (error) {
const message = `Failed to start process: ${(error as Error).message}`;
this.writeSystemLine(`[${message}]`);
this.setStatus(message);
new Notice(message, 7000);
this.processHandle = null;
this.runningRuntime = null;
this.runningRuntimeId = null;
return;
}
this.setStatus("Running");
@ -239,11 +295,11 @@ class ClaudeCliView extends ItemView {
this.writeSystemLine(`[${message}]`);
this.setStatus(message);
this.processHandle = null;
this.runningRuntime = null;
const nextRuntime = this.pendingStartRuntime;
this.pendingStartRuntime = null;
if (nextRuntime) {
void this.startClaudeProcess(nextRuntime);
this.runningRuntimeId = null;
const nextRuntimeId = this.pendingStartRuntimeId;
this.pendingStartRuntimeId = null;
if (nextRuntimeId) {
void this.startClaudeProcess(nextRuntimeId);
}
});
@ -255,7 +311,7 @@ class ClaudeCliView extends ItemView {
return;
}
if (!preservePendingStart) {
this.pendingStartRuntime = null;
this.pendingStartRuntimeId = null;
}
this.writeSystemLine(`[Stopping ${this.getRunningRuntimeLabel()} process...]`);
@ -274,32 +330,42 @@ class ClaudeCliView extends ItemView {
this.statusEl?.setText(`Status: ${message}`);
}
private getRuntimeLabel(runtime: CliRuntime = this.plugin.settings.runtime): string {
return runtime === "codex" ? "Codex" : "Claude";
private getSelectedRuntime(): CliRuntimeConfig | null {
const { runtimes, selectedRuntimeId } = this.plugin.settings;
return (
runtimes.find((r) => r.id === selectedRuntimeId) ??
runtimes[0] ??
null
);
}
private getRuntimeLabel(runtimeId?: string): string {
if (runtimeId) {
const match = this.plugin.settings.runtimes.find((r) => r.id === runtimeId);
return match?.name || "(unnamed runtime)";
}
return this.getSelectedRuntime()?.name || "(no runtime)";
}
private getRunningRuntimeLabel(): string {
if (!this.runningRuntime) {
if (!this.runningRuntimeId) {
return this.getRuntimeLabel();
}
return this.getRuntimeLabel(this.runningRuntime);
}
private getRuntimeCommand(runtime: CliRuntime = this.plugin.settings.runtime): string {
if (runtime === "codex") {
return this.plugin.settings.codexCommand.trim() || DEFAULT_SETTINGS.codexCommand;
}
return this.plugin.settings.command.trim();
return this.getRuntimeLabel(this.runningRuntimeId);
}
private restartClaudeProcess(): void {
const targetRuntime = this.plugin.settings.runtime;
if (!this.processHandle) {
void this.startClaudeProcess(targetRuntime);
const target = this.getSelectedRuntime();
if (!target) {
void this.startClaudeProcess();
return;
}
this.pendingStartRuntime = targetRuntime;
this.writeSystemLine(`[Restart requested: ${this.getRuntimeLabel(targetRuntime)}]`);
if (!this.processHandle) {
void this.startClaudeProcess(target.id);
return;
}
this.pendingStartRuntimeId = target.id;
this.writeSystemLine(`[Restart requested: ${target.name}]`);
this.stopClaudeProcess(true);
}
@ -312,14 +378,22 @@ class ClaudeCliView extends ItemView {
this.fitAddon?.fit();
}
private setRuntime(runtime: CliRuntime): void {
if (this.plugin.settings.runtime === runtime) {
private setRuntime(runtimeId: string): void {
if (!runtimeId) {
return;
}
const exists = this.plugin.settings.runtimes.some((r) => r.id === runtimeId);
if (!exists) {
this.refreshRuntimeSelect();
return;
}
if (this.plugin.settings.selectedRuntimeId === runtimeId) {
return;
}
this.plugin.settings.runtime = runtime;
this.plugin.settings.selectedRuntimeId = runtimeId;
void this.plugin.saveSettings();
this.updateRuntimeButtons();
this.refreshRuntimeSelect();
const selectedLabel = this.getRuntimeLabel();
this.writeSystemLine(`[Runtime selected: ${selectedLabel}]`);
@ -335,15 +409,6 @@ class ClaudeCliView extends ItemView {
this.setStatus(`${selectedLabel} selected`);
}
private updateRuntimeButtons(): void {
if (!this.runtimeButtons) {
return;
}
this.runtimeButtons.claude.toggleClass("is-active", this.plugin.settings.runtime === "claude");
this.runtimeButtons.codex.toggleClass("is-active", this.plugin.settings.runtime === "codex");
}
private insertActiveFileMention(): void {
const activeFile = this.app.workspace.getActiveFile();
if (!activeFile) {
@ -427,12 +492,36 @@ export default class ClaudeCliPlugin extends Plugin {
}
async loadSettings(): Promise<void> {
this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData());
const raw = ((await this.loadData()) ?? {}) as Record<string, unknown>;
const { runtimes, selectedRuntimeId } = migrateRuntimeSettings(raw, cloneDefaultRuntimes());
this.settings = {
runtimes,
selectedRuntimeId,
autoRestartOnRuntimeSwitch:
typeof raw.autoRestartOnRuntimeSwitch === "boolean"
? raw.autoRestartOnRuntimeSwitch
: DEFAULT_SETTINGS.autoRestartOnRuntimeSwitch,
autoStart:
typeof raw.autoStart === "boolean" ? raw.autoStart : DEFAULT_SETTINGS.autoStart,
nodeExecutable:
typeof raw.nodeExecutable === "string" && raw.nodeExecutable.trim()
? raw.nodeExecutable
: DEFAULT_SETTINGS.nodeExecutable
};
}
async saveSettings(): Promise<void> {
await this.saveData(this.settings);
}
notifyRuntimesChanged(): void {
this.app.workspace.getLeavesOfType(VIEW_TYPE_CLAUDE).forEach((leaf) => {
const view = leaf.view;
if (view instanceof ClaudeCliView) {
view.refreshRuntimeSelect();
}
});
}
}
class ClaudeCliSettingTab extends PluginSettingTab {
@ -458,16 +547,29 @@ class ClaudeCliSettingTab extends PluginSettingTab {
new Setting(containerEl)
.setName("Default runtime")
.setDesc("Runtime selected by default when opening the panel (and used by auto-start).")
.addDropdown((dropdown) =>
dropdown
.addOption("claude", "Claude")
.addOption("codex", "Codex")
.setValue(this.plugin.settings.runtime)
.onChange(async (value) => {
this.plugin.settings.runtime = (value === "codex" ? "codex" : "claude") as CliRuntime;
await this.plugin.saveSettings();
})
);
.addDropdown((dropdown) => {
const runtimes = this.plugin.settings.runtimes;
if (runtimes.length === 0) {
dropdown.addOption("", "(no runtime configured)");
dropdown.setDisabled(true);
} else {
for (const runtime of runtimes) {
dropdown.addOption(runtime.id, runtime.name || "(unnamed)");
}
const validSelection = runtimes.some((r) => r.id === this.plugin.settings.selectedRuntimeId);
dropdown.setValue(
validSelection ? this.plugin.settings.selectedRuntimeId : runtimes[0].id
);
}
dropdown.onChange(async (value) => {
if (!value) {
return;
}
this.plugin.settings.selectedRuntimeId = value;
await this.plugin.saveSettings();
this.plugin.notifyRuntimesChanged();
});
});
new Setting(containerEl)
.setName("Auto-start")
@ -483,7 +585,7 @@ class ClaudeCliSettingTab extends PluginSettingTab {
new Setting(containerEl)
.setName("Auto-restart on runtime switch")
.setDesc("Automatically restart the running process when switching Claude/Codex from the toolbar.")
.setDesc("Automatically restart the running process when changing the runtime from the sidebar dropdown.")
.addToggle((toggle) =>
toggle
.setValue(this.plugin.settings.autoRestartOnRuntimeSwitch)
@ -493,33 +595,80 @@ class ClaudeCliSettingTab extends PluginSettingTab {
})
);
containerEl.createEl("h3", { text: "Commands" });
containerEl.createEl("h3", { text: "Runtimes" });
containerEl.createEl("p", {
text: "Configure the CLIs that show up in the sidebar dropdown. Each entry needs a display name and a launch command. Add as many as you want.",
cls: "setting-item-description"
});
new Setting(containerEl)
.setName("Claude command")
.setDesc("Command used to launch Claude in the embedded terminal.")
.addText((text) =>
text
.setPlaceholder("claude")
.setValue(this.plugin.settings.command)
.onChange(async (value) => {
this.plugin.settings.command = value.trim() || "claude";
await this.plugin.saveSettings();
})
);
const runtimesListEl = containerEl.createDiv({ cls: "claude-cli-runtimes-list" });
new Setting(containerEl)
.setName("Codex command")
.setDesc("Command used to launch Codex in the embedded terminal.")
.addText((text) =>
text
.setPlaceholder(DEFAULT_SETTINGS.codexCommand)
.setValue(this.plugin.settings.codexCommand)
.onChange(async (value) => {
this.plugin.settings.codexCommand = value.trim() || DEFAULT_SETTINGS.codexCommand;
await this.plugin.saveSettings();
})
);
if (this.plugin.settings.runtimes.length === 0) {
runtimesListEl.createEl("p", {
text: "No runtimes configured yet. Click 'Add runtime' to create one.",
cls: "setting-item-description"
});
}
this.plugin.settings.runtimes.forEach((runtime, index) => {
const setting = new Setting(runtimesListEl).setClass("claude-cli-runtime-item");
setting.infoEl.detach();
setting
.addText((text) =>
text
.setPlaceholder("Name")
.setValue(runtime.name)
.onChange(async (value) => {
this.plugin.settings.runtimes[index].name = value;
await this.plugin.saveSettings();
this.plugin.notifyRuntimesChanged();
})
)
.addText((text) => {
text
.setPlaceholder("Command (e.g. claude, codex --no-alt-screen ...)")
.setValue(runtime.command)
.onChange(async (value) => {
this.plugin.settings.runtimes[index].command = value;
await this.plugin.saveSettings();
});
text.inputEl.addClass("claude-cli-runtime-command-input");
})
.addExtraButton((btn) =>
btn
.setIcon("trash-2")
.setTooltip("Remove runtime")
.onClick(async () => {
if (this.plugin.settings.runtimes.length <= 1) {
new Notice("Keep at least one runtime configured.", 4000);
return;
}
const removed = this.plugin.settings.runtimes.splice(index, 1)[0];
if (this.plugin.settings.selectedRuntimeId === removed.id) {
this.plugin.settings.selectedRuntimeId = this.plugin.settings.runtimes[0].id;
}
await this.plugin.saveSettings();
this.plugin.notifyRuntimesChanged();
this.display();
})
);
});
new Setting(containerEl).addButton((btn) =>
btn
.setButtonText("Add runtime")
.setIcon("plus")
.onClick(async () => {
this.plugin.settings.runtimes.push({
id: defaultGenerateRuntimeId(),
name: "New runtime",
command: ""
});
await this.plugin.saveSettings();
this.plugin.notifyRuntimesChanged();
this.display();
})
);
containerEl.createEl("h3", { text: "Advanced" });

View file

@ -1,10 +1,10 @@
{
"id": "obsidian-any-ai-code",
"name": "Any AI Code",
"name": "Any AI CLI",
"version": "0.1.26",
"minAppVersion": "1.5.0",
"description": "Open Claude Code CLI in a right sidebar view.",
"author": "",
"authorUrl": "",
"description": "Open AI CLI in a right sidebar view.",
"author": "Benoit Lamouche",
"authorUrl": "https://lamouche.fr",
"isDesktopOnly": true
}

View file

@ -7,10 +7,111 @@ export interface PathApi {
join(...parts: string[]): string;
}
export interface CliRuntimeConfig {
id: string;
name: string;
command: string;
}
export interface LegacyRuntimeSettings {
command?: unknown;
codexCommand?: unknown;
runtime?: unknown;
runtimes?: unknown;
selectedRuntimeId?: unknown;
}
export function formatActiveFileMention(fileName: string): string {
return `@${fileName.trim()} `;
}
export function isCodexLikeCommand(command: string | undefined | null): boolean {
if (typeof command !== "string") {
return false;
}
const trimmed = command.trim();
if (!trimmed) {
return false;
}
return trimmed === "codex" || /^codex(\s|$)/.test(trimmed);
}
export function migrateRuntimeSettings(
raw: LegacyRuntimeSettings | null | undefined,
defaults: CliRuntimeConfig[],
generateId: () => string = defaultGenerateRuntimeId
): { runtimes: CliRuntimeConfig[]; selectedRuntimeId: string } {
const fallbackDefaults = defaults.length > 0
? defaults.map((d) => ({ ...d }))
: [{ id: generateId(), name: "Default", command: "" }];
if (raw && Array.isArray(raw.runtimes)) {
const sanitized = sanitizeRuntimes(raw.runtimes, generateId);
if (sanitized.length > 0) {
const selected =
typeof raw.selectedRuntimeId === "string" &&
sanitized.some((r) => r.id === raw.selectedRuntimeId)
? raw.selectedRuntimeId
: sanitized[0].id;
return { runtimes: sanitized, selectedRuntimeId: selected };
}
}
const runtimes = fallbackDefaults;
if (raw && typeof raw.command === "string" && raw.command.trim()) {
const claude = runtimes.find((r) => r.id === "claude");
if (claude) {
claude.command = raw.command.trim();
}
}
if (raw && typeof raw.codexCommand === "string" && raw.codexCommand.trim()) {
const codex = runtimes.find((r) => r.id === "codex");
if (codex) {
codex.command = raw.codexCommand.trim();
}
}
const legacyRuntime =
typeof raw?.runtime === "string" && (raw.runtime === "claude" || raw.runtime === "codex")
? raw.runtime
: undefined;
const selectedRuntimeId =
legacyRuntime && runtimes.some((r) => r.id === legacyRuntime)
? legacyRuntime
: runtimes[0].id;
return { runtimes, selectedRuntimeId };
}
function sanitizeRuntimes(
raw: unknown[],
generateId: () => string
): CliRuntimeConfig[] {
const result: CliRuntimeConfig[] = [];
const seenIds = new Set<string>();
for (const entry of raw) {
if (!entry || typeof entry !== "object") continue;
const candidate = entry as Partial<CliRuntimeConfig>;
const name = typeof candidate.name === "string" ? candidate.name : "";
const command = typeof candidate.command === "string" ? candidate.command : "";
let id = typeof candidate.id === "string" && candidate.id.trim() ? candidate.id : generateId();
while (seenIds.has(id)) {
id = generateId();
}
seenIds.add(id);
result.push({ id, name, command });
}
return result;
}
export function defaultGenerateRuntimeId(): string {
const cryptoApi = (globalThis as { crypto?: { randomUUID?: () => string } }).crypto;
if (cryptoApi && typeof cryptoApi.randomUUID === "function") {
return cryptoApi.randomUUID();
}
return `runtime-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
}
export function resolvePluginDir(
pluginDir: string | undefined,
vaultBasePath: string | undefined,

View file

@ -44,21 +44,67 @@
stroke-width: 2.1;
}
.claude-cli-runtime-toggle {
.claude-cli-runtime-picker {
display: inline-flex;
align-items: center;
gap: 6px;
border: 1px solid var(--background-modifier-border);
background: var(--interactive-normal);
border-radius: 6px;
padding: 2px 6px 2px 8px;
}
.claude-cli-runtime-picker-icon {
display: inline-flex;
align-items: center;
justify-content: center;
color: var(--text-muted);
}
.claude-cli-runtime-picker-icon svg {
width: 14px;
height: 14px;
stroke-width: 2.1;
}
.claude-cli-runtime-select {
border: 0;
background: transparent;
color: var(--text-normal);
font: inherit;
padding: 4px 4px 4px 0;
cursor: pointer;
outline: none;
}
.claude-cli-runtime-select:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.claude-cli-runtimes-list {
display: flex;
flex-direction: column;
gap: 6px;
margin-bottom: 12px;
}
.claude-cli-runtime-item {
border: 1px solid var(--background-modifier-border);
border-radius: 6px;
overflow: hidden;
padding: 6px 8px;
background: var(--background-secondary);
}
.claude-cli-runtime-toggle button {
border: 0;
border-radius: 0;
.claude-cli-runtime-item .setting-item-control {
flex-wrap: wrap;
gap: 6px;
width: 100%;
}
.claude-cli-runtime-toggle button.is-active {
background: var(--interactive-accent);
color: var(--text-on-accent);
.claude-cli-runtime-command-input {
flex: 1 1 240px;
min-width: 200px;
}
.claude-cli-status {

View file

@ -3,9 +3,12 @@ import {
detectNodeExecutable,
formatActiveFileMention,
getLaunchSpecs,
isCodexLikeCommand,
mergePathEntries,
migrateRuntimeSettings,
resolveExecutableInPath,
resolvePluginDir
resolvePluginDir,
type CliRuntimeConfig
} from "../runtime-utils";
describe("resolvePluginDir", () => {
@ -128,3 +131,103 @@ describe("formatActiveFileMention", () => {
expect(formatActiveFileMention(" monfichier.md ")).toBe("@monfichier.md ");
});
});
describe("isCodexLikeCommand", () => {
it("matches bare codex", () => {
expect(isCodexLikeCommand("codex")).toBe(true);
});
it("matches codex with arguments", () => {
expect(isCodexLikeCommand("codex --no-alt-screen")).toBe(true);
});
it("trims surrounding whitespace before matching", () => {
expect(isCodexLikeCommand(" codex ")).toBe(true);
});
it("does not match commands that merely contain codex", () => {
expect(isCodexLikeCommand("my-codex-wrapper")).toBe(false);
expect(isCodexLikeCommand("/usr/local/bin/codex")).toBe(false);
});
it("returns false for non-codex commands and empty values", () => {
expect(isCodexLikeCommand("claude")).toBe(false);
expect(isCodexLikeCommand("")).toBe(false);
expect(isCodexLikeCommand(undefined)).toBe(false);
expect(isCodexLikeCommand(null)).toBe(false);
});
});
describe("migrateRuntimeSettings", () => {
const defaults: CliRuntimeConfig[] = [
{ id: "claude", name: "Claude", command: "claude" },
{ id: "codex", name: "Codex", command: "codex --no-alt-screen" }
];
const stableId = () => "generated-id";
it("returns defaults when no data is stored", () => {
const result = migrateRuntimeSettings(null, defaults, stableId);
expect(result.runtimes).toEqual(defaults);
expect(result.selectedRuntimeId).toBe("claude");
});
it("migrates legacy claude/codex command fields and preserves selected runtime", () => {
const result = migrateRuntimeSettings(
{
command: " claude --print ",
codexCommand: "codex --foo",
runtime: "codex"
},
defaults,
stableId
);
expect(result.runtimes).toEqual([
{ id: "claude", name: "Claude", command: "claude --print" },
{ id: "codex", name: "Codex", command: "codex --foo" }
]);
expect(result.selectedRuntimeId).toBe("codex");
});
it("ignores invalid legacy runtime values and falls back to first runtime", () => {
const result = migrateRuntimeSettings(
{ runtime: "openai" },
defaults,
stableId
);
expect(result.selectedRuntimeId).toBe("claude");
});
it("keeps already migrated data intact and clamps invalid selectedRuntimeId", () => {
const stored = {
runtimes: [
{ id: "abc", name: "A", command: "a" },
{ id: "def", name: "D", command: "d" }
],
selectedRuntimeId: "ghost"
};
const result = migrateRuntimeSettings(stored, defaults, stableId);
expect(result.runtimes).toEqual(stored.runtimes);
expect(result.selectedRuntimeId).toBe("abc");
});
it("regenerates missing or duplicated ids when sanitizing migrated data", () => {
let counter = 0;
const generateId = () => `gen-${++counter}`;
const result = migrateRuntimeSettings(
{
runtimes: [
{ id: "shared", name: "First", command: "first" },
{ id: "shared", name: "Second", command: "second" },
{ name: "Third", command: "third" }
],
selectedRuntimeId: "shared"
},
defaults,
generateId
);
const ids = result.runtimes.map((r) => r.id);
expect(new Set(ids).size).toBe(3);
expect(result.runtimes[0].id).toBe("shared");
expect(result.selectedRuntimeId).toBe("shared");
});
});