Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions apps/local-agent-cli/esbuild.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
// Bundles the headless local agent into the single executable the
// `tangent-local-agent` skill ships. Workspace packages and socket.io-client
// are inlined, so the skill folder needs no node_modules.
import { chmod, copyFile, mkdir } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";

import { build } from "esbuild";

const cliDir = path.dirname(fileURLToPath(import.meta.url));
const repoRoot = path.resolve(cliDir, "..", "..");
const scriptsDir = path.join(
repoRoot,
".cursor",
"skills",
"tangent-local-agent",
"scripts",
);
const outfile = path.join(scriptsDir, "tangent-local-agent.cjs");

await mkdir(scriptsDir, { recursive: true });
await copyFile(
fileURLToPath(
import.meta.resolve("@tangent/local-agent-host/resources/proxyProvider.ts"),
),
path.join(scriptsDir, "proxyProvider.ts"),
);
await copyFile(
fileURLToPath(
import.meta
.resolve("@tangent/local-agent-host/resources/sessionResources.ts"),
),
path.join(scriptsDir, "sessionResources.ts"),
);
await build({
entryPoints: [path.join(cliDir, "src", "main.ts")],
bundle: true,
platform: "node",
format: "cjs",
target: "node20",
outfile,
banner: {
js: [
"#!/usr/bin/env node",
'const __bundleUrl = require("node:url").pathToFileURL(__filename).href;',
].join("\n"),
},
define: { "import.meta.url": "__bundleUrl" },
// The ws native add-ons are optional and loaded behind a try/catch.
external: ["bufferutil", "utf-8-validate"],
legalComments: "none",
});
await chmod(outfile, 0o755);
console.log(
"[build:local-agent-cli] bundled to .cursor/skills/tangent-local-agent/scripts/",
);
3 changes: 3 additions & 0 deletions apps/local-agent-cli/eslint.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import node from "@tangent/build/eslint/node";

export default [{ ignores: ["node_modules"] }, ...node];
26 changes: 26 additions & 0 deletions apps/local-agent-cli/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
{
"name": "tangent-local-agent-cli",
"version": "0.0.1",
"description": "Headless Tangent local agent: host Tangent sub-agents locally with pi.",
"private": true,
"type": "module",
"prettier": "@tangent/build/prettier",
"scripts": {
"build": "node esbuild.mjs",
"lint": "eslint .",
"test": "tsx --test \"src/**/*.test.ts\"",
"typecheck": "tsc --noEmit",
"format": "prettier --write ."
},
"dependencies": {
"@tangent/local-agent-host": "workspace:*"
},
"devDependencies": {
"@tangent/build": "workspace:*",
"@types/node": "^25.9.1",
"esbuild": "0.28.0",
"eslint": "^10.4.0",
"tsx": "^4.22.3",
"typescript": "^6.0.3"
}
}
85 changes: 85 additions & 0 deletions apps/local-agent-cli/src/config.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import assert from "node:assert/strict";
import path from "node:path";
import { test } from "node:test";

import { parseCli } from "./config.ts";

const env = { REMOTE_ENV_TOKEN: "secret" };

function parse(argv: string[], extraEnv = {}) {
const config = parseCli(argv, { ...env, ...extraEnv });
assert.notEqual(config, "help");
return config as Exclude<typeof config, "help">;
}

test("a session URL sets the shell origin and scopes the host id", () => {
const config = parse([
"--url",
"http://localhost:5173/sessions/abc?x=1",
"--host-id",
"cli-test",
]);

assert.equal(config.url, "http://localhost:5173");
assert.equal(config.sessionId, "abc");
assert.equal(config.selector, "sessionId:abc");
assert.equal(config.environmentId, "abc:cli-test");
assert.equal(config.token, "secret");
});

test("an explicit session wins over the URL and accepts a pasted URL", () => {
const config = parse([
"--url",
"http://h/sessions/from-url",
"--session",
"http://h/sessions/explicit",
"--host-id",
"h1",
]);

assert.equal(config.environmentId, "explicit:h1");
});

test("without a session the host connects unscoped with a generated id", () => {
const config = parse(["--url", "https://shell.example/tangent/"]);

assert.equal(config.url, "https://shell.example/tangent");
assert.equal(config.sessionId, undefined);
assert.equal(config.selector, config.environmentId);
assert.match(config.environmentId, /^cli-[a-z0-9-]+-[0-9a-f]{8}$/);
});

