bottleship 0.1.0-prototype.0 → 0.1.0-prototype.1
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.
- package/CHANGELOG.md +7 -0
- package/README.md +4 -0
- package/dist/src/cli.js +3 -2
- package/dist/src/init.d.ts +1 -0
- package/dist/src/init.js +55 -4
- package/dist/src/secrets.d.ts +1 -0
- package/dist/src/secrets.js +11 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,13 @@
|
|
|
3
3
|
Add a section on top with a new version to release it: pushing it to main publishes that version to npm.
|
|
4
4
|
Prerelease versions like `0.1.0-prototype.1` go to the dist-tag named after them (`prototype`), plain versions to `latest`.
|
|
5
5
|
|
|
6
|
+
## 0.1.0-prototype.1
|
|
7
|
+
|
|
8
|
+
- The last step of `bottleship init` opens your local agent (Claude Code, Codex, opencode or Cursor) to add bottleship to its global instructions, instead of showing text to copy.
|
|
9
|
+
- `bottleship init --reset` starts over: sets the config aside as `config.json.bak` and asks everything again, and on a terminal offers to forget saved logins.
|
|
10
|
+
- Fixed: a status check could read a box's state while it was being written and report a waiting login as running, or a finished agent as stopped.
|
|
11
|
+
- Fixed: removing a box that was still waiting for its device login left the login process running.
|
|
12
|
+
|
|
6
13
|
## 0.1.0-prototype.0
|
|
7
14
|
|
|
8
15
|
First prototype. `bottleship init` sets up a provider and a coding agent once, then your local agent runs other agents in remote boxes with `new`, `status`, `ask`, `exec` and `rm`.
|
package/README.md
CHANGED
|
@@ -34,6 +34,10 @@ bottleship init --yes --provider docker --agent claude --git-name "Ada Lovelace"
|
|
|
34
34
|
|
|
35
35
|
For an agent on the device login route (codex by default), the live check stops at the login with `login_needs_human`, since someone has to approve it in a browser. Ask the user to run `bottleship init` in their terminal.
|
|
36
36
|
|
|
37
|
+
At the end, init opens the user's own agent on this machine (Claude Code, Codex, opencode or Cursor, whichever is installed) with a prompt to add the instructions below to its global instructions (for Cursor, a user rule file `~/.cursor/rules/bottleship.mdc`; Cursor documents that folder, not whether its CLI reads it), so the user only approves the change. With `--yes`, the JSON's `next` asks the calling agent to add `instructions` to its own global instructions file and show the user the change.
|
|
38
|
+
|
|
39
|
+
Rerunning `bottleship init` only asks what is missing. `bottleship init --reset` starts over: it sets `~/.bottleship/config.json` aside as `config.json.bak` and asks everything again; on a terminal it also offers to forget the saved logins. With `--yes` it never deletes saved logins.
|
|
40
|
+
|
|
37
41
|
If a secret is missing it fails with `secret_missing`. Then ask the user to run `bottleship init` in their own terminal, where it asks with a hidden prompt. Never ask the user to paste a secret to you.
|
|
38
42
|
|
|
39
43
|
### Secrets
|
package/dist/src/cli.js
CHANGED
|
@@ -16,6 +16,7 @@ const HELP = `bottleship: run coding agents in remote sandboxes ("boxes"), like
|
|
|
16
16
|
[--host H] (ssh) the host boxes run on
|
|
17
17
|
[--i-understand-no-isolation] (local, ssh) the user accepts agents running unsandboxed
|
|
18
18
|
[--provider custom --name N] scaffolds ~/.bottleship/providers/N.ts for your own sandboxes
|
|
19
|
+
[--reset] start over: sets config.json aside as config.json.bak (on a terminal, offers to forget saved logins)
|
|
19
20
|
one-time setup; a human runs it in a terminal, or an agent with --yes
|
|
20
21
|
new "<task>" [--repo owner/name | --no-repo] [--provider P] [--agent A]
|
|
21
22
|
new box: clones the repo (default: the cwd's origin; local: a worktree), starts the agent
|
|
@@ -154,8 +155,8 @@ async function cmdRm(argv) {
|
|
|
154
155
|
return { removed: `${provider}:${box}` };
|
|
155
156
|
}
|
|
156
157
|
async function cmdInit(argv, ui) {
|
|
157
|
-
const { values } = args(argv, { yes: { type: "boolean", short: "y" }, provider: { type: "string" }, host: { type: "string" }, name: { type: "string" }, agent: { type: "string" }, "git-name": { type: "string" }, "git-email": { type: "string" }, "i-understand-no-isolation": { type: "boolean" } });
|
|
158
|
-
const result = await init({ provider: values.provider, host: values.host, name: values.name, agent: values.agent, gitName: values["git-name"], gitEmail: values["git-email"], noIsolation: values["i-understand-no-isolation"] }, ui);
|
|
158
|
+
const { values } = args(argv, { yes: { type: "boolean", short: "y" }, provider: { type: "string" }, host: { type: "string" }, name: { type: "string" }, agent: { type: "string" }, "git-name": { type: "string" }, "git-email": { type: "string" }, "i-understand-no-isolation": { type: "boolean" }, reset: { type: "boolean" } });
|
|
159
|
+
const result = await init({ provider: values.provider, host: values.host, name: values.name, agent: values.agent, gitName: values["git-name"], gitEmail: values["git-email"], noIsolation: values["i-understand-no-isolation"], reset: values.reset }, ui);
|
|
159
160
|
if (!result.ok)
|
|
160
161
|
process.exitCode = 1;
|
|
161
162
|
return result;
|
package/dist/src/init.d.ts
CHANGED
package/dist/src/init.js
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
// One-time onboarding: provider, agent, git identity, secrets, then a live check.
|
|
2
2
|
// Re-running it only asks for what is missing. Without a UI it never prompts.
|
|
3
3
|
import { execFileSync } from "node:child_process";
|
|
4
|
-
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
5
5
|
import { join } from "node:path";
|
|
6
6
|
import { CONFIG_FILE, HOME, ROOT } from "./paths.js";
|
|
7
7
|
import { BsError, DEFAULTS, getAgent, getProvider, isSource, loadConfig, neededSecrets, noIsolationAccepted, noIsolationError, readUserConfig, unisolated, writeUserConfig } from "./config.js";
|
|
8
|
-
import { describe, hasSecret, storeSecret } from "./secrets.js";
|
|
8
|
+
import { describe, forgetSecret, hasSecret, storeSecret } from "./secrets.js";
|
|
9
9
|
import { agentState, loginState, missingSecrets, newBoxId, setupAndStart, templateSpec, waitAgent } from "./boxes.js";
|
|
10
10
|
import { c } from "./ui.js";
|
|
11
|
+
import { shq } from "../presets/providers/util.js";
|
|
11
12
|
export const INSTRUCTIONS = `## Remote agents with bottleship
|
|
12
13
|
|
|
13
14
|
You can run other coding agents in remote sandboxes ("boxes") with the \`bottleship\` CLI, like subagents. Every command prints JSON; errors carry a \`fix\`.
|
|
@@ -87,7 +88,57 @@ function storeAndPoint(raw, secret, value) {
|
|
|
87
88
|
raw.env = { ...(raw.env ?? DEFAULTS.env), [secret.name]: source };
|
|
88
89
|
return source;
|
|
89
90
|
}
|
|
91
|
+
// local agents that keep a global instructions file, and how to open one with a prompt
|
|
92
|
+
const section = (file) => `Add the section below to your global instructions file ${file}, so you can use bottleship in every project. If the file already has a "## Remote agents with bottleship" section, replace it; keep everything else as it is, and create the file if it does not exist. Show me the change before you write it.\n\n${INSTRUCTIONS}`;
|
|
93
|
+
// Cursor reads user rule files from ~/.cursor/rules (https://cursor.com/help/customization/rules), so bottleship gets a rule of its own
|
|
94
|
+
const cursorRule = (file) => `Write the text below as the Cursor user rule file ${file}, so you can use bottleship in every project. Give it this frontmatter, then the text: "---" / "description: How to run coding agents in remote boxes with bottleship" / "alwaysApply: true" / "---". Replace the file if it exists. Show me the file before you write it.\n\n${INSTRUCTIONS}`;
|
|
95
|
+
const LOCAL_AGENTS = [
|
|
96
|
+
{ name: "claude", file: "~/.claude/CLAUDE.md", prompt: section, open: (prompt) => `claude ${shq(prompt)}` },
|
|
97
|
+
{ name: "codex", file: "~/.codex/AGENTS.md", prompt: section, open: (prompt) => `codex ${shq(prompt)}` },
|
|
98
|
+
{ name: "opencode", file: "~/.config/opencode/AGENTS.md", prompt: section, open: (prompt) => `opencode --prompt ${shq(prompt)}` },
|
|
99
|
+
{ name: "cursor-agent", file: "~/.cursor/rules/bottleship.mdc", prompt: cursorRule, open: (prompt) => `cursor-agent ${shq(prompt)}` },
|
|
100
|
+
];
|
|
101
|
+
const onPath = (bin) => (process.env.PATH ?? "").split(":").some((d) => d && existsSync(join(d, bin)));
|
|
102
|
+
const TEACH_YOURSELF = 'Add `instructions` to your own global instructions file (e.g. ~/.claude/CLAUDE.md for Claude Code, ~/.codex/AGENTS.md for Codex, a rule file ~/.cursor/rules/bottleship.mdc with `alwaysApply: true` for Cursor), replacing an older "## Remote agents with bottleship" section, and show the user the change.';
|
|
103
|
+
// the last step: the user's own agent adds the instructions to its global file, or the user copies them
|
|
104
|
+
async function teachLocalAgent(ui) {
|
|
105
|
+
const found = LOCAL_AGENTS.filter((a) => onPath(a.name));
|
|
106
|
+
const pick = found.length
|
|
107
|
+
? await ui.choose("Which agent on this machine should learn about bottleship?", [...found.map((a) => ({ value: a.name, hint: `${a.prompt === cursorRule ? "writes" : "adds a section to"} ${a.file}` })), { value: "skip", hint: "show the text to copy instead" }], found[0].name)
|
|
108
|
+
: "skip";
|
|
109
|
+
const local = found.find((a) => a.name === pick);
|
|
110
|
+
if (local)
|
|
111
|
+
await ui.handOver(local.open(local.prompt(local.file)));
|
|
112
|
+
else
|
|
113
|
+
ui.note("Add this to your agent's global instructions (e.g. ~/.claude/CLAUDE.md)", INSTRUCTIONS);
|
|
114
|
+
}
|
|
115
|
+
// the store names of saved logins the config refers to, for reset to offer forgetting
|
|
116
|
+
function savedLogins(cfg) {
|
|
117
|
+
const names = new Set();
|
|
118
|
+
const walk = (v) => {
|
|
119
|
+
if (!v || typeof v !== "object")
|
|
120
|
+
return;
|
|
121
|
+
if ("store" in v && typeof v.store === "string" && hasSecret({ store: v.store }))
|
|
122
|
+
names.add(v.store);
|
|
123
|
+
Object.values(v).forEach(walk);
|
|
124
|
+
};
|
|
125
|
+
walk(cfg);
|
|
126
|
+
return [...names].sort();
|
|
127
|
+
}
|
|
128
|
+
async function reset(ui) {
|
|
129
|
+
if (!existsSync(CONFIG_FILE))
|
|
130
|
+
return;
|
|
131
|
+
if (ui && !(await ui.confirm("Start over? Your current setup is set aside and every question comes back.")))
|
|
132
|
+
throw new BsError("reset_cancelled", "nothing changed", "bottleship init");
|
|
133
|
+
const saved = savedLogins(loadConfig(readUserConfig()));
|
|
134
|
+
renameSync(CONFIG_FILE, `${CONFIG_FILE}.bak`);
|
|
135
|
+
// only a human on the terminal can forget saved logins
|
|
136
|
+
if (ui && saved.length && (await ui.confirm(`Also forget the saved logins (${saved.join(", ")})?`)))
|
|
137
|
+
saved.forEach(forgetSecret);
|
|
138
|
+
}
|
|
90
139
|
export async function init(flags, ui) {
|
|
140
|
+
if (flags.reset)
|
|
141
|
+
await reset(ui);
|
|
91
142
|
const raw = readUserConfig();
|
|
92
143
|
let cfg = loadConfig(raw);
|
|
93
144
|
ui?.intro();
|
|
@@ -198,12 +249,12 @@ export async function init(flags, ui) {
|
|
|
198
249
|
if (failed)
|
|
199
250
|
ui.fail(`${failed.step} failed`, JSON.stringify(failed.error));
|
|
200
251
|
else {
|
|
201
|
-
|
|
252
|
+
await teachLocalAgent(ui);
|
|
202
253
|
ui.say(`config ${c.dim(CONFIG_FILE)}\nboxes ${provider} running ${agent}, committing as ${git.name} <${git.email}>`);
|
|
203
254
|
ui.close(c.green("All set. Tell your agent to start something in a box."));
|
|
204
255
|
}
|
|
205
256
|
}
|
|
206
|
-
return { ok, config: CONFIG_FILE, provider, agent, git, secrets, checks, instructions: INSTRUCTIONS };
|
|
257
|
+
return { ok, config: CONFIG_FILE, provider, agent, git, secrets, checks, instructions: INSTRUCTIONS, ...(ok && { next: TEACH_YOURSELF }) };
|
|
207
258
|
}
|
|
208
259
|
// boxes push and open PRs with GH_TOKEN: a fine-grained token by default, the gh CLI's token only after a warning
|
|
209
260
|
async function useGhAuthToken(ui) {
|
package/dist/src/secrets.d.ts
CHANGED
|
@@ -3,3 +3,4 @@ export declare function describe(source: SecretSource): string;
|
|
|
3
3
|
export declare function readSecret(source: SecretSource): string;
|
|
4
4
|
export declare function storeSecret(name: string, value: string): string;
|
|
5
5
|
export declare function hasSecret(source: SecretSource): boolean;
|
|
6
|
+
export declare function forgetSecret(name: string): void;
|
package/dist/src/secrets.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// Reads and stores secret values on this machine. Sources are described in types.ts (SecretSource).
|
|
2
2
|
import { execFileSync } from "node:child_process";
|
|
3
|
-
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
import { HOME } from "./paths.js";
|
|
6
6
|
const SERVICE = "bottleship";
|
|
@@ -56,3 +56,13 @@ export function hasSecret(source) {
|
|
|
56
56
|
return false;
|
|
57
57
|
}
|
|
58
58
|
}
|
|
59
|
+
export function forgetSecret(name) {
|
|
60
|
+
if (useKeychain()) {
|
|
61
|
+
try {
|
|
62
|
+
sh("security", ["delete-generic-password", "-s", SERVICE, "-a", name]);
|
|
63
|
+
}
|
|
64
|
+
catch { }
|
|
65
|
+
}
|
|
66
|
+
else
|
|
67
|
+
rmSync(join(FILE_DIR, name), { force: true });
|
|
68
|
+
}
|
package/package.json
CHANGED