diff --git a/ReadMe.md b/ReadMe.md index 227468e..cdfaf5b 100644 --- a/ReadMe.md +++ b/ReadMe.md @@ -48,20 +48,21 @@ npx skynot [options] The following command‑line flags are available: -| Flag | Alias | Description | -|--------------|--------|-----------------------------------------------------------------------------------| -|`--help` | `-h` | Show the help message with all available options. | -|`--auth` | `-a` | Prompt for AI model's credentials to add env var to script or cook auth.json file.| -|`--extensions`| `-e` | DEPRECATED: Use `spi install ` instead, after install. | -|`--git ["id"]`| `-g[i]`| Set git `user.name`/`user.email` for `aidev`. No arg: copies from current user. | -| | | With arg (e.g. `"Name Surname "`): uses that instead. | -|`--npm` | `-n` | Install Pi using npm instead of tarball (likely to be slower though). | -|`--paranoid` | `-p` | Refrain from caching the sudo password; ask for it every time it is needed. | -|`--ssh` | `-s` | Copy SSH keys to the `aidev` user for git+ssh (& add GitHub to `known_hosts`). | -|`--update` | `-u` | Wipe any previous existing install of Pi and reinstall, to get the latest version.| -|`--verbose` | `-v` | Show more output from install commands (useful for debugging/low-bandwidth). | -|`--version` | `-V` | Output the version number. | -|`--destroy` |`--BURN`| Delete the `aidev` user, all its data (in `$HOME`), and the `aiteam` group. | +| Flag | Alias | Description | +|----------------|--------|-----------------------------------------------------------------------------------| +|`--help` | `-h` | Show the help message with all available options. | +|`--auth` | `-a` | Prompt for AI model's credentials to add env var to script or cook auth.json file.| +|`--extensions` | `-e` | DEPRECATED: Use `spi install ` instead, after install. | +|`--git ["id"]` | `-g[i]`| Set git `user.name`/`user.email` for `aidev`. No arg: copies from current user. | +| | | With arg (e.g. `"Name Surname "`): uses that instead. | +|`--npm` | `-n` | Install Pi using npm instead of tarball (likely to be slower though). | +|`--context-lens`| `-c` | Install context-lens and wrapper script `cpi` for launching pi with context-lens. | +|`--paranoid` | `-p` | Refrain from caching the sudo password; ask for it every time it is needed. | +|`--ssh` | `-s` | Copy SSH keys to the `aidev` user for git+ssh (& add GitHub to `known_hosts`). | +|`--update` | `-u` | Wipe any previous existing install of Pi and reinstall, to get the latest version.| +|`--verbose` | `-v` | Show more output from install commands (useful for debugging/low-bandwidth). | +|`--version` | `-V` | Output the version number. | +|`--destroy` |`--BURN`| Delete the `aidev` user, all its data (in `$HOME`), and the `aiteam` group. | Please note, `-u` would technically not wipe or reinstall extensions, as they normally live in a different place (`.pi` subdir under `aidev` user's $HOME, and/or $NPM_CONFIG_PREFIX dir). @@ -80,3 +81,4 @@ Please note, `-u` would technically not wipe or reinstall extensions, as they no * NPM's `npx` (install with `brew install npm` or `apt install npm`) * git v2.46 or newer (required for wildcard support in `git config --global safe.directory`) * `setfacl` command in Linux (to setup ACLs; in Ubuntu this command is provided by the `acl` APT package) +* mitmproxy for using with context-lens (`-c` or `--context-lens` option) diff --git a/context-lens-patches/0001-When-launching-pi-add-providers-based-on-env.-vars-a.patch b/context-lens-patches/0001-When-launching-pi-add-providers-based-on-env.-vars-a.patch new file mode 100644 index 0000000..20bb5ba --- /dev/null +++ b/context-lens-patches/0001-When-launching-pi-add-providers-based-on-env.-vars-a.patch @@ -0,0 +1,96 @@ +From ec9f52b5a09a18f90aeccde3e5e39284640e9cfc Mon Sep 17 00:00:00 2001 +From: webwarrior-ws +Date: Wed, 6 May 2026 14:48:07 +0200 +Subject: [PATCH] When launching pi, add providers based on env. vars & + auth.json + +Add provider configs to models.json in temp dir used by pi based +on defined env. vars & records in auth.json file. + +Fixes https://github.com/larsderidder/context-lens/issues/55 +--- + src/cli.ts | 61 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ + 1 file changed, 61 insertions(+) + +diff --git a/src/cli.ts b/src/cli.ts +index a761733..c479f07 100644 +--- a/src/cli.ts ++++ b/src/cli.ts +@@ -23,6 +23,37 @@ import { VERSION } from "./version.generated.js"; + const __filename = fileURLToPath(import.meta.url); + const __dirname = dirname(__filename); + ++// copied from https://github.com/badlogic/pi-mono/blob/main/packages/ai/src/env-api-keys.ts ++const piEnvMap: Record = { ++ openai: "OPENAI_API_KEY", ++ "azure-openai-responses": "AZURE_OPENAI_API_KEY", ++ deepseek: "DEEPSEEK_API_KEY", ++ google: "GEMINI_API_KEY", ++ "google-vertex": "GOOGLE_CLOUD_API_KEY", ++ groq: "GROQ_API_KEY", ++ cerebras: "CEREBRAS_API_KEY", ++ xai: "XAI_API_KEY", ++ openrouter: "OPENROUTER_API_KEY", ++ "vercel-ai-gateway": "AI_GATEWAY_API_KEY", ++ zai: "ZAI_API_KEY", ++ mistral: "MISTRAL_API_KEY", ++ minimax: "MINIMAX_API_KEY", ++ "minimax-cn": "MINIMAX_CN_API_KEY", ++ moonshotai: "MOONSHOT_API_KEY", ++ "moonshotai-cn": "MOONSHOT_API_KEY", ++ huggingface: "HF_TOKEN", ++ fireworks: "FIREWORKS_API_KEY", ++ opencode: "OPENCODE_API_KEY", ++ "opencode-go": "OPENCODE_API_KEY", ++ "kimi-coding": "KIMI_API_KEY", ++ "cloudflare-workers-ai": "CLOUDFLARE_API_KEY", ++ "cloudflare-ai-gateway": "CLOUDFLARE_API_KEY", ++ xiaomi: "XIAOMI_API_KEY", ++ "xiaomi-token-plan-cn": "XIAOMI_TOKEN_PLAN_CN_API_KEY", ++ "xiaomi-token-plan-ams": "XIAOMI_TOKEN_PLAN_AMS_API_KEY", ++ "xiaomi-token-plan-sgp": "XIAOMI_TOKEN_PLAN_SGP_API_KEY", ++}; ++ + // Known tool config: env vars for the child process, extra CLI args, server env vars, and whether mitmproxy is needed + // Note: actual tool config lives in cli-utils.ts so it can be unit-tested without importing this entrypoint. + +@@ -842,6 +873,36 @@ if (parsedArgs.commandName === "analyze") { + "https://us-central1-aiplatform.googleapis.com", + ]); + ++ // Add providers based on env. vars ++ for (const [providerName, envVarName] of Object.entries(piEnvMap)) { ++ if ( ++ Object.hasOwn(process.env, envVarName) && ++ !Object.hasOwn(providers, providerName) ++ ) { ++ providers[providerName] = { baseUrl: proxyBaseUrl }; ++ } ++ } ++ ++ // Add providers based on auth.json ++ const authJsonPath = join(sourceDir, "auth.json"); ++ if (fs.existsSync(authJsonPath)) { ++ let authConfig: Record = {}; ++ try { ++ authConfig = JSON.parse(fs.readFileSync(authJsonPath, "utf8")); ++ } catch { ++ console.error( ++ "Warning: ~/.pi/agent/auth.json is not valid JSON; ignoring", ++ ); ++ } ++ if (authConfig && typeof authConfig === "object") { ++ for (const providerName in Object.keys(authConfig)) { ++ if (!Object.hasOwn(providers, providerName)) { ++ providers[providerName] = { baseUrl: proxyBaseUrl }; ++ } ++ } ++ } ++ } ++ + // Rewrite every provider that has an external baseUrl, regardless of its + // key name. For providers whose upstream isn't natively known to the proxy, + // stash the real URL as x-target-url so the proxy forwards correctly. +-- +2.54.0 + diff --git a/package.json b/package.json index d24b2e2..65fa058 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,10 @@ "bin": { "skynot": "bin/skynot" }, + "files": [ + "dist/", + "context-lens-patches/" + ], "scripts": { "dist": "mkdir -p bin && echo '#!/usr/bin/env node' > bin/skynot && echo \"require('../dist/index.js');\" >> bin/skynot && chmod +x bin/skynot", "build": "npm install && npx tsc && npm run dist", diff --git a/src/index.ts b/src/index.ts index ba55e66..88802e2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -28,6 +28,7 @@ const AGENT_NPM_PACKAGE = "@earendil-works/pi-coding-agent"; const AGENT_GITHUB_REPO = "earendil-works/pi"; const AGENT_USER = "aidev"; const LAUNCHER_SCRIPT_FILENAME = "spi"; +const CONTEXT_LENS_SCRIPT_FILENAME = "cpi"; const AGENT_GROUP_NAME = "aiteam"; const DEFAULT_UMASK = "007"; const MIN_NODE_MAJOR_VERSION = 22; @@ -51,6 +52,16 @@ type RunProcessOptions = { verboseStdErr?: boolean; }; +function getProcessOptions(verbose?: boolean, cwd?: string) { + const opts: RunProcessOptions = Empty.object(); + if (verbose) { + opts.verboseStdErr = true; + opts.verboseStdOut = true; + } + opts.cwd = cwd; + return opts; +} + function runCommand( command: string, args: string[], @@ -426,6 +437,21 @@ async function checkWget(): Promise { } } +async function checkMitmProxy(): Promise { + try { + await execAsync("which mitmproxy"); + } catch (err) { + const installHint = + os.platform() == "darwin" + ? "e.g., 'brew install mitmproxy'" + : "e.g., 'apt install mitmproxy' on Debian/Ubuntu"; + console.error( + `Error: mitmproxy not found. It is needed to use skynot with context-lens. Please install it (${installHint}).` + ); + process.exit(1); + } +} + async function installAgentFromTarball( update: boolean, verbose?: boolean @@ -585,13 +611,22 @@ set_dir_umask console.log(`${rcFile} updated with umask script.`); } +function getExportPrefix( + apiKeyExport: Option<{ name: string; value: string }> = Nothing +) { + return apiKeyExport instanceof Some + ? `export ${apiKeyExport.value.name}=${apiKeyExport.value.value} && ` + : Empty.string(); +} + async function createLauncherScript( - piBinaryPath: string, + command: string, + scriptFileName: string, apiKeyExport: Option<{ name: string; value: string }> = Nothing ): Promise { const currentUserHome = os.homedir(); const binDir = path.join(currentUserHome, "bin"); - const scriptPath = path.join(binDir, LAUNCHER_SCRIPT_FILENAME); + const scriptPath = path.join(binDir, scriptFileName); console.log(`Creating launcher script at ${scriptPath}...`); @@ -603,11 +638,6 @@ async function createLauncherScript( const platform = os.platform(); const homeBase = platform === "darwin" ? "/Users" : "/home"; - const exportPrefix = - apiKeyExport instanceof Some - ? `export ${apiKeyExport.value.name}=${apiKeyExport.value.value} && ` - : Empty.string(); - // Write the launcher shell script with permission checks const scriptContent = `#!/bin/bash @@ -676,9 +706,7 @@ if [ \${#EXPOSED_DIRS[@]} -gt 0 ]; then echo "" fi -FULL_SUDO_CMD="${exportPrefix}export npm_config_prefix=$AGENT_USER_HOME/.npm-global && umask ${DEFAULT_UMASK} && cd $CURRENT_DIR && ${piBinaryPath} $@" -echo "Launching Pi with ${AGENT_USER} user (sudo is required to impersonate '${AGENT_USER}' user)..." -exec sudo -i -u ${AGENT_USER} bash -c "$FULL_SUDO_CMD" +${command} `; fs.writeFileSync(scriptPath, scriptContent, { mode: 0o755 }); console.log("Launcher script created."); @@ -702,6 +730,36 @@ exec sudo -i -u ${AGENT_USER} bash -c "$FULL_SUDO_CMD" } } +async function createPiLauncherScript( + piBinaryPath: string, + apiKeyExport: Option<{ name: string; value: string }> = Nothing +): Promise { + const exportPrefix = getExportPrefix(apiKeyExport); + const command = ` +FULL_SUDO_CMD="${exportPrefix}export npm_config_prefix=$AGENT_USER_HOME/.npm-global && umask ${DEFAULT_UMASK} && cd $CURRENT_DIR && ${piBinaryPath} $@" +echo "Launching Pi with ${AGENT_USER} user (sudo is required to impersonate '${AGENT_USER}' user)..." +exec sudo -i -u ${AGENT_USER} bash -c "$FULL_SUDO_CMD"`; + await createLauncherScript(command, LAUNCHER_SCRIPT_FILENAME, apiKeyExport); +} + +async function createContextLensLauncherScript( + contextLensDir: string, + apiKeyExport: Option<{ name: string; value: string }> = Nothing +): Promise { + const cmd = `node ${contextLensDir}/dist/cli.js --mitm pi`; + const exportPrefix = getExportPrefix(apiKeyExport); + const command = ` +FULL_SUDO_CMD="${exportPrefix}export npm_config_prefix=$AGENT_USER_HOME/.npm-global && umask ${DEFAULT_UMASK} && cd $CURRENT_DIR && ${cmd} $@" +echo "Launching Pi using context-lens warapper with ${AGENT_USER} user (sudo is required to impersonate '${AGENT_USER}' user)..." +echo "The context-lens UI is available at http://localhost:4041/" +exec sudo -i -u ${AGENT_USER} bash -c "$FULL_SUDO_CMD"`; + await createLauncherScript( + command, + CONTEXT_LENS_SCRIPT_FILENAME, + apiKeyExport + ); +} + async function createMacOsGroup( sudoReason: string, freeGroupIdFindingCount: number @@ -909,6 +967,79 @@ async function installExtensions( } } +async function buildContextLens( + contextLensDir: string, + verbose?: boolean +): Promise { + console.log("Building context-lens..."); + const commandOptions = getProcessOptions(verbose); + for (const dir of [contextLensDir, path.join(contextLensDir, "ui")]) { + commandOptions.cwd = dir; + await runCommand("npm", ["install"], commandOptions); + await runCommand("npm", ["run", "build"], commandOptions); + } + console.log("context-lens built."); +} + +async function installContextLens( + update: boolean, + apiKeyExport: Option<{ name: string; value: string }> = Nothing, + verbose?: boolean +): Promise { + const contextLensRepoName = "context-lens"; + const contextLensGithubRepoUrl = `https://github.com/larsderidder/${contextLensRepoName}.git`; + const contextLensDir = path.join(agentUserHome, contextLensRepoName); + const commandOptions = getProcessOptions(verbose, agentUserHome); + const commandOptionsForContextLensDir = getProcessOptions( + verbose, + contextLensDir + ); + + async function applyPatches() { + const patchesDir = path.join(__dirname, "..", "context-lens-patches"); + const patchFiles = fs + .readdirSync(patchesDir) + .filter((fileName) => fileName.endsWith(".patch")); + + for (const patchFile of patchFiles) { + const patchPath = path.join(patchesDir, patchFile); + console.log(`Applying patch: ${patchFile}`); + await runCommand( + "git", + ["apply", patchPath], + commandOptionsForContextLensDir + ); + } + + } + + if (fs.existsSync(contextLensDir)) { + console.log("context-lens already installed."); + if (update) { + console.log("Updating context-lens..."); + await runCommand("git", ["fetch"], commandOptionsForContextLensDir); + await runCommand("git", ["reset", "--hard", "origin/main"], commandOptionsForContextLensDir); + await applyPatches(); + await buildContextLens(contextLensDir, verbose); + console.log("context-lens updated."); + } + } else { + console.log("Installing context-lens..."); + await runCommand( + "git", + ["clone", contextLensGithubRepoUrl], + commandOptions + ); + + await applyPatches(); + + await buildContextLens(contextLensDir, verbose); + console.log("context-lens installed."); + } + + await createContextLensLauncherScript(contextLensDir, apiKeyExport); +} + async function launchAgent(): Promise { const scriptPath = path.join(os.homedir(), "bin", LAUNCHER_SCRIPT_FILENAME); const child = spawn(scriptPath, [], { stdio: "inherit" }); @@ -1146,6 +1277,9 @@ async function destroyInstallation(): Promise { ); console.log(` - The '${AGENT_GROUP_NAME}' group`); console.log(` - The launcher script ~/bin/${LAUNCHER_SCRIPT_FILENAME}`); + console.log( + ` - The launcher script ~/bin/${CONTEXT_LENS_SCRIPT_FILENAME}` + ); console.log(""); const confirmation = await askQuestion( @@ -1203,16 +1337,17 @@ async function destroyInstallation(): Promise { ); } - // Remove the launcher script - const launcherPath = path.join( - os.homedir(), - "bin", - LAUNCHER_SCRIPT_FILENAME - ); - if (fs.existsSync(launcherPath)) { - console.log(`Removing launcher script at ${launcherPath}...`); - fs.unlinkSync(launcherPath); - console.log("Launcher script removed."); + // Remove the launcher scripts + for (const scriptFileName of [ + LAUNCHER_SCRIPT_FILENAME, + CONTEXT_LENS_SCRIPT_FILENAME, + ]) { + const launcherPath = path.join(os.homedir(), "bin", scriptFileName); + if (fs.existsSync(launcherPath)) { + console.log(`Removing launcher script at ${launcherPath}...`); + fs.unlinkSync(launcherPath); + console.log("Launcher script removed."); + } } console.log("\n=== DESTROY COMPLETE ==="); @@ -1243,6 +1378,10 @@ async function main() { "-e, --extensions", `DEPRECATED: rather use \`${LAUNCHER_SCRIPT_FILENAME} install \` instead, after install.` ) + .option( + "-c, --context-lens", + `This flag additionaly installs context-lens after installing Pi and creates a launcher script "cpi" for it.` + ) .option( "-a, --auth", `Prompt for AI model's credentials to add env var to launcher script, or to create an auth.json file.` @@ -1364,6 +1503,10 @@ async function main() { if (!opts.npm) { await checkWget(); } + // mitmproxy is needed for context-lens + if (opts.contextLens) { + await checkMitmProxy(); + } await ensureAgentGroupExists(); await ensureAgentUserExists(); @@ -1414,7 +1557,11 @@ async function main() { apiKeyExport = await configureAuth(); } - await createLauncherScript(piBinaryPath, apiKeyExport); + await createPiLauncherScript(piBinaryPath, apiKeyExport); + + if (opts.contextLens) { + await installContextLens(opts.update, apiKeyExport, opts.verbose); + } const workDir = await setupWorkDir(); console.log( @@ -1428,6 +1575,11 @@ async function main() { console.log(`3. Clone the git repository where you will work on`); console.log(`4. \`cd\` into the cloned repository`); console.log(`5. Launch via \`${LAUNCHER_SCRIPT_FILENAME}\`\n`); + if (opts.contextLens) { + console.log( + `6. Launch with context-lens via \`${CONTEXT_LENS_SCRIPT_FILENAME}\`\n` + ); + } } main().catch((err) => {