test("flags fall back to environment variables", () => {
const config = parse([], {
TANGENT_SHELL_URL: "http://env-shell",
TANGENT_PI_BIN: "/opt/pi",
TANGENT_PI_MODEL: "openai/gpt-5",
});

assert.equal(config.url, "http://env-shell");
assert.equal(config.launch.bin, "/opt/pi");
assert.equal(config.launch.model, "openai/gpt-5");
});

test("pi launch options resolve cwd and only pass the proxy key with a proxy", () => {
const plain = parse(["--url", "http://h", "--cwd", "some/dir"], {
PI_PROXY_API_KEY: "k",
});
assert.equal(plain.launch.cwd, path.resolve("some/dir"));
assert.equal(plain.launch.bin, "pi");
assert.equal(plain.launch.proxyApiKey, undefined);

const proxied = parse(["--url", "http://h", "--pi-proxy-url", "https://p"], {
PI_PROXY_API_KEY: "k",
});
assert.equal(proxied.launch.proxyUrl, "https://p");
assert.equal(proxied.launch.proxyApiKey, "k");
assert.match(proxied.launch.proxyExtensionPath, /proxyProvider\.ts$/);
});

test("missing URL or token fails clearly; --help short-circuits", () => {
assert.throws(() => parseCli([], env), /--url/);
assert.throws(() => parseCli(["--url", "http://h"], {}), /REMOTE_ENV_TOKEN/);
assert.throws(() => parseCli(["--bogus"], env));
assert.equal(parseCli(["-h"], {}), "help");
});
141 changes: 141 additions & 0 deletions apps/local-agent-cli/src/config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
import { randomUUID } from "node:crypto";
import { hostname } from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { parseArgs } from "node:util";

import type { PiLaunchOptions } from "@tangent/local-agent-host/piAdapter.ts";
import {
normalizeShellUrl,
parseSessionRef,
sessionIdFromUrl,
} from "@tangent/local-agent-host/shellUrl.ts";

const OPTIONS = {
url: { type: "string" },
session: { type: "string" },
cwd: { type: "string" },
"host-id": { type: "string" },
"pi-bin": { type: "string" },
model: { type: "string" },
"pi-proxy-url": { type: "string" },
description: { type: "string" },
help: { type: "boolean", short: "h" },
} as const;

/** pi loads this from disk, so the build copies it next to the bundle. */
const PROXY_PROVIDER_PATH = fileURLToPath(
new URL("proxyProvider.ts", import.meta.url),
);

/** Session-resources extension; copied next to the bundle like the proxy one. */
const SESSION_RESOURCES_PATH = fileURLToPath(
new URL("sessionResources.ts", import.meta.url),
);

type Env = Record<string, string | undefined>;
type CliValues = ReturnType<typeof parseFlags>["values"];

export interface CliConfig {
url: string;
token: string;
sessionId?: string;
/** Routes this host to matching sessions, e.g. `sessionId:<id>`. */
selector: string;
environmentId: string;
/** Self-description Prime reads to tell this host apart and match work to it. */
metadata: Record<string, string>;
launch: PiLaunchOptions & { cwd: string };
}

function parseFlags(argv: string[]) {
return parseArgs({ args: argv, options: OPTIONS });
}

/** A flag, else its environment variable, trimmed; `undefined` when empty. */
function setting(
flag: string | undefined,
env: Env,
envName: string,
): string | undefined {
return (flag ?? env[envName])?.trim() || undefined;
}

function required(value: string | undefined, message: string): string {
if (!value) throw new Error(message);
return value;
}

function defaultHostId(): string {
const host = hostname()
.toLowerCase()
.replace(/[^a-z0-9-]+/g, "-");
return `cli-${host}-${randomUUID().slice(0, 8)}`;
}

function launchOptions(values: CliValues, env: Env): CliConfig["launch"] {
const proxyUrl = setting(values["pi-proxy-url"], env, "TANGENT_PI_PROXY_URL");
return {
bin: setting(values["pi-bin"], env, "TANGENT_PI_BIN") ?? "pi",
cwd: path.resolve(values.cwd ?? "."),
model: setting(values.model, env, "TANGENT_PI_MODEL"),
proxyUrl,
proxyApiKey: proxyUrl ? env.PI_PROXY_API_KEY : undefined,
proxyExtensionPath: PROXY_PROVIDER_PATH,
sessionResourceExtensionPath: SESSION_RESOURCES_PATH,
};
}

