@9thprotocol/cli 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/shared.js ADDED
@@ -0,0 +1,192 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { AUTO_MODEL, } from "@9thprotocol/agent-core";
5
+ export const dim = (s) => `\x1b[2m${s}\x1b[0m`;
6
+ export const cyan = (s) => `\x1b[36m${s}\x1b[0m`;
7
+ export const yellow = (s) => `\x1b[33m${s}\x1b[0m`;
8
+ export const red = (s) => `\x1b[31m${s}\x1b[0m`;
9
+ /** Auto routing is the product default (PLAN.md §5.5); `/model <id>` pins a model. */
10
+ export const DEFAULT_MODEL = AUTO_MODEL;
11
+ const BIASES = ["economy", "balanced", "quality"];
12
+ /** Auto bias from NINEP_AUTO_BIAS or ~/.9p/config.json; defaults to balanced. */
13
+ export function getAutoBias() {
14
+ const fromEnv = process.env.NINEP_AUTO_BIAS;
15
+ if (fromEnv && BIASES.includes(fromEnv))
16
+ return fromEnv;
17
+ try {
18
+ const cfg = JSON.parse(fs.readFileSync(path.join(os.homedir(), ".9p", "config.json"), "utf8"));
19
+ if (cfg.autoBias && BIASES.includes(cfg.autoBias))
20
+ return cfg.autoBias;
21
+ }
22
+ catch {
23
+ // no config file
24
+ }
25
+ return "balanced";
26
+ }
27
+ export function setAutoBias(bias) {
28
+ const dir = path.join(os.homedir(), ".9p");
29
+ fs.mkdirSync(dir, { recursive: true });
30
+ const file = path.join(dir, "config.json");
31
+ let cfg = {};
32
+ try {
33
+ cfg = JSON.parse(fs.readFileSync(file, "utf8"));
34
+ }
35
+ catch {
36
+ // fresh config
37
+ }
38
+ cfg.autoBias = bias;
39
+ fs.writeFileSync(file, JSON.stringify(cfg, null, 2) + "\n");
40
+ }
41
+ export const AUTO_BIASES = BIASES;
42
+ /**
43
+ * Catalog with plan locks for the Auto router. Platform-only: BYOK has no plan
44
+ * gating, so the router falls back to its built-in ladders.
45
+ */
46
+ export async function fetchCatalog(auth) {
47
+ if (!auth.platform)
48
+ return undefined;
49
+ try {
50
+ const res = await fetch(`${auth.platform.baseUrl}/models`, {
51
+ headers: { Authorization: `Bearer ${auth.apiKey}` },
52
+ });
53
+ if (!res.ok)
54
+ return undefined;
55
+ const data = (await res.json());
56
+ // contextLength feeds compaction, without it every model falls back to a
57
+ // generic default and a 32k model would blow past its window.
58
+ return data.models?.map((m) => ({
59
+ id: m.id,
60
+ tier: m.tier,
61
+ locked: m.locked,
62
+ contextLength: m.contextLength ?? null,
63
+ }));
64
+ }
65
+ catch {
66
+ // Routing must never block a session on a catalog fetch, ladders still work.
67
+ return undefined;
68
+ }
69
+ }
70
+ /**
71
+ * Auth resolution order:
72
+ * 1. NINEP_API_URL + NINEP_TOKEN (platform, metered)
73
+ * 2. ~/.9p/auth.json {"apiUrl", "token"} (platform)
74
+ * 3. OPENROUTER_API_KEY or ~/.9p/auth.json {"openrouterApiKey"} (BYOK, direct)
75
+ */
76
+ export function getAuth() {
77
+ if (process.env.NINEP_API_URL && process.env.NINEP_TOKEN) {
78
+ return { apiKey: process.env.NINEP_TOKEN, platform: { baseUrl: process.env.NINEP_API_URL } };
79
+ }
80
+ let file = {};
81
+ try {
82
+ file = JSON.parse(fs.readFileSync(path.join(os.homedir(), ".9p", "auth.json"), "utf8"));
83
+ }
84
+ catch {
85
+ // no auth file
86
+ }
87
+ if (file.apiUrl && file.token)
88
+ return { apiKey: file.token, platform: { baseUrl: file.apiUrl } };
89
+ if (process.env.OPENROUTER_API_KEY)
90
+ return { apiKey: process.env.OPENROUTER_API_KEY };
91
+ if (file.openrouterApiKey)
92
+ return { apiKey: file.openrouterApiKey };
93
+ return null;
94
+ }
95
+ const authFilePath = () => path.join(os.homedir(), ".9p", "auth.json");
96
+ /** Seconds of remaining life below which we refresh rather than risk a mid-turn 401. */
97
+ const REFRESH_SKEW_S = 120;
98
+ /** `exp` from a JWT payload without verifying. We only need the expiry hint. */
99
+ function tokenExpiry(token) {
100
+ try {
101
+ const payload = JSON.parse(Buffer.from(token.split(".")[1] ?? "", "base64url").toString("utf8"));
102
+ return typeof payload.exp === "number" ? payload.exp : null;
103
+ }
104
+ catch {
105
+ return null;
106
+ }
107
+ }
108
+ /**
109
+ * Exchange the stored refresh token for a fresh pair and persist it.
110
+ *
111
+ * Access tokens live 15 minutes, so without this a `9p login` session would 401
112
+ * partway through the first real task. Refresh tokens rotate server-side, so the
113
+ * new one must be written back or the next refresh fails.
114
+ */
115
+ async function refreshStoredAuth() {
116
+ let file = {};
117
+ try {
118
+ file = JSON.parse(fs.readFileSync(authFilePath(), "utf8"));
119
+ }
120
+ catch {
121
+ return null;
122
+ }
123
+ const { apiUrl, refreshToken } = file;
124
+ if (typeof apiUrl !== "string" || typeof refreshToken !== "string")
125
+ return null;
126
+ try {
127
+ const res = await fetch(`${apiUrl}/auth/refresh`, {
128
+ method: "POST",
129
+ headers: { "Content-Type": "application/json" },
130
+ body: JSON.stringify({ refreshToken }),
131
+ });
132
+ if (!res.ok)
133
+ return null;
134
+ const next = (await res.json());
135
+ fs.writeFileSync(authFilePath(), JSON.stringify({ ...file, token: next.accessToken, refreshToken: next.refreshToken }, null, 2) + "\n", { mode: 0o600 });
136
+ return { apiKey: next.accessToken, platform: { baseUrl: apiUrl } };
137
+ }
138
+ catch {
139
+ return null;
140
+ }
141
+ }
142
+ /**
143
+ * Resolve credentials, refreshing an expiring platform token first.
144
+ *
145
+ * Returns a reason rather than exiting, so hosts that are not a terminal (the
146
+ * desktop app) can surface it in their own UI. A library that calls
147
+ * `process.exit` kills an Electron app with a message nobody ever sees.
148
+ */
149
+ export async function resolveAuth() {
150
+ const auth = getAuth();
151
+ if (auth?.platform && !process.env.NINEP_TOKEN) {
152
+ const exp = tokenExpiry(auth.apiKey);
153
+ if (exp !== null && exp - Date.now() / 1000 < REFRESH_SKEW_S) {
154
+ const refreshed = await refreshStoredAuth();
155
+ if (refreshed)
156
+ return { ok: true, auth: refreshed };
157
+ return { ok: false, reason: "expired" };
158
+ }
159
+ }
160
+ if (!auth)
161
+ return { ok: false, reason: "none" };
162
+ return { ok: true, auth };
163
+ }
164
+ /** CLI entry point: resolve credentials, or print and exit. */
165
+ export async function requireAuth() {
166
+ const result = await resolveAuth();
167
+ if (result.ok)
168
+ return result.auth;
169
+ console.error(result.reason === "expired"
170
+ ? red("Session expired.") + ` Run ${cyan("9p login")} to sign in again.`
171
+ : red("No credentials.") +
172
+ ` Run ${cyan("9p login")}, or set OPENROUTER_API_KEY for BYOK.`);
173
+ process.exit(1);
174
+ }
175
+ const k = (n) => (n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n));
176
+ export function usageLine(u) {
177
+ return `${u.requests} calls · ${k(u.inputTokens)} in (${k(u.cachedTokens)} cached) · ${k(u.outputTokens)} out`;
178
+ }
179
+ /**
180
+ * What delegation kept out of the context. Empty when nothing was delegated,
181
+ * so callers can append it unconditionally without printing a zero every turn.
182
+ *
183
+ * Phrased as context rather than money on purpose: those tokens are not merely
184
+ * unspent once, they are tokens that never get resent on any later turn, which
185
+ * is the whole reason a session window lasts longer with delegation on.
186
+ */
187
+ export function delegationLine(d) {
188
+ if (!d.calls)
189
+ return "";
190
+ return `⇢ ${d.calls} delegated · ${k(d.contextTokensSaved)} tokens kept out of context`;
191
+ }
192
+ //# sourceMappingURL=shared.js.map
@@ -0,0 +1,28 @@
1
+ import React from "react";
2
+ import type { AgentEvent, DelegationTotals, PermissionMode, Skill, UsageTotals } from "@9thprotocol/agent-core";
3
+ /** What the TUI needs from a session, narrow so tests can inject a fake. */
4
+ export interface AgentLike {
5
+ model: string;
6
+ mode: PermissionMode;
7
+ readonly usage: UsageTotals;
8
+ readonly delegated: DelegationTotals;
9
+ send(text: string): AsyncGenerator<AgentEvent>;
10
+ clear(): void;
11
+ }
12
+ export interface AppMeta {
13
+ cwd: string;
14
+ authLabel: string;
15
+ vault: string | null;
16
+ skills: Skill[];
17
+ mcpSummary: string | null;
18
+ }
19
+ export interface AppBridge {
20
+ requestPermission: (summary: string) => Promise<boolean>;
21
+ requestAnswer: (question: string, options: string[]) => Promise<string>;
22
+ }
23
+ export declare function App({ session, meta, register, }: {
24
+ session: AgentLike;
25
+ meta: AppMeta;
26
+ /** Called on mount with the UI bridge for permission/ask prompts. */
27
+ register?: (bridge: AppBridge) => void;
28
+ }): React.JSX.Element;
@@ -0,0 +1,191 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useCallback, useEffect, useRef, useState } from "react";
3
+ import { Box, Static, Text, useApp, useInput } from "ink";
4
+ import TextInput from "ink-text-input";
5
+ import { skillMessage } from "@9thprotocol/agent-core";
6
+ const kfmt = (n) => (n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n));
7
+ const MODES = ["default", "accept-edits", "plan", "bypass"];
8
+ export function App({ session, meta, register, }) {
9
+ const { exit } = useApp();
10
+ const [items, setItems] = useState(() => {
11
+ const banner = [
12
+ { id: -4, kind: "banner", text: "9th Protocol (9p)" },
13
+ {
14
+ id: -3,
15
+ kind: "info",
16
+ text: `auth: ${meta.authLabel} · cwd: ${meta.cwd}` +
17
+ (meta.vault ? " · vault linked" : "") +
18
+ (meta.mcpSummary ? ` · mcp: ${meta.mcpSummary}` : ""),
19
+ },
20
+ ];
21
+ if (meta.skills.length) {
22
+ banner.push({ id: -2, kind: "info", text: `skills: ${meta.skills.map((s) => "/" + s.name).join(" ")}` });
23
+ }
24
+ banner.push({ id: -1, kind: "info", text: "/help for commands" });
25
+ return banner;
26
+ });
27
+ const [activeText, setActiveText] = useState("");
28
+ const [input, setInput] = useState("");
29
+ const [busy, setBusy] = useState(false);
30
+ const [statusTick, setStatusTick] = useState(0); // re-render statusline after /model etc.
31
+ const [pendingPerm, setPendingPerm] = useState(null);
32
+ const [pendingAsk, setPendingAsk] = useState(null);
33
+ const nextId = useRef(1);
34
+ const activeRef = useRef("");
35
+ const push = useCallback((kind, text) => {
36
+ setItems((prev) => [...prev, { id: nextId.current++, kind, text }]);
37
+ }, []);
38
+ const flushActive = useCallback(() => {
39
+ if (activeRef.current.trim())
40
+ push("assistant", activeRef.current.trimEnd());
41
+ activeRef.current = "";
42
+ setActiveText("");
43
+ }, [push]);
44
+ const runMessage = useCallback(async (message) => {
45
+ setBusy(true);
46
+ try {
47
+ for await (const ev of session.send(message)) {
48
+ switch (ev.type) {
49
+ case "text_delta":
50
+ activeRef.current += ev.text;
51
+ setActiveText(activeRef.current);
52
+ break;
53
+ case "tool_start":
54
+ flushActive();
55
+ push("tool", `⚙ ${ev.summary}`);
56
+ break;
57
+ case "tool_end":
58
+ if (ev.isError)
59
+ push("warn", ` ✗ ${ev.output.split("\n")[0] ?? ""}`);
60
+ break;
61
+ case "permission_denied":
62
+ flushActive();
63
+ push("warn", `✗ denied: ${ev.summary}`);
64
+ break;
65
+ case "turn_end":
66
+ flushActive();
67
+ break;
68
+ case "error":
69
+ flushActive();
70
+ push("error", `error: ${ev.message}`);
71
+ if (ev.code === "window_limit" || ev.code === "weekly_limit") {
72
+ push("warn", "session limit hit, buy a reset from the web UI, or wait for the window to roll");
73
+ }
74
+ break;
75
+ }
76
+ }
77
+ }
78
+ finally {
79
+ setBusy(false);
80
+ }
81
+ }, [session, push, flushActive]);
82
+ const handleSlash = useCallback((line) => {
83
+ const [cmd = "", ...rest] = line.slice(1).split(/\s+/);
84
+ const arg = rest.join(" ").trim();
85
+ switch (cmd) {
86
+ case "help":
87
+ push("info", "/models /model [id] /mode [default|accept-edits|plan|bypass] /skills /<skill> [args] /clear /usage /exit");
88
+ return;
89
+ case "model":
90
+ if (arg) {
91
+ session.model = arg;
92
+ setStatusTick((t) => t + 1);
93
+ }
94
+ push("info", `model: ${session.model}`);
95
+ return;
96
+ case "mode":
97
+ if (arg && MODES.includes(arg)) {
98
+ session.mode = arg;
99
+ setStatusTick((t) => t + 1);
100
+ }
101
+ push("info", `mode: ${session.mode}`);
102
+ return;
103
+ case "skills":
104
+ push("info", meta.skills.length
105
+ ? meta.skills.map((s) => `/${s.name} ${s.description}`).join("\n")
106
+ : "no skills found (~/.9p/skills or .9p/skills)");
107
+ return;
108
+ case "clear":
109
+ session.clear();
110
+ push("info", "context cleared");
111
+ return;
112
+ case "usage": {
113
+ const u = session.usage;
114
+ push("info", `${u.requests} calls · ${u.inputTokens} in (${u.cachedTokens} cached) · ${u.outputTokens} out`);
115
+ const d = session.delegated;
116
+ if (d.calls) {
117
+ push("info", `⇢ ${d.calls} delegated · ${d.contextTokensSaved} tokens kept out of context ` +
118
+ `(worker: ${d.workerUsage.inputTokens} in / ${d.workerUsage.outputTokens} out)`);
119
+ }
120
+ return;
121
+ }
122
+ case "exit":
123
+ case "quit":
124
+ exit();
125
+ return;
126
+ default: {
127
+ const skill = meta.skills.find((s) => s.name === cmd);
128
+ if (skill) {
129
+ push("user", line);
130
+ void runMessage(skillMessage(skill, arg));
131
+ return;
132
+ }
133
+ push("error", `unknown command: /${cmd}`);
134
+ }
135
+ }
136
+ }, [session, meta.skills, push, exit, runMessage]);
137
+ const submit = useCallback((value) => {
138
+ const line = value.trim();
139
+ setInput("");
140
+ if (!line)
141
+ return;
142
+ if (pendingAsk && !pendingAsk.options.length) {
143
+ const { resolve } = pendingAsk;
144
+ setPendingAsk(null);
145
+ push("info", `answered: ${line}`);
146
+ resolve(line);
147
+ return;
148
+ }
149
+ if (line.startsWith("/")) {
150
+ handleSlash(line);
151
+ return;
152
+ }
153
+ push("user", line);
154
+ void runMessage(line);
155
+ }, [pendingAsk, handleSlash, push, runMessage]);
156
+ useInput((char) => {
157
+ if (pendingPerm) {
158
+ const allow = /^y$/i.test(char);
159
+ if (/^[yn]$/i.test(char)) {
160
+ const { resolve, summary } = pendingPerm;
161
+ setPendingPerm(null);
162
+ push(allow ? "info" : "warn", `${allow ? "✓ allowed" : "✗ denied"}: ${summary}`);
163
+ resolve(allow);
164
+ }
165
+ return;
166
+ }
167
+ if (pendingAsk?.options.length) {
168
+ const idx = Number(char);
169
+ const pick = pendingAsk.options[idx - 1];
170
+ if (pick !== undefined) {
171
+ const { resolve } = pendingAsk;
172
+ setPendingAsk(null);
173
+ push("info", `answered: ${pick}`);
174
+ resolve(pick);
175
+ }
176
+ }
177
+ }, { isActive: pendingPerm !== null || (pendingAsk?.options.length ?? 0) > 0 });
178
+ useEffect(() => {
179
+ register?.({
180
+ requestPermission: (summary) => new Promise((resolve) => setPendingPerm({ summary, resolve })),
181
+ requestAnswer: (question, options) => new Promise((resolve) => setPendingAsk({ question, options, resolve })),
182
+ });
183
+ }, [register]);
184
+ const u = session.usage;
185
+ void statusTick;
186
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Static, { items: items, children: (item) => (_jsxs(Box, { marginBottom: item.kind === "assistant" || item.kind === "user" ? 1 : 0, children: [item.kind === "banner" && (_jsx(Text, { color: "magenta", bold: true, children: item.text })), item.kind === "user" && _jsx(Text, { color: "cyan", children: `❯ ${item.text}` }), item.kind === "assistant" && _jsx(Text, { children: item.text }), item.kind === "tool" && _jsx(Text, { dimColor: true, children: item.text }), item.kind === "info" && _jsx(Text, { dimColor: true, children: item.text }), item.kind === "warn" && _jsx(Text, { color: "yellow", children: item.text }), item.kind === "error" && _jsx(Text, { color: "red", children: item.text })] }, item.id)) }), activeText !== "" && _jsx(Text, { children: activeText }), pendingPerm && (_jsx(Box, { borderStyle: "round", borderColor: "yellow", paddingX: 1, children: _jsx(Text, { color: "yellow", children: `● permission: ${pendingPerm.summary}, allow? [y/n]` }) })), pendingAsk && (_jsxs(Box, { borderStyle: "round", borderColor: "magenta", paddingX: 1, flexDirection: "column", children: [_jsx(Text, { color: "magenta", children: `● ${pendingAsk.question}` }), pendingAsk.options.map((o, i) => (_jsx(Text, { dimColor: true, children: ` ${i + 1}. ${o}` }, o))), pendingAsk.options.length > 0 && _jsx(Text, { dimColor: true, children: "press a number to choose" })] })), !pendingPerm && (_jsxs(Box, { children: [_jsx(Text, { color: "cyan", children: busy ? "… " : "9p ❯ " }), _jsx(TextInput, { value: input, onChange: setInput, onSubmit: submit, focus: !pendingPerm })] })), _jsx(Text, { dimColor: true, children: `${session.model} · ${session.mode} · ${u.requests} calls · ${u.inputTokens} in · ${u.outputTokens} out` +
187
+ (session.delegated.calls
188
+ ? ` · ⇢ ${kfmt(session.delegated.contextTokensSaved)} saved`
189
+ : "") })] }));
190
+ }
191
+ //# sourceMappingURL=app.js.map
@@ -0,0 +1,2 @@
1
+ /** `9p` in a TTY, the Ink TUI host. */
2
+ export declare function runTui(cwd: string): Promise<void>;
@@ -0,0 +1,35 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { render } from "ink";
3
+ import { AgentSession, McpManager, loadSkills, resolveVault, } from "@9thprotocol/agent-core";
4
+ import { requireAuth, DEFAULT_MODEL, fetchCatalog, getAutoBias } from "../shared.js";
5
+ import { App } from "./app.js";
6
+ /** `9p` in a TTY, the Ink TUI host. */
7
+ export async function runTui(cwd) {
8
+ const auth = await requireAuth();
9
+ const mcp = await McpManager.fromCwd(cwd);
10
+ const skills = loadSkills(cwd);
11
+ const vault = resolveVault(cwd);
12
+ let bridge = null;
13
+ const catalog = await fetchCatalog(auth);
14
+ const session = new AgentSession({
15
+ apiKey: auth.apiKey,
16
+ ...(auth.platform ? { platform: auth.platform } : {}),
17
+ mcp,
18
+ model: process.env.NINEP_MODEL ?? DEFAULT_MODEL,
19
+ autoRouter: { bias: getAutoBias(), ...(catalog ? { catalog } : {}) },
20
+ cwd,
21
+ mode: "default",
22
+ decide: async (req) => (bridge ? bridge.requestPermission(req.summary) : false),
23
+ askUser: async (q) => bridge ? bridge.requestAnswer(q.question, q.options ?? []) : "",
24
+ });
25
+ const { waitUntilExit } = render(_jsx(App, { session: session, meta: {
26
+ cwd,
27
+ authLabel: auth.platform ? "platform" : "byok",
28
+ vault,
29
+ skills,
30
+ mcpSummary: mcp.servers.length ? `${mcp.servers.join(",")} (${mcp.tools.length})` : null,
31
+ }, register: (b) => (bridge = b) }));
32
+ await waitUntilExit();
33
+ await mcp.close();
34
+ }
35
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1,13 @@
1
+ /**
2
+ * HTML for the local session (`9p serve`) and the desktop app window.
3
+ *
4
+ * Deliberately one self-contained file with no build step and no dependencies:
5
+ * it is served by the CLI itself, so a bundler here would mean a second build
6
+ * pipeline for a single page.
7
+ *
8
+ * Layout: a full-height left sidebar, a breadcrumb bar over the main column,
9
+ * and a composer that owns its own controls. The sidebar runs to the top of the
10
+ * window on purpose. On macOS the desktop app hides the title bar, so the
11
+ * traffic lights sit in the sidebar's header strip instead of over the content.
12
+ */
13
+ export declare function chatHtml(): string;