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/LICENSE +21 -0
- package/README.md +168 -0
- package/lib/bind-spec.js +104 -0
- package/lib/config.js +158 -0
- package/lib/docker.js +170 -0
- package/lib/types.js +64 -0
- package/lib/update.js +194 -0
- package/lib/ycrc.js +98 -0
- package/package.json +57 -0
- package/yc.js +361 -0
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
|
+
};
|