/**
* The self-description this host advertises: what it is and what it can build,
* so Prime can tell it apart from other connected hosts and match work to it.
* `description` is overridable; the rest is derived from the host's environment.
*/
function buildMetadata(
cwd: string,
description: string | undefined,
): Record<string, string> {
return {
editor: "tangent-local-agent",
workspacePath: cwd,
description:
description ?? `Tangent Local Agent on ${hostname()}, working in ${cwd}.`,
capabilities:
"Runs local pi sub-agents that read, edit, and run commands in this workspace.",
};
}

/** Parses argv and the environment; `"help"` when usage was requested. */
export function parseCli(argv: string[], env: Env): CliConfig | "help" {
const { values } = parseFlags(argv);
if (values.help) return "help";
const rawUrl = required(
setting(values.url, env, "TANGENT_SHELL_URL"),
"Pass --url <shell url> or set TANGENT_SHELL_URL.",
);
const token = required(
setting(undefined, env, "REMOTE_ENV_TOKEN"),
"Set REMOTE_ENV_TOKEN to the Shell's remote environment token.",
);
const session = setting(values.session, env, "TANGENT_SESSION_ID");
const sessionId = session
? parseSessionRef(session)
: sessionIdFromUrl(rawUrl);
const hostId =
setting(values["host-id"], env, "TANGENT_HOST_ID") ?? defaultHostId();
const launch = launchOptions(values, env);
const description = setting(
values.description,
env,
"TANGENT_HOST_DESCRIPTION",
);
return {
url: normalizeShellUrl(rawUrl),
token,
sessionId,
selector: sessionId ? `sessionId:${sessionId}` : hostId,
environmentId: sessionId ? `${sessionId}:${hostId}` : hostId,
metadata: buildMetadata(launch.cwd, description),
launch,
};
}
71 changes: 71 additions & 0 deletions apps/local-agent-cli/src/main.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { statSync } from "node:fs";

import { LocalAgentBridge } from "@tangent/local-agent-host/bridge.ts";
import { createFileTools } from "@tangent/local-agent-host/remoteFileTools.ts";

import { type CliConfig, parseCli } from "./config.ts";
import { USAGE } from "./usage.ts";

function log(line: string): void {
console.log(`${new Date().toISOString()} ${line}`);
}

function assertDirectory(dir: string): void {
if (!statSync(dir, { throwIfNoEntry: false })?.isDirectory()) {
throw new Error(`Not a directory: ${dir}`);
}
}

function statusLine(bridge: LocalAgentBridge): string {
return `status: ${bridge.state}, ${bridge.listAgents().length} sub-agent(s)`;
}

function startBridge(config: CliConfig): LocalAgentBridge {
let lastStatus = "";
const bridge: LocalAgentBridge = new LocalAgentBridge({
url: config.url,
selector: config.selector,
metadata: config.metadata,
tools: createFileTools(() => config.launch.cwd),
piLaunchOptions: () => Promise.resolve(config.launch),
log,
onChange: () => {
const status = statusLine(bridge);
if (status === lastStatus) return;
lastStatus = status;
log(status);
},
onSpawned: (agent) =>
log(`sub-agent "${agent.name}" (${agent.agentId}) started`),
});
bridge.connect(config.token, config.environmentId);
return bridge;
}

function main(): void {
const config = parseCli(process.argv.slice(2), process.env);
if (config === "help") {
console.log(USAGE);
return;
}
assertDirectory(config.launch.cwd);
log(
`connecting to ${config.url} as ${config.environmentId}, working in ${config.launch.cwd}`,
);
const bridge = startBridge(config);
const stop = (signal: NodeJS.Signals): void => {
log(`${signal}: stopping local sub-agents`);
bridge.dispose();
process.exit(0);
};
process.once("SIGINT", stop);
process.once("SIGTERM", stop);
}

try {
main();
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
console.error(`tangent-local-agent: ${message}\nRun with --help for usage.`);
process.exitCode = 1;
}
Loading
Loading