@hanamorilabs/tab 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,103 @@
1
+ /**
2
+ * What each agent needs in its environment to run on a tab.
3
+ *
4
+ * Two wire shapes exist. Claude Code speaks the Anthropic Messages API and
5
+ * reads `ANTHROPIC_BASE_URL`; everything else speaks OpenAI and reads
6
+ * `OPENAI_BASE_URL`. The proxy serves both. Provider-specific agents get the
7
+ * provider-scoped base so the proxy picks the right key.
8
+ *
9
+ * An unknown command gets both sets, so `tab <anything>` still routes
10
+ * whichever API the program happens to use.
11
+ */
12
+ const KNOWN = {
13
+ claude: {
14
+ command: "claude",
15
+ label: "Claude Code",
16
+ shape: "anthropic",
17
+ install: "npm i -g @anthropic-ai/claude-code",
18
+ },
19
+ codex: {
20
+ command: "codex",
21
+ label: "Codex",
22
+ shape: "openai",
23
+ install: "npm i -g @openai/codex",
24
+ // An isolated CODEX_HOME does the real work (see codex-home.ts); this
25
+ // flag is belt and braces for versions that honour it.
26
+ args: ["-c", 'preferred_auth_method="apikey"'],
27
+ isolatedHome: "codex",
28
+ },
29
+ grok: {
30
+ command: "grok",
31
+ label: "Grok",
32
+ shape: "openai",
33
+ provider: "xai",
34
+ install: "see https://docs.x.ai for the Grok CLI",
35
+ },
36
+ kimi: {
37
+ command: "kimi",
38
+ label: "Kimi",
39
+ shape: "openai",
40
+ provider: "moonshot",
41
+ install: "see https://platform.moonshot.ai for the Kimi CLI",
42
+ },
43
+ gemini: {
44
+ command: "gemini",
45
+ label: "Gemini CLI",
46
+ shape: "openai",
47
+ provider: "google",
48
+ install: "npm i -g @google/gemini-cli",
49
+ },
50
+ };
51
+ export function knownClients() {
52
+ return Object.keys(KNOWN);
53
+ }
54
+ export function clientFor(name) {
55
+ const known = KNOWN[name];
56
+ if (known)
57
+ return known;
58
+ return {
59
+ command: name,
60
+ label: name,
61
+ // Unknown: we do not know which API it speaks, so both are set below.
62
+ shape: "openai",
63
+ install: `make sure \`${name}\` is on your PATH`,
64
+ };
65
+ }
66
+ /** `<proxy>/v1` or `<proxy>/v1/<provider>` for vendor-scoped OpenAI routes. */
67
+ export function openaiBase(proxyUrl, provider) {
68
+ return provider ? `${proxyUrl}/v1/${provider}` : `${proxyUrl}/v1`;
69
+ }
70
+ /**
71
+ * The environment to hand the child. Existing variables for the same API are
72
+ * overridden on purpose: a stray `OPENAI_BASE_URL` from another tool would
73
+ * otherwise route around the tab, which is the one thing this must not do.
74
+ */
75
+ export function envFor(input) {
76
+ const spec = clientFor(input.name);
77
+ const known = input.name in KNOWN;
78
+ const env = { ...(input.base ?? process.env) };
79
+ const setAnthropic = () => {
80
+ env.ANTHROPIC_BASE_URL = input.proxyUrl;
81
+ env.ANTHROPIC_API_KEY = input.presentedKey;
82
+ // Claude Code prefers an auth token when one is set; make sure a stale
83
+ // one cannot win over the tab key.
84
+ delete env.ANTHROPIC_AUTH_TOKEN;
85
+ };
86
+ const setOpenAI = () => {
87
+ env.OPENAI_BASE_URL = openaiBase(input.proxyUrl, spec.provider);
88
+ env.OPENAI_API_KEY = input.presentedKey;
89
+ };
90
+ if (!known) {
91
+ setAnthropic();
92
+ setOpenAI();
93
+ }
94
+ else if (spec.shape === "anthropic") {
95
+ setAnthropic();
96
+ }
97
+ else {
98
+ setOpenAI();
99
+ }
100
+ // So the child, and anything it spawns, can tell it is on a tab.
101
+ env.FLOCKTAB_PROXY_URL = input.proxyUrl;
102
+ return { spec, env };
103
+ }
@@ -0,0 +1,49 @@
1
+ /**
2
+ * An isolated Codex home for runs on a tab.
3
+ *
4
+ * Codex resolves auth from `$CODEX_HOME/auth.json` and its provider from
5
+ * `$CODEX_HOME/config.toml`, and a stored ChatGPT login or a custom
6
+ * `model_provider` there wins over anything in the environment. So a plain
7
+ * `OPENAI_BASE_URL` never reaches the tab for a signed-in user. `tab codex`
8
+ * therefore points `CODEX_HOME` at a directory it owns, with an API-key auth
9
+ * file and one provider: the tab, over the Responses wire the proxy serves.
10
+ *
11
+ * The person's own `~/.codex` is never read or written.
12
+ */
13
+ import { chmod, mkdir, writeFile } from "node:fs/promises";
14
+ import path from "node:path";
15
+ export function codexAuthJson(presentedKey) {
16
+ return `${JSON.stringify({ auth_mode: "apikey", OPENAI_API_KEY: presentedKey })}\n`;
17
+ }
18
+ /** Codex's /status prints the provider *id*, not its name, so the id says which FlockTab this is. */
19
+ export function codexProviderId(mode) {
20
+ return mode === "self-hosted" ? "FlockTab - Self-hosted" : "FlockTab - Hosted";
21
+ }
22
+ export function codexConfigToml(proxyUrl, model, mode = "hosted") {
23
+ const id = codexProviderId(mode);
24
+ const lines = [
25
+ `model_provider = ${JSON.stringify(id)}`,
26
+ ...(model ? [`model = ${JSON.stringify(model)}`] : []),
27
+ ``,
28
+ `[model_providers.${JSON.stringify(id)}]`,
29
+ `name = ${JSON.stringify(id)}`,
30
+ `base_url = ${JSON.stringify(`${proxyUrl}/v1`)}`,
31
+ `env_key = "OPENAI_API_KEY"`,
32
+ `wire_api = "responses"`,
33
+ ``,
34
+ ];
35
+ return lines.join("\n");
36
+ }
37
+ /** Write the home and return its path. Files are owner-only. */
38
+ export async function prepareCodexHome(input) {
39
+ const home = path.join(input.baseDir, "codex");
40
+ await mkdir(home, { recursive: true, mode: 0o700 });
41
+ await chmod(home, 0o700);
42
+ const auth = path.join(home, "auth.json");
43
+ const config = path.join(home, "config.toml");
44
+ await writeFile(auth, codexAuthJson(input.presentedKey), { mode: 0o600 });
45
+ await writeFile(config, codexConfigToml(input.proxyUrl, input.model, input.mode ?? "hosted"), { mode: 0o600 });
46
+ await chmod(auth, 0o600);
47
+ await chmod(config, 0o600);
48
+ return home;
49
+ }
package/dist/config.js ADDED
@@ -0,0 +1,123 @@
1
+ /**
2
+ * Where `tab` keeps what it needs: the proxy to talk to, the machine session
3
+ * from `tab login`, the unlock, and one virtual key per Agent this machine
4
+ * has run as. One JSON file, owner-only, under the home directory.
5
+ *
6
+ * Login is per machine; which Agent a folder runs as lives in that folder's
7
+ * `.flocktab` (see project.ts) and points at an entry in `agents` here. The
8
+ * unlock is stored here so `tab claude` is one command. That is the user's
9
+ * own machine and their own choice, the same trade every CLI makes with a
10
+ * token file. Environment variables override the file for CI.
11
+ */
12
+ import { chmod, mkdir, readFile, rm, writeFile } from "node:fs/promises";
13
+ import { homedir } from "node:os";
14
+ import path from "node:path";
15
+ export const HOSTED_PROXY = "https://proxy.flocktab.com";
16
+ export const LOCAL_PROXY = "http://127.0.0.1:8787";
17
+ export function configDir(env = process.env) {
18
+ return env.FLOCKTAB_HOME?.trim() || path.join(homedir(), ".flocktab");
19
+ }
20
+ export function configPath(env = process.env) {
21
+ return path.join(configDir(env), "config.json");
22
+ }
23
+ /** A proxy base without a trailing slash or a stray `/v1`. */
24
+ export function normalizeProxyUrl(raw) {
25
+ let url = raw.trim().replace(/\/+$/, "");
26
+ url = url.replace(/\/v1$/, "");
27
+ if (!/^https?:\/\//.test(url))
28
+ url = `http://${url}`;
29
+ return url;
30
+ }
31
+ export function isLocalProxy(url) {
32
+ try {
33
+ const host = new URL(url).hostname;
34
+ return host === "127.0.0.1" || host === "localhost" || host === "::1";
35
+ }
36
+ catch {
37
+ return false;
38
+ }
39
+ }
40
+ function isRecord(value) {
41
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
42
+ }
43
+ function str(value) {
44
+ return typeof value === "string" && value.trim() ? value : undefined;
45
+ }
46
+ function readAgents(raw) {
47
+ const out = {};
48
+ for (const [slug, value] of Object.entries(raw)) {
49
+ if (!isRecord(value) || typeof value.key !== "string")
50
+ continue;
51
+ out[slug] = {
52
+ key: value.key,
53
+ agentId: str(value.agentId) ?? "",
54
+ agentName: str(value.agentName) ?? slug,
55
+ };
56
+ }
57
+ return out;
58
+ }
59
+ /**
60
+ * The saved config, with the environment on top. `FLOCKTAB_PROXY_URL` and
61
+ * `FLOCKTAB_UNLOCK` override their fields; `FLOCKTAB_KEY` is an Agent key
62
+ * for CI and scripts, used as the `env` Agent without any folder picker.
63
+ */
64
+ export async function loadConfig(env = process.env) {
65
+ let fromFile = {};
66
+ try {
67
+ const parsed = JSON.parse(await readFile(configPath(env), "utf8"));
68
+ if (isRecord(parsed)) {
69
+ fromFile = {
70
+ ...(str(parsed.proxyUrl) ? { proxyUrl: parsed.proxyUrl } : {}),
71
+ ...(parsed.mode === "hosted" || parsed.mode === "self-hosted" ? { mode: parsed.mode } : {}),
72
+ ...(str(parsed.token) ? { token: parsed.token } : {}),
73
+ ...(str(parsed.consoleUrl) ? { consoleUrl: parsed.consoleUrl } : {}),
74
+ ...(str(parsed.flockName) ? { flockName: parsed.flockName } : {}),
75
+ ...(str(parsed.unlock) ? { unlock: parsed.unlock } : {}),
76
+ ...(isRecord(parsed.agents) ? { agents: readAgents(parsed.agents) } : {}),
77
+ };
78
+ }
79
+ }
80
+ catch {
81
+ // No file yet, or unreadable: fall through to env.
82
+ }
83
+ const proxyUrl = env.FLOCKTAB_PROXY_URL?.trim() || fromFile.proxyUrl;
84
+ const envKey = env.FLOCKTAB_KEY?.trim();
85
+ const unlock = env.FLOCKTAB_UNLOCK?.trim() || fromFile.unlock;
86
+ if (!proxyUrl)
87
+ return undefined;
88
+ if (!fromFile.token && !envKey)
89
+ return undefined;
90
+ const normalized = normalizeProxyUrl(proxyUrl);
91
+ const agents = { ...(fromFile.agents ?? {}) };
92
+ if (envKey)
93
+ agents.env = { key: envKey, agentId: "", agentName: "env" };
94
+ return {
95
+ proxyUrl: normalized,
96
+ mode: fromFile.mode ?? (isLocalProxy(normalized) ? "self-hosted" : "hosted"),
97
+ ...(fromFile.token ? { token: fromFile.token } : {}),
98
+ ...(fromFile.consoleUrl ? { consoleUrl: fromFile.consoleUrl } : {}),
99
+ ...(fromFile.flockName ? { flockName: fromFile.flockName } : {}),
100
+ ...(unlock ? { unlock } : {}),
101
+ agents,
102
+ };
103
+ }
104
+ export async function saveConfig(config, env = process.env) {
105
+ const dir = configDir(env);
106
+ await mkdir(dir, { recursive: true, mode: 0o700 });
107
+ const file = configPath(env);
108
+ await writeFile(file, `${JSON.stringify(config, null, 2)}\n`, { mode: 0o600 });
109
+ // mkdir/writeFile modes are masked by umask; set them explicitly.
110
+ await chmod(dir, 0o700);
111
+ await chmod(file, 0o600);
112
+ return file;
113
+ }
114
+ export async function clearConfig(env = process.env) {
115
+ await rm(configPath(env), { force: true });
116
+ }
117
+ /**
118
+ * The key a client presents: the virtual key, then the unlock after a dot.
119
+ * With no unlock (self-hosted, or no BYOK on the flock) it is the bare key.
120
+ */
121
+ export function presentedKey(input) {
122
+ return input.unlock ? `${input.key}.${input.unlock}` : input.key;
123
+ }
@@ -0,0 +1,34 @@
1
+ /**
2
+ * The console, spoken with the machine session: which Agents the flock has,
3
+ * make one, key one. All three are `/api/cli/*` with `Bearer ft_cli_...`.
4
+ * Injectable fetch so the folder picker is testable without a console.
5
+ */
6
+ export class ConsoleApiError extends Error {
7
+ status;
8
+ constructor(message, status) {
9
+ super(message);
10
+ this.status = status;
11
+ this.name = "ConsoleApiError";
12
+ }
13
+ }
14
+ async function call(consoleUrl, token, path, init, fetchImpl) {
15
+ const response = await fetchImpl(`${consoleUrl}${path}`, {
16
+ ...init,
17
+ headers: { authorization: `Bearer ${token}`, "content-type": "application/json", ...(init.headers ?? {}) },
18
+ });
19
+ const body = (await response.json().catch(() => ({})));
20
+ if (!response.ok) {
21
+ const detail = body.error ?? `console answered ${response.status}`;
22
+ throw new ConsoleApiError(body.hint ? `${detail} (${body.hint})` : detail, response.status);
23
+ }
24
+ return body;
25
+ }
26
+ export async function listAgents(consoleUrl, token, fetchImpl = fetch) {
27
+ return call(consoleUrl, token, "/api/cli/agents", { method: "GET" }, fetchImpl);
28
+ }
29
+ export async function createAgent(consoleUrl, token, input, fetchImpl = fetch) {
30
+ return call(consoleUrl, token, "/api/cli/agents", { method: "POST", body: JSON.stringify(input) }, fetchImpl);
31
+ }
32
+ export async function issueAgentKey(consoleUrl, token, agentId, fetchImpl = fetch) {
33
+ return call(consoleUrl, token, `/api/cli/agents/${encodeURIComponent(agentId)}/key`, { method: "POST" }, fetchImpl);
34
+ }
@@ -0,0 +1,120 @@
1
+ /**
2
+ * The CLI half of `tab login`: ask the console for a code, put the person in
3
+ * front of it, and poll until they approve. The key arrives over the poll,
4
+ * never through the clipboard or the screen.
5
+ *
6
+ * Pure apart from `fetch` and `openBrowser`, both injectable, so the whole
7
+ * dance is testable without a console.
8
+ */
9
+ export class DeviceLoginError extends Error {
10
+ constructor(message) {
11
+ super(message);
12
+ this.name = "DeviceLoginError";
13
+ }
14
+ }
15
+ function isRecord(value) {
16
+ return Boolean(value) && typeof value === "object";
17
+ }
18
+ export async function startDeviceLogin(consoleUrl, fetchImpl = fetch) {
19
+ const response = await fetchImpl(`${consoleUrl}/api/cli/login/start`, { method: "POST" });
20
+ if (!response.ok)
21
+ throw new DeviceLoginError(`console answered ${response.status} to login start`);
22
+ const body = await response.json();
23
+ if (!isRecord(body) ||
24
+ typeof body.userCode !== "string" ||
25
+ typeof body.deviceSecret !== "string" ||
26
+ typeof body.verifyUrl !== "string") {
27
+ throw new DeviceLoginError("console returned an unexpected login start");
28
+ }
29
+ return {
30
+ userCode: body.userCode,
31
+ deviceSecret: body.deviceSecret,
32
+ verifyUrl: body.verifyUrl,
33
+ expiresAt: typeof body.expiresAt === "string" ? body.expiresAt : "",
34
+ intervalMs: typeof body.intervalMs === "number" ? body.intervalMs : 2_000,
35
+ };
36
+ }
37
+ /**
38
+ * Poll until approved. Backs off a little on server errors instead of
39
+ * hammering, and gives up at `deadline` or when the console says expired.
40
+ */
41
+ export async function waitForApproval(input) {
42
+ const fetchImpl = input.fetchImpl ?? fetch;
43
+ const sleep = input.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
44
+ const now = input.now ?? (() => Date.now());
45
+ let interval = Math.max(500, input.intervalMs);
46
+ // The device secret travels as a bearer, never in the URL, so it stays
47
+ // out of access logs.
48
+ const url = `${input.consoleUrl}/api/cli/login/poll`;
49
+ const init = { headers: { authorization: `Bearer ${input.deviceSecret}` } };
50
+ while (now() < input.deadline) {
51
+ let response;
52
+ try {
53
+ response = await fetchImpl(url, init);
54
+ }
55
+ catch {
56
+ await sleep(interval);
57
+ interval = Math.min(interval * 2, 15_000);
58
+ continue;
59
+ }
60
+ if (response.status === 410)
61
+ throw new DeviceLoginError("the code expired before it was approved");
62
+ if (!response.ok) {
63
+ await sleep(interval);
64
+ interval = Math.min(interval * 2, 15_000);
65
+ continue;
66
+ }
67
+ const body = await response.json().catch(() => undefined);
68
+ if (isRecord(body) && body.status === "approved" && typeof body.token === "string") {
69
+ const flock = isRecord(body.flock) ? body.flock : {};
70
+ return {
71
+ token: body.token,
72
+ flock: {
73
+ id: typeof flock.id === "string" ? flock.id : "",
74
+ name: typeof flock.name === "string" ? flock.name : "flock",
75
+ plan: typeof flock.plan === "string" ? flock.plan : "solo",
76
+ byok: flock.byok === true,
77
+ },
78
+ ...(typeof body.proxyUrl === "string" ? { proxyUrl: body.proxyUrl } : {}),
79
+ };
80
+ }
81
+ await sleep(interval);
82
+ }
83
+ throw new DeviceLoginError("timed out waiting for approval");
84
+ }
85
+ /**
86
+ * Where the console lives: FLOCKTAB_CONSOLE_URL, else flocktab.com. A
87
+ * local proxy is no reason to guess a local console; a self-hosted proxy
88
+ * settles on flocktab.com, and a developer's stack sets the variable.
89
+ */
90
+ export function defaultConsoleUrl(_proxyUrl, env = process.env) {
91
+ const configured = env.FLOCKTAB_CONSOLE_URL?.trim();
92
+ if (configured)
93
+ return configured.replace(/\/+$/, "");
94
+ return "https://console.flocktab.com";
95
+ }
96
+ /**
97
+ * A self-hosted proxy knows which console it settles through and says so
98
+ * on /health; ask it before falling back to the static default, so `tab
99
+ * login` against 127.0.0.1:8787 lands on flocktab.com when that is where
100
+ * the ledger is.
101
+ */
102
+ export async function consoleUrlFor(proxyUrl, env = process.env, fetchImpl = fetch) {
103
+ const configured = env.FLOCKTAB_CONSOLE_URL?.trim();
104
+ if (configured)
105
+ return configured.replace(/\/+$/, "");
106
+ try {
107
+ const controller = new AbortController();
108
+ const timer = setTimeout(() => controller.abort(), 3_000);
109
+ const response = await fetchImpl(`${proxyUrl}/health`, { signal: controller.signal });
110
+ clearTimeout(timer);
111
+ const body = await response.json().catch(() => undefined);
112
+ if (isRecord(body) && typeof body.console === "string" && /^https?:\/\//.test(body.console)) {
113
+ return body.console.replace(/\/+$/, "");
114
+ }
115
+ }
116
+ catch {
117
+ // The proxy is down or old; the default still works for hosted flocks.
118
+ }
119
+ return defaultConsoleUrl(proxyUrl, env);
120
+ }
package/dist/folder.js ADDED
@@ -0,0 +1,47 @@
1
+ import { execFileSync } from "node:child_process";
2
+ function parseGitRepo(remote) {
3
+ const trimmed = remote.trim();
4
+ const ssh = /^git@github\.com:([^/]+)\/(.+?)(?:\.git)?$/i.exec(trimmed);
5
+ if (ssh)
6
+ return `${ssh[1]}/${ssh[2]}`;
7
+ try {
8
+ const url = new URL(trimmed);
9
+ if (!/(^|\.)github\.com$/i.test(url.hostname))
10
+ return null;
11
+ const segs = url.pathname.replace(/^\//, "").replace(/\.git$/i, "").split("/");
12
+ if (segs.length < 2 || !segs[0] || !segs[1])
13
+ return null;
14
+ return `${segs[0]}/${segs[1]}`;
15
+ }
16
+ catch {
17
+ return null;
18
+ }
19
+ }
20
+ export function gitRepoFromCwd(cwd) {
21
+ try {
22
+ const remote = execFileSync("git", ["-C", cwd, "remote", "get-url", "origin"], {
23
+ encoding: "utf8",
24
+ timeout: 800,
25
+ stdio: ["ignore", "pipe", "ignore"],
26
+ }).trim();
27
+ return parseGitRepo(remote);
28
+ }
29
+ catch {
30
+ return null;
31
+ }
32
+ }
33
+ /** Fire-and-forget: tell the proxy which folder this Agent is in. */
34
+ export function reportFolder(proxyUrl, presentedKey) {
35
+ const cwd = process.cwd();
36
+ const git = gitRepoFromCwd(cwd);
37
+ void fetch(`${proxyUrl}/v1/here`, {
38
+ method: "POST",
39
+ headers: {
40
+ authorization: `Bearer ${presentedKey}`,
41
+ "content-type": "application/json",
42
+ },
43
+ body: JSON.stringify({ cwd, git }),
44
+ }).catch(() => {
45
+ // The Agent still runs if this misses.
46
+ });
47
+ }
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Which Agent a folder runs as: `.flocktab` in the project, one line of
3
+ * JSON naming the Agent's slug. No secrets in it, so it can be committed;
4
+ * the key for that Agent lives in the machine's config. Found by walking up
5
+ * from the working directory to the git root (or the filesystem root), so
6
+ * `tab claude` in a subfolder is the same Agent as at the top.
7
+ */
8
+ import { access, readFile, writeFile } from "node:fs/promises";
9
+ import path from "node:path";
10
+ export const PROJECT_FILE = ".flocktab";
11
+ async function exists(file) {
12
+ try {
13
+ await access(file);
14
+ return true;
15
+ }
16
+ catch {
17
+ return false;
18
+ }
19
+ }
20
+ /** The nearest `.flocktab` at or above `cwd`, stopping at a `.git` boundary. */
21
+ export async function findProjectFile(cwd) {
22
+ let dir = path.resolve(cwd);
23
+ for (;;) {
24
+ const candidate = path.join(dir, PROJECT_FILE);
25
+ if (await exists(candidate))
26
+ return candidate;
27
+ if (await exists(path.join(dir, ".git")))
28
+ return undefined;
29
+ const parent = path.dirname(dir);
30
+ if (parent === dir)
31
+ return undefined;
32
+ dir = parent;
33
+ }
34
+ }
35
+ export async function readProject(cwd) {
36
+ const file = await findProjectFile(cwd);
37
+ if (!file)
38
+ return undefined;
39
+ try {
40
+ const parsed = JSON.parse(await readFile(file, "utf8"));
41
+ const agent = parsed && typeof parsed === "object" ? parsed.agent : undefined;
42
+ if (typeof agent === "string" && /^[a-z0-9][a-z0-9-]*$/.test(agent))
43
+ return { file, agent };
44
+ }
45
+ catch {
46
+ // Unreadable: treat as absent and ask again.
47
+ }
48
+ return undefined;
49
+ }
50
+ /** Where a new `.flocktab` goes: the git root above `cwd` when there is one, else `cwd`. */
51
+ export async function projectRoot(cwd) {
52
+ let dir = path.resolve(cwd);
53
+ for (;;) {
54
+ if (await exists(path.join(dir, ".git")))
55
+ return dir;
56
+ const parent = path.dirname(dir);
57
+ if (parent === dir)
58
+ return path.resolve(cwd);
59
+ dir = parent;
60
+ }
61
+ }
62
+ export async function writeProject(dir, config) {
63
+ const file = path.join(dir, PROJECT_FILE);
64
+ await writeFile(file, `${JSON.stringify(config)}\n`);
65
+ return file;
66
+ }
67
+ /** A default Agent name for a folder: its basename, kebab-cased. */
68
+ export function agentNameFor(dir) {
69
+ return path
70
+ .basename(path.resolve(dir))
71
+ .toLowerCase()
72
+ .replace(/[^a-z0-9]+/g, "-")
73
+ .replace(/^-+|-+$/g, "")
74
+ .slice(0, 64) || "agent";
75
+ }