yolocage 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.
package/yc.js ADDED
@@ -0,0 +1,361 @@
1
+ #!/usr/bin/env node
2
+ // yc — yolocage CLI. Two surfaces:
3
+ //
4
+ // Shortcut form (one-shot, ephemeral):
5
+ // yc → default type (claude) in $(pwd)
6
+ // yc claude → explicit
7
+ // yc codex → codex instead
8
+ // yc claude -- --resume → pass-through args after `--`
9
+ //
10
+ // Subcommand form (named cages, persistent):
11
+ // yc create NAME --type=…
12
+ // yc run NAME
13
+ // yc list
14
+ // yc rm NAME
15
+ // yc logs NAME
16
+ // yc pull
17
+ // yc help
18
+ //
19
+ // Argv dispatch happens here BEFORE commander runs because the shortcut
20
+ // form would otherwise collide with commander's "unknown command" path.
21
+
22
+ 'use strict';
23
+
24
+ const path = require('path');
25
+ const os = require('os');
26
+ const { Command } = require('commander');
27
+
28
+ const { resolveCascade } = require('./lib/config');
29
+ const { isKnownType, getType, TYPE_DEFAULTS } = require('./lib/types');
30
+ const crypto = require('crypto');
31
+ const {
32
+ buildRunArgs,
33
+ runDocker,
34
+ dockerArgv,
35
+ cageExists,
36
+ cageRunning,
37
+ } = require('./lib/docker');
38
+ const { update: runUpdate } = require('./lib/update');
39
+
40
+ const SUBCOMMANDS = new Set(['create', 'run', 'list', 'rm', 'logs', 'pull', 'update', 'help']);
41
+
42
+ // Detect shortcut vs subcommand form. Returns:
43
+ // { mode: 'shortcut', type, passthrough } or
44
+ // { mode: 'subcommand', argv }
45
+ function classifyArgv(argv) {
46
+ // argv: process.argv.slice(2)
47
+ // Shortcut cases:
48
+ // [] → claude
49
+ // ['claude'|'codex'|'opencode'] → that type
50
+ // ['claude', '--', ...] → claude + passthrough
51
+ // Help flags forward to commander.
52
+ if (argv.length === 0) {
53
+ return { mode: 'shortcut', type: 'claude', passthrough: [] };
54
+ }
55
+ const first = argv[0];
56
+ if (first === '--help' || first === '-h' || first === '--version' || first === '-V') {
57
+ return { mode: 'subcommand', argv };
58
+ }
59
+ if (SUBCOMMANDS.has(first)) {
60
+ return { mode: 'subcommand', argv };
61
+ }
62
+ if (isKnownType(first)) {
63
+ // `yc claude [-- passthrough...]`
64
+ const rest = argv.slice(1);
65
+ let passthrough = [];
66
+ if (rest.length > 0) {
67
+ if (rest[0] === '--') {
68
+ passthrough = rest.slice(1);
69
+ } else {
70
+ // Tokens after `yc claude` without a `--` separator are
71
+ // unexpected in shortcut form. Treat anything starting with
72
+ // `-` as passthrough (user forgot `--`), otherwise error.
73
+ if (rest.every((r) => r.startsWith('-'))) {
74
+ passthrough = rest;
75
+ } else {
76
+ throw new Error(
77
+ `unexpected positional arg(s) after "yc ${first}": ${JSON.stringify(rest)}. ` +
78
+ `Use "yc ${first} -- ${rest.join(' ')}" to pass through.`
79
+ );
80
+ }
81
+ }
82
+ }
83
+ return { mode: 'shortcut', type: first, passthrough };
84
+ }
85
+ // Unknown first token — let commander handle the error message.
86
+ return { mode: 'subcommand', argv };
87
+ }
88
+
89
+ function homeYcrcPath() {
90
+ return path.join(os.homedir(), '.ycrc');
91
+ }
92
+
93
+ function projectYcrcPath() {
94
+ return path.join(process.cwd(), '.ycrc');
95
+ }
96
+
97
+ function assertSafeCwdForShortcut() {
98
+ const cwd = path.resolve(process.cwd());
99
+ const home = path.resolve(os.homedir());
100
+ if (cwd === home) {
101
+ throw new Error(
102
+ `refusing to run shortcut form in $HOME (${cwd}). ` +
103
+ `cd into a project directory, or use "yc create NAME --bind-workspace=…" for an explicit workspace.`
104
+ );
105
+ }
106
+ if (cwd === '/' || cwd === path.parse(cwd).root) {
107
+ throw new Error(`refusing to run shortcut form at filesystem root (${cwd}).`);
108
+ }
109
+ }
110
+
111
+ // Derive a deterministic cage name from the cwd + agent type. Same dir +
112
+ // same type → same name → `yc claude` re-runs attach to the same cage.
113
+ // Different cwds with the same basename get disambiguated by the 8-hex hash
114
+ // of the full path. Output respects docker container name rules
115
+ // (^[a-zA-Z0-9][a-zA-Z0-9_.-]*$).
116
+ function getCwdCageName(type, cwd) {
117
+ cwd = cwd || process.cwd();
118
+ const raw = path.basename(cwd).toLowerCase();
119
+ // Squash anything that isn't a docker-name char to '-', then trim leading
120
+ // and trailing punctuation so the result starts with a letter or digit.
121
+ let basename = raw
122
+ .replace(/[^a-z0-9._-]/g, '-')
123
+ .replace(/^[-._]+/, '')
124
+ .replace(/[-._]+$/, '')
125
+ .substring(0, 32);
126
+ if (!basename) basename = 'cage';
127
+ const hash = crypto.createHash('sha256').update(cwd).digest('hex').substring(0, 8);
128
+ return `yc-${type}-${basename}-${hash}`;
129
+ }
130
+
131
+ function runShortcut(type, passthrough) {
132
+ assertSafeCwdForShortcut();
133
+ // Validate type (throws on opencode/unknown).
134
+ getType(type);
135
+ const cageName = getCwdCageName(type);
136
+
137
+ // Branch 1: cage is already running. Attach to it and let the user
138
+ // rejoin the existing claude/codex session. docker attach's default
139
+ // detach key (Ctrl-P Ctrl-Q) leaves the cage running; Ctrl-C exits
140
+ // claude which stops the cage.
141
+ if (cageRunning(cageName)) {
142
+ process.stderr.write(
143
+ `yc: cage '${cageName}' already running; attaching ` +
144
+ '(Ctrl-P Ctrl-Q to detach, Ctrl-C exits claude)\n'
145
+ );
146
+ const res = runDocker(['attach', cageName]);
147
+ process.exit(res.status == null ? 1 : res.status);
148
+ }
149
+
150
+ // Branch 2: cage exists but is stopped. Start it back up; the entrypoint
151
+ // re-runs the original CMD which (for claude) includes --continue so the
152
+ // prior in-cwd session is restored.
153
+ if (cageExists(cageName)) {
154
+ process.stderr.write(`yc: resuming cage '${cageName}'\n`);
155
+ const res = runDocker(['start', '-ai', cageName]);
156
+ process.exit(res.status == null ? 1 : res.status);
157
+ }
158
+
159
+ // Branch 3: no cage yet for this cwd + type. Create + start + attach in
160
+ // one docker run. Persistent (no --rm) so subsequent `yc claude` in this
161
+ // dir lands in branches 1 or 2.
162
+ const spec = resolveCascade({
163
+ type,
164
+ homeYcrcPath: homeYcrcPath(),
165
+ projectYcrcPath: projectYcrcPath(),
166
+ cliLayer: { type, passthrough },
167
+ });
168
+ process.stderr.write(`yc: creating cage '${cageName}' for ${process.cwd()}\n`);
169
+ const { argv: runArgs, cmd } = buildRunArgs(spec, {
170
+ rm: false,
171
+ interactive: true,
172
+ name: cageName,
173
+ });
174
+ const res = runDocker([...runArgs, ...cmd]);
175
+ process.exit(res.status == null ? 1 : res.status);
176
+ }
177
+
178
+ function buildCliLayer(opts) {
179
+ // Translate commander option flags → cascade-shaped object.
180
+ const out = {};
181
+ if (opts.type) out.type = opts.type;
182
+ if (opts.image) out.image = opts.image;
183
+ if (opts.bindWorkspace) out.workspace = path.resolve(opts.bindWorkspace);
184
+ if (opts.configDir) out.config_dir = path.resolve(opts.configDir);
185
+ if (opts.memory) out.memory = opts.memory;
186
+ if (opts.cpus) out.cpus = opts.cpus;
187
+ if (opts.tmux !== undefined) out.tmux = !!opts.tmux;
188
+ if (opts.ssproxyExtensions) out.ssproxy_extensions = path.resolve(opts.ssproxyExtensions);
189
+ if (opts.bindDirs && opts.bindDirs.length) out.bind_dirs = opts.bindDirs;
190
+ if (opts.extraBindDirs && opts.extraBindDirs.length) out.extra_bind_dirs = opts.extraBindDirs;
191
+ return out;
192
+ }
193
+
194
+ function commandCreate(name, opts) {
195
+ assertCageName(name);
196
+ if (opts.bindDirs && opts.bindDirs.length && !opts.configDir) {
197
+ // bind_dirs replaces type defaults entirely, so the user must
198
+ // also tell us where the config dir lives (otherwise we have no
199
+ // mount for ~/.claude or ~/.codex). This guards against the common
200
+ // foot-gun "I overrode everything and now the login is gone".
201
+ throw new Error(
202
+ `--bind-dirs replaces all default mounts. Add --config-dir=PATH so claude/codex can find its login state.`
203
+ );
204
+ }
205
+ const cliLayer = buildCliLayer(opts);
206
+ const spec = resolveCascade({
207
+ type: opts.type,
208
+ homeYcrcPath: homeYcrcPath(),
209
+ projectYcrcPath: projectYcrcPath(),
210
+ cliLayer,
211
+ });
212
+ const { argv: runArgs, cmd } = buildRunArgs(spec, {
213
+ rm: false,
214
+ interactive: false,
215
+ detach: true,
216
+ name,
217
+ volName: `${name}-mitmproxy`,
218
+ });
219
+ const res = runDocker([...runArgs, ...cmd]);
220
+ process.exit(res.status == null ? 1 : res.status);
221
+ }
222
+
223
+ function commandRun(name) {
224
+ assertCageName(name);
225
+ const res = runDocker(['exec', '-it', name, 'bash', '-l']);
226
+ process.exit(res.status == null ? 1 : res.status);
227
+ }
228
+
229
+ function commandList() {
230
+ const res = runDocker(['ps', '-a', '--filter', 'label=yolocage=1', '--format', 'table {{.Names}}\t{{.Status}}\t{{.Image}}']);
231
+ process.exit(res.status == null ? 1 : res.status);
232
+ }
233
+
234
+ function commandRm(name) {
235
+ assertCageName(name);
236
+ const res = runDocker(['rm', '-f', name]);
237
+ process.exit(res.status == null ? 1 : res.status);
238
+ }
239
+
240
+ function commandLogs(name) {
241
+ assertCageName(name);
242
+ const res = runDocker(['logs', '-f', name]);
243
+ process.exit(res.status == null ? 1 : res.status);
244
+ }
245
+
246
+ function commandPull() {
247
+ // Pull both type defaults' images. Tolerates missing tags by not
248
+ // exiting on the first failure.
249
+ let lastStatus = 0;
250
+ for (const t of Object.keys(TYPE_DEFAULTS)) {
251
+ if (!TYPE_DEFAULTS[t].v0) continue;
252
+ const res = runDocker(['pull', TYPE_DEFAULTS[t].image]);
253
+ if (res.status !== 0) lastStatus = res.status;
254
+ }
255
+ process.exit(lastStatus == null ? 1 : lastStatus);
256
+ }
257
+
258
+ function assertCageName(name) {
259
+ if (!name || !/^[a-z0-9][a-z0-9_.-]{0,62}$/i.test(name)) {
260
+ throw new Error(`invalid cage name: ${JSON.stringify(name)} (must match docker container name rules)`);
261
+ }
262
+ }
263
+
264
+ function buildProgram() {
265
+ const program = new Command();
266
+ program
267
+ .name('yc')
268
+ .description('yolocage — sandboxed claude-code / codex with built-in egress credential scrubber')
269
+ .version('0.1.0');
270
+
271
+ program
272
+ .command('create <name>')
273
+ .description('Create + start a named cage')
274
+ .option('--type <type>', 'agent type (claude|codex)')
275
+ .option('--image <ref>', 'override default image (repo:tag)')
276
+ .option('--bind-workspace <path>', 'host path mounted at /workspace')
277
+ .option('--config-dir <path>', 'host path for the agent config dir (~/.claude / ~/.codex)')
278
+ .option('--bind-dirs <spec>', 'replace default mounts (repeatable; host:container[:mode])', collectInto, [])
279
+ .option('--extra-bind-dirs <spec>', 'append extra mounts (repeatable; host:container[:mode])', collectInto, [])
280
+ .option('--ssproxy-extensions <path>', 'custom scrub-pattern file')
281
+ .option('--tmux', 'run agent inside tmux')
282
+ .option('--no-tmux', 'disable tmux (default)')
283
+ .option('--memory <amount>', 'docker --memory (e.g. 4g)')
284
+ .option('--cpus <amount>', 'docker --cpus (e.g. 2)')
285
+ .action(commandCreate);
286
+
287
+ program
288
+ .command('run <name>')
289
+ .description('Attach to a running named cage')
290
+ .action(commandRun);
291
+
292
+ program.command('list').description('List all yolocage cages').action(commandList);
293
+ program.command('rm <name>').description('Destroy a named cage').action(commandRm);
294
+ program.command('logs <name>').description('Tail logs of a named cage').action(commandLogs);
295
+ program.command('pull').description('Pull / refresh yolocage images').action(commandPull);
296
+
297
+ program
298
+ .command('update')
299
+ .description('Update yolocage itself + refresh cage images')
300
+ .option('--check', 'only report the version delta; do not install')
301
+ .option('--no-pull', 'skip the docker image refresh after binary update')
302
+ .option('--force', 'install even if already on the latest version')
303
+ .action(async (opts) => {
304
+ const exit = await runUpdate({
305
+ check: !!opts.check,
306
+ pull: opts.pull !== false,
307
+ force: !!opts.force,
308
+ });
309
+ process.exit(exit || 0);
310
+ });
311
+
312
+ return program;
313
+ }
314
+
315
+ function collectInto(value, prev) {
316
+ prev.push(value);
317
+ return prev;
318
+ }
319
+
320
+ function main(argv) {
321
+ const userArgv = (argv || process.argv).slice(2);
322
+ let cls;
323
+ try {
324
+ cls = classifyArgv(userArgv);
325
+ } catch (e) {
326
+ process.stderr.write(`yc: ${e.message}\n`);
327
+ process.exit(2);
328
+ return;
329
+ }
330
+ if (cls.mode === 'shortcut') {
331
+ try {
332
+ runShortcut(cls.type, cls.passthrough);
333
+ } catch (e) {
334
+ process.stderr.write(`yc: ${e.message}\n`);
335
+ process.exit(2);
336
+ }
337
+ return;
338
+ }
339
+ // Subcommand form: hand to commander.
340
+ const program = buildProgram();
341
+ try {
342
+ program.parse(['node', 'yc.js', ...cls.argv]);
343
+ } catch (e) {
344
+ process.stderr.write(`yc: ${e.message}\n`);
345
+ process.exit(2);
346
+ }
347
+ }
348
+
349
+ if (require.main === module) {
350
+ main(process.argv);
351
+ }
352
+
353
+ module.exports = {
354
+ classifyArgv,
355
+ buildProgram,
356
+ buildCliLayer,
357
+ assertSafeCwdForShortcut,
358
+ assertCageName,
359
+ getCwdCageName,
360
+ main,
361
+ };