nimi-code 1.0.0 → 1.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.
Files changed (3) hide show
  1. package/dist/main.js +465 -40
  2. package/package.json +1 -1
  3. package/readme.md +160 -50
package/dist/main.js CHANGED
@@ -797,6 +797,69 @@ var init_permissions = __esm({
797
797
  }
798
798
  });
799
799
 
800
+ // src/config/auth.ts
801
+ import { mkdirSync, readFileSync } from "fs";
802
+ import { mkdir as mkdir2, writeFile as writeFile3 } from "fs/promises";
803
+ import { homedir as homedir2 } from "os";
804
+ import { dirname as dirname2, join as join5 } from "path";
805
+ function getAuthPath() {
806
+ return join5(homedir2(), ".nimi", "auth.json");
807
+ }
808
+ function parseAuthJson(raw) {
809
+ try {
810
+ const parsed = JSON.parse(raw);
811
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return {};
812
+ const out = {};
813
+ for (const [id, cred] of Object.entries(parsed)) {
814
+ if (typeof cred === "object" && cred !== null && !Array.isArray(cred)) {
815
+ const key = cred.key;
816
+ if (key === void 0 || typeof key === "string") out[id] = { type: "api_key", key };
817
+ }
818
+ }
819
+ return out;
820
+ } catch {
821
+ return {};
822
+ }
823
+ }
824
+ function readAuthFileSync(authPath = getAuthPath()) {
825
+ try {
826
+ return parseAuthJson(readFileSync(authPath, "utf-8"));
827
+ } catch {
828
+ return {};
829
+ }
830
+ }
831
+ function getStoredKeySync(providerId, authPath) {
832
+ const key = readAuthFileSync(authPath)[providerId]?.key;
833
+ return key ? key : void 0;
834
+ }
835
+ function resolveKeySync(providerId, envVar) {
836
+ return getStoredKeySync(providerId) || process.env[envVar] || void 0;
837
+ }
838
+ function formatNoApiKeyFoundMessage(providerId, envVar) {
839
+ return `No API key found for ${providerId}. Run /login ${providerId} (or set ${envVar}). Credentials are saved to ${getAuthPath()}.`;
840
+ }
841
+ async function saveApiKey(providerId, key, authPath = getAuthPath()) {
842
+ const data = readAuthFileSync(authPath);
843
+ data[providerId] = { type: "api_key", key };
844
+ mkdirSync(dirname2(authPath), { recursive: true, mode: 448 });
845
+ await writeFile3(authPath, `${JSON.stringify(data, null, 2)}
846
+ `, { encoding: "utf-8", mode: 384 });
847
+ }
848
+ async function deleteApiKey(providerId, authPath = getAuthPath()) {
849
+ const data = readAuthFileSync(authPath);
850
+ if (!(providerId in data)) return false;
851
+ delete data[providerId];
852
+ await mkdir2(dirname2(authPath), { recursive: true, mode: 448 });
853
+ await writeFile3(authPath, `${JSON.stringify(data, null, 2)}
854
+ `, { encoding: "utf-8", mode: 384 });
855
+ return true;
856
+ }
857
+ var init_auth = __esm({
858
+ "src/config/auth.ts"() {
859
+ "use strict";
860
+ }
861
+ });
862
+
800
863
  // src/providers/anthropic.ts
801
864
  import Anthropic from "@anthropic-ai/sdk";
