feat: support bare command names for cross-platform vault sync

Use bare command names (e.g., `claude-agent-acp`) as defaults instead of
empty strings, allowing the login shell to resolve them via PATH. This
removes the need for absolute paths in settings, which is especially
useful when syncing vaults across machines with different environments.

- Change DEFAULT_SETTINGS to use bare command names
- Add resolveCommandPath() utility (async, using execFile)
- Add Auto-detect button for Node.js path setting
- Guard WSL nodeDir injection to absolute paths only
- Align nodePath empty-string fallback with command fields
- Remove unused resolveCommandPath import from acp.adapter
This commit is contained in:
Tykis 2026-03-17 10:15:17 +09:00
parent 4fd43a341e
commit 236843c876
6 changed files with 173 additions and 51 deletions

View file

@ -209,19 +209,27 @@ export class AcpAdapter implements IAgentClient, IAcpClient {
baseEnv = getEnhancedWindowsEnv(baseEnv);
}
// Add Node.js path to PATH if specified in settings
if (
this.plugin.settings.nodePath &&
this.plugin.settings.nodePath.trim().length > 0
) {
const nodeDir = resolveCommandDirectory(
this.plugin.settings.nodePath.trim(),
);
// Add Node.js directory to PATH only when nodePath is an explicit absolute path.
// On macOS/Linux the login-shell wrapper (-l) already loads the user's PATH
// (nvm, mise, volta shims, etc.), so injection is only needed when the user
// has a non-standard node location they've pinpointed manually.
// When nodePath is empty or just a command name ("node"), we skip injection
// and let the login shell handle it.
const explicitNodePath = this.plugin.settings.nodePath?.trim() ?? "";
const isAbsoluteNodePath =
explicitNodePath.startsWith("/") ||
/^[A-Za-z]:[\\/]/.test(explicitNodePath);
if (isAbsoluteNodePath) {
const nodeDir = resolveCommandDirectory(explicitNodePath);
if (nodeDir) {
const separator = Platform.isWin ? ";" : ":";
baseEnv.PATH = baseEnv.PATH
? `${nodeDir}${separator}${baseEnv.PATH}`
: nodeDir;
this.logger.log(
"[AcpAdapter] Node.js directory added to PATH:",
nodeDir,
);
}
}
@ -236,11 +244,9 @@ export class AcpAdapter implements IAgentClient, IAcpClient {
// WSL mode for Windows (wrap command to run inside WSL)
if (Platform.isWin && this.plugin.settings.windowsWslMode) {
// Extract node directory from settings for PATH
const nodeDir = this.plugin.settings.nodePath
? resolveCommandDirectory(
this.plugin.settings.nodePath.trim(),
) || undefined
// Only inject node directory when nodePath is an absolute path
const nodeDir = isAbsoluteNodePath
? resolveCommandDirectory(explicitNodePath) || undefined
: undefined;
const wslWrapped = wrapCommandForWsl(
@ -261,29 +267,21 @@ export class AcpAdapter implements IAgentClient, IAcpClient {
);
}
// On macOS and Linux, wrap the command in a login shell to inherit the user's environment
// This ensures that PATH modifications in .zshrc/.bash_profile are available
// This ensures that PATH modifications in .zshrc/.bash_profile are available.
// Relative command names (e.g. "claude-agent-acp", "node") work as-is because
// the login shell loads the full user PATH including nvm/mise/volta shims.
else if (Platform.isMacOS || Platform.isLinux) {
const shell = getLoginShell();
const commandString = [command, ...args]
.map((arg) => "'" + arg.replace(/'/g, "'\\''") + "'")
.join(" ");
// If nodePath is configured, prepend PATH export to ensure node is available.
// This is necessary because:
// 1. Login shells (-l) re-initialize PATH from shell config files, overwriting env.PATH
// 2. Even when the agent command uses an absolute path, scripts with shebang
// "#!/usr/bin/env node" require node to be in PATH for the env command to find it
// Therefore, we must explicitly set PATH inside the shell command
// Only prepend PATH export when the user has configured an absolute nodePath.
// Relative names or empty → login shell already handles PATH correctly.
let fullCommand = commandString;
if (
this.plugin.settings.nodePath &&
this.plugin.settings.nodePath.trim().length > 0
) {
const nodeDir = resolveCommandDirectory(
this.plugin.settings.nodePath.trim(),
);
if (isAbsoluteNodePath) {
const nodeDir = resolveCommandDirectory(explicitNodePath);
if (nodeDir) {
// Escape single quotes in nodeDir for shell safety
const escapedNodeDir = nodeDir.replace(/'/g, "'\\''");
fullCommand = `export PATH='${escapedNodeDir}':"$PATH"; ${commandString}`;
}

View file

@ -12,6 +12,7 @@ import type {
ChatViewLocation,
} from "../../plugin";
import { normalizeEnvVars } from "../../shared/settings-utils";
import { resolveCommandPath } from "../../shared/path-utils";
import {
CHAT_FONT_SIZE_MAX,
CHAT_FONT_SIZE_MIN,
@ -66,19 +67,27 @@ export class AgentClientSettingTab extends PluginSettingTab {
// Also update immediately on display to sync with current settings
this.updateAgentDropdown();
new Setting(containerEl)
const nodePathSetting = new Setting(containerEl)
.setName("Node.js path")
.setDesc(
'Absolute path to Node.js executable. On macOS/Linux, use "which node", and on Windows, use "where node" to find it.',
"Path to Node.js. Usually leave blank — the login shell automatically picks up nvm/mise/volta. Only needed if node is in a non-standard location (enter absolute path, e.g. /usr/local/bin/node).",
)
.addText((text) => {
text.setPlaceholder("Absolute path to node")
text.setPlaceholder("Leave blank (login shell auto-resolves)")
.setValue(this.plugin.settings.nodePath)
.onChange(async (value) => {
this.plugin.settings.nodePath = value.trim();
await this.plugin.saveSettings();
});
});
this.addAutoDetectButton(
nodePathSetting,
"node",
async (path) => {
this.plugin.settings.nodePath = path;
await this.plugin.saveSettings();
},
);
new Setting(containerEl)
.setName("Send message shortcut")
@ -799,19 +808,27 @@ export class AgentClientSettingTab extends PluginSettingTab {
text.inputEl.type = "password";
});
new Setting(sectionEl)
const geminiPathSetting = new Setting(sectionEl)
.setName("Path")
.setDesc(
'Absolute path to the Gemini CLI. On macOS/Linux, use "which gemini", and on Windows, use "where gemini" to find it.',
'Command name or path to the Gemini CLI. Use just "gemini" to let the login shell resolve it, or enter an absolute path for a specific version.',
)
.addText((text) => {
text.setPlaceholder("Absolute path to gemini")
text.setPlaceholder("gemini")
.setValue(gemini.command)
.onChange(async (value) => {
this.plugin.settings.gemini.command = value.trim();
await this.plugin.saveSettings();
});
});
this.addAutoDetectButton(
geminiPathSetting,
"gemini",
async (path) => {
this.plugin.settings.gemini.command = path;
await this.plugin.saveSettings();
},
);
new Setting(sectionEl)
.setName("Arguments")
@ -867,19 +884,27 @@ export class AgentClientSettingTab extends PluginSettingTab {
text.inputEl.type = "password";
});
new Setting(sectionEl)
const claudePathSetting = new Setting(sectionEl)
.setName("Path")
.setDesc(
'Absolute path to the claude-agent-acp. On macOS/Linux, use "which claude-agent-acp", and on Windows, use "where claude-agent-acp" to find it.',
'Command name or path to claude-agent-acp. Use just "claude-agent-acp" to let the login shell resolve it, or enter an absolute path.',
)
.addText((text) => {
text.setPlaceholder("Absolute path to claude-agent-acp")
text.setPlaceholder("claude-agent-acp")
.setValue(claude.command)
.onChange(async (value) => {
this.plugin.settings.claude.command = value.trim();
await this.plugin.saveSettings();
});
});
this.addAutoDetectButton(
claudePathSetting,
"claude-agent-acp",
async (path) => {
this.plugin.settings.claude.command = path;
await this.plugin.saveSettings();
},
);
new Setting(sectionEl)
.setName("Arguments")
@ -935,19 +960,27 @@ export class AgentClientSettingTab extends PluginSettingTab {
text.inputEl.type = "password";
});
new Setting(sectionEl)
const codexPathSetting = new Setting(sectionEl)
.setName("Path")
.setDesc(
'Absolute path to the codex-acp. On macOS/Linux, use "which codex-acp", and on Windows, use "where codex-acp" to find it.',
'Command name or path to codex-acp. Use just "codex-acp" to let the login shell resolve it, or enter an absolute path.',
)
.addText((text) => {
text.setPlaceholder("Absolute path to codex-acp")
text.setPlaceholder("codex-acp")
.setValue(codex.command)
.onChange(async (value) => {
this.plugin.settings.codex.command = value.trim();
await this.plugin.saveSettings();
});
});
this.addAutoDetectButton(
codexPathSetting,
"codex-acp",
async (path) => {
this.plugin.settings.codex.command = path;
await this.plugin.saveSettings();
},
);
new Setting(sectionEl)
.setName("Arguments")
@ -1172,6 +1205,45 @@ export class AgentClientSettingTab extends PluginSettingTab {
return candidate;
}
/**
* Shared helper: adds an "Auto-detect" button to a Path setting.
* Calls `resolveCommandPath(commandName)` and, on success, writes the
* resolved absolute path via `onResolved`, then re-renders the tab.
*/
private addAutoDetectButton(
setting: import("obsidian").Setting,
commandName: string,
onResolved: (path: string) => Promise<void>,
): void {
setting.addButton((btn) => {
btn.setButtonText("Auto-detect")
.setTooltip(`Run \`which ${commandName}\` to find the path`)
.onClick(async () => {
btn.setButtonText("Detecting…");
btn.setDisabled(true);
try {
const found = await resolveCommandPath(commandName);
if (found) {
await onResolved(found);
this.display();
} else {
btn.setButtonText("Not found");
setTimeout(() => {
btn.setButtonText("Auto-detect");
btn.setDisabled(false);
}, 2000);
}
} catch {
btn.setButtonText("Error");
setTimeout(() => {
btn.setButtonText("Auto-detect");
btn.setDisabled(false);
}, 2000);
}
});
});
}
private formatArgs(args: string[]): string {
return args.join("\n");
}

View file

@ -117,7 +117,7 @@ const DEFAULT_SETTINGS: AgentClientPluginSettings = {
id: "claude-code-acp",
displayName: "Claude Code",
apiKey: "",
command: "",
command: "claude-agent-acp",
args: [],
env: [],
},
@ -125,7 +125,7 @@ const DEFAULT_SETTINGS: AgentClientPluginSettings = {
id: "codex-acp",
displayName: "Codex",
apiKey: "",
command: "",
command: "codex-acp",
args: [],
env: [],
},
@ -133,7 +133,7 @@ const DEFAULT_SETTINGS: AgentClientPluginSettings = {
id: "gemini-cli",
displayName: "Gemini CLI",
apiKey: "",
command: "",
command: "gemini",
args: ["--experimental-acp"],
env: [],
},
@ -1006,7 +1006,8 @@ export default class AgentClientPlugin extends Plugin {
? rawSettings.debugMode
: DEFAULT_SETTINGS.debugMode,
nodePath:
typeof rawSettings.nodePath === "string"
typeof rawSettings.nodePath === "string" &&
rawSettings.nodePath.trim().length > 0
? rawSettings.nodePath.trim()
: DEFAULT_SETTINGS.nodePath,
exportSettings: (() => {

View file

@ -1,3 +1,45 @@
import { execFile } from "child_process";
import { Platform } from "obsidian";
/**
* Resolve the absolute path of a command using `which` (macOS/Linux) or `where` (Windows).
* If the command is already an absolute path, returns it as-is.
* Runs asynchronously to avoid blocking the Electron main thread.
*
* @param command - Command name (e.g. "node", "claude") or absolute path
* @returns Absolute path string, or null if not found
*/
export function resolveCommandPath(command: string): Promise<string | null> {
if (!command || command.trim().length === 0) return Promise.resolve(null);
const trimmed = command.trim();
// Already absolute — return as-is
if (trimmed.startsWith("/") || /^[A-Za-z]:[\\/]/.test(trimmed)) {
return Promise.resolve(trimmed);
}
return new Promise((resolve) => {
if (Platform.isWin) {
execFile("where", [trimmed], { timeout: 5000, windowsHide: true }, (err, stdout) => {
if (err) { resolve(null); return; }
const resolved = stdout.split("\n")[0].trim();
resolve(resolved.length > 0 ? resolved : null);
});
} else {
// Use login shell to pick up nvm/mise/volta shims etc.
const shell = process.env.SHELL || "/bin/sh";
// Escape single quotes in the command name to prevent injection
const escaped = trimmed.replace(/'/g, "'\\''");
execFile(shell, ["-l", "-c", `which '${escaped}'`], { timeout: 5000 }, (err, stdout) => {
if (err) { resolve(null); return; }
const resolved = stdout.split("\n")[0].trim();
resolve(resolved.length > 0 ? resolved : null);
});
}
});
}
/**
* Extract the directory containing a command (for PATH adjustments).
* Example: /usr/local/bin/node /usr/local/bin

View file

@ -61,11 +61,13 @@ export class TerminalManager {
// Platform-specific shell wrapping
// Each platform wraps the command once in its appropriate shell
if (Platform.isWin && this.plugin.settings.windowsWslMode) {
// Extract node directory from settings for PATH (if available)
const nodeDir = this.plugin.settings.nodePath
? resolveCommandDirectory(
this.plugin.settings.nodePath.trim(),
) || undefined
// Only inject node directory when nodePath is an absolute path
const explicitNodePath = this.plugin.settings.nodePath?.trim() ?? "";
const isAbsoluteNodePath =
explicitNodePath.startsWith("/") ||
/^[A-Za-z]:[\\/]/.test(explicitNodePath);
const nodeDir = isAbsoluteNodePath
? resolveCommandDirectory(explicitNodePath) || undefined
: undefined;
const wslWrapped = wrapCommandForWsl(

View file

@ -72,8 +72,15 @@ export function wrapCommandForWsl(
pathPrefix = `export PATH="${escapePathForShell(wslPath)}:$PATH"; `;
}
const fullCommand = `${pathPrefix}cd ${escapeShellArg(wslCwd)} && ${command}${argsString}`;
wslArgs.push("bash", "-l", "-c", fullCommand);
// Use the user's actual default shell ($SHELL) instead of hardcoding bash.
// This ensures the correct profile files are loaded (e.g., .zprofile for zsh,
// .bash_profile for bash). We first source ~/.profile via sh as a fallback,
// because bash -l skips ~/.profile when ~/.bash_profile exists, and many tools
// (linuxbrew, nvm, mise) install their PATH setup in ~/.profile.
const innerCommand = `${pathPrefix}cd ${escapeShellArg(wslCwd)} && ${command}${argsString}`;
const innerEscaped = innerCommand.replace(/'/g, "'\\''");
const wrapperCommand = `. ~/.profile 2>/dev/null; exec "\${SHELL:-/bin/bash}" -l -c '${innerEscaped}'`;
wslArgs.push("sh", "-c", wrapperCommand);
return {
command: "C:\\Windows\\System32\\wsl.exe",