@starci/cli 1.0.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,54 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+
6
+ const runtimeEntry = (root) => path.join(root, 'scripts', 'cli', 'main.mjs');
7
+ const embeddedRuntimeRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..', '..');
8
+
9
+ const usableRoot = (candidate, exists) => {
10
+ if (!candidate || typeof candidate !== 'string') return null;
11
+ const root = path.resolve(candidate);
12
+ return exists(runtimeEntry(root)) ? root : null;
13
+ };
14
+
15
+ /**
16
+ * Locate the runtime in the binding order: env, per-user record, upward .claude, embedded checkout.
17
+ * A source that is set but unusable (a stale record, a bad env root) never throws: it falls through to the next source
18
+ * and is described in `skipped` (when given) so the caller can say why the lookup came up empty.
19
+ */
20
+ export function locateRuntime({ cwd = process.cwd(), env = process.env, home = os.homedir(), exists = existsSync, read = readFileSync,
21
+ embeddedRoot = embeddedRuntimeRoot, skipped = [] } = {}) {
22
+ const fromEnv = usableRoot(env.STARCI_RUNTIME, exists);
23
+ if (fromEnv) return { root: fromEnv, source: 'STARCI_RUNTIME' };
24
+ if (env.STARCI_RUNTIME) skipped.push(`STARCI_RUNTIME "${env.STARCI_RUNTIME}" has no scripts/cli/main.mjs`);
25
+
26
+ const record = path.join(home, '.starci', 'runtime.json');
27
+ if (exists(record)) {
28
+ let recorded;
29
+ let readable = true;
30
+ try {
31
+ recorded = JSON.parse(read(record, 'utf8'))?.root;
32
+ } catch {
33
+ readable = false;
34
+ skipped.push(`${record} is not valid JSON`);
35
+ }
36
+ const root = usableRoot(recorded, exists);
37
+ if (root) return { root, source: record };
38
+ if (readable) skipped.push(`${record} points at ${JSON.stringify(recorded ?? null)}, which has no scripts/cli/main.mjs (stale record)`);
39
+ }
40
+
41
+ let directory = path.resolve(cwd);
42
+ for (;;) {
43
+ const root = usableRoot(path.join(directory, '.claude'), exists);
44
+ if (root) return { root, source: 'upward' };
45
+ const parent = path.dirname(directory);
46
+ if (parent === directory) break;
47
+ directory = parent;
48
+ }
49
+ const embedded = usableRoot(embeddedRoot, exists);
50
+ if (embedded) return { root: embedded, source: 'embedded' };
51
+ return null;
52
+ }
53
+
54
+ export const runtimeEntryOf = runtimeEntry;
package/src/shim.mjs ADDED
@@ -0,0 +1,82 @@
1
+ import { mkdirSync, writeFileSync, chmodSync, unlinkSync } from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import process from 'node:process';
5
+
6
+ /** Programs whose PATH entries enforce the shared command policy before reaching the host tool. */
7
+ const TOOL_WRAPPERS = Object.freeze(['git', 'npm', 'npx', 'node', 'docker', 'supabase', 'gh', 'schtasks']);
8
+
9
+ const wrapperName = (program, platform) => platform === 'win32' ? `${program}.cmd` : program;
10
+ const wrapperText = ({ program, platform, node, cli }) => platform === 'win32'
11
+ ? `@echo off\r\n"${node}" "${cli}" guard raw ${program} -- %*\r\n`
12
+ : `#!/bin/sh\nexec "${node}" "${cli}" guard raw ${program} -- "$@"\n`;
13
+
14
+ /** Write the per-user runtime record, StarCi launcher and guarded PATH tool wrappers for that runtime. */
15
+ export function writeRuntimeShim({ root, home = os.homedir(), cli: launcher = null } = {}, deps = {}) {
16
+ const mkdir = deps.mkdir ?? ((directory) => mkdirSync(directory, { recursive: true }));
17
+ const write = deps.write ?? writeFileSync;
18
+ const chmod = deps.chmod ?? chmodSync;
19
+ const platform = deps.platform ?? process.platform;
20
+ const node = deps.node ?? process.execPath;
21
+ const runtimeRoot = path.resolve(root);
22
+ const starciHome = path.join(home, '.starci');
23
+ const shimDir = path.join(starciHome, 'bin');
24
+ const runtimeJson = path.join(starciHome, 'runtime.json');
25
+ const cli = launcher == null ? path.join(runtimeRoot, 'packages', 'cli', 'bin', 'starci.mjs') : path.resolve(launcher);
26
+
27
+ mkdir(shimDir);
28
+ write(runtimeJson, `${JSON.stringify({ root: runtimeRoot }, null, 2)}\n`);
29
+ if (platform === 'win32') {
30
+ const shim = path.join(shimDir, 'starci.cmd');
31
+ write(shim, `@echo off\r\n"${node}" "${cli}" %*\r\n`);
32
+ for (const program of TOOL_WRAPPERS) {
33
+ write(path.join(shimDir, wrapperName(program, platform)), wrapperText({ program, platform, node, cli }));
34
+ }
35
+ return { root: runtimeRoot, runtimeJson, shim };
36
+ }
37
+
38
+ const shim = path.join(shimDir, 'starci');
39
+ write(shim, `#!/bin/sh\nexec "${node}" "${cli}" "$@"\n`);
40
+ chmod(shim, 0o755);
41
+ for (const program of TOOL_WRAPPERS) {
42
+ const wrapper = path.join(shimDir, wrapperName(program, platform));
43
+ write(wrapper, wrapperText({ program, platform, node, cli }));
44
+ chmod(wrapper, 0o755);
45
+ }
46
+ return { root: runtimeRoot, runtimeJson, shim };
47
+ }
48
+
49
+ /** Remove only the guarded tool wrappers; a missing wrapper is already unlinked. */
50
+ export function removeToolWrappers({ home = os.homedir() } = {}, deps = {}) {
51
+ const platform = deps.platform ?? process.platform;
52
+ const unlink = deps.unlink ?? unlinkSync;
53
+ const shimDir = path.join(home, '.starci', 'bin');
54
+ const removed = [];
55
+ for (const program of TOOL_WRAPPERS) {
56
+ const wrapper = path.join(shimDir, wrapperName(program, platform));
57
+ try {
58
+ unlink(wrapper);
59
+ removed.push(wrapper);
60
+ } catch (error) {
61
+ if (error?.code !== 'ENOENT') throw error;
62
+ }
63
+ }
64
+ return removed;
65
+ }
66
+
67
+ /**
68
+ * The environment a runtime process runs in: the same as the caller's, without the guarded wrapper directory on PATH.
69
+ * An agent's own `git` or `npm` meets the wrappers; the runtime verbs that agent started act with the real tools, so a
70
+ * verb can do what its role allows. Nothing the agent can set changes this: the launcher always strips it.
71
+ */
72
+ export function runtimeEnv(env = process.env, { home = os.homedir(), platform = process.platform } = {}) {
73
+ const key = Object.keys(env).find((name) => name.toLowerCase() === 'path');
74
+ if (!key) return env;
75
+ const same = (entry) => {
76
+ const left = path.resolve(String(entry).replace(/^"(.*)"$/, '$1'));
77
+ const right = path.resolve(home, '.starci', 'bin');
78
+ return platform === 'win32' ? left.toLowerCase() === right.toLowerCase() : left === right;
79
+ };
80
+ const separator = platform === 'win32' ? ';' : ':';
81
+ return { ...env, [key]: String(env[key]).split(separator).filter((entry) => entry && !same(entry)).join(separator) };
82
+ }
@@ -0,0 +1,170 @@
1
+ const optionName = (token) => token.slice(2).split('=', 1)[0];
2
+
3
+ // A value may start with a single dash; one that is `--` or names a known flag is a missing value (use --flag=value).
4
+ const looksLikeFlag = (token, known) => token === '--' || (token.startsWith('--') && known.has(optionName(token)));
5
+
6
+ const optionValue = (argv, index, flag, known) => {
7
+ const token = argv[index];
8
+ const equals = token.indexOf('=');
9
+ if (equals >= 0) return { value: token.slice(equals + 1), consumed: 0 };
10
+ if (flag.type === 'boolean') return { value: true, consumed: 0 };
11
+ if (index + 1 >= argv.length || looksLikeFlag(argv[index + 1], known)) {
12
+ throw new Error(`--${flag.name} needs a value (use --${flag.name}=<value> for a value that looks like an option)`);
13
+ }
14
+ return { value: argv[index + 1], consumed: 1 };
15
+ };
16
+
17
+ const typedValue = (flag, raw) => {
18
+ if (flag.type === 'boolean') {
19
+ if (raw === true || raw === 'true') return true;
20
+ if (raw === 'false') return false;
21
+ throw new Error(`--${flag.name} expects true or false`);
22
+ }
23
+ if (flag.type === 'number') {
24
+ const value = String(raw).trim() === '' ? Number.NaN : Number(raw);
25
+ if (!Number.isFinite(value)) throw new Error(`--${flag.name} expects a number`);
26
+ return value;
27
+ }
28
+ if (flag.type === 'enum' && !flag.enum?.includes(raw)) {
29
+ throw new Error(`--${flag.name} expects one of: ${(flag.enum ?? []).join(', ')}`);
30
+ }
31
+ return raw;
32
+ };
33
+
34
+ /**
35
+ * Validate the arguments after a catalog group and verb.
36
+ *
37
+ * Global options are returned separately so dispatchers can implement them once;
38
+ * localArgs contains only the verb's positionals and local options, in input order.
39
+ * Everything after a bare `--` is passed through unchanged: it is never parsed as an option and
40
+ * only fills positional slots the verb declares.
41
+ */
42
+ export function validateArgs(argv, verb, globalFlags = []) {
43
+ const globals = new Map(globalFlags.map((flag) => [flag.name, { ...flag, global: true }]));
44
+ const locals = new Map((verb.flags ?? []).map((flag) => [flag.name, flag]));
45
+ const known = new Map([...globals, ...locals]);
46
+ const values = {};
47
+ const global = {};
48
+ const localArgs = [];
49
+ const positionals = [];
50
+ const passthrough = [];
51
+ let positionalOnly = false;
52
+
53
+ try {
54
+ for (let index = 0; index < argv.length; index += 1) {
55
+ const token = argv[index];
56
+ if (positionalOnly) {
57
+ passthrough.push(token);
58
+ localArgs.push(token);
59
+ continue;
60
+ }
61
+ if (token === '--') {
62
+ positionalOnly = true;
63
+ localArgs.push(token);
64
+ continue;
65
+ }
66
+ if (token.startsWith('--')) {
67
+ const name = optionName(token);
68
+ const flag = known.get(name);
69
+ if (!flag) throw new Error(`unknown option --${name}`);
70
+ const { value: raw, consumed } = optionValue(argv, index, flag, known);
71
+ const value = typedValue(flag, raw);
72
+ index += consumed;
73
+ if (flag.type === 'list') {
74
+ values[name] = [...(values[name] ?? []), value];
75
+ } else if (Object.hasOwn(values, name)) {
76
+ throw new Error(`--${name} may be given only once`);
77
+ } else {
78
+ values[name] = value;
79
+ }
80
+ if (flag.global) global[name] = values[name];
81
+ else {
82
+ localArgs.push(token);
83
+ if (consumed) localArgs.push(argv[index]);
84
+ }
85
+ continue;
86
+ }
87
+ if (token.startsWith('-')) throw new Error(`unknown option ${token}`);
88
+ positionals.push(token);
89
+ localArgs.push(token);
90
+ }
91
+
92
+ for (const flag of locals.values()) {
93
+ if (flag.required && !Object.hasOwn(values, flag.name)) throw new Error(`missing required option --${flag.name}`);
94
+ }
95
+
96
+ const positionalSchema = verb.positional ?? [];
97
+ const variadicAt = positionalSchema.findIndex((entry) => entry.variadic === true);
98
+ const maximum = variadicAt >= 0 ? Infinity : positionalSchema.length;
99
+ if (positionals.length > maximum) throw new Error(`too many positional arguments for ${verb.verb ?? 'command'}`);
100
+ positionals.push(...passthrough.slice(0, Math.max(0, maximum - positionals.length)));
101
+ for (let index = 0; index < positionalSchema.length; index += 1) {
102
+ const schema = positionalSchema[index];
103
+ const supplied = variadicAt === index ? positionals.slice(index) : positionals[index];
104
+ const empty = Array.isArray(supplied) ? supplied.length === 0 : supplied === undefined;
105
+ if (schema.required && empty) throw new Error(`missing required positional ${schema.name}`);
106
+ const candidates = Array.isArray(supplied) ? supplied : supplied === undefined ? [] : [supplied];
107
+ for (const candidate of candidates) {
108
+ if (schema.enum && !schema.enum.includes(candidate)) {
109
+ throw new Error(`${schema.name} expects one of: ${schema.enum.join(', ')}`);
110
+ }
111
+ }
112
+ }
113
+
114
+ if (global.edition !== undefined && Array.isArray(verb.editions) && !verb.editions.includes(global.edition)) {
115
+ throw new Error(`${verb.group ?? 'this command'} ${verb.verb ?? ''}`.trim() + ` is not available in the ${global.edition} edition (editions: ${verb.editions.join(', ')})`);
116
+ }
117
+ if (global.cwd === '') throw new Error('--cwd needs a value');
118
+ if (global.json === true && verb.json === 'none') {
119
+ throw new Error(`${verb.group ?? 'this command'} ${verb.verb ?? ''}`.trim() + ' has no machine output');
120
+ }
121
+ const args = {};
122
+ for (const flag of locals.values()) {
123
+ if (Object.hasOwn(values, flag.name)) args[flag.name] = values[flag.name];
124
+ else if (flag.default !== undefined) args[flag.name] = flag.type === 'list' && !Array.isArray(flag.default) ? [flag.default] : flag.default;
125
+ }
126
+ return { ok: true, values, args, global, localArgs, positionals };
127
+ } catch (error) {
128
+ return { ok: false, code: 2, error: String(error?.message ?? error) };
129
+ }
130
+ }
131
+
132
+ /** Insert dispatcher-owned flags before a pass-through `--`, where handlers still parse them as options. */
133
+ export function withFlagsBeforeDashes(args, extra) {
134
+ const at = args.indexOf('--');
135
+ return at < 0 ? [...args, ...extra] : [...args.slice(0, at), ...extra, ...args.slice(at)];
136
+ }
137
+
138
+ const isHelpToken = (token) => token === '--help' || token === '-h' || token === '--help=true';
139
+
140
+ /**
141
+ * Find the group and verb while allowing catalog global options before them.
142
+ * `help` is true when --help or -h appears before any bare `--`; everything after `--` is pass-through.
143
+ */
144
+ export function splitCommand(argv, globalFlags = []) {
145
+ const globals = new Map(globalFlags.map((flag) => [flag.name, flag]));
146
+ const command = [];
147
+ const args = [];
148
+ let help = false;
149
+ for (let index = 0; index < argv.length; index += 1) {
150
+ const token = argv[index];
151
+ if (token === '--') {
152
+ args.push(...argv.slice(index));
153
+ break;
154
+ }
155
+ if (isHelpToken(token)) {
156
+ help = true;
157
+ continue;
158
+ }
159
+ if (token.startsWith('--')) {
160
+ const flag = globals.get(optionName(token));
161
+ args.push(token);
162
+ const next = argv[index + 1];
163
+ if (flag && flag.type !== 'boolean' && !token.includes('=') && next !== undefined && !looksLikeFlag(next, globals)) args.push(argv[++index]);
164
+ } else if (token.startsWith('-') && token.length > 1) {
165
+ args.push(token);
166
+ } else if (command.length < 2) command.push(token);
167
+ else args.push(token);
168
+ }
169
+ return { group: command[0] ?? null, verb: command[1] ?? null, args, help };
170
+ }