@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/LICENSE ADDED
@@ -0,0 +1,16 @@
1
+ 9th Protocol — Proprietary License
2
+
3
+ Copyright (c) 2026 9th Protocol. All rights reserved.
4
+
5
+ This software and its source code are the property of 9th Protocol.
6
+ It is licensed, not sold. You may install and use the unmodified
7
+ package for its intended purpose. You may not copy, modify, merge,
8
+ publish, distribute, sublicense, decompile, or reverse engineer it,
9
+ or use it to build a competing agent product, without a separate
10
+ written license from 9th Protocol.
11
+
12
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
13
+ EXPRESS OR IMPLIED. IN NO EVENT SHALL 9TH PROTOCOL BE LIABLE FOR
14
+ ANY CLAIM, DAMAGES OR OTHER LIABILITY ARISING FROM THE SOFTWARE.
15
+
16
+ Contact: licensing@9thprotocol.com
package/README.md ADDED
@@ -0,0 +1,43 @@
1
+ # 9p — the 9th Protocol terminal coding agent
2
+
3
+ An agentic coder for your terminal, powered by OpenRouter so you can use the
4
+ model of your choice: the agent loop, tools, permissions, sub-agents, memory
5
+ and model routing all live in `9p`. Subscriptions and credit budgets are
6
+ managed on the [9th Protocol dashboard](https://9thprotocol.com).
7
+
8
+ ## Install
9
+
10
+ ```sh
11
+ npm install -g @9thprotocol/cli
12
+ ```
13
+
14
+ Requires Node 22 or newer (macOS and Linux; Windows best-effort).
15
+
16
+ ## Quick start
17
+
18
+ ```sh
19
+ 9p login # opens a browser to sign in / create your account
20
+ 9p # start an interactive session in your project directory
21
+ ```
22
+
23
+ Other modes:
24
+
25
+ ```sh
26
+ 9p --plain # non-TUI REPL for scripting and CI
27
+ 9p serve 4310 # local web session UI on port 4310
28
+ 9p init # set up a project config / vault
29
+ 9p map # generate a codebase wiki
30
+ ```
31
+
32
+ Credentials are stored in `~/.9p/auth.json`. To use your own OpenRouter key
33
+ instead of the platform, set `OPENROUTER_API_KEY`.
34
+
35
+ ## Links
36
+
37
+ - Dashboard: https://9thprotocol.com
38
+ - Docs: https://docs.9thprotocol.com
39
+
40
+ ## License
41
+
42
+ Proprietary — see [LICENSE](./LICENSE). The engine it runs on is
43
+ [`@9thprotocol/agent-core`](https://www.npmjs.com/package/@9thprotocol/agent-core).
package/dist/init.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ /** `9p init`: set up project memory: plain 9P.md, or an Obsidian-style vault. */
2
+ export declare function runInit(cwd: string): Promise<void>;
package/dist/init.js ADDED
@@ -0,0 +1,61 @@
1
+ import readline from "node:readline/promises";
2
+ import { stdin, stdout } from "node:process";
3
+ import fs from "node:fs";
4
+ import path from "node:path";
5
+ import { saveProjectConfig, scaffoldVault, loadProjectConfig } from "@9thprotocol/agent-core";
6
+ import { cyan, dim, yellow } from "./shared.js";
7
+ const STARTER_9P_MD = `# Project memory
8
+
9
+ Standing instructions for the 9p agent in this project. Keep this file short -
10
+ it loads into every session.
11
+
12
+ ## Conventions
13
+ - (add your project's conventions here)
14
+ `;
15
+ /** `9p init`: set up project memory: plain 9P.md, or an Obsidian-style vault. */
16
+ export async function runInit(cwd) {
17
+ const rl = readline.createInterface({ input: stdin, output: stdout });
18
+ // piped/scripted stdin can hit EOF mid-question. Treat it as "accept defaults"
19
+ let closed = false;
20
+ const onClose = new Promise((resolve) => rl.once("close", () => {
21
+ closed = true;
22
+ resolve("");
23
+ }));
24
+ const ask = (q) => (closed ? Promise.resolve("") : Promise.race([rl.question(q), onClose]));
25
+ console.log(cyan("9p init, project memory setup"));
26
+ const existing = loadProjectConfig(cwd);
27
+ if (existing.vault) {
28
+ console.log(dim(`vault already configured: ${existing.vault}`));
29
+ }
30
+ const choice = (await ask(`How should this project remember things?\n` +
31
+ ` 1. simple, a 9P.md file with standing instructions\n` +
32
+ ` 2. vault, an Obsidian-style folder: index, jobs, daily logs, decisions, codebase maps\n` +
33
+ `choose [1/2]: `)).trim();
34
+ if (choice === "2") {
35
+ const answer = (await ask(`vault location (default: ./vault): `)).trim();
36
+ const vaultRel = answer || "vault";
37
+ const vaultAbs = path.resolve(cwd, vaultRel);
38
+ const created = scaffoldVault(vaultAbs);
39
+ saveProjectConfig(cwd, { ...existing, vault: vaultRel });
40
+ console.log(created.length ? dim(`created:\n ${created.join("\n ")}`) : dim("vault already scaffolded"));
41
+ console.log(`${cyan("vault linked.")} The agent now reads ${vaultRel}/INDEX.md first, appends instead of
42
+ creating notes, and logs work to daily notes. Run ${cyan("9p map")} to generate codebase maps.`);
43
+ console.log(dim("Tip: open the vault folder in Obsidian (obsidian.md) to see the knowledge graph.\n" +
44
+ "Your memory store stays on this device; prompts that reference it go to the models you use."));
45
+ }
46
+ else {
47
+ const file = path.join(cwd, "9P.md");
48
+ if (fs.existsSync(file)) {
49
+ console.log(dim(`9P.md already exists at ${file}, left untouched`));
50
+ }
51
+ else {
52
+ fs.writeFileSync(file, STARTER_9P_MD);
53
+ console.log(`${cyan("created")} ${file}. It loads into every session in this project.`);
54
+ }
55
+ if (choice !== "1" && choice !== "")
56
+ console.log(yellow("(unrecognized choice, defaulted to simple)"));
57
+ }
58
+ if (!closed)
59
+ rl.close();
60
+ }
61
+ //# sourceMappingURL=init.js.map
@@ -0,0 +1,4 @@
1
+ export declare function runLogin(): Promise<void>;
2
+ export declare function runLogout(): Promise<void>;
3
+ /** Prompt-based fallback for `9p login --paste` (headless boxes with no browser). */
4
+ export declare function runLoginPaste(): Promise<void>;
package/dist/login.js ADDED
@@ -0,0 +1,161 @@
1
+ /**
2
+ * `9p login` / `9p logout`: device authorization grant (RFC 8628).
3
+ *
4
+ * Replaces hand-editing ~/.9p/auth.json. The CLI never sees the password: it
5
+ * gets a short code, the user approves it in the browser, and the CLI polls
6
+ * until tokens come back.
7
+ */
8
+ import fs from "node:fs";
9
+ import os from "node:os";
10
+ import path from "node:path";
11
+ import { spawn } from "node:child_process";
12
+ import readline from "node:readline/promises";
13
+ import { stdin, stdout } from "node:process";
14
+ import { cyan, dim, red, yellow } from "./shared.js";
15
+ const DEFAULT_API_URL = "https://9th-prot-api.bundleboss.net/v1";
16
+ const authFile = path.join(os.homedir(), ".9p", "auth.json");
17
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
18
+ /** Best-effort browser open; the code is always printed so this failing is harmless. */
19
+ function openBrowser(url) {
20
+ const cmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
21
+ try {
22
+ spawn(cmd, [url], { stdio: "ignore", detached: true, shell: process.platform === "win32" })
23
+ .on("error", () => { })
24
+ .unref();
25
+ }
26
+ catch {
27
+ // no browser available. The printed URL is the fallback
28
+ }
29
+ }
30
+ function readAuthFile() {
31
+ try {
32
+ return JSON.parse(fs.readFileSync(authFile, "utf8"));
33
+ }
34
+ catch {
35
+ return {};
36
+ }
37
+ }
38
+ function writeAuthFile(data) {
39
+ fs.mkdirSync(path.dirname(authFile), { recursive: true });
40
+ // 0600: this file holds a refresh token good for 30 days.
41
+ fs.writeFileSync(authFile, JSON.stringify(data, null, 2) + "\n", { mode: 0o600 });
42
+ fs.chmodSync(authFile, 0o600);
43
+ }
44
+ export async function runLogin() {
45
+ const apiUrl = (process.env.NINEP_API_URL ?? DEFAULT_API_URL).replace(/\/$/, "");
46
+ let start;
47
+ try {
48
+ const res = await fetch(`${apiUrl}/auth/device/code`, {
49
+ method: "POST",
50
+ headers: { "Content-Type": "application/json" },
51
+ body: JSON.stringify({ clientLabel: `9p CLI on ${os.hostname()} (${process.platform})` }),
52
+ });
53
+ if (!res.ok)
54
+ throw new Error(`${res.status}: ${await res.text()}`);
55
+ start = (await res.json());
56
+ }
57
+ catch (err) {
58
+ console.error(red("Could not reach the 9th Protocol API.") + ` ${String(err)}`);
59
+ console.error(dim(`API URL: ${apiUrl}, override with NINEP_API_URL`));
60
+ process.exit(1);
61
+ }
62
+ console.log(`\n${cyan("Sign in to 9th Protocol")}`);
63
+ console.log(`\n code: ${cyan(start.userCode)}`);
64
+ console.log(` open: ${start.verificationUri}\n`);
65
+ console.log(dim("Approve the code in your browser. Waiting…"));
66
+ openBrowser(start.verificationUriComplete);
67
+ const deadline = Date.now() + start.expiresIn * 1000;
68
+ let interval = Math.max(1, start.interval) * 1000;
69
+ while (Date.now() < deadline) {
70
+ await sleep(interval);
71
+ let payload;
72
+ let ok;
73
+ try {
74
+ const res = await fetch(`${apiUrl}/auth/device/token`, {
75
+ method: "POST",
76
+ headers: { "Content-Type": "application/json" },
77
+ body: JSON.stringify({ deviceCode: start.deviceCode }),
78
+ });
79
+ ok = res.ok;
80
+ payload = (await res.json());
81
+ }
82
+ catch {
83
+ continue; // transient network blip. Keep polling until the deadline
84
+ }
85
+ if (ok) {
86
+ const tokens = payload;
87
+ writeAuthFile({
88
+ ...readAuthFile(),
89
+ apiUrl,
90
+ token: tokens.accessToken,
91
+ refreshToken: tokens.refreshToken,
92
+ email: tokens.user.email,
93
+ });
94
+ console.log(`\n${cyan("✓ signed in")} as ${tokens.user.email} ${dim(`(plan: ${tokens.user.plan})`)}`);
95
+ console.log(dim(`credentials saved to ${authFile}`));
96
+ return;
97
+ }
98
+ switch (payload.error) {
99
+ case "authorization_pending":
100
+ break; // expected while the user is still approving
101
+ case "slow_down":
102
+ interval += 2000; // the server says we're too eager
103
+ break;
104
+ case "access_denied":
105
+ console.error(`\n${red("Request denied.")} Nothing was saved.`);
106
+ process.exit(1);
107
+ // eslint-disable-next-line no-fallthrough
108
+ case "expired_token":
109
+ console.error(`\n${red("Code expired.")} Run ${cyan("9p login")} again.`);
110
+ process.exit(1);
111
+ // eslint-disable-next-line no-fallthrough
112
+ default:
113
+ console.error(`\n${red("Login failed:")} ${String(payload.message ?? payload.error)}`);
114
+ process.exit(1);
115
+ }
116
+ }
117
+ console.error(`\n${red("Timed out")} waiting for approval. Run ${cyan("9p login")} again.`);
118
+ process.exit(1);
119
+ }
120
+ export async function runLogout() {
121
+ const file = readAuthFile();
122
+ if (!file.token && !file.refreshToken) {
123
+ console.log(dim("Not signed in."));
124
+ return;
125
+ }
126
+ const apiUrl = String(file.apiUrl ?? process.env.NINEP_API_URL ?? DEFAULT_API_URL).replace(/\/$/, "");
127
+ if (typeof file.refreshToken === "string") {
128
+ // Revoke server-side so a leaked file can't be replayed.
129
+ try {
130
+ await fetch(`${apiUrl}/auth/logout`, {
131
+ method: "POST",
132
+ headers: { "Content-Type": "application/json" },
133
+ body: JSON.stringify({ refreshToken: file.refreshToken }),
134
+ });
135
+ }
136
+ catch {
137
+ console.log(yellow("Could not reach the API to revoke the token; clearing locally."));
138
+ }
139
+ }
140
+ // Keep any BYOK key, logging out of the platform shouldn't wipe that.
141
+ const { token: _t, refreshToken: _r, apiUrl: _u, email: _e, ...rest } = file;
142
+ if (Object.keys(rest).length)
143
+ writeAuthFile(rest);
144
+ else
145
+ fs.rmSync(authFile, { force: true });
146
+ console.log(cyan("✓ signed out"));
147
+ }
148
+ /** Prompt-based fallback for `9p login --paste` (headless boxes with no browser). */
149
+ export async function runLoginPaste() {
150
+ const rl = readline.createInterface({ input: stdin, output: stdout });
151
+ const apiUrl = ((await rl.question(`API URL [${DEFAULT_API_URL}]: `)).trim() || DEFAULT_API_URL).replace(/\/$/, "");
152
+ const token = (await rl.question("access token: ")).trim();
153
+ rl.close();
154
+ if (!token) {
155
+ console.error(red("No token given."));
156
+ process.exit(1);
157
+ }
158
+ writeAuthFile({ ...readAuthFile(), apiUrl, token });
159
+ console.log(cyan("✓ saved") + dim(` → ${authFile}`));
160
+ }
161
+ //# sourceMappingURL=login.js.map
package/dist/main.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/main.js ADDED
@@ -0,0 +1,53 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * 9p, the 9th Protocol coding agent.
4
+ * 9p interactive session (Ink TUI in a TTY; --plain for the bare REPL)
5
+ * 9p login sign in via the browser (device grant); --paste for headless
6
+ * 9p logout revoke and clear saved credentials
7
+ * 9p init project memory setup: 9P.md or an Obsidian-style vault
8
+ * 9p map generate/refresh codebase wiki notes in the vault
9
+ * 9p serve [port] local web session (used standalone and by the VS Code extension)
10
+ */
11
+ import { runRepl } from "./repl.js";
12
+ import { runInit } from "./init.js";
13
+ import { runMap } from "./map.js";
14
+ import { runServe } from "./serve.js";
15
+ import { runLogin, runLoginPaste, runLogout } from "./login.js";
16
+ const cwd = process.cwd();
17
+ const args = process.argv.slice(2);
18
+ const cmd = args[0];
19
+ switch (cmd) {
20
+ case "login":
21
+ await (args.includes("--paste") ? runLoginPaste() : runLogin());
22
+ break;
23
+ case "logout":
24
+ await runLogout();
25
+ break;
26
+ case "init":
27
+ await runInit(cwd);
28
+ break;
29
+ case "map":
30
+ await runMap(cwd);
31
+ break;
32
+ case "serve": {
33
+ const port = Number(args[1] ?? process.env.NINEP_PORT ?? 4310);
34
+ await runServe(cwd, port);
35
+ break;
36
+ }
37
+ case "--plain":
38
+ await runRepl(cwd);
39
+ break;
40
+ case undefined:
41
+ if (process.stdout.isTTY && process.stdin.isTTY) {
42
+ const { runTui } = await import("./tui/index.js");
43
+ await runTui(cwd);
44
+ }
45
+ else {
46
+ await runRepl(cwd);
47
+ }
48
+ break;
49
+ default:
50
+ console.error(`unknown command: ${cmd}\nusage: 9p [login|logout|init|map|serve [port]|--plain]`);
51
+ process.exit(1);
52
+ }
53
+ //# sourceMappingURL=main.js.map
package/dist/map.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ /** `9p map`: generate/refresh graphify-style codebase wiki notes in the vault. */
2
+ export declare function runMap(cwd: string): Promise<void>;
package/dist/map.js ADDED
@@ -0,0 +1,57 @@
1
+ import { AgentSession, resolveVault } from "@9thprotocol/agent-core";
2
+ import { cyan, dim, red, requireAuth, usageLine, DEFAULT_MODEL, fetchCatalog } from "./shared.js";
3
+ function mapPrompt(vault) {
4
+ return `Map this codebase into the vault's graph section so future sessions can navigate the map instead of re-reading raw files.
5
+
6
+ 1. Explore the project with glob/grep/read: layout, packages/modules, entry points, configs.
7
+ 2. Write concise wiki notes (each under 60 lines) into ${vault}/graph/:
8
+ - index.md, the entry point: one line per note with a [[wiki link]] and what it covers
9
+ - one note per major module/package/area: purpose, key files with paths, how it connects to the rest, [[wiki links]] to related notes
10
+ 3. If a note already exists, update it in place rather than duplicating.
11
+ 4. Update ${vault}/INDEX.md so it links to graph/index (only if not already linked).
12
+
13
+ Return a one-paragraph summary of what you mapped.`;
14
+ }
15
+ /** `9p map`: generate/refresh graphify-style codebase wiki notes in the vault. */
16
+ export async function runMap(cwd) {
17
+ const vault = resolveVault(cwd);
18
+ if (!vault) {
19
+ console.error(red("No vault configured.") + ` Run ${cyan("9p init")} and choose the vault option first.`);
20
+ process.exit(1);
21
+ }
22
+ const auth = await requireAuth();
23
+ const catalog = await fetchCatalog(auth);
24
+ const session = new AgentSession({
25
+ apiKey: auth.apiKey,
26
+ ...(auth.platform ? { platform: auth.platform } : {}),
27
+ model: process.env.NINEP_MODEL ?? DEFAULT_MODEL,
28
+ // Mapping is bulk read-and-summarise across a whole repo, economy by design.
29
+ autoRouter: { bias: "economy", ...(catalog ? { catalog } : {}) },
30
+ cwd,
31
+ // writes auto-allowed (the prompt constrains them to the vault); bash denied, mapping is read+write only
32
+ mode: "accept-edits",
33
+ decide: async () => false,
34
+ });
35
+ console.log(cyan("9p map") + dim(`, mapping ${cwd} → ${vault}/graph (model: ${session.model})`));
36
+ for await (const ev of session.send(mapPrompt(vault))) {
37
+ switch (ev.type) {
38
+ case "text_delta":
39
+ process.stdout.write(ev.text);
40
+ break;
41
+ case "tool_start":
42
+ console.log(dim(`⚙ ${ev.summary}`));
43
+ break;
44
+ case "tool_end":
45
+ if (ev.isError)
46
+ console.log(dim(` ✗ ${ev.output.split("\n")[0] ?? ""}`));
47
+ break;
48
+ case "turn_end":
49
+ console.log(`\n${dim("⏺ " + usageLine(ev.usage))}`);
50
+ break;
51
+ case "error":
52
+ console.error(red(`\nerror: ${ev.message}`));
53
+ process.exitCode = 1;
54
+ }
55
+ }
56
+ }
57
+ //# sourceMappingURL=map.js.map
package/dist/repl.d.ts ADDED
@@ -0,0 +1 @@
1
+ export declare function runRepl(cwd: string): Promise<void>;