802
865
  function toAnthropic(message) {
@@ -1018,50 +1081,72 @@ var init_openai_compat = __esm({
1018
1081
  });
1019
1082
 
1020
1083
  // src/providers/index.ts
1021
- function requireKey(envVar, provider2) {
1022
- const key = process.env[envVar];
1023
- if (!key) throw new Error(`Missing ${envVar} for ${provider2}. Add it to .env or environment.`);
1024
- return key;
1084
+ function listProviders() {
1085
+ return Object.values(PROVIDER_DEFS);
1086
+ }
1087
+ function getProviderMeta(name) {
1088
+ const meta = PROVIDER_DEFS[name];
1089
+ if (!meta) {
1090
+ throw new Error(`Unknown provider: ${name}. Available: ${Object.keys(PROVIDER_DEFS).join(", ")}`);
1091
+ }
1092
+ return meta;
1093
+ }
1094
+ function getProviderDefaultModel(name) {
1095
+ return getProviderMeta(name).defaultModel;
1025
1096
  }
1026
1097
  function getProvider(name) {
1027
- const create = providers[name];
1028
- if (!create) {
1029
- throw new Error(`Unknown provider: ${name}. Available: ${Object.keys(providers).join(", ")}`);
1098
+ const meta = getProviderMeta(name);
1099
+ const key = resolveKeySync(meta.id, meta.envVar);
1100
+ if (!key) throw new MissingApiKeyError(meta.id, meta.envVar);
1101
+ if (!process.env[meta.envVar]) process.env[meta.envVar] = key;
1102
+ if (meta.kind === "anthropic") {
1103
+ return createAnthropic();
1030
1104
  }
1031
- return create();
1105
+ return createOpenAICompat(meta.id, meta.baseURL, key, meta.defaultModel);
1032
1106
  }
1033
- var providers;
1107
+ var PROVIDER_DEFS, MissingApiKeyError;
1034
1108
  var init_providers = __esm({
1035
1109
  "src/providers/index.ts"() {
1036
1110
  "use strict";
1111
+ init_auth();
1037
1112
  init_anthropic();
1038
1113
  init_openai_compat();
1039
- providers = {
1040
- anthropic: createAnthropic,
1041
- "anthropic-openai": () => createOpenAICompat("anthropic-openai", "https://api.anthropic.com/v1/", requireKey("ANTHROPIC_API_KEY", "anthropic-openai"), "claude-sonnet-5"),
1042
- "groq": () => createOpenAICompat("groq", "https://api.groq.com/openai/v1", requireKey("GROQ_API_KEY", "groq"), "qwen/qwen3.8-27b"),
1043
- "meta": () => createOpenAICompat("meta", "https://api.meta.ai/v1", requireKey("META_API_KEY", "meta"), "muse-spark-1.3-contributor"),
1044
- "deepseek": () => createOpenAICompat("deepseek", "https://api.deepseek.com", requireKey("DEEPSEEK_API_KEY", "deepseek"), "deepseek-flash"),
1045
- "openai": () => createOpenAICompat("openai", "https://api.openai.com/v1", requireKey("OPENAI_API_KEY", "openai"), "gpt-4o-mini")
1114
+ PROVIDER_DEFS = {
1115
+ anthropic: { id: "anthropic", label: "Anthropic", envVar: "ANTHROPIC_API_KEY", defaultModel: "claude-sonnet-5", kind: "anthropic" },
1116
+ "anthropic-openai": { id: "anthropic-openai", label: "Anthropic (OpenAI-compatible)", envVar: "ANTHROPIC_API_KEY", defaultModel: "claude-sonnet-5", baseURL: "https://api.anthropic.com/v1/", kind: "openai-compat" },
1117
+ groq: { id: "groq", label: "Groq", envVar: "GROQ_API_KEY", defaultModel: "qwen/qwen3.8-27b", baseURL: "https://api.groq.com/openai/v1", kind: "openai-compat" },
1118
+ meta: { id: "meta", label: "Meta", envVar: "META_API_KEY", defaultModel: "muse-spark-1.3-contributor", baseURL: "https://api.meta.ai/v1", kind: "openai-compat" },
1119
+ deepseek: { id: "deepseek", label: "DeepSeek", envVar: "DEEPSEEK_API_KEY", defaultModel: "deepseek-flash", baseURL: "https://api.deepseek.com", kind: "openai-compat" },
1120
+ openai: { id: "openai", label: "OpenAI", envVar: "OPENAI_API_KEY", defaultModel: "gpt-4o-mini", baseURL: "https://api.openai.com/v1", kind: "openai-compat" }
1121
+ };
1122
+ MissingApiKeyError = class extends Error {
1123
+ provider;
1124
+ envVar;
1125
+ constructor(provider2, envVar) {
1126
+ super(formatNoApiKeyFoundMessage(provider2, envVar));
1127
+ this.name = "MissingApiKeyError";
1128
+ this.provider = provider2;
1129
+ this.envVar = envVar;
1130
+ }
1046
1131
  };
1047
1132
  }
1048
1133
  });
1049
1134
 
1050
1135
  // src/session/store.ts
1051
1136
  import { existsSync as existsSync3 } from "fs";
1052
- import { appendFile, mkdir as mkdir2, readdir as readdir4, readFile as readFile6 } from "fs/promises";
1053
- import { join as join5 } from "path";
1137
+ import { appendFile, mkdir as mkdir3, readdir as readdir4, readFile as readFile6 } from "fs/promises";
1138
+ import { join as join6 } from "path";
1054
1139
  function getSessionDir() {
1055
- return join5(process.cwd(), ".nimi", "sessions");
1140
+ return join6(process.cwd(), ".nimi", "sessions");
1056
1141
  }
1057
1142
  function getSessionPath(id) {
1058
- return join5(getSessionDir(), id + ".jsonl");
1143
+ return join6(getSessionDir(), id + ".jsonl");
1059
1144
  }
1060
1145
  function newSessionId() {
1061
1146
  return Date.now().toString(36) + Math.random().toString(36).slice(2, 8);
1062
1147
  }
1063
1148
  async function appendEntry(id, entry) {
1064
- await mkdir2(getSessionDir(), { recursive: true });
1149
+ await mkdir3(getSessionDir(), { recursive: true });
1065
1150
  await appendFile(getSessionPath(id), JSON.stringify(entry) + "\n", "utf-8");
1066
1151
  }
1067
1152
  async function appendMessages(id, messages) {
@@ -1089,7 +1174,7 @@ async function loadSession(id) {
1089
1174
  return { info, messages };
1090
1175
  }
1091
1176
  async function listSessions() {
1092
- await mkdir2(getSessionDir(), { recursive: true });
1177
+ await mkdir3(getSessionDir(), { recursive: true });
1093
1178
  const files = await readdir4(getSessionDir());
1094
1179
  const ids = [];
1095
1180
  for (const f of files) {
@@ -1105,13 +1190,13 @@ var init_store = __esm({
1105
1190
  });
1106
1191
 
1107
1192
  // src/trace/recorder.ts
1108
- import { appendFileSync, mkdirSync, writeFileSync } from "fs";
1109
- import { dirname as dirname2 } from "path";
1193
+ import { appendFileSync, mkdirSync as mkdirSync2, writeFileSync } from "fs";
1194
+ import { dirname as dirname3 } from "path";
1110
1195
  function isSnapshotEvent(type) {
1111
1196
  return type === "turn_end" || type === "agent_end" || type === "compacted";
1112
1197
  }
1113
1198
  function createTraceRecorder(path, info) {
1114
- mkdirSync(dirname2(path), { recursive: true });
1199
+ mkdirSync2(dirname3(path), { recursive: true });
1115
1200
  writeFileSync(
1116
1201
  path,
1117
1202
  JSON.stringify({
@@ -1235,6 +1320,8 @@ function nameToAction(name, arg = "") {
1235
1320
  if (name === "compact") return { type: "compact" };
1236
1321
  if (name === "context") return { type: "context" };
1237
1322
  if (name === "model") return { type: "model", arg: arg.trim() };
1323
+ if (name === "login") return { type: "login", arg: arg.trim() };
1324
+ if (name === "logout") return { type: "logout", arg: arg.trim() };
1238
1325
  if (name === "tools") return { type: "tools" };
1239
1326
  if (name === "sessions") return { type: "sessions" };
1240
1327
  if (name === "session") return { type: "session" };
@@ -1245,7 +1332,7 @@ var HELP_TEXT;
1245
1332
  var init_slash = __esm({
1246
1333
  "src/tui/slash.ts"() {
1247
1334
  "use strict";
1248
- HELP_TEXT = "Commands:\n /help Show this help\n /clear Clear the transcript\n /compact Summarize old history to free context\n /context Show token usage vs the window\n /model [id] List models, or switch (keeps history)\n /tools List the tools the model can call\n /sessions List saved sessions\n /session Show this session's resume command\n /quit Exit nimi";
1335
+ HELP_TEXT = "Commands:\n /help Show this help\n /clear Clear the transcript\n /compact Summarize old history to free context\n /context Show token usage vs the window\n /model [id] List models, or switch (keeps history)\n /login [provider] Sign in with an API key (saved to ~/.nimi/auth.json)\n /logout [provider] Remove a saved API key\n /tools List the tools the model can call\n /sessions List saved sessions\n /session Show this session's resume command\n /quit Exit nimi";
1249
1336
  }
1250
1337
  });
1251
1338
 
@@ -1314,7 +1401,7 @@ function ToolRow({ row }) {
1314
1401
  ] })
1315
1402
  ] });
1316
1403
  }
1317
- function App({ provider: provider2, model: model2, system: system2, tools, initialPrompt, yolo, sessionId, initialMessages, contextLimit }) {
1404
+ function App({ provider: provider2, model: model2, system: system2, tools, initialPrompt, yolo, sessionId, initialMessages, contextLimit, initialSetup }) {
1318
1405
  const { exit } = useApp();
1319
1406
  const [rows, setRows] = useState([]);
1320
1407
  const [draft, setDraft] = useState("");
@@ -1322,6 +1409,9 @@ function App({ provider: provider2, model: model2, system: system2, tools, initi
1322
1409
  const [error, setError] = useState(null);
1323
1410
  const [session, setSession] = useState(0);
1324
1411
  const [pending, setPending] = useState(null);
1412
+ const [authFlow, setAuthFlow] = useState(null);
1413
+ const [keyDraft, setKeyDraft] = useState("");
1414
+ const [authError, setAuthError] = useState(null);
1325
1415
  const idRef = useRef(1);
1326
1416
  const messagesRef = useRef([...initialMessages]);
1327
1417
  const savedCount = useRef(initialMessages.length);
@@ -1359,6 +1449,83 @@ function App({ provider: provider2, model: model2, system: system2, tools, initi
1359
1449
  },
1360
1450
  [yolo]
1361
1451
  );
1452
+ const openKeyPrompt = useCallback(
1453
+ (providerId) => {
1454
+ try {
1455
+ const meta = getProviderMeta(providerId);
1456
+ setKeyDraft("");
1457
+ setAuthError(null);
1458
+ setAuthFlow({ step: "key", providerId: meta.id, envVar: meta.envVar });
1459
+ } catch (e) {
1460
+ const id = nextId();
1461
+ setRows((prev) => [...prev, { kind: "text", id, text: e instanceof Error ? e.message : String(e) }]);
1462
+ }
1463
+ },
1464
+ [nextId]
1465
+ );
1466
+ const openProviderPicker = useCallback((mode) => {
1467
+ setAuthError(null);
1468
+ setAuthFlow({ step: "provider", mode, selected: 0 });
1469
+ }, []);
1470
+ const submitKey = useCallback(
1471
+ async (providerId, key) => {
1472
+ const trimmed = key.trim();
1473
+ if (!trimmed) {
1474
+ setAuthError("Empty key \u2014 paste an API key, or press esc to go back.");
1475
+ return;
1476
+ }
1477
+ try {
1478
+ await saveApiKey(providerId, trimmed);
1479
+ const created = getProvider(providerId);
1480
+ setActiveProvider(created);
1481
+ const current = getModelInfo(activeModel);
1482
+ setActiveModel(current?.provider === providerId ? activeModel : created.defaultModel);
1483
+ setAuthFlow(null);
1484
+ setKeyDraft("");
1485
+ setAuthError(null);
1486
+ setError(null);
1487
+ const id = nextId();
1488
+ setRows((prev) => [...prev, { kind: "text", id, text: `Saved API key for ${providerId} to ${getAuthPath()}.` }]);
1489
+ } catch (e) {
1490
+ setAuthError(e instanceof Error ? e.message : String(e));
1491
+ }
1492
+ },
1493
+ [activeModel, nextId]
1494
+ );
1495
+ const runLogout = useCallback(
1496
+ async (providerId, echoCommand) => {
1497
+ const id = nextId();
1498
+ try {
1499
+ getProviderMeta(providerId);
1500
+ } catch (e) {
1501
+ setRows((prev) => [...prev, { kind: "user", id, text: echoCommand }, { kind: "text", id: id + 1, text: e instanceof Error ? e.message : String(e) }]);
1502
+ idRef.current++;
1503
+ return;
1504
+ }
1505
+ const removed = await deleteApiKey(providerId);
1506
+ if (removed) delete process.env[getProviderMeta(providerId).envVar];
1507
+ setAuthFlow(null);
1508
+ const stillConfigured = (() => {
1509
+ try {
1510
+ getProvider(providerId);
1511
+ return true;
1512
+ } catch {
1513
+ return false;
1514
+ }
1515
+ })();
1516
+ if (activeProvider?.name === providerId && !stillConfigured) {
1517
+ setActiveProvider(null);
1518
+ setAuthFlow({ step: "provider", mode: "login", selected: 0 });
1519
+ }
1520
+ setRows((prev) => [
1521
+ ...prev,
1522
+ { kind: "user", id, text: echoCommand },
1523
+ { kind: "text", id: id + 1, text: removed ? `Removed stored API key for ${providerId}. Environment variables are unchanged.` : `No stored API key for ${providerId}.` }
1524
+ ]);
1525
+ idRef.current++;
1526
+ },
1527
+ [activeProvider, nextId]
1528
+ );
1362
1529
  const submitPrompt = useCallback(
1363
1530
  async (prompt) => {
1364
1531
  const text = prompt.trim();
@@ -1377,6 +1544,24 @@ function App({ provider: provider2, model: model2, system: system2, tools, initi
1377
1544
  setSession((s) => s + 1);
1378
1545
  return;
1379
1546
  }
1547
+ if (slash.type === "login") {
1548
+ const id2 = nextId();
1549
+ if (!slash.arg) {
1550
+ setRows((prev) => [...prev, { kind: "user", id: id2, text }]);
1551
+ idRef.current++;
1552
+ openProviderPicker("login");
1553
+ } else {
1554
+ setRows((prev) => [...prev, { kind: "user", id: id2, text }]);
1555
+ idRef.current++;
1556
+ openKeyPrompt(slash.arg);
1557
+ }
1558
+ return;
1559
+ }
1560
+ if (slash.type === "logout") {
1561
+ if (!slash.arg) openProviderPicker("logout");
1562
+ else await runLogout(slash.arg, text);
1563
+ return;
1564
+ }
1380
1565
  if (slash.type === "model") {
1381
1566
  const id2 = nextId();
1382
1567
  if (!slash.arg) {
@@ -1393,6 +1578,7 @@ function App({ provider: provider2, model: model2, system: system2, tools, initi
1393
1578
  setRows((prev) => [...prev, { kind: "user", id: id2, text }, { kind: "text", id: id2 + 1, text: `Switched to ${info.id} \u2014 history kept.` }]);
1394
1579
  } catch (e) {
1395
1580
  setRows((prev) => [...prev, { kind: "user", id: id2, text }, { kind: "text", id: id2 + 1, text: e instanceof Error ? e.message : String(e) }]);
1581
+ if (e instanceof MissingApiKeyError) openKeyPrompt(e.provider);
1396
1582
  }
1397
1583
  }
1398
1584
  }
@@ -1436,6 +1622,12 @@ function App({ provider: provider2, model: model2, system: system2, tools, initi
1436
1622
  if (slash.type === "compact") {
1437
1623
  if (workingRef.current) return;
1438
1624
  const id2 = nextId();
1625
+ if (!activeProvider) {
1626
+ setRows((prev) => [...prev, { kind: "user", id: id2, text }, { kind: "text", id: id2 + 1, text: "Not logged in. Run /login to add an API key first." }]);
1627
+ idRef.current++;
1628
+ openProviderPicker("login");
1629
+ return;
1630
+ }
1439
1631
  if (messagesRef.current.length === 0) {
1440
1632
  setRows((prev) => [...prev, { kind: "user", id: id2, text }, { kind: "text", id: id2 + 1, text: "Nothing to compact." }]);
1441
1633
  idRef.current++;
@@ -1476,6 +1668,12 @@ function App({ provider: provider2, model: model2, system: system2, tools, initi
1476
1668
  return;
1477
1669
  }
1478
1670
  const userId = nextId();
1671
+ if (!activeProvider) {
1672
+ setRows((prev) => [...prev, { kind: "user", id: userId, text }, { kind: "text", id: userId + 1, text: "Not logged in. Pick a provider to add its API key." }]);
1673
+ idRef.current++;
1674
+ if (!authFlow) openProviderPicker("login");
1675
+ return;
1676
+ }
1479
1677
  setRows((prev) => [...prev, { kind: "user", id: userId, text }]);
1480
1678
  messagesRef.current.push({ role: "user", content: text });
1481
1679
  workingRef.current = true;
@@ -1543,9 +1741,44 @@ function App({ provider: provider2, model: model2, system: system2, tools, initi
1543
1741
  }
1544
1742
  }
1545
1743
  },
1546
- [appendText, canUseTool, contextLimit, exit, activeModel, activeProvider, nextId, sessionId, system2, tools]
1744
+ [appendText, canUseTool, contextLimit, exit, activeModel, activeProvider, authFlow, nextId, openKeyPrompt, openProviderPicker, runLogout, sessionId, system2, tools]
1547
1745
  );
1548
1746
  useInput((input, key) => {
1747
+ if (authFlow?.step === "provider") {
1748
+ const options = listProviders();
1749
+ if (key.upArrow) {
1750
+ setAuthFlow((prev) => prev?.step === "provider" ? { ...prev, selected: (prev.selected + options.length - 1) % options.length } : prev);
1751
+ return;
1752
+ }
1753
+ if (key.downArrow) {
1754
+ setAuthFlow((prev) => prev?.step === "provider" ? { ...prev, selected: (prev.selected + 1) % options.length } : prev);
1755
+ return;
1756
+ }
1757
+ if (key.return) {
1758
+ const current = authFlow;
1759
+ const picked = options[current.selected];
1760
+ if (!picked) return;
1761
+ if (current.mode === "login") {
1762
+ setKeyDraft("");
1763
+ setAuthError(null);
1764
+ setAuthFlow({ step: "key", providerId: picked.id, envVar: picked.envVar });
1765
+ } else {
1766
+ void runLogout(picked.id, `/logout ${picked.id}`);
1767
+ }
1768
+ return;
1769
+ }
1770
+ if (key.escape && activeProvider) {
1771
+ setAuthFlow(null);
1772
+ setAuthError(null);
1773
+ }
1774
+ return;
1775
+ }
1776
+ if (authFlow?.step === "key" && key.escape) {
1777
+ setAuthFlow({ step: "provider", mode: "login", selected: 0 });
1778
+ setAuthError(null);
1779
+ setKeyDraft("");
1780
+ return;
1781
+ }
1549
1782
  if (pending) {
1550
1783
  const ch = input.toLowerCase();
1551
1784
  if (ch === "y") pending.resolve("yes");
@@ -1558,11 +1791,16 @@ function App({ provider: provider2, model: model2, system: system2, tools, initi
1558
1791
  }
1559
1792
  });
1560
1793
  useEffect(() => {
1561
- if (!startedRef.current && initialPrompt) {
1794
+ if (!activeProvider && initialSetup && !authFlow) {
1795
+ setAuthFlow({ step: "key", providerId: initialSetup.providerName, envVar: initialSetup.envVar });
1796
+ }
1797
+ }, [activeProvider, initialSetup, authFlow]);
1798
+ useEffect(() => {
1799
+ if (!startedRef.current && initialPrompt && activeProvider) {
1562
1800
  startedRef.current = true;
1563
1801
  void submitPrompt(initialPrompt);
1564
1802
  }
1565
- }, [initialPrompt, submitPrompt]);
1803
+ }, [initialPrompt, submitPrompt, activeProvider]);
1566
1804
  const settled = rows.length > 0 ? rows.slice(0, -1) : [];
1567
1805
  const live = rows.length > 0 ? rows[rows.length - 1] : null;
1568
1806
  return /* @__PURE__ */ jsxs(Box, { flexDirection: "column", children: [
@@ -1570,6 +1808,60 @@ function App({ provider: provider2, model: model2, system: system2, tools, initi
1570
1808
  rows.length === 0 && /* @__PURE__ */ jsx(Box, { marginTop: 1, children: /* @__PURE__ */ jsx(Text, { dimColor: true, children: initialMessages.length > 0 ? `Resumed session \u2014 ${initialMessages.length} messages restored. Type, or /help.` : "Type a message, or /help." }) }),
1571
1809
  /* @__PURE__ */ jsx(Static, { items: settled, children: (row) => renderRow(row) }, session),
1572
1810
  live && renderRow(live),
1811
+ authFlow?.step === "provider" && /* @__PURE__ */ jsxs(Box, { marginTop: 1, flexDirection: "column", children: [
1812
+ /* @__PURE__ */ jsx(Text, { bold: true, children: authFlow.mode === "login" ? "Select a provider to log in:" : "Select a provider to log out:" }),
1813
+ listProviders().map((p, i) => {
1814
+ const selected = i === authFlow.selected;
1815
+ const saved = getStoredKeySync(p.id) ? " \u2713 saved" : "";
1816
+ return /* @__PURE__ */ jsxs(Text, { color: selected ? "cyan" : void 0, dimColor: !selected, children: [
1817
+ selected ? "\u2192 " : " ",
1818
+ p.label,
1819
+ " ",
1820
+ /* @__PURE__ */ jsxs(Text, { dimColor: true, children: [
1821
+ "(",
1822
+ p.id,
1823
+ ")",
1824
+ saved
1825
+ ] })
1826
+ ] }, p.id);
1827
+ }),
1828
+ /* @__PURE__ */ jsxs(Text, { dimColor: true, children: [
1829
+ "\u2191\u2193 navigate \xB7 enter select",
1830
+ activeProvider ? " \xB7 esc cancel" : ""
1831
+ ] })
1832
+ ] }),
1833
+ authFlow?.step === "key" && /* @__PURE__ */ jsxs(Box, { marginTop: 1, flexDirection: "column", children: [
1834
+ /* @__PURE__ */ jsxs(Text, { bold: true, children: [
1835
+ "Login to ",
1836
+ authFlow.providerId
1837
+ ] }),
1838
+ /* @__PURE__ */ jsxs(Text, { dimColor: true, children: [
1839
+ "Paste ",
1840
+ authFlow.envVar,
1841
+ " \u2014 saved to ",
1842
+ getAuthPath()
1843
+ ] }),
1844
+ /* @__PURE__ */ jsxs(Box, { marginTop: 1, children: [
1845
+ /* @__PURE__ */ jsx(Text, { dimColor: true, children: "\u203A " }),
1846
+ /* @__PURE__ */ jsx(
1847
+ TextInput,
1848
+ {
1849
+ value: keyDraft,
1850
+ onChange: setKeyDraft,
1851
+ onSubmit: (value) => {
1852
+ void submitKey(authFlow.providerId, value);
1853
+ },
1854
+ placeholder: "sk-\u2026",
1855
+ mask: "*"
1856
+ }
1857
+ )
1858
+ ] }),
1859
+ authError && /* @__PURE__ */ jsx(Box, { marginTop: 1, children: /* @__PURE__ */ jsxs(Text, { color: "red", children: [
1860
+ "\u2715 ",
1861
+ authError
1862
+ ] }) }),
1863
+ /* @__PURE__ */ jsx(Text, { dimColor: true, children: "enter save \xB7 esc back" })
1864
+ ] }),
1573
1865
  pending && /* @__PURE__ */ jsxs(Box, { marginTop: 1, flexDirection: "column", children: [
1574
1866
  /* @__PURE__ */ jsxs(Text, { color: "yellow", children: [
1575
1867
  "Permission needed: ",
@@ -1581,7 +1873,7 @@ function App({ provider: provider2, model: model2, system: system2, tools, initi
1581
1873
  "\u2715 ",
1582
1874
  error
1583
1875
  ] }) }),
1584
- /* @__PURE__ */ jsxs(Box, { marginTop: 1, children: [
1876
+ !authFlow && /* @__PURE__ */ jsxs(Box, { marginTop: 1, children: [
1585
1877
  /* @__PURE__ */ jsx(Text, { dimColor: working, children: "\u203A " }),
1586
1878
  /* @__PURE__ */ jsx(
1587
1879
  TextInput,
@@ -1593,11 +1885,11 @@ function App({ provider: provider2, model: model2, system: system2, tools, initi
1593
1885
  setDraft("");
1594
1886
  void submitPrompt(value);
1595
1887
  },
1596
- placeholder: pending ? "answer y/a/n\u2026" : working ? "working\u2026" : "Ask nimi\u2026"
1888
+ placeholder: pending ? "answer y/a/n\u2026" : working ? "working\u2026" : activeProvider ? "Ask nimi\u2026" : "Run /login\u2026"
1597
1889
  }
1598
1890
  )
1599
1891
  ] }),
1600
- /* @__PURE__ */ jsx(Box, { children: /* @__PURE__ */ jsx(Text, { dimColor: true, children: working ? "working\u2026 esc to interrupt" : "enter send \xB7 /help /clear /quit \xB7 esc interrupt" }) })
1892
+ /* @__PURE__ */ jsx(Box, { children: /* @__PURE__ */ jsx(Text, { dimColor: true, children: authFlow ? "login flow \xB7 esc back" : working ? "working\u2026 esc to interrupt" : "enter send \xB7 /login /model /help \xB7 esc interrupt" }) })
1601
1893
  ] });
1602
1894
  }
1603
1895
  var DIVIDER_MAX;
@@ -1608,6 +1900,7 @@ var init_App = __esm({
1608
1900
  init_compaction();
1609
1901
  init_models();
1610
1902
  init_providers();
1903
+ init_auth();
1611
1904
  init_store();
1612
1905
  init_format();
1613
1906
  init_slash();
@@ -1630,6 +1923,21 @@ async function runClassic(opts) {
1630
1923
  const canUseTool = opts.yolo ? allowAll() : createPromptPermission(rl);
1631
1924
  let trace = null;
1632
1925
  const ask = (q) => new Promise((resolve7) => rl.question(q, resolve7));
1926
+ const ensureKey = async (providerId) => {
1927
+ try {
1928
+ const meta = getProviderMeta(providerId);
1929
+ const answer = (await ask(`Enter ${meta.envVar} for ${providerId} (saved to ${getAuthPath()}): `)).trim();
1930
+ if (!answer) {
1931
+ console.log("Empty key \u2014 cancelled.");
1932
+ return false;
1933
+ }
1934
+ await saveApiKey(providerId, answer);
1935
+ return true;
1936
+ } catch (e) {
1937
+ console.log(e instanceof Error ? e.message : String(e));
1938
+ return false;
1939
+ }
1940
+ };
1633
1941
  console.log(`nimi ${model2} \xB7 session ${opts.sessionId.slice(-6)} \xB7 /help for commands`);
1634
1942
  if (messages.length > 0) console.log(`Resumed \u2014 ${messages.length} messages restored.`);
1635
1943
  if (opts.yolo) console.log("Permission prompts off (--yolo).");
@@ -1654,6 +1962,10 @@ async function runClassic(opts) {
1654
1962
  continue;
1655
1963
  }
1656
1964
  if (slash.type === "compact") {
1965
+ if (!provider2) {
1966
+ console.log("Not logged in. Use /login <provider> first.");
1967
+ continue;
1968
+ }
1657
1969
  const result = await compactMessages(messages, provider2, model2);
1658
1970
  if (!result.compacted) console.log("History is already short.");
1659
1971
  else {
@@ -1676,11 +1988,66 @@ async function runClassic(opts) {
1676
1988
  console.log(`Switched to ${model2} \u2014 history kept.`);
1677
1989
  } catch (e) {
1678
1990
  console.log(e instanceof Error ? e.message : String(e));
1991
+ if (e instanceof MissingApiKeyError && await ensureKey(e.provider)) {
1992
+ try {
1993
+ provider2 = getProvider(info.provider);
1994
+ model2 = info.id;
1995
+ console.log(`Switched to ${model2} \u2014 history kept.`);
1996
+ } catch (retry) {
1997
+ console.log(retry instanceof Error ? retry.message : String(retry));
1998
+ }
1999
+ }
1679
2000
  }
1680
2001
  }
1681
2002
  }
1682
2003
  continue;
1683
2004
  }
2005
+ if (slash.type === "login") {
2006
+ if (!slash.arg) {
2007
+ console.log("Usage: /login <provider>");
2008
+ continue;
2009
+ }
2010
+ try {
2011
+ getProviderMeta(slash.arg);
2012
+ } catch (e) {
2013
+ console.log(e instanceof Error ? e.message : String(e));
2014
+ continue;
2015
+ }
2016
+ if (await ensureKey(slash.arg)) {
2017
+ try {
2018
+ provider2 = getProvider(slash.arg);
2019
+ const current = getModelInfo(model2);
2020
+ model2 = current?.provider === slash.arg ? model2 : provider2.defaultModel;
2021
+ console.log(`Logged in to ${slash.arg}. Credentials saved to ${getAuthPath()}.`);
2022
+ } catch (e) {
2023
+ console.log(e instanceof Error ? e.message : String(e));
2024
+ }
2025
+ }
2026
+ continue;
2027
+ }
2028
+ if (slash.type === "logout") {
2029
+ if (!slash.arg) {
2030
+ console.log("Usage: /logout <provider>");
2031
+ continue;
2032
+ }
2033
+ try {
2034
+ getProviderMeta(slash.arg);
2035
+ } catch (e) {
2036
+ console.log(e instanceof Error ? e.message : String(e));
2037
+ continue;
2038
+ }
2039
+ const removed = await deleteApiKey(slash.arg);
2040
+ console.log(removed ? `Removed stored API key for ${slash.arg}. Environment variables are unchanged.` : `No stored API key for ${slash.arg}.`);
2041
+ if (provider2?.name === slash.arg) {
2042
+ try {
2043
+ provider2 = getProvider(slash.arg);
2044
+ } catch {
2045
+ provider2 = null;
2046
+ console.log(`Logged out of ${slash.arg} \u2014 add a key with /login or switch with /model.`);
2047
+ }
2048
+ }
2049
+ continue;
2050
+ }
1684
2051
  if (slash.type === "tools") {
1685
2052
  for (const t of ALL_TOOLS) console.log(` ${t.name}${t.readOnly ? " [read-only]" : " [asks permission]"}`);
1686
2053
  continue;
@@ -1701,6 +2068,10 @@ async function runClassic(opts) {
1701
2068
  console.log(`Unknown command: ${slash.name}. Try /help.`);
1702
2069
  continue;
1703
2070
  }
2071
+ if (!provider2) {
2072
+ console.log("Not logged in. Use /login <provider> first.");
2073
+ continue;
2074
+ }
1704
2075
  messages.push({ role: "user", content: line });
1705
2076
  if (opts.tracePath && !trace) {
1706
2077
  trace = createTraceRecorder(opts.tracePath, {
@@ -1743,6 +2114,7 @@ var init_classic = __esm({
1743
2114
  init_agent();
1744
2115
  init_compaction();
1745
2116
  init_providers();
2117
+ init_auth();
1746
2118
  init_models();
1747
2119
  init_permissions();
1748
2120
  init_store();
@@ -1865,7 +2237,11 @@ OPTIONS
1865
2237
  -h, --help Show this help
1866
2238
 
1867
2239
  PROVIDERS
1868
- anthropic, anthropic-openai, groq, meta, deepseek
2240
+ anthropic, anthropic-openai, groq, meta, deepseek, openai
2241
+
2242
+ AUTH
2243
+ First run opens /login in the TUI (keys saved to ~/.nimi/auth.json).
2244
+ Headless/CI: export the provider's key instead (e.g. META_API_KEY).
1869
2245
 
1870
2246
  EXAMPLES
1871
2247
  nimi -p "explain this codebase"
@@ -1874,7 +2250,7 @@ EXAMPLES
1874
2250
  `);
1875
2251
  }
1876
2252
  function printVersion() {
1877
- console.log(`nimi ${true ? "1.0.0" : "dev"}`);
2253
+ console.log(`nimi ${true ? "1.1.0" : "dev"}`);
1878
2254
  }
1879
2255
 
1880
2256
  // src/config/system-prompt.ts
@@ -1945,6 +2321,7 @@ async function readJsonSafe(path) {
1945
2321
  // src/main.tsx
1946
2322
  init_permissions();
1947
2323
  init_providers();
2324
+ init_auth();
1948
2325
  init_store();
1949
2326
  init_recorder();
1950
2327
  init_tools();
@@ -1967,9 +2344,6 @@ if (args.version) {
1967
2344
  process.exit(0);
1968
2345
  }
1969
2346
  var settings = await loadSettings();
1970
- var provider = getProvider(args.provider ?? settings.provider ?? "meta");
1971
- var model = args.model ?? settings.model ?? provider.defaultModel;
1972
- var system = await buildSystemPrompt(settings.systemPromptExtra);
1973
2347
  if (args.listSessions) {
1974
2348
  const ids = await listSessions();
1975
2349
  if (ids.length === 0) console.log("No saved sessions yet.");
@@ -1988,7 +2362,37 @@ if (args.listSessions) {
1988
2362
  }
1989
2363
  process.exit(0);
1990
2364
  }
2365
+ var providerName = args.provider ?? settings.provider ?? "meta";
2366
+ var provider = null;
2367
+ var missingAuth = null;
2368
+ var model;
2369
+ try {
2370
+ provider = getProvider(providerName);
2371
+ model = args.model ?? settings.model ?? provider.defaultModel;
2372
+ } catch (e) {
2373
+ if (e instanceof MissingApiKeyError) {
2374
+ missingAuth = { providerName: e.provider, envVar: e.envVar };
2375
+ try {
2376
+ model = args.model ?? settings.model ?? getProviderDefaultModel(providerName);
2377
+ } catch (unknownProvider) {
2378
+ console.error(unknownProvider instanceof Error ? unknownProvider.message : String(unknownProvider));
2379
+ process.exit(1);
2380
+ }
2381
+ } else {
2382
+ console.error(e instanceof Error ? e.message : String(e));
2383
+ process.exit(1);
2384
+ }
2385
+ }
2386
+ var system = await buildSystemPrompt(settings.systemPromptExtra);
1991
2387
  if (!args.classic && (args.print || !process.stdin.isTTY || !process.stdout.isTTY)) {
2388
+ if (missingAuth) {
2389
+ console.error(`No API key found for ${missingAuth.providerName}. Set ${missingAuth.envVar}, or run "nimi" interactively and use /login ${missingAuth.providerName}.`);
2390
+ process.exit(1);
2391
+ }
2392
+ if (!provider) {
2393
+ console.error("No provider configured.");
2394
+ process.exit(1);
2395
+ }
1992
2396
  if (!args.prompt) {
1993
2397
  console.error('No prompt given. Run "nimi --help" for usage.');
1994
2398
  process.exit(1);
@@ -2069,13 +2473,34 @@ if (args.session) {
2069
2473
  } else {
2070
2474
  tuiSessionId = newSessionId();
2071
2475
  tuiMessages = [];
2072
- await appendEntry(tuiSessionId, { type: "session_info", id: tuiSessionId, created_at: (/* @__PURE__ */ new Date()).toISOString(), model, provider: provider.name });
2476
+ await appendEntry(tuiSessionId, { type: "session_info", id: tuiSessionId, created_at: (/* @__PURE__ */ new Date()).toISOString(), model, provider: provider?.name ?? providerName });
2073
2477
  }
2074
2478
  if (args.classic) {
2479
+ if (!provider && missingAuth) {
2480
+ const askRl = createInterface2({ input: process.stdin, output: process.stdout });
2481
+ const answer = await new Promise((resolve7) => askRl.question(`No API key for ${missingAuth.providerName}. Enter ${missingAuth.envVar} (saved to ~/.nimi/auth.json): `, resolve7));
2482
+ askRl.close();
2483
+ if (!answer.trim()) {
2484
+ console.error(`No API key given. Set ${missingAuth.envVar} or run "nimi" and use /login.`);
2485
+ process.exit(1);
2486
+ }
2487
+ await saveApiKey(missingAuth.providerName, answer.trim());
2488
+ try {
2489
+ provider = getProvider(missingAuth.providerName);
2490
+ model = args.model ?? settings.model ?? provider.defaultModel;
2491
+ } catch (e) {
2492
+ console.error(e instanceof Error ? e.message : String(e));
2493
+ process.exit(1);
2494
+ }
2495
+ }
2496
+ if (!provider) {
2497
+ console.error("No provider configured.");
2498
+ process.exit(1);
2499
+ }
2075
2500
  const { runClassic: runClassic2 } = await Promise.resolve().then(() => (init_classic(), classic_exports));
2076
2501
  await runClassic2({ provider, model, system, tools: ALL_TOOLS, sessionId: tuiSessionId, initialMessages: tuiMessages, yolo: args.yolo, contextLimit: args.contextLimit, tracePath: args.trace });
2077
2502
  process.exit(0);
2078
2503
  }
2079
2504
  render(
2080
- /* @__PURE__ */ jsx2(App2, { provider, model, system, tools: ALL_TOOLS, initialPrompt: args.prompt, yolo: args.yolo, sessionId: tuiSessionId, initialMessages: tuiMessages, contextLimit: args.contextLimit })
2505
+ /* @__PURE__ */ jsx2(App2, { provider, model, system, tools: ALL_TOOLS, initialPrompt: args.prompt, yolo: args.yolo, sessionId: tuiSessionId, initialMessages: tuiMessages, contextLimit: args.contextLimit, initialSetup: missingAuth })
2081
2506
  );
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nimi-code",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
4
4
  "description": "Minimal coding agent: a language model with controlled access to your files, shell, and git.",
5
5
  "license": "ISC",
6
6
  "author": "",
package/readme.md CHANGED
@@ -1,77 +1,187 @@
1
- # nimi — minimal coding agent
1
+ <p align="center">
2
+ <picture>
3
+ <source media="(prefers-color-scheme: dark)" srcset="docs/assets/nimi-logo-dark.png">
4
+ <img alt="Nimi" src="docs/assets/nimi-logo-light.png" width="480">
5
+ </picture>
6
+ </p>
2
7
 
3
- A lightweight terminal agent: a language model with controlled access to your
4
- files, shell, and git, running a read-act loop until the task is done.
8
+ <p align="center">
9
+ <strong>A lightweight, open-source terminal coding-agent harness. Every step, inspectable.</strong>
10
+ </p>
5
11
 
6
- ```
12
+ <p align="center">
13
+ <a href="https://www.npmjs.com/package/nimi-code"><img alt="npm version" src="https://img.shields.io/npm/v/nimi-code?style=flat-square"></a>
14
+ <a href="LICENSE"><img alt="license" src="https://img.shields.io/npm/l/nimi-code?style=flat-square"></a>
15
+ </p>
16
+
17
+ # nimi
18
+
19
+ Nimi points a language model at your codebase and gives it controlled access to your files, shell, and git — read, search, write, edit, run commands — until the job is done. Read-only tools run freely; anything that mutates pauses at an explicit permission gate. Every run is recorded as a JSONL trace you can replay, and a browser playground visualizes a sample run step by step.
20
+
21
+ What Nimi is **not**: not a model, not a subscription, not a hosted platform. You bring your own provider key, run everything on your own machine, and can fork the whole thing (ISC licensed).
22
+
23
+ ## Quickstart
24
+
25
+ Requires **Node 22+** and one provider key.
26
+
27
+ ```bash
28
+ npm install -g nimi-code
29
+ export META_API_KEY=... # or ANTHROPIC_API_KEY, GROQ_API_KEY, DEEPSEEK_API_KEY, OPENAI_API_KEY
30
+ cd /path/to/your/project
7
31
  nimi "list the typescript files under src"
8
- echo "explain the agent loop" | nimi -p
9
- nimi --classic "refactor src/tools/grep.ts"
10
32
  ```
11
33
 
12
- ## Quickstart
34
+ That's it — Nimi opens an interactive session in your terminal. Give it a task in plain language.
35
+
36
+ ## Usage
13
37
 
14
- Requires Node 22+ and one provider key.
38
+ | Mode | Command | Behavior |
39
+ |---|---|---|
40
+ | Interactive (default) | `nimi "task"` | Full terminal UI with streaming output |
41
+ | Classic REPL | `nimi --classic "task"` | Plain readline loop, compact result previews |
42
+ | One-shot | `nimi --print "task"` | Runs once, streams to stdout, exits (scriptable) |
43
+ | Pipe | `echo ... \| nimi -p` | Prompt from stdin, no TTY needed |
44
+
45
+ ### Flags
15
46
 
16
47
  ```
17
- npm install -g nimi
18
- export META_API_KEY=... # or ANTHROPIC_API_KEY, OPENAI_API_KEY, ...
19
- nimi "hello"
48
+ nimi [message] interactive (default)
49
+ nimi --classic [message] plain REPL
50
+ nimi --print [message] one-shot, streams to stdout
51
+ echo ... | nimi -p prompt from pipe
52
+
53
+ -p, --prompt <text> prompt to send
54
+ -z, --provider <id> anthropic, anthropic-openai, groq, meta (default), deepseek, openai
55
+ --model <id> override the provider's default model
56
+ --session <id> resume a session (--list-sessions to list)
57
+ --context-limit <n> override context window
58
+ --trace <path> write a JSONL showcase trace (print mode)
59
+ --classic --print --yolo
60
+ -v, --version -h, --help
20
61
  ```
21
62
 
22
- From source:
63
+ ### Slash commands (interactive mode)
64
+
65
+ `/model` `/tools` `/sessions` `/session` `/compact` `/context` `/clear` `/help` `/quit` — bare `clear`, `quit`, `exit`, and `q` work too.
66
+
67
+ Examples:
23
68
 
69
+ ```bash
70
+ nimi -p "explain this codebase" --provider deepseek
71
+ nimi "find all TODOs and write them to NOTES.md"
72
+ nimi --session abc123 "continue where we left off"
73
+ nimi --list-sessions
24
74
  ```
25
- npm install
26
- cp .env.example .env # add your key(s)
27
- npm run build
28
- node bin/nimi.js "hello"
75
+
76
+ ## Tools & permissions
77
+
78
+ Read-only tools run without asking. Mutating tools pause at a gate: `[y]es` / `[a]lways` / `[n]o` — `always` is remembered per tool for the session. Piped/non-TTY runs auto-allow (pass `--yolo` explicitly to skip prompts in a terminal).
79
+
80
+ | Runs freely (read-only) | Asks first (mutating) |
81
+ |---|---|
82
+ | `read`, `ls`, `find`, `grep` | `write`, `edit`, `bash` |
83
+ | `git_status`, `git_log`, `git_diff` | `git_add`, `git_commit`, `git_branch`, `git_checkout`, `git_reset` |
84
+
85
+ Denying a tool doesn't crash the run — the model gets an error result (`User denied <name>…`) and decides what to do next.
86
+
87
+ ## Providers
88
+
89
+ Nimi is provider-agnostic over OpenAI-compatible APIs. The default provider is `meta`.
90
+
91
+ | Provider (`-z`) | Key | Default model |
92
+ |---|---|---|
93
+ | `anthropic` | `ANTHROPIC_API_KEY` | `claude-sonnet-5` |
94
+ | `anthropic-openai` | `ANTHROPIC_API_KEY` | `claude-sonnet-5` (OpenAI-compatible endpoint) |
95
+ | `groq` | `GROQ_API_KEY` | `qwen/qwen3.8-27b` |
96
+ | `meta` (default) | `META_API_KEY` | `muse-spark-1.3-contributor` |
97
+ | `deepseek` | `DEEPSEEK_API_KEY` | `deepseek-flash` |
98
+ | `openai` | `OPENAI_API_KEY` | `gpt-4o-mini` |
99
+
100
+ Override the model per run with `--model <id>`, per environment with `NIMI_MODEL`, or switch mid-session with `/model`.
101
+
102
+ ## Config, sessions, context
103
+
104
+ Settings cascade (each level wins over the previous):
105
+
106
+ `~/.nimi/settings.json` → `.nimi/settings.json` (project) → `NIMI_PROVIDER` / `NIMI_MODEL` → CLI flags
107
+
108
+ Sessions persist per project in `.nimi/sessions/` — resume with `--session <id>`, list with `--list-sessions`. On startup Nimi also loads project context from `AGENTS.md`, `CLAUDE.md`, `.context`, and `.nimi/context.md` when present.
109
+
110
+ ## Traces
111
+
112
+ Every run can be recorded as JSONL — one line per event and message snapshot — and resumed or inspected later:
113
+
114
+ ```bash
115
+ nimi --print -p "find all TODOs" --trace demo/samples/showcase.jsonl
29
116
  ```
30
117
 
31
- ```env
32
- ANTHROPIC_API_KEY=...
33
- GROQ_API_KEY=...
34
- META_API_KEY=...
35
- DEEPSEEK_API_KEY=...
36
- OPENAI_API_KEY=...
118
+ The trace is the same artifact the web playground replays below: events fold into turns, tool calls, permission decisions, and terminal output.
119
+
120
+ ## Web playground (local)
121
+
122
+ `demo/` is a static Vite + React app that replays the bundled sample trace — a trace viewer and explainer, not a live agent. No API key needed.
123
+
124
+ ```bash
125
+ cd demo
126
+ npm install
127
+ npm run dev # → http://localhost:5173
37
128
  ```
38
129
 
39
- ## Usage
130
+ - **Home** explains the harness; **Playground** (`#/playground`) is the workbench: Dataflow/Streaming graph, step inspector (Overview, Events, Context, Source, plus raw payload/stdout), timeline scrubber, keyboard shortcuts (`space` play, `←/→` step), and URL-addressable playback state.
131
+ - The **sample selector** replays four lenses over the same trace (full run, denied-permission branch, Classic view, TUI view). **Allow/Deny only switches the replay branch** — it controls no live machine.
132
+ - Production build: `npm run build` → `demo/dist/` (gitignored), deployable to any static host.
133
+
134
+ ## Architecture
40
135
 
41
136
  ```
42
- nimi [message] Interactive TUI (default)
43
- nimi --classic [message] Plain readline REPL (no Ink needed)
44
- nimi --print [message] One-shot: stream the answer to stdout
45
- echo ... | nimi -p Read the prompt from a pipe
46
-
47
- Options
48
- -p, --prompt <text> Prompt to send
49
- -z, --provider <id> anthropic, groq, meta, deepseek, openai, anthropic-openai
50
- --model <id> Model id (registry in src/providers/models.ts)
51
- --session <id> Resume a saved session (--list-sessions to list)
52
- --context-limit <n> Override the model's context window
53
- --classic --print --yolo REPL / one-shot / skip permission prompts
54
- -v, --version -h, --help
137
+ prompt → parseArgs → settings → system prompt + tool catalog → runAgent()
138
+ → provider.stream() → assistant reply → permission gate → tool.execute()
139
+ → tool result → … → session file (.nimi/sessions/<id>.jsonl) + optional --trace JSONL
55
140
  ```
56
141
 
57
- TUI slash: `/model /tools /sessions /session /compact /context /clear /help /quit`.
142
+ <picture>
143
+ <source media="(prefers-color-scheme: dark)" srcset="docs/assets/architecture-dark.svg">
144
+ <img alt="Nimi architecture map: shell and entry, config, session, agent loop, provider, tools, permissions, renderer" src="docs/assets/architecture-light.svg">
145
+ </picture>
146
+
147
+ Generated from the same graph data as the playground (`demo/src/data/architecture.js`). Regenerate with `node scripts/export-architecture-svg.mjs`.
148
+
149
+ ### Repo layout
150
+
151
+ | Path | Contents |
152
+ |---|---|
153
+ | `bin/` | `nimi` executable entry |
154
+ | `src/main.tsx` | CLI entry, mode router, trace wiring |
155
+ | `src/agent.ts` | Agent loop (`runAgent`) |
156
+ | `src/cli/` | Flag parser |
157
+ | `src/config/` | Settings cascade, system prompt |
158
+ | `src/providers/` | Anthropic + OpenAI-compatible providers, default models |
159
+ | `src/tools/` | Read/write/shell/git tool implementations |
160
+ | `src/permissions.ts` | `y/a/n` permission gate |
161
+ | `src/session/` | JSONL session store |
162
+ | `src/trace/` | JSONL trace recorder |
163
+ | `src/modes/` `src/tui/` | Classic REPL and Ink terminal UI |
164
+ | `demo/` | Static web playground (sample trace replay) |
165
+ | `test/` | Offline tool tests |
166
+ | `docs/assets/` | README logo and architecture diagram |
58
167
 
59
- ## Tools (15)
168
+ ## Development
60
169
 
61
- Read-only (never prompt): `read ls find grep git_status git_log git_diff`.
62
- Ask `y/a/always/n` (or `--yolo`): `write edit bash` + `git_add git_commit
63
- git_branch git_checkout git_reset`. Output capped at 20000 chars, 30s timeouts.
170
+ ```bash
171
+ npm install
172
+ cp .env.example .env # add one provider key
173
+ npm run build # build with tsup
174
+ node bin/nimi.js "hello"
64
175
 
65
- ## Config
176
+ npm run check # biome lint + tsc typecheck
177
+ npm test # offline tool tests (vitest)
178
+ npm run test:tools
179
+ ```
66
180
 
67
- `~/.nimi/settings.json` < `.nimi/settings.json` < `NIMI_PROVIDER`/`NIMI_MODEL`
68
- < CLI flags. Sessions live in `.nimi/sessions/<id>.jsonl` (gitignored).
181
+ ## Contributing
69
182
 
70
- ## Development
183
+ Issues and pull requests are welcome. Run `npm run check` and `npm test` before submitting. See `AGENTS.md` for repo conventions (Astryx workflow for `demo/`, tokens, required checks).
71
184
 
72
- ```
73
- npm run check # biome lint + tsc --noEmit (0 errors)
74
- npm run test:tools # offline tool suite, no keys (7 tests)
75
- ```
185
+ ## License
76
186
 
77
- Context auto-compacts at 75% of the window (`/compact` for manual).
187
+ [ISC](LICENSE) — do what you want, keep the copyright notice.