anon-pi 0.5.0 → 0.7.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/src/cli.ts CHANGED
@@ -21,21 +21,31 @@ import {
21
21
  } from 'node:fs';
22
22
  import {readSync} from 'node:fs';
23
23
  import {spawnSync, execFileSync} from 'node:child_process';
24
- import {join, dirname} from 'node:path';
24
+ import {join, dirname, resolve} from 'node:path';
25
25
  import {
26
26
  AnonPiError,
27
27
  HELP,
28
28
  MODELS_FILE,
29
+ SETTINGS_FILE,
30
+ SETTINGS_SEED_FILE,
29
31
  SEED_MARKER,
30
32
  DEFAULT_MACHINE,
31
33
  envFromProcess,
32
34
  buildMenuChoiceList,
33
35
  buildMenuEntries,
36
+ builtinProjectsRoot,
34
37
  deriveProjectUsage,
38
+ globalModelsSeedPath,
39
+ globalSettingsSeedPath,
40
+ machineAgentDir,
35
41
  machineDir,
36
42
  machineHomeDir,
37
43
  machineJsonPath,
44
+ machineModelsSeedPath,
38
45
  machineSessionsDir,
46
+ mergeModelSelection,
47
+ resolveModelsSeedPath,
48
+ resolveSettingsSeedPath,
39
49
  validateName,
40
50
  resolveDeleteHome,
41
51
  resolveDeleteProject,
@@ -60,6 +70,12 @@ import {
60
70
  interpretSocks5Handshake,
61
71
  initImageMenu,
62
72
  generateModelsJson,
73
+ generateModelSelection,
74
+ pickLocalProviderModels,
75
+ parseModelsListing,
76
+ mergeModelSources,
77
+ resolveHostModelsPath,
78
+ LOCAL_PROVIDER_API_KEY,
63
79
  parseVerifyExitIp,
64
80
  processHint,
65
81
  socks5hUrl,
@@ -73,12 +89,16 @@ import {
73
89
  type InitImageChoice,
74
90
  type AnonPiConfig,
75
91
  type AnonPiEnv,
92
+ type GeneratedModel,
93
+ type ModelCandidate,
94
+ type ModelSelection,
76
95
  type KeptContainer,
77
96
  type LaunchIntent,
78
97
  type Machine,
79
98
  type MachineConfig,
80
99
  type MachineCommand,
81
100
  type ParsedLaunch,
101
+ type PiModelsFile,
82
102
  } from './anon-pi.js';
83
103
 
84
104
  // The netcage label anon-pi stamps its launch-identity key onto (keptContainerKey)
@@ -136,9 +156,57 @@ function main(argv: string[]): number {
136
156
  return reportAnonPiError(e);
137
157
  }
138
158
 
159
+ // FIRST RUN: no config.json yet. Rather than fail deep in the launch with the
160
+ // bare "set ANON_PI_PROXY" wall (which reads like a doc dump the first time),
161
+ // welcome the user and run `init` automatically, then continue into the
162
+ // launch they asked for. Needs a TTY (init is interactive); without one we
163
+ // fall through to the launch path, whose fail-closed proxy error is the right
164
+ // signal for a script. An explicit ANON_PI_PROXY/ANON_PI_LLM env pair also
165
+ // skips this (the user is driving config via env, not the file).
166
+ if (isFirstRun()) {
167
+ const code = runFirstRunInit();
168
+ if (code !== 0) return code; // init aborted / failed: do not launch.
169
+ }
170
+
139
171
  return runLaunch(parsed);
140
172
  }
141
173
 
174
+ /**
175
+ * First run = no config.json in the anon-pi home AND the user has not supplied
176
+ * the forced-egress inputs via env (ANON_PI_PROXY is what the launch fails
177
+ * closed on; if it is set the user is configuring via env and we do not
178
+ * onboard). We only auto-onboard on an interactive terminal.
179
+ */
180
+ function isFirstRun(): boolean {
181
+ const env = envFromProcess(process.env);
182
+ if (nonEmptyEnv(env.proxy)) return false; // env-driven config; no onboarding.
183
+ if (!process.stdin.isTTY) return false; // scripts get the fail-closed error.
184
+ const configPath = join(resolveAnonPiHome(env), 'config.json');
185
+ return !existsSync(configPath);
186
+ }
187
+
188
+ /** Show a first-time welcome, then run `init`. Returns init's exit code. */
189
+ function runFirstRunInit(): number {
190
+ process.stdout.write(
191
+ '\n' +
192
+ "Welcome to anon-pi. It looks like this is your first run (there's no\n" +
193
+ 'config yet), so let us set things up before launching.\n' +
194
+ '\n' +
195
+ 'anon-pi runs pi on anonymized, jailed MACHINES: all of pi\u2019s web/DNS egress\n' +
196
+ 'is forced through your socks5h proxy (fail-closed), with ONE direct hole to\n' +
197
+ 'a local model. Your machines + conversations live in ~/.anon-pi/.\n' +
198
+ '\n' +
199
+ 'Running `anon-pi init` now (re-runnable any time; nothing is destroyed).\n' +
200
+ '\n',
201
+ );
202
+ return runInit([]);
203
+ }
204
+
205
+ /** Whether an env value is present + non-blank. */
206
+ function nonEmptyEnv(v: string | undefined): boolean {
207
+ return typeof v === 'string' && v.trim() !== '';
208
+ }
209
+
142
210
  // --- the launch path --------------------------------------------------------
143
211
  function runLaunch(parsed: ParsedLaunch): number {
144
212
  const env = envFromProcess(process.env);
@@ -185,9 +253,12 @@ function runLaunch(parsed: ParsedLaunch): number {
185
253
  const home = machineHomeDir(env, machineName);
186
254
  const machine: Machine = {name: machineName, home, image};
187
255
 
188
- // The generated models.json for this machine (mounted read-only for the
189
- // first-launch seed) when present. Keyed per machine, not per import.
190
- const modelsSeed = join(machineDir(env, machineName), MODELS_FILE);
256
+ // The local-model models.json + settings seed for this machine's FRESH-home
257
+ // promotion. GLOBAL by default (<home>/models.json, shared across every
258
+ // machine because the `llm` endpoint is global), with an optional
259
+ // per-machine override (machines/<M>/models.json). Mounted read-only.
260
+ const modelsSeed = resolveModelsSeedPath(env, machineName, existsSync);
261
+ const settingsSeed = resolveSettingsSeedPath(env, machineName, existsSync);
191
262
 
192
263
  intent = {
193
264
  machine,
@@ -199,7 +270,8 @@ function runLaunch(parsed: ParsedLaunch): number {
199
270
  keep: parsed.keep,
200
271
  proxy,
201
272
  llmDirect: llm,
202
- modelsSeed: existsSync(modelsSeed) ? modelsSeed : undefined,
273
+ modelsSeed,
274
+ settingsSeed,
203
275
  };
204
276
  } catch (e) {
205
277
  return reportAnonPiError(e);
@@ -900,9 +972,23 @@ WHAT IT DOES
900
972
  1. PROXY: probes common SOCKS ports, confirms SOCKS5 via a real handshake,
901
973
  shows the findings (EVIDENCE only, never a provider label), then runs
902
974
  \`netcage verify\` and shows the real EXIT IP as proof. You confirm.
903
- 2. LOCAL MODEL: captures host:port, probes reachability, generates models.json.
975
+ 2. LOCAL MODEL: captures host:port, probes it, then IMPORTS models. It merges
976
+ your pi config's matching provider ([configured], well-tuned) with the
977
+ endpoint's live /v1/models ([server]); you pick which to import + the
978
+ default. Only the provider served by this endpoint (the one --allow-direct
979
+ hole) is ever read, so no other provider or key can enter the seed. Writes
980
+ models.json + a settings seed (the default-model selection).
904
981
  3. IMAGE: pick a shipped Dockerfile (built via podman), an existing ref, or skip.
982
+ 4. PROJECTS ROOT: the host folder mounted at /projects (default ~/.anon-pi/
983
+ projects); point it at your own dev folder, or keep the default.
905
984
  Then writes ~/.anon-pi/config.json + the \`default\` machine. Never destroys homes.
985
+
986
+ Runs AUTOMATICALLY the first time you launch anon-pi with no config yet.
987
+
988
+ FLAGS
989
+ --force-allow-local-llm-api-key carry a REAL apiKey from the matching host
990
+ provider into the seed (init refuses by default: a host credential should
991
+ not enter the anonymized machine home unless you say so).
906
992
  `;
907
993
 
908
994
  function runInit(args: string[]): number {
@@ -910,9 +996,12 @@ function runInit(args: string[]): number {
910
996
  process.stdout.write(INIT_HELP);
911
997
  return 0;
912
998
  }
913
- if (args.length > 0) {
999
+ const FORCE_KEY_FLAG = '--force-allow-local-llm-api-key';
1000
+ const forceLocalApiKey = args.includes(FORCE_KEY_FLAG);
1001
+ const extra = args.filter((a) => a !== FORCE_KEY_FLAG);
1002
+ if (extra.length > 0) {
914
1003
  process.stderr.write(
915
- `anon-pi: init takes no arguments, got: ${args.join(' ')}. Run \`anon-pi init --help\`.\n`,
1004
+ `anon-pi: init takes no arguments (except ${FORCE_KEY_FLAG}), got: ${extra.join(' ')}. Run \`anon-pi init --help\`.\n`,
916
1005
  );
917
1006
  return 1;
918
1007
  }
@@ -940,16 +1029,23 @@ function runInit(args: string[]): number {
940
1029
  if (proxyHostPort === undefined) return 1;
941
1030
  const proxyUrl = socks5hUrl(proxyHostPort);
942
1031
 
943
- // 2) LOCAL MODEL endpoint: capture + probe + generate models.json.
944
- const llm = initLlmStep(current.llm);
945
- // llm may be undefined if the user skipped it (the launch path still errors
946
- // without one, but init lets you set it later; we do not force it here).
1032
+ // 2) LOCAL MODEL endpoint + model import: capture the endpoint, then merge the
1033
+ // host config's matching provider (well-tuned) with the endpoint's live
1034
+ // /v1/models, let the user pick which to import + the default. May ABORT
1035
+ // (a real host apiKey without --force-allow-local-llm-api-key).
1036
+ const llmResult = initLlmStep(env, current.llm, forceLocalApiKey);
1037
+ if (llmResult === ABORT) return 1;
1038
+ const llm = llmResult.endpoint;
947
1039
 
948
1040
  // 3) DEFAULT MACHINE IMAGE: menu (shipped Dockerfiles / existing ref / skip).
949
1041
  const image = initImageStep();
950
1042
  if (image === ABORT) return 1;
951
1043
 
952
- // 4) WRITE config.json + the `default` machine (never destroying an existing
1044
+ // 4) PROJECTS ROOT: the host dir mounted at /projects (default ~/.anon-pi/
1045
+ // projects). Overridable per-launch with `--mount`; this sets the default.
1046
+ const projects = initProjectsStep(env, current.projects);
1047
+
1048
+ // 5) WRITE config.json + the `default` machine (never destroying an existing
953
1049
  // home). The proxy is always present (we only reach here on a chosen proxy).
954
1050
  const anonHome = resolveAnonPiHome(env);
955
1051
  mkdirSync(anonHome, {recursive: true});
@@ -958,7 +1054,7 @@ function runInit(args: string[]): number {
958
1054
  proxy: proxyUrl,
959
1055
  llm: llm ?? current.llm,
960
1056
  defaultMachine: current.defaultMachine ?? DEFAULT_MACHINE,
961
- projects: current.projects,
1057
+ projects: projects ?? current.projects,
962
1058
  };
963
1059
  writeFileSync(configPath, serializeConfigJson(nextConfig));
964
1060
  process.stdout.write(`\nanon-pi: wrote ${configPath}.\n`);
@@ -967,20 +1063,75 @@ function runInit(args: string[]): number {
967
1063
  // pin/re-pin its image when one was chosen. Its home seeds on first launch.
968
1064
  initWriteDefaultMachine(env, image);
969
1065
 
970
- // The per-machine models.json seed for the default machine, generated from the
971
- // captured endpoint (this is the `import` replacement). Only when we have an
972
- // endpoint; written next to the machine so the first-launch seed mounts it.
973
- if ((llm ?? current.llm) !== undefined) {
974
- const mdir = machineDir(env, DEFAULT_MACHINE);
975
- mkdirSync(mdir, {recursive: true});
976
- const models = generateModelsJson((llm ?? current.llm) as string);
977
- writeFileSync(
978
- join(mdir, MODELS_FILE),
979
- JSON.stringify(models, null, '\t') + '\n',
1066
+ // The GLOBAL local-model models.json + settings seed, generated from the
1067
+ // captured endpoint + the CHOSEN models (this is the `import` replacement).
1068
+ const endpoint = llm ?? current.llm;
1069
+ if (endpoint !== undefined) {
1070
+ const models = generateModelsJson(
1071
+ endpoint,
1072
+ llmResult.models,
1073
+ llmResult.apiKey,
980
1074
  );
1075
+ const modelsBody = JSON.stringify(models, null, '\t') + '\n';
1076
+ // GLOBAL seed: the local model is a workspace-level thing (config.llm is
1077
+ // global), so its models.json lives once at the workspace root and seeds
1078
+ // EVERY machine's fresh home. A machine may still override with its own
1079
+ // machines/<M>/models.json.
1080
+ mkdirSync(resolveAnonPiHome(env), {recursive: true});
1081
+ writeFileSync(globalModelsSeedPath(env), modelsBody);
1082
+
1083
+ // Migration: earlier versions wrote this seed under machines/default/. Now
1084
+ // that it is global, remove the old default-machine copy so `default`
1085
+ // picks up the global seed like every other machine (leaving it would look
1086
+ // like a deliberate per-machine override and shadow the global one). Only
1087
+ // the `default` machine's init-generated copy is migrated; a per-machine
1088
+ // override you created for ANY OTHER machine is left untouched.
1089
+ for (const stale of [
1090
+ machineModelsSeedPath(env, DEFAULT_MACHINE),
1091
+ join(machineDir(env, DEFAULT_MACHINE), SETTINGS_SEED_FILE),
1092
+ ]) {
1093
+ if (existsSync(stale)) rmSync(stale, {force: true});
1094
+ }
981
1095
  process.stdout.write(
982
- `anon-pi: wrote the local-model models.json for machine "${DEFAULT_MACHINE}".\n`,
1096
+ `anon-pi: wrote the global local-model models.json ` +
1097
+ `(${llmResult.models.length} model${llmResult.models.length === 1 ? '' : 's'}; shared by all machines).\n`,
983
1098
  );
1099
+
1100
+ // settings.json seed: the default model + enabledModels for the imported
1101
+ // set. The first-launch promotion merges it into each home's settings (so
1102
+ // image-staged packages/extensions survive). Only when the user picked at
1103
+ // least one model + a default.
1104
+ const selection =
1105
+ llmResult.defaultId !== undefined && llmResult.models.length > 0
1106
+ ? generateModelSelection(
1107
+ llmResult.models.map((m) => m.id),
1108
+ llmResult.defaultId,
1109
+ )
1110
+ : undefined;
1111
+ if (selection) {
1112
+ writeFileSync(
1113
+ globalSettingsSeedPath(env),
1114
+ JSON.stringify(selection, null, '\t') + '\n',
1115
+ );
1116
+ process.stdout.write(
1117
+ `anon-pi: default model set to "${llmResult.defaultId}".\n`,
1118
+ );
1119
+ }
1120
+
1121
+ // Re-run reconfigure: the seed above only takes effect on a FRESH home
1122
+ // (the first-launch promotion is marker-guarded). Apply the new
1123
+ // models.json + settings selection DIRECTLY to EVERY already-seeded machine
1124
+ // home now, so re-running `init` updates existing environments without
1125
+ // wiping conversations (init runs on the host; homes are host dirs). Fresh
1126
+ // (unseeded) homes are left to the launch-time seed. A machine with its OWN
1127
+ // per-machine models.json override is skipped (its local model differs).
1128
+ const updated = applyModelsToSeededHomes(env, modelsBody, selection);
1129
+ if (updated.length > 0) {
1130
+ process.stdout.write(
1131
+ `anon-pi: updated ${updated.length} existing machine home${updated.length === 1 ? '' : 's'} ` +
1132
+ `(${updated.join(', ')}); conversations untouched.\n`,
1133
+ );
1134
+ }
984
1135
  }
985
1136
 
986
1137
  process.stdout.write(
@@ -990,6 +1141,42 @@ function runInit(args: string[]): number {
990
1141
  return 0;
991
1142
  }
992
1143
 
1144
+ /**
1145
+ * Apply the freshly-generated GLOBAL local-model config to EVERY already-seeded
1146
+ * machine home directly (init runs on the host; homes are host dirs). The
1147
+ * launch-time seed only promotes on a FRESH home (marker-guarded), so without
1148
+ * this a re-run of `init` would update the global seed but never reach existing
1149
+ * homes. For each machine: skip a FRESH home (no marker — the launch seed will
1150
+ * pick up the new global seed), skip a machine with its OWN per-machine
1151
+ * models.json override (its local model differs on purpose), else overwrite the
1152
+ * home's models.json and merge the settings selection. Conversations/sessions
1153
+ * are untouched. Returns the machine names updated.
1154
+ */
1155
+ function applyModelsToSeededHomes(
1156
+ env: AnonPiEnv,
1157
+ modelsBody: string,
1158
+ selection: ModelSelection | undefined,
1159
+ ): string[] {
1160
+ const updated: string[] = [];
1161
+ for (const machine of listMachineNames(env).sort()) {
1162
+ const agentDir = machineAgentDir(env, machine);
1163
+ // Only an already-seeded home (marker present).
1164
+ if (!existsSync(join(agentDir, SEED_MARKER))) continue;
1165
+ // A machine that deliberately overrides the global models.json keeps its
1166
+ // own local model; do not clobber its home with the global one.
1167
+ if (existsSync(machineModelsSeedPath(env, machine))) continue;
1168
+
1169
+ writeFileSync(join(agentDir, MODELS_FILE), modelsBody);
1170
+ if (selection) {
1171
+ const settingsPath = join(agentDir, SETTINGS_FILE);
1172
+ const merged = mergeModelSelection(readJsonFile(settingsPath), selection);
1173
+ writeFileSync(settingsPath, JSON.stringify(merged, null, '\t') + '\n');
1174
+ }
1175
+ updated.push(machine);
1176
+ }
1177
+ return updated;
1178
+ }
1179
+
993
1180
  /** A sentinel a step returns when the user aborted (distinct from "skipped"). */
994
1181
  const ABORT = Symbol('abort');
995
1182
 
@@ -1003,7 +1190,7 @@ const ABORT = Symbol('abort');
1003
1190
  */
1004
1191
  function initProxyStep(currentProxy: string | undefined): string | undefined {
1005
1192
  process.stdout.write(
1006
- 'Step 1/3 - proxy (the socks5h endpoint that anonymizes egress)\n',
1193
+ 'Step 1/4 - proxy (the socks5h endpoint that anonymizes egress)\n',
1007
1194
  );
1008
1195
  if (currentProxy) {
1009
1196
  process.stdout.write(` current: ${currentProxy}\n`);
@@ -1122,15 +1309,31 @@ function initProxyStep(currentProxy: string | undefined): string | undefined {
1122
1309
  }
1123
1310
  }
1124
1311
 
1312
+ /** What initLlmStep resolves: the endpoint, the chosen model entries, the apiKey to seed, and the default id. */
1313
+ interface LlmStepResult {
1314
+ endpoint: string | undefined;
1315
+ models: GeneratedModel[];
1316
+ apiKey: string;
1317
+ defaultId: string | undefined;
1318
+ }
1319
+
1125
1320
  /**
1126
1321
  * The LOCAL MODEL step: capture host:port (pre-filled from config), probe TCP
1127
- * reachability (evidence, not a gate), and return the endpoint (undefined if the
1128
- * user skips it). models.json is generated from it by runInit. The one direct
1129
- * hole; all other egress stays proxied.
1322
+ * reachability, then IMPORT models. It merges TWO sources, both scoped to the
1323
+ * endpoint (the one `--allow-direct` hole, so no other provider can enter the
1324
+ * seed): the host `~/.pi/agent/models.json` provider whose baseUrl matches the
1325
+ * endpoint (well-tuned entries, marked [configured]) and the endpoint's live
1326
+ * `/v1/models` (bare ids, marked [server]). The user picks which to import and
1327
+ * the default. Returns the endpoint + chosen entries + apiKey + default, or
1328
+ * ABORT (a real host apiKey without --force-allow-local-llm-api-key).
1130
1329
  */
1131
- function initLlmStep(currentLlm: string | undefined): string | undefined {
1330
+ function initLlmStep(
1331
+ env: AnonPiEnv,
1332
+ currentLlm: string | undefined,
1333
+ forceLocalApiKey: boolean,
1334
+ ): LlmStepResult | typeof ABORT {
1132
1335
  process.stdout.write(
1133
- '\nStep 2/3 - local model endpoint (the ONE direct hole)\n',
1336
+ '\nStep 2/4 - local model endpoint (the ONE direct hole)\n',
1134
1337
  );
1135
1338
  if (currentLlm) process.stdout.write(` current: ${currentLlm}\n`);
1136
1339
  const prefill = currentLlm
@@ -1139,10 +1342,17 @@ function initLlmStep(currentLlm: string | undefined): string | undefined {
1139
1342
  const ans = promptLine(
1140
1343
  ` Local model host:port, e.g. 192.168.1.150:8080${prefill}: `,
1141
1344
  );
1142
- if (ans === undefined) return currentLlm;
1143
- const trimmed = ans.trim();
1144
- if (trimmed === '') return currentLlm;
1145
- const endpoint = trimmed;
1345
+ const raw = ans === undefined ? '' : ans.trim();
1346
+ const endpoint = raw === '' ? currentLlm : raw;
1347
+ if (endpoint === undefined) {
1348
+ // No endpoint at all: nothing to import.
1349
+ return {
1350
+ endpoint: undefined,
1351
+ models: [],
1352
+ apiKey: LOCAL_PROVIDER_API_KEY,
1353
+ defaultId: undefined,
1354
+ };
1355
+ }
1146
1356
 
1147
1357
  // Probe reachability: evidence only. A closed port is not fatal (the model may
1148
1358
  // start later); we just report it.
@@ -1150,15 +1360,152 @@ function initLlmStep(currentLlm: string | undefined): string | undefined {
1150
1360
  const colon = key.lastIndexOf(':');
1151
1361
  const host = colon > 0 ? key.slice(0, colon) : key;
1152
1362
  const port = colon > 0 ? Number(key.slice(colon + 1)) : 80;
1363
+ const reachable = Number.isFinite(port) ? probeTcp(host, port) : false;
1364
+ process.stdout.write(
1365
+ reachable
1366
+ ? ` reachable: ${host}:${port} accepted a TCP connection.\n`
1367
+ : ` note: ${host}:${port} did not accept a connection now (the model may not be up yet).\n`,
1368
+ );
1369
+
1370
+ // SOURCE A: the host models.json provider matching this endpoint (only that
1371
+ // one — the anonymity scoping). Its apiKey is checked: a REAL key is refused
1372
+ // unless --force-allow-local-llm-api-key.
1373
+ const hostModels = readJsonFile(resolveHostModelsPath(env));
1374
+ const match = pickLocalProviderModels(
1375
+ (hostModels as PiModelsFile) ?? {},
1376
+ endpoint,
1377
+ );
1378
+ let apiKey = LOCAL_PROVIDER_API_KEY;
1379
+ if (match && match.apiKeyLooksReal) {
1380
+ if (!forceLocalApiKey) {
1381
+ process.stderr.write(
1382
+ `\n anon-pi: the matching provider in your pi config carries a real-looking\n` +
1383
+ ` apiKey. Seeding it would put a host credential into the anonymized machine\n` +
1384
+ ` home. Refusing. If this key is genuinely safe for the local model, re-run\n` +
1385
+ ` \`anon-pi init --force-allow-local-llm-api-key\` to carry it through.\n`,
1386
+ );
1387
+ return ABORT;
1388
+ }
1389
+ apiKey = (match.apiKey ?? LOCAL_PROVIDER_API_KEY).trim();
1390
+ process.stdout.write(
1391
+ ' WARNING: carrying the host provider apiKey into the seed (--force-allow-local-llm-api-key).\n',
1392
+ );
1393
+ }
1394
+ const hostModelEntries = match?.models ?? [];
1395
+
1396
+ // SOURCE B: the endpoint's live /v1/models (bare ids).
1397
+ let serverIds: string[] = [];
1153
1398
  if (Number.isFinite(port)) {
1154
- const reachable = probeTcp(host, port);
1399
+ const listing = fetchModelsListing(host, port);
1400
+ serverIds = parseModelsListing(listing);
1401
+ }
1402
+
1403
+ if (hostModelEntries.length === 0 && serverIds.length === 0) {
1155
1404
  process.stdout.write(
1156
- reachable
1157
- ? ` reachable: ${host}:${port} accepted a TCP connection.\n`
1158
- : ` note: ${host}:${port} did not accept a connection now (the model may not be up yet).\n`,
1405
+ ' No models found (no matching provider in your pi config, and the server\n' +
1406
+ ' returned none). The provider is still seeded; add models in pi later.\n',
1159
1407
  );
1408
+ return {endpoint, models: [], apiKey, defaultId: undefined};
1409
+ }
1410
+
1411
+ const candidates = mergeModelSources(hostModelEntries, serverIds);
1412
+ const chosen = initModelPicker(candidates);
1413
+ if (chosen === undefined) {
1414
+ // Skip: seed the provider with no models.
1415
+ return {endpoint, models: [], apiKey, defaultId: undefined};
1416
+ }
1417
+ const defaultId = initDefaultModelPicker(chosen);
1418
+ return {endpoint, models: chosen, apiKey, defaultId};
1419
+ }
1420
+
1421
+ /**
1422
+ * Present the merged candidate list and let the user choose which to import.
1423
+ * Options: Enter/`c` = all CONFIGURED (host-tuned; the safe default), `a` = ALL
1424
+ * (server + configured), space/comma-separated NUMBERS = those, `s` = skip.
1425
+ * Returns the chosen entries, or undefined to skip (seed no models).
1426
+ */
1427
+ function initModelPicker(
1428
+ candidates: readonly ModelCandidate[],
1429
+ ): GeneratedModel[] | undefined {
1430
+ const configured = candidates.filter((c) => c.configured);
1431
+ process.stdout.write('\n Models served by this endpoint:\n');
1432
+ candidates.forEach((c, i) => {
1433
+ const tag = c.configured ? '[configured]' : '[server]';
1434
+ process.stdout.write(` [${i + 1}] ${c.id} ${tag}\n`);
1435
+ });
1436
+ process.stdout.write(
1437
+ ' [configured] = from your pi config (well-tuned); [server] = the server\n' +
1438
+ ' also reports it (a minimal entry is synthesized).\n',
1439
+ );
1440
+ const hasConfigured = configured.length > 0;
1441
+ const defaultHint = hasConfigured
1442
+ ? 'Enter/c = all configured, a = all, numbers = pick, s = skip'
1443
+ : 'Enter/a = all, numbers = pick, s = skip';
1444
+ for (;;) {
1445
+ const ans = promptLine(` Import which? (${defaultHint}): `);
1446
+ const v = (ans ?? '').trim().toLowerCase();
1447
+ if (v === 's') return undefined;
1448
+ if (v === '' && hasConfigured) return configured.map((c) => c.entry);
1449
+ if (v === 'c') {
1450
+ if (!hasConfigured) {
1451
+ process.stdout.write(
1452
+ ' No [configured] models; pick numbers or `a` for all.\n',
1453
+ );
1454
+ continue;
1455
+ }
1456
+ return configured.map((c) => c.entry);
1457
+ }
1458
+ if (v === 'a' || (v === '' && !hasConfigured)) {
1459
+ return candidates.map((c) => c.entry);
1460
+ }
1461
+ // Numbers (space/comma separated).
1462
+ const picks = v
1463
+ .split(/[\s,]+/)
1464
+ .filter((t) => t !== '')
1465
+ .map((t) => Number(t) - 1);
1466
+ if (
1467
+ picks.length > 0 &&
1468
+ picks.every((i) => i >= 0 && i < candidates.length)
1469
+ ) {
1470
+ // De-dup, keep list order.
1471
+ const seen = new Set<number>();
1472
+ const out: GeneratedModel[] = [];
1473
+ for (const i of picks) {
1474
+ if (!seen.has(i)) {
1475
+ seen.add(i);
1476
+ out.push(candidates[i].entry);
1477
+ }
1478
+ }
1479
+ return out;
1480
+ }
1481
+ process.stdout.write(
1482
+ ` Please enter Enter/c/a/s or numbers 1-${candidates.length}.\n`,
1483
+ );
1484
+ }
1485
+ }
1486
+
1487
+ /**
1488
+ * Pick the DEFAULT model among the chosen ones. Defaults to the first (Enter);
1489
+ * accepts a number. Returns the chosen id.
1490
+ */
1491
+ function initDefaultModelPicker(chosen: readonly GeneratedModel[]): string {
1492
+ if (chosen.length === 1) return chosen[0].id;
1493
+ process.stdout.write('\n Which is the DEFAULT model?\n');
1494
+ chosen.forEach((m, i) => {
1495
+ process.stdout.write(` [${i + 1}] ${m.id}\n`);
1496
+ });
1497
+ for (;;) {
1498
+ const ans = promptLine(
1499
+ ` Default [1-${chosen.length}] (Enter = ${chosen[0].id}): `,
1500
+ );
1501
+ const v = (ans ?? '').trim();
1502
+ if (v === '') return chosen[0].id;
1503
+ const idx = Number(v) - 1;
1504
+ if (Number.isInteger(idx) && idx >= 0 && idx < chosen.length) {
1505
+ return chosen[idx].id;
1506
+ }
1507
+ process.stdout.write(` Please pick a number 1-${chosen.length}.\n`);
1160
1508
  }
1161
- return endpoint;
1162
1509
  }
1163
1510
 
1164
1511
  /**
@@ -1168,7 +1515,7 @@ function initLlmStep(currentLlm: string | undefined): string | undefined {
1168
1515
  */
1169
1516
  function initImageStep(): string | undefined | typeof ABORT {
1170
1517
  process.stdout.write(
1171
- '\nStep 3/3 - default machine image (an image with `pi` on PATH)\n',
1518
+ '\nStep 3/4 - default machine image (an image with `pi` on PATH)\n',
1172
1519
  );
1173
1520
  const menu = initImageMenu();
1174
1521
  menu.forEach((e, i) => {
@@ -1220,6 +1567,44 @@ function initImageStep(): string | undefined | typeof ABORT {
1220
1567
  }
1221
1568
  }
1222
1569
 
1570
+ /**
1571
+ * The PROJECTS-ROOT step: the host directory mounted into the jail at /projects
1572
+ * (pi's cwd; a project is /projects/<name>). It defaults to the built-in
1573
+ * `~/.anon-pi/projects/`; the user may point it at their own dev folder so bare
1574
+ * `anon-pi` works there without passing `--mount` every time. `--mount <parent>`
1575
+ * still overrides it per-launch. Returns the chosen root, or undefined to keep
1576
+ * the current/default (so an omitted `projects` in config.json means the
1577
+ * built-in default). Enter accepts the shown default.
1578
+ */
1579
+ function initProjectsStep(
1580
+ env: AnonPiEnv,
1581
+ currentProjects: string | undefined,
1582
+ ): string | undefined {
1583
+ process.stdout.write(
1584
+ '\nStep 4/4 - projects root (the host folder mounted at /projects)\n',
1585
+ );
1586
+ const builtin = builtinProjectsRoot(env);
1587
+ const shown = currentProjects ?? builtin;
1588
+ if (currentProjects) {
1589
+ process.stdout.write(` current: ${currentProjects}\n`);
1590
+ }
1591
+ process.stdout.write(
1592
+ ' This is where bare `anon-pi` looks for projects. Point it at your own\n' +
1593
+ ' dev folder to jail pi into files you edit with host tools; `--mount\n' +
1594
+ ' <parent>` still overrides it per-launch. Leave it at the default if\n' +
1595
+ " you're unsure.\n",
1596
+ );
1597
+ const ans = promptLine(` Projects root (Enter to keep ${shown}): `);
1598
+ if (ans === undefined) return currentProjects;
1599
+ const trimmed = ans.trim();
1600
+ if (trimmed === '') return currentProjects;
1601
+ // Store the built-in as "unset" (undefined) so config.json stays clean when
1602
+ // the user just accepts the default path explicitly.
1603
+ const chosen = resolve(trimmed);
1604
+ if (chosen === builtin) return undefined;
1605
+ return chosen;
1606
+ }
1607
+
1223
1608
  /**
1224
1609
  * Create or update the `default` machine: create it (dir + machine.json) if
1225
1610
  * absent, pinning the chosen image; if it already exists, re-pin the image only
@@ -1334,6 +1719,37 @@ function probeTcp(host: string, port: number): boolean {
1334
1719
  return socks5Handshake(host, port, [], 500) !== undefined;
1335
1720
  }
1336
1721
 
1722
+ /**
1723
+ * Best-effort SYNCHRONOUS HTTP GET of `http://<host:port>/v1/models` (the
1724
+ * OpenAI-compatible model listing llama.cpp / vLLM / LM Studio serve). Runs a
1725
+ * tiny worker on the same node binary (node has no sync HTTP), which fetches +
1726
+ * prints the body, so init stays synchronous. Returns the PARSED JSON body, or
1727
+ * undefined on any failure (unreachable / timeout / non-JSON) — init then falls
1728
+ * back to manual entry. This is a DIRECT LAN fetch on the operator's host at
1729
+ * init time (not inside the jail); it only ever touches the local-model
1730
+ * endpoint, the same host:port that becomes the one `--allow-direct` hole.
1731
+ */
1732
+ function fetchModelsListing(host: string, port: number): unknown {
1733
+ const timeoutMs = 3000;
1734
+ const script =
1735
+ `const http=require('http');` +
1736
+ `const req=http.get({host:${JSON.stringify(host)},port:${port},path:'/v1/models',timeout:${timeoutMs}},(res)=>{` +
1737
+ `let b='';res.on('data',(c)=>{b+=c;if(b.length>1_000_000)req.destroy()});` +
1738
+ `res.on('end',()=>{process.stdout.write(b);process.exit(0)})});` +
1739
+ `req.on('timeout',()=>{req.destroy();process.exit(1)});` +
1740
+ `req.on('error',()=>process.exit(1));`;
1741
+ try {
1742
+ const out = execFileSync(process.execPath, ['-e', script], {
1743
+ encoding: 'utf8',
1744
+ timeout: timeoutMs + 1500,
1745
+ maxBuffer: 4 * 1024 * 1024,
1746
+ });
1747
+ return JSON.parse(out);
1748
+ } catch {
1749
+ return undefined;
1750
+ }
1751
+ }
1752
+
1337
1753
  /**
1338
1754
  * Observe LOCAL process names (best-effort) so init can offer WEAK hints (a
1339
1755
  * running `tor` -> likely Tor). Returns the lowercased process names seen, or []