Replace skills auto-sync with explicit install/update/uninstall (#797)
* Replace skills auto-sync with explicit install/update/uninstall Skills no longer sync silently on every desktop launch. Settings now shows Install (fresh), Update (with a confirm dialog listing per-skill add/update/delete ops), or Uninstall depending on what's on disk. A manifest at ~/.agents/skills/.paseo-manifest.json tracks Paseo-owned skills so uninstall removes only what we wrote, never user content. Existing users migrate transparently — first read synthesises a manifest from on-disk hashes, so an unchanged install flips silently to up-to-date. Also restructures the integrations layer: skills/ and cli-install/ each own their files behind a designed index.ts. The shared integrations-manager.ts is gone. * Rewrite skills install flow with disk-truth model Drops the manifest sidecar, migration logic, and the agentsDirHasSkillContent heuristic that misread user-authored skills as Paseo content. Replaces them with a hardcoded PASEO_SKILL_NAMES list (current bundle + paseo-chat for retired-skill cleanup); the disk is the source of truth for what's installed. Renames the SkillsState "fresh" → "not-installed" everywhere (server, IPC parser, hook test mocks). Install and update share an applySkills body. Errors propagate instead of being swallowed. Fixes the case where Uninstall left state at "drift" because the user had personal skills under ~/.agents/skills/. * Stop symlinking claude skills, write real files If the user has ~/.claude/skills symlinked to ~/.agents/skills, the per-skill symlink path resolved through the parent symlink and fs.rm'd the agents-side files we'd just written. Mirror real files to claude the same way as codex; agents stays the canonical source-of-truth dir. * Make Uninstall button visible (outline, not ghost)
This commit is contained in:
@@ -0,0 +1 @@
|
||||
export { getCliInstallStatus, installCli } from "./install.js";
|
||||
@@ -0,0 +1,71 @@
|
||||
import { promises as fs } from "node:fs";
|
||||
import { app } from "electron";
|
||||
import log from "electron-log/main";
|
||||
import { resolveCliInstallSourcePath } from "./path.js";
|
||||
import { getBundledCliShimPath, getCliTargetPath, getLocalBinDir } from "./paths.js";
|
||||
import { ensurePathInShellRc } from "./shell-rc.js";
|
||||
|
||||
interface InstallStatus {
|
||||
installed: boolean;
|
||||
}
|
||||
|
||||
async function pathOrSymlinkExists(p: string): Promise<boolean> {
|
||||
try {
|
||||
await fs.lstat(p);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function installCli(): Promise<InstallStatus> {
|
||||
const targetPath = getCliTargetPath();
|
||||
const shimPath = getBundledCliShimPath();
|
||||
const installSourcePath = resolveCliInstallSourcePath({
|
||||
platform: process.platform,
|
||||
isPackaged: app.isPackaged,
|
||||
executablePath: app.getPath("exe"),
|
||||
shimPath,
|
||||
appImagePath: process.env.APPIMAGE,
|
||||
});
|
||||
const binDir = getLocalBinDir();
|
||||
|
||||
await fs.mkdir(binDir, { recursive: true });
|
||||
|
||||
if (process.platform === "win32") {
|
||||
if (await pathOrSymlinkExists(targetPath)) {
|
||||
await fs.unlink(targetPath);
|
||||
}
|
||||
// Generate a thin .cmd trampoline that delegates to the bundled shim.
|
||||
// Only the app install path is baked in — internal details (asar layout,
|
||||
// entrypoint scripts) live in the bundled shim and update with the app.
|
||||
const cmdContent = [
|
||||
"@echo off",
|
||||
`set "BUNDLED_CLI=${shimPath}"`,
|
||||
`if not exist "%BUNDLED_CLI%" (`,
|
||||
` echo Paseo CLI not found at %BUNDLED_CLI% — is Paseo installed? 1>&2`,
|
||||
` exit /b 1`,
|
||||
`)`,
|
||||
`call "%BUNDLED_CLI%" %*`,
|
||||
`exit /b %errorlevel%`,
|
||||
].join("\r\n");
|
||||
await fs.writeFile(targetPath, cmdContent, "utf-8");
|
||||
} else {
|
||||
if (await pathOrSymlinkExists(targetPath)) {
|
||||
await fs.unlink(targetPath);
|
||||
}
|
||||
await fs.symlink(installSourcePath, targetPath);
|
||||
}
|
||||
|
||||
const { shellUpdated } = await ensurePathInShellRc();
|
||||
if (shellUpdated) {
|
||||
log.info("[integrations] Updated shell rc with ~/.local/bin PATH");
|
||||
}
|
||||
|
||||
return getCliInstallStatus();
|
||||
}
|
||||
|
||||
export async function getCliInstallStatus(): Promise<InstallStatus> {
|
||||
const targetPath = getCliTargetPath();
|
||||
return { installed: await pathOrSymlinkExists(targetPath) };
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveCliInstallSourcePath } from "./path";
|
||||
|
||||
describe("cli-install-path", () => {
|
||||
it("uses the bundled shim for packaged macOS installs", () => {
|
||||
expect(
|
||||
resolveCliInstallSourcePath({
|
||||
platform: "darwin",
|
||||
isPackaged: true,
|
||||
executablePath: "/Applications/Paseo.app/Contents/MacOS/Paseo",
|
||||
shimPath: "/Applications/Paseo.app/Contents/Resources/bin/paseo",
|
||||
}),
|
||||
).toBe("/Applications/Paseo.app/Contents/Resources/bin/paseo");
|
||||
});
|
||||
|
||||
it("prefers the original AppImage path on linux", () => {
|
||||
expect(
|
||||
resolveCliInstallSourcePath({
|
||||
platform: "linux",
|
||||
isPackaged: true,
|
||||
executablePath: "/tmp/.mount_paseo123/paseo",
|
||||
shimPath: "/tmp/.mount_paseo123/resources/bin/paseo",
|
||||
appImagePath: "/home/user/Applications/Paseo.AppImage",
|
||||
}),
|
||||
).toBe("/home/user/Applications/Paseo.AppImage");
|
||||
});
|
||||
|
||||
it("falls back to the shim on windows and in development", () => {
|
||||
expect(
|
||||
resolveCliInstallSourcePath({
|
||||
platform: "win32",
|
||||
isPackaged: true,
|
||||
executablePath: "C:\\Users\\user\\AppData\\Local\\Programs\\Paseo\\Paseo.exe",
|
||||
shimPath: "C:\\Users\\user\\AppData\\Local\\Programs\\Paseo\\resources\\bin\\paseo.cmd",
|
||||
}),
|
||||
).toBe("C:\\Users\\user\\AppData\\Local\\Programs\\Paseo\\resources\\bin\\paseo.cmd");
|
||||
|
||||
expect(
|
||||
resolveCliInstallSourcePath({
|
||||
platform: "linux",
|
||||
isPackaged: false,
|
||||
executablePath: "/opt/Paseo/paseo",
|
||||
shimPath: "/opt/Paseo/resources/bin/paseo",
|
||||
}),
|
||||
).toBe("/opt/Paseo/resources/bin/paseo");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
export function resolveCliInstallSourcePath(input: {
|
||||
platform: NodeJS.Platform;
|
||||
isPackaged: boolean;
|
||||
executablePath: string;
|
||||
shimPath: string;
|
||||
appImagePath?: string | null;
|
||||
}): string {
|
||||
if (input.platform === "win32") {
|
||||
return input.shimPath;
|
||||
}
|
||||
|
||||
if (!input.isPackaged) {
|
||||
return input.shimPath;
|
||||
}
|
||||
|
||||
if (input.platform === "darwin") {
|
||||
return input.shimPath;
|
||||
}
|
||||
|
||||
if (input.platform === "linux") {
|
||||
const appImagePath = input.appImagePath?.trim();
|
||||
if (appImagePath) {
|
||||
return appImagePath;
|
||||
}
|
||||
}
|
||||
|
||||
return input.executablePath;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import path from "node:path";
|
||||
import os from "node:os";
|
||||
import { app } from "electron";
|
||||
|
||||
export function getLocalBinDir(): string {
|
||||
return path.join(os.homedir(), ".local", "bin");
|
||||
}
|
||||
|
||||
export function getCliTargetPath(): string {
|
||||
const filename = process.platform === "win32" ? "paseo.cmd" : "paseo";
|
||||
return path.join(getLocalBinDir(), filename);
|
||||
}
|
||||
|
||||
export function getBundledCliShimPath(): string {
|
||||
const cliShimFilename = process.platform === "win32" ? "paseo.cmd" : "paseo";
|
||||
|
||||
if (process.platform === "darwin") {
|
||||
const electronExePath = app.getPath("exe");
|
||||
const appBundle = electronExePath.replace(/\/Contents\/MacOS\/.+$/, "");
|
||||
return path.join(appBundle, "Contents", "Resources", "bin", cliShimFilename);
|
||||
}
|
||||
|
||||
if (process.platform === "win32") {
|
||||
const electronExePath = app.getPath("exe");
|
||||
return path.join(path.dirname(electronExePath), "resources", "bin", cliShimFilename);
|
||||
}
|
||||
|
||||
// Linux
|
||||
const electronExePath = app.getPath("exe");
|
||||
return path.join(path.dirname(electronExePath), "resources", "bin", cliShimFilename);
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import { promises as fs } from "node:fs";
|
||||
import path from "node:path";
|
||||
import os from "node:os";
|
||||
import log from "electron-log/main";
|
||||
|
||||
export interface ShellRcInfo {
|
||||
shell: string;
|
||||
rcFile: string;
|
||||
pathCheckPattern: RegExp;
|
||||
exportLine: string;
|
||||
}
|
||||
|
||||
async function pathOrSymlinkExists(p: string): Promise<boolean> {
|
||||
try {
|
||||
await fs.lstat(p);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function detectShellRcInfo(): ShellRcInfo | null {
|
||||
if (process.platform === "win32") return null;
|
||||
|
||||
const shell = process.env.SHELL;
|
||||
if (!shell) return null;
|
||||
|
||||
const shellName = path.basename(shell);
|
||||
|
||||
if (shellName === "zsh") {
|
||||
return {
|
||||
shell: "zsh",
|
||||
rcFile: path.join(os.homedir(), ".zshrc"),
|
||||
pathCheckPattern: /\.local\/bin/,
|
||||
exportLine: 'export PATH="$HOME/.local/bin:$PATH"',
|
||||
};
|
||||
}
|
||||
|
||||
if (shellName === "bash") {
|
||||
const rcFile =
|
||||
process.platform === "darwin"
|
||||
? path.join(os.homedir(), ".bash_profile")
|
||||
: path.join(os.homedir(), ".bashrc");
|
||||
return {
|
||||
shell: "bash",
|
||||
rcFile,
|
||||
pathCheckPattern: /\.local\/bin/,
|
||||
exportLine: 'export PATH="$HOME/.local/bin:$PATH"',
|
||||
};
|
||||
}
|
||||
|
||||
if (shellName === "fish") {
|
||||
return {
|
||||
shell: "fish",
|
||||
rcFile: path.join(os.homedir(), ".config", "fish", "config.fish"),
|
||||
pathCheckPattern: /\.local\/bin/,
|
||||
exportLine: "fish_add_path $HOME/.local/bin",
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function pathAlreadyContainsLocalBin(): boolean {
|
||||
const pathEnv = process.env.PATH ?? "";
|
||||
const localBin = path.join(os.homedir(), ".local", "bin");
|
||||
return pathEnv.split(path.delimiter).some((p) => p === localBin || p === "~/.local/bin");
|
||||
}
|
||||
|
||||
export async function ensurePathInShellRc(): Promise<{ shellUpdated: boolean }> {
|
||||
if (pathAlreadyContainsLocalBin()) {
|
||||
return { shellUpdated: false };
|
||||
}
|
||||
|
||||
const info = detectShellRcInfo();
|
||||
if (!info) {
|
||||
return { shellUpdated: false };
|
||||
}
|
||||
|
||||
try {
|
||||
const exists = await pathOrSymlinkExists(info.rcFile);
|
||||
if (exists) {
|
||||
const content = await fs.readFile(info.rcFile, "utf-8");
|
||||
if (info.pathCheckPattern.test(content)) {
|
||||
return { shellUpdated: false };
|
||||
}
|
||||
}
|
||||
|
||||
await fs.mkdir(path.dirname(info.rcFile), { recursive: true });
|
||||
await fs.appendFile(info.rcFile, `\n# Added by Paseo\n${info.exportLine}\n`);
|
||||
|
||||
return { shellUpdated: true };
|
||||
} catch (err) {
|
||||
log.warn("[integrations] Failed to update shell rc file", { rcFile: info.rcFile, err });
|
||||
return { shellUpdated: false };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user