palcli1 1.0.2
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/README.md +101 -0
- package/bin/palcli.js +2 -0
- package/package.json +41 -0
- package/src/commands/chat-session.js +154 -0
- package/src/commands/login.js +240 -0
- package/src/commands/wakeup.js +73 -0
- package/src/commands/whoami.js +43 -0
- package/src/config.js +16 -0
- package/src/lib/api.js +41 -0
- package/src/lib/markdown.js +26 -0
- package/src/lib/token-store.js +55 -0
- package/src/main.js +47 -0
- package/src/publish-config.js +11 -0
package/README.md
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# `pal-cli` (npm package)
|
|
2
|
+
|
|
3
|
+
Thin command-line client for **PAL**: it calls your **hosted** PAL API (`/api/me`, `/api/conversations`, …). It does **not** connect to Postgres or load `GOOGLE_API_KEY` locally—those stay on the server.
|
|
4
|
+
|
|
5
|
+
**Package name:** the unscoped name `palcli` is blocked by npm (too close to the existing package `pal-cli`). This package is published as **`palcli`**. To use your own scope, change the `name` field in `package.json` to `@your-npm-username/palcli` before publishing.
|
|
6
|
+
|
|
7
|
+
## Prerequisites
|
|
8
|
+
|
|
9
|
+
- **Node.js 20+**
|
|
10
|
+
- A running PAL **server** (`server/` in this repo) with Postgres, migrations, and env vars set (see `server/.env.example`).
|
|
11
|
+
- A **GitHub OAuth App** with the callback URL matching your server, e.g. `https://your-api.example.com/api/auth/callback/github`, and **Device flow** enabled where GitHub requires it.
|
|
12
|
+
|
|
13
|
+
## Global command name: `pal-cli`
|
|
14
|
+
|
|
15
|
+
The npm **binary** is **`pal-cli`** (with a hyphen), not `palcli`. That avoids a common **Windows** problem: the server package exposes **`palCLI`**, and on a case-insensitive filesystem `palCLI` and `palcli` are treated as the same global shim, so `npm link` can hit **EEXIST** and `palcli` in your PATH may run the **wrong** CLI.
|
|
16
|
+
|
|
17
|
+
## Install from this monorepo (development)
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
cd packages/palcli
|
|
21
|
+
npm install
|
|
22
|
+
node ./bin/palcli.js --help
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
### `npm link` on Windows (EEXIST)
|
|
26
|
+
|
|
27
|
+
1. See what is using the name (often the server CLI): `npm ls -g --depth=0`
|
|
28
|
+
2. Unlink the old tool if needed: `npm unlink -g server` (or whatever package installed the conflicting bin)
|
|
29
|
+
3. Remove stale shims if they remain, e.g. delete `palcli`, `palcli.cmd`, `palCLI`, `palCLI.cmd` under `%AppData%\Roaming\npm\` **only if** you know they are leftovers
|
|
30
|
+
4. Link this package: `npm link` (creates **`pal-cli`** globally)
|
|
31
|
+
5. Run: `pal-cli --help`
|
|
32
|
+
|
|
33
|
+
If npm still complains, use **`npm link --force`** after backing up/removing the conflicting file it names in the error.
|
|
34
|
+
|
|
35
|
+
## Configure before publishing to npm
|
|
36
|
+
|
|
37
|
+
1. Edit **`src/publish-config.js`**:
|
|
38
|
+
- Set **`PUBLISH_API_URL`** to your public API base URL (same value as server `BETTER_AUTH_URL` / `API_PUBLIC_URL`).
|
|
39
|
+
- Set **`PUBLISH_GITHUB_CLIENT_ID`** to the GitHub OAuth App’s **Client ID** (public). Never put the **client secret** in this file or in the published package.
|
|
40
|
+
|
|
41
|
+
2. On the **server**, set matching env (see `server/.env.example`):
|
|
42
|
+
- `BETTER_AUTH_URL`, `TRUSTED_ORIGINS`, `CLIENT_APP_URL`, `CORS_ORIGIN`, `GITHUB_*`, `GOOGLE_API_KEY`, `DATABASE_URL`, etc.
|
|
43
|
+
|
|
44
|
+
3. Optional overrides for testers (no publish needed):
|
|
45
|
+
- `PALCLI_API_URL` — API base URL
|
|
46
|
+
- `PALCLI_GITHUB_CLIENT_ID` — GitHub Client ID
|
|
47
|
+
|
|
48
|
+
## Publish to npm
|
|
49
|
+
|
|
50
|
+
The package is **scoped** (`pal-cli`) so it does not collide with the existing **`pal-cli`** package on the registry.
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
cd packages/palcli
|
|
54
|
+
npm whoami # must be logged in; scope must match your npm user/org
|
|
55
|
+
npm publish
|
|
56
|
+
# publishConfig.access is already "public" in package.json
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
### `403 Forbidden` — “Two-factor authentication … is required to publish”
|
|
60
|
+
|
|
61
|
+
npm now requires **either**:
|
|
62
|
+
|
|
63
|
+
- **2FA on your account** with **“Authorization and publishing”** (not “Auth only”), then publish again with `npm publish` (you may be prompted for an OTP), **or**
|
|
64
|
+
- A **granular access token** with permission to **write/publish** packages (and “bypass 2FA” if your org policy requires it), then:
|
|
65
|
+
`npm config set //registry.npmjs.org/:_authToken=YOUR_TOKEN`
|
|
66
|
+
|
|
67
|
+
Configure this at [npmjs.com](https://www.npmjs.com/) → **Access Tokens** / **Two-Factor Authentication**.
|
|
68
|
+
|
|
69
|
+
Optional: run `npm pkg fix` in this folder if `npm publish` warns about `package.json`.
|
|
70
|
+
|
|
71
|
+
**Private package (invite-only):** use an npm org private package or GitHub Packages; add collaborators in npm UI.
|
|
72
|
+
|
|
73
|
+
## Device login and `localhost:3000`
|
|
74
|
+
|
|
75
|
+
`pal-cli login` opens the **web app** URL (usually `http://localhost:3000/device?...`) so you can enter the device code. That page is **Next.js** (`client/`), not Express. If Edge shows **connection refused**, start the client:
|
|
76
|
+
|
|
77
|
+
```bash
|
|
78
|
+
cd client
|
|
79
|
+
npm run dev
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
Keep the **API** running on port 3005 (`cd server && npm run dev`) and set the client’s API base URL (e.g. `NEXT_PUBLIC_*` / `auth-client` `baseURL`) to match.
|
|
83
|
+
|
|
84
|
+
## End-user commands
|
|
85
|
+
|
|
86
|
+
```bash
|
|
87
|
+
pal-cli login # device flow; opens browser
|
|
88
|
+
pal-cli whoami # GET /api/me
|
|
89
|
+
pal-cli chat # create conversation + chat via POST .../messages
|
|
90
|
+
pal-cli wakeup # menu → chat
|
|
91
|
+
pal-cli logout # delete ~/.palcli/credentials.json
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
After `npm install -g pal-cli`, the command is still **`pal-cli`** (see `package.json` → `bin`).
|
|
95
|
+
|
|
96
|
+
Credentials are stored under **`~/.palcli/credentials.json`**.
|
|
97
|
+
|
|
98
|
+
## Security reminders
|
|
99
|
+
|
|
100
|
+
- Do **not** ship database URLs, Google keys, or GitHub **client secrets** in this package.
|
|
101
|
+
- Publishing with real `PUBLISH_API_URL` + `PUBLISH_GITHUB_CLIENT_ID` only tells the world **where** to authenticate; access is still gated by your server and OAuth.
|
package/bin/palcli.js
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "palcli1",
|
|
3
|
+
"version": "1.0.2",
|
|
4
|
+
"description": "Thin CLI for PAL — talks to your hosted PAL API (no local database).",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./src/main.js",
|
|
7
|
+
"bin": {
|
|
8
|
+
"pal-cli": "bin/palcli.js"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"bin",
|
|
12
|
+
"src",
|
|
13
|
+
"README.md"
|
|
14
|
+
],
|
|
15
|
+
"engines": {
|
|
16
|
+
"node": ">=20"
|
|
17
|
+
},
|
|
18
|
+
"keywords": [
|
|
19
|
+
"pal-cli",
|
|
20
|
+
"cli",
|
|
21
|
+
"ai",
|
|
22
|
+
"chat"
|
|
23
|
+
],
|
|
24
|
+
"license": "ISC",
|
|
25
|
+
"publishConfig": {
|
|
26
|
+
"access": "public"
|
|
27
|
+
},
|
|
28
|
+
"dependencies": {
|
|
29
|
+
"@clack/prompts": "^1.1.0",
|
|
30
|
+
"better-auth": "^1.5.6",
|
|
31
|
+
"boxen": "^8.0.1",
|
|
32
|
+
"chalk": "^5.6.2",
|
|
33
|
+
"commander": "^14.0.3",
|
|
34
|
+
"figlet": "^1.11.0",
|
|
35
|
+
"marked": "^15.0.12",
|
|
36
|
+
"marked-terminal": "^7.3.0",
|
|
37
|
+
"open": "^11.0.0",
|
|
38
|
+
"yocto-spinner": "^1.1.0",
|
|
39
|
+
"zod": "^4.3.6"
|
|
40
|
+
}
|
|
41
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { intro, isCancel, outro, text } from "@clack/prompts";
|
|
2
|
+
import chalk from "chalk";
|
|
3
|
+
import boxen from "boxen";
|
|
4
|
+
import yoctoSpinner from "yocto-spinner";
|
|
5
|
+
import { getApiUrl } from "../config.js";
|
|
6
|
+
import { apiRequest } from "../lib/api.js";
|
|
7
|
+
import { renderMarkdown } from "../lib/markdown.js";
|
|
8
|
+
import {
|
|
9
|
+
getStoredToken,
|
|
10
|
+
isTokenExpired,
|
|
11
|
+
} from "../lib/token-store.js";
|
|
12
|
+
|
|
13
|
+
async function getBearer() {
|
|
14
|
+
const token = await getStoredToken();
|
|
15
|
+
if (!token?.access_token || (await isTokenExpired())) {
|
|
16
|
+
throw new Error("Not signed in. Run: pal-cli login");
|
|
17
|
+
}
|
|
18
|
+
return token.access_token;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* @param {string} baseUrl
|
|
23
|
+
* @param {string} mode
|
|
24
|
+
*/
|
|
25
|
+
export async function runChatSession(baseUrl, mode = "chat") {
|
|
26
|
+
const tokenGetter = getBearer;
|
|
27
|
+
|
|
28
|
+
const spin = yoctoSpinner({ text: "Starting conversation…" }).start();
|
|
29
|
+
let conversation;
|
|
30
|
+
try {
|
|
31
|
+
const created = await apiRequest(baseUrl, tokenGetter, "/api/conversations", {
|
|
32
|
+
method: "POST",
|
|
33
|
+
body: JSON.stringify({ mode }),
|
|
34
|
+
});
|
|
35
|
+
conversation = created?.data;
|
|
36
|
+
if (!conversation?.id) throw new Error("Could not create conversation");
|
|
37
|
+
spin.success("Ready");
|
|
38
|
+
} catch (e) {
|
|
39
|
+
spin.error("Failed");
|
|
40
|
+
throw e;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
console.log(
|
|
44
|
+
boxen(
|
|
45
|
+
`${chalk.bold("Conversation")}: ${conversation.title}\n${chalk.gray("ID: " + conversation.id)}`,
|
|
46
|
+
{
|
|
47
|
+
padding: 1,
|
|
48
|
+
margin: { top: 1, bottom: 1 },
|
|
49
|
+
borderStyle: "round",
|
|
50
|
+
borderColor: "cyan",
|
|
51
|
+
title: "PAL chat",
|
|
52
|
+
titleAlignment: "center",
|
|
53
|
+
},
|
|
54
|
+
),
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
const help = boxen(
|
|
58
|
+
`${chalk.gray("Enter message · exit or quit to leave · Ctrl+C to abort")}`,
|
|
59
|
+
{
|
|
60
|
+
padding: 1,
|
|
61
|
+
margin: { bottom: 1 },
|
|
62
|
+
borderStyle: "round",
|
|
63
|
+
borderColor: "gray",
|
|
64
|
+
dimBorder: true,
|
|
65
|
+
},
|
|
66
|
+
);
|
|
67
|
+
console.log(help);
|
|
68
|
+
|
|
69
|
+
let firstExchange = true;
|
|
70
|
+
|
|
71
|
+
while (true) {
|
|
72
|
+
const userInput = await text({
|
|
73
|
+
message: chalk.blue("You"),
|
|
74
|
+
placeholder: "Message…",
|
|
75
|
+
validate(value) {
|
|
76
|
+
if (!value || !value.trim()) return "Message cannot be empty";
|
|
77
|
+
},
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
if (isCancel(userInput)) {
|
|
81
|
+
console.log(chalk.yellow("Bye."));
|
|
82
|
+
process.exit(0);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const trimmed = userInput.trim();
|
|
86
|
+
const lower = trimmed.toLowerCase();
|
|
87
|
+
if (lower === "exit" || lower === "quit") break;
|
|
88
|
+
|
|
89
|
+
const wait = yoctoSpinner({ text: "Thinking…" }).start();
|
|
90
|
+
try {
|
|
91
|
+
const res = await apiRequest(
|
|
92
|
+
baseUrl,
|
|
93
|
+
tokenGetter,
|
|
94
|
+
`/api/conversations/${conversation.id}/messages`,
|
|
95
|
+
{
|
|
96
|
+
method: "POST",
|
|
97
|
+
body: JSON.stringify({ content: trimmed, role: "user" }),
|
|
98
|
+
},
|
|
99
|
+
);
|
|
100
|
+
wait.stop();
|
|
101
|
+
|
|
102
|
+
const assistantContent = res?.data?.assistantMessage?.content;
|
|
103
|
+
if (assistantContent == null) {
|
|
104
|
+
throw new Error("No assistant reply in response");
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (firstExchange) {
|
|
108
|
+
firstExchange = false;
|
|
109
|
+
const title =
|
|
110
|
+
trimmed.slice(0, 50) + (trimmed.length > 50 ? "…" : "");
|
|
111
|
+
try {
|
|
112
|
+
await apiRequest(
|
|
113
|
+
baseUrl,
|
|
114
|
+
tokenGetter,
|
|
115
|
+
`/api/conversations/${conversation.id}`,
|
|
116
|
+
{
|
|
117
|
+
method: "PUT",
|
|
118
|
+
body: JSON.stringify({ title }),
|
|
119
|
+
},
|
|
120
|
+
);
|
|
121
|
+
} catch {
|
|
122
|
+
/* title update is best-effort */
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const rendered = renderMarkdown(assistantContent);
|
|
127
|
+
console.log(
|
|
128
|
+
boxen(rendered.trim(), {
|
|
129
|
+
padding: 1,
|
|
130
|
+
margin: { left: 0, bottom: 1 },
|
|
131
|
+
borderStyle: "round",
|
|
132
|
+
borderColor: "green",
|
|
133
|
+
title: "Assistant",
|
|
134
|
+
titleAlignment: "left",
|
|
135
|
+
}),
|
|
136
|
+
);
|
|
137
|
+
} catch (e) {
|
|
138
|
+
wait.stop();
|
|
139
|
+
console.log(boxen(chalk.red(e.message), { padding: 1, borderColor: "red" }));
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
outro(chalk.green("Session ended."));
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export async function startChatFromCli() {
|
|
147
|
+
intro(boxen(chalk.bold.cyan("PAL CLI chat"), { padding: 1, borderStyle: "double" }));
|
|
148
|
+
try {
|
|
149
|
+
await runChatSession(getApiUrl(), "chat");
|
|
150
|
+
} catch (e) {
|
|
151
|
+
console.log(boxen(chalk.red(e.message), { padding: 1, borderColor: "red" }));
|
|
152
|
+
process.exit(1);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
import { cancel, confirm, intro, isCancel, outro } from "@clack/prompts";
|
|
2
|
+
import { createAuthClient } from "better-auth/client";
|
|
3
|
+
import { deviceAuthorizationClient } from "better-auth/client/plugins";
|
|
4
|
+
import chalk from "chalk";
|
|
5
|
+
import { Command } from "commander";
|
|
6
|
+
import open from "open";
|
|
7
|
+
import yoctoSpinner from "yocto-spinner";
|
|
8
|
+
import * as z from "zod";
|
|
9
|
+
import { getApiUrl, getGithubClientId } from "../config.js";
|
|
10
|
+
import {
|
|
11
|
+
getCredentialsPath,
|
|
12
|
+
getStoredToken,
|
|
13
|
+
isTokenExpired,
|
|
14
|
+
storeToken,
|
|
15
|
+
} from "../lib/token-store.js";
|
|
16
|
+
|
|
17
|
+
async function pollForToken(authClient, deviceCode, clientId, initialInterval) {
|
|
18
|
+
let pollingInterval = initialInterval;
|
|
19
|
+
const spinner = yoctoSpinner({ text: "", color: "cyan" });
|
|
20
|
+
let dots = 0;
|
|
21
|
+
|
|
22
|
+
return new Promise((resolve, reject) => {
|
|
23
|
+
const poll = async () => {
|
|
24
|
+
dots = (dots + 1) % 4;
|
|
25
|
+
spinner.text = chalk.gray(
|
|
26
|
+
`Polling for authorization${".".repeat(dots)}${" ".repeat(3 - dots)}`,
|
|
27
|
+
);
|
|
28
|
+
if (!spinner.isSpinning) spinner.start();
|
|
29
|
+
|
|
30
|
+
try {
|
|
31
|
+
const { data, error } = await authClient.device.token({
|
|
32
|
+
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
|
|
33
|
+
device_code: deviceCode,
|
|
34
|
+
client_id: clientId,
|
|
35
|
+
fetchOptions: {
|
|
36
|
+
headers: { "user-agent": "palcli" },
|
|
37
|
+
},
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
if (data?.access_token) {
|
|
41
|
+
spinner.stop();
|
|
42
|
+
resolve(data);
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (error) {
|
|
47
|
+
switch (error.error) {
|
|
48
|
+
case "authorization_pending":
|
|
49
|
+
break;
|
|
50
|
+
case "slow_down":
|
|
51
|
+
pollingInterval += 5;
|
|
52
|
+
break;
|
|
53
|
+
case "access_denied":
|
|
54
|
+
spinner.stop();
|
|
55
|
+
reject(new Error("Access denied"));
|
|
56
|
+
return;
|
|
57
|
+
case "expired_token":
|
|
58
|
+
spinner.stop();
|
|
59
|
+
reject(new Error("Device code expired"));
|
|
60
|
+
return;
|
|
61
|
+
default:
|
|
62
|
+
spinner.stop();
|
|
63
|
+
reject(
|
|
64
|
+
new Error(error.error_description || error.error || "Token error"),
|
|
65
|
+
);
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
} catch (err) {
|
|
70
|
+
spinner.stop();
|
|
71
|
+
reject(err);
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
setTimeout(poll, pollingInterval * 1000);
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
setTimeout(poll, pollingInterval * 1000);
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export async function loginAction(opts) {
|
|
83
|
+
const options = z
|
|
84
|
+
.object({
|
|
85
|
+
serverUrl: z.string().optional(),
|
|
86
|
+
clientId: z.string().optional(),
|
|
87
|
+
})
|
|
88
|
+
.parse(opts);
|
|
89
|
+
|
|
90
|
+
const serverUrl = options.serverUrl || getApiUrl();
|
|
91
|
+
const clientId = options.clientId || getGithubClientId();
|
|
92
|
+
|
|
93
|
+
intro(chalk.bold("PAL CLI — sign in"));
|
|
94
|
+
|
|
95
|
+
if (!clientId) {
|
|
96
|
+
console.log(
|
|
97
|
+
chalk.red(
|
|
98
|
+
"GitHub OAuth Client ID is not configured.\nThe package maintainer must set PUBLISH_GITHUB_CLIENT_ID in src/publish-config.js before publish,\nor set PALCLI_GITHUB_CLIENT_ID for local use.",
|
|
99
|
+
),
|
|
100
|
+
);
|
|
101
|
+
process.exit(1);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const existingToken = await getStoredToken();
|
|
105
|
+
if (existingToken && !(await isTokenExpired())) {
|
|
106
|
+
const again = await confirm({
|
|
107
|
+
message: "Already signed in. Sign in again?",
|
|
108
|
+
initialValue: false,
|
|
109
|
+
});
|
|
110
|
+
if (isCancel(again) || !again) {
|
|
111
|
+
cancel("Cancelled");
|
|
112
|
+
process.exit(0);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const authClient = createAuthClient({
|
|
117
|
+
baseURL: serverUrl,
|
|
118
|
+
plugins: [deviceAuthorizationClient()],
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
const spinner = yoctoSpinner({ text: "Requesting device authorization" });
|
|
122
|
+
spinner.start();
|
|
123
|
+
|
|
124
|
+
try {
|
|
125
|
+
const { data, error } = await authClient.device.code({
|
|
126
|
+
client_id: clientId,
|
|
127
|
+
scope: "openid profile email",
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
spinner.stop();
|
|
131
|
+
|
|
132
|
+
if (error || !data) {
|
|
133
|
+
console.log(
|
|
134
|
+
chalk.red(
|
|
135
|
+
error?.error_description || error?.message || "Device code request failed",
|
|
136
|
+
),
|
|
137
|
+
);
|
|
138
|
+
if (error?.status === 404) {
|
|
139
|
+
console.log(chalk.yellow("Is the server running at " + serverUrl + "?"));
|
|
140
|
+
}
|
|
141
|
+
process.exit(1);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const {
|
|
145
|
+
device_code,
|
|
146
|
+
user_code,
|
|
147
|
+
verification_uri,
|
|
148
|
+
verification_uri_complete,
|
|
149
|
+
interval = 5,
|
|
150
|
+
expires_in,
|
|
151
|
+
} = data;
|
|
152
|
+
|
|
153
|
+
console.log("");
|
|
154
|
+
console.log(chalk.cyan("Device sign-in"));
|
|
155
|
+
console.log(
|
|
156
|
+
chalk.white("Open: ") +
|
|
157
|
+
chalk.underline.blue(verification_uri_complete || verification_uri),
|
|
158
|
+
);
|
|
159
|
+
console.log(chalk.white("Code: ") + chalk.bold.green(user_code));
|
|
160
|
+
console.log("");
|
|
161
|
+
const verifyUrl = verification_uri_complete || verification_uri;
|
|
162
|
+
if (typeof verifyUrl === "string" && verifyUrl.includes("localhost:3000")) {
|
|
163
|
+
console.log(
|
|
164
|
+
chalk.yellow(
|
|
165
|
+
"Tip: this URL is served by the Next.js app, not the API. If the browser says connection refused, run:\n cd client && npm run dev\n",
|
|
166
|
+
),
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const shouldOpen = await confirm({
|
|
171
|
+
message: "Open browser automatically?",
|
|
172
|
+
initialValue: true,
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
if (!isCancel(shouldOpen) && shouldOpen) {
|
|
176
|
+
await open(verification_uri_complete || verification_uri);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
console.log(
|
|
180
|
+
chalk.gray(
|
|
181
|
+
`Waiting (expires in ~${Math.floor(expires_in / 60)} min)…`,
|
|
182
|
+
),
|
|
183
|
+
);
|
|
184
|
+
|
|
185
|
+
const token = await pollForToken(
|
|
186
|
+
authClient,
|
|
187
|
+
device_code,
|
|
188
|
+
clientId,
|
|
189
|
+
interval,
|
|
190
|
+
);
|
|
191
|
+
|
|
192
|
+
await storeToken(token);
|
|
193
|
+
|
|
194
|
+
const { data: session } = await authClient.getSession({
|
|
195
|
+
fetchOptions: {
|
|
196
|
+
headers: { Authorization: `Bearer ${token.access_token}` },
|
|
197
|
+
},
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
outro(
|
|
201
|
+
chalk.green(
|
|
202
|
+
`Signed in as ${session?.user?.name || session?.user?.email || "user"}`,
|
|
203
|
+
),
|
|
204
|
+
);
|
|
205
|
+
console.log(chalk.gray(`Credentials: ${getCredentialsPath()}\n`));
|
|
206
|
+
} catch (err) {
|
|
207
|
+
spinner.stop();
|
|
208
|
+
console.error(chalk.red("Login failed:"), err.message);
|
|
209
|
+
process.exit(1);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export async function logoutAction() {
|
|
214
|
+
intro(chalk.bold("Sign out"));
|
|
215
|
+
const token = await getStoredToken();
|
|
216
|
+
if (!token) {
|
|
217
|
+
console.log(chalk.yellow("Not signed in."));
|
|
218
|
+
process.exit(0);
|
|
219
|
+
}
|
|
220
|
+
const ok = await confirm({
|
|
221
|
+
message: "Clear saved credentials?",
|
|
222
|
+
initialValue: true,
|
|
223
|
+
});
|
|
224
|
+
if (isCancel(ok) || !ok) {
|
|
225
|
+
cancel("Cancelled");
|
|
226
|
+
process.exit(0);
|
|
227
|
+
}
|
|
228
|
+
await clearStoredToken();
|
|
229
|
+
outro(chalk.green("Signed out."));
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
export const login = new Command("login")
|
|
233
|
+
.description("Sign in with GitHub (device flow)")
|
|
234
|
+
.option("--server-url <url>", "PAL API base URL (Better Auth)")
|
|
235
|
+
.option("--client-id <id>", "GitHub OAuth App client ID")
|
|
236
|
+
.action(loginAction);
|
|
237
|
+
|
|
238
|
+
export const logout = new Command("logout")
|
|
239
|
+
.description("Remove saved credentials")
|
|
240
|
+
.action(logoutAction);
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import chalk from "chalk";
|
|
2
|
+
import { Command } from "commander";
|
|
3
|
+
import { select, isCancel, cancel } from "@clack/prompts";
|
|
4
|
+
import yoctoSpinner from "yocto-spinner";
|
|
5
|
+
import { getApiUrl } from "../config.js";
|
|
6
|
+
import { apiRequest } from "../lib/api.js";
|
|
7
|
+
import {
|
|
8
|
+
getStoredToken,
|
|
9
|
+
isTokenExpired,
|
|
10
|
+
} from "../lib/token-store.js";
|
|
11
|
+
import { runChatSession } from "./chat-session.js";
|
|
12
|
+
|
|
13
|
+
async function getBearer() {
|
|
14
|
+
const token = await getStoredToken();
|
|
15
|
+
if (!token?.access_token || (await isTokenExpired())) {
|
|
16
|
+
console.log(chalk.red("Not signed in. Run: pal-cli login"));
|
|
17
|
+
process.exit(1);
|
|
18
|
+
}
|
|
19
|
+
return token.access_token;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async function wakeupAction() {
|
|
23
|
+
const baseUrl = getApiUrl();
|
|
24
|
+
const spin = yoctoSpinner({ text: "Loading profile…" }).start();
|
|
25
|
+
try {
|
|
26
|
+
const json = await apiRequest(baseUrl, getBearer, "/api/me", {
|
|
27
|
+
method: "GET",
|
|
28
|
+
});
|
|
29
|
+
spin.stop();
|
|
30
|
+
const user = json?.data;
|
|
31
|
+
if (!user) {
|
|
32
|
+
console.log(chalk.red("Unexpected /api/me response"));
|
|
33
|
+
process.exit(1);
|
|
34
|
+
}
|
|
35
|
+
console.log(chalk.green(`\nHi, ${user.name}!\n`));
|
|
36
|
+
} catch (e) {
|
|
37
|
+
spin.stop();
|
|
38
|
+
console.log(chalk.red(e.message));
|
|
39
|
+
process.exit(1);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const choice = await select({
|
|
43
|
+
message: "What do you want to do?",
|
|
44
|
+
options: [
|
|
45
|
+
{ value: "chat", label: "Chat", hint: "Talk to the assistant (uses your hosted API)" },
|
|
46
|
+
{
|
|
47
|
+
value: "tools",
|
|
48
|
+
label: "Tool / agent mode",
|
|
49
|
+
hint: "Use the web app for tool & agent flows for now",
|
|
50
|
+
},
|
|
51
|
+
],
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
if (isCancel(choice)) {
|
|
55
|
+
cancel("Cancelled");
|
|
56
|
+
process.exit(0);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (choice === "tools") {
|
|
60
|
+
console.log(
|
|
61
|
+
chalk.yellow(
|
|
62
|
+
"\nTool and agent modes are not exposed in this CLI yet. Use the Next.js app or the in-repo server CLI.\n",
|
|
63
|
+
),
|
|
64
|
+
);
|
|
65
|
+
process.exit(0);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
await runChatSession(baseUrl, "chat");
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export const wakeup = new Command("wakeup")
|
|
72
|
+
.description("Open the PAL menu (chat via API)")
|
|
73
|
+
.action(wakeupAction);
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import chalk from "chalk";
|
|
2
|
+
import { Command } from "commander";
|
|
3
|
+
import { getApiUrl } from "../config.js";
|
|
4
|
+
import { apiRequest } from "../lib/api.js";
|
|
5
|
+
import {
|
|
6
|
+
getStoredToken,
|
|
7
|
+
isTokenExpired,
|
|
8
|
+
} from "../lib/token-store.js";
|
|
9
|
+
|
|
10
|
+
async function getBearer() {
|
|
11
|
+
const token = await getStoredToken();
|
|
12
|
+
if (!token?.access_token || (await isTokenExpired())) {
|
|
13
|
+
console.log(chalk.red("Not signed in. Run: pal-cli login"));
|
|
14
|
+
process.exit(1);
|
|
15
|
+
}
|
|
16
|
+
return token.access_token;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export async function whoamiAction(opts) {
|
|
20
|
+
const baseUrl = opts.serverUrl || getApiUrl();
|
|
21
|
+
try {
|
|
22
|
+
const json = await apiRequest(baseUrl, getBearer, "/api/me", {
|
|
23
|
+
method: "GET",
|
|
24
|
+
});
|
|
25
|
+
const user = json?.data;
|
|
26
|
+
if (!user) {
|
|
27
|
+
console.log(chalk.red("Unexpected response from /api/me"));
|
|
28
|
+
process.exit(1);
|
|
29
|
+
}
|
|
30
|
+
console.log(
|
|
31
|
+
chalk.bold.green(`\n${user.name}\n`) +
|
|
32
|
+
chalk.gray(`email: ${user.email}\nid: ${user.id}\n`),
|
|
33
|
+
);
|
|
34
|
+
} catch (e) {
|
|
35
|
+
console.log(chalk.red(e.message));
|
|
36
|
+
process.exit(1);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export const whoami = new Command("whoami")
|
|
41
|
+
.description("Show signed-in user (from API)")
|
|
42
|
+
.option("--server-url <url>", "PAL API base URL")
|
|
43
|
+
.action(whoamiAction);
|
package/src/config.js
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import {
|
|
2
|
+
PUBLISH_API_URL,
|
|
3
|
+
PUBLISH_GITHUB_CLIENT_ID,
|
|
4
|
+
} from "./publish-config.js";
|
|
5
|
+
|
|
6
|
+
/** @returns {string} */
|
|
7
|
+
export function getApiUrl() {
|
|
8
|
+
const fromEnv = process.env.PALCLI_API_URL;
|
|
9
|
+
if (fromEnv) return fromEnv.replace(/\/$/, "");
|
|
10
|
+
return PUBLISH_API_URL.replace(/\/$/, "");
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** @returns {string} */
|
|
14
|
+
export function getGithubClientId() {
|
|
15
|
+
return process.env.PALCLI_GITHUB_CLIENT_ID || PUBLISH_GITHUB_CLIENT_ID;
|
|
16
|
+
}
|
package/src/lib/api.js
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @param {string} baseUrl
|
|
3
|
+
* @param {() => Promise<string|null>} getBearerToken
|
|
4
|
+
* @param {string} path
|
|
5
|
+
* @param {RequestInit} [init]
|
|
6
|
+
*/
|
|
7
|
+
export async function apiRequest(baseUrl, getBearerToken, path, init = {}) {
|
|
8
|
+
const root = baseUrl.replace(/\/$/, "");
|
|
9
|
+
const url = `${root}${path.startsWith("/") ? path : `/${path}`}`;
|
|
10
|
+
const headers = new Headers(init.headers || {});
|
|
11
|
+
if (!headers.has("Content-Type") && init.body) {
|
|
12
|
+
headers.set("Content-Type", "application/json");
|
|
13
|
+
}
|
|
14
|
+
const token = await getBearerToken();
|
|
15
|
+
if (token) headers.set("Authorization", `Bearer ${token}`);
|
|
16
|
+
|
|
17
|
+
const res = await fetch(url, { ...init, headers });
|
|
18
|
+
const text = await res.text();
|
|
19
|
+
let json = null;
|
|
20
|
+
if (text) {
|
|
21
|
+
try {
|
|
22
|
+
json = JSON.parse(text);
|
|
23
|
+
} catch {
|
|
24
|
+
throw new Error(`Non-JSON response (${res.status}): ${text.slice(0, 200)}`);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (!res.ok) {
|
|
29
|
+
const msg =
|
|
30
|
+
json?.error?.message ||
|
|
31
|
+
json?.message ||
|
|
32
|
+
(typeof json === "string" ? json : null) ||
|
|
33
|
+
`Request failed (${res.status})`;
|
|
34
|
+
const err = new Error(msg);
|
|
35
|
+
err.status = res.status;
|
|
36
|
+
err.body = json;
|
|
37
|
+
throw err;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return json;
|
|
41
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import chalk from "chalk";
|
|
2
|
+
import { marked } from "marked";
|
|
3
|
+
import { markedTerminal } from "marked-terminal";
|
|
4
|
+
|
|
5
|
+
marked.use(
|
|
6
|
+
markedTerminal({
|
|
7
|
+
code: chalk.cyan,
|
|
8
|
+
blockquote: chalk.gray.italic,
|
|
9
|
+
heading: chalk.green.bold,
|
|
10
|
+
firstHeading: chalk.magenta.underline.bold,
|
|
11
|
+
hr: chalk.reset,
|
|
12
|
+
listitem: chalk.reset,
|
|
13
|
+
list: chalk.reset,
|
|
14
|
+
paragraph: chalk.reset,
|
|
15
|
+
strong: chalk.bold,
|
|
16
|
+
em: chalk.italic,
|
|
17
|
+
codespan: chalk.yellow.bgBlack,
|
|
18
|
+
del: chalk.dim.gray.strikethrough,
|
|
19
|
+
link: chalk.blue.underline,
|
|
20
|
+
href: chalk.blue.underline,
|
|
21
|
+
}),
|
|
22
|
+
);
|
|
23
|
+
|
|
24
|
+
export function renderMarkdown(text) {
|
|
25
|
+
return marked.parse(text || "");
|
|
26
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import fs from "fs/promises";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import os from "os";
|
|
4
|
+
|
|
5
|
+
const CONFIG_DIR = path.join(os.homedir(), ".palcli");
|
|
6
|
+
const TOKEN_FILE = path.join(CONFIG_DIR, "credentials.json");
|
|
7
|
+
|
|
8
|
+
export async function getStoredToken() {
|
|
9
|
+
try {
|
|
10
|
+
const data = await fs.readFile(TOKEN_FILE, "utf-8");
|
|
11
|
+
return JSON.parse(data);
|
|
12
|
+
} catch (error) {
|
|
13
|
+
if (error.code !== "ENOENT") {
|
|
14
|
+
console.warn("Could not read credentials:", error.message);
|
|
15
|
+
}
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export async function storeToken(token) {
|
|
21
|
+
await fs.mkdir(CONFIG_DIR, { recursive: true });
|
|
22
|
+
const tokenData = {
|
|
23
|
+
access_token: token.access_token,
|
|
24
|
+
refresh_token: token.refresh_token,
|
|
25
|
+
token_type: token.token_type || "Bearer",
|
|
26
|
+
scope: token.scope,
|
|
27
|
+
expires_at: token.expires_in
|
|
28
|
+
? new Date(Date.now() + token.expires_in * 1000).toISOString()
|
|
29
|
+
: null,
|
|
30
|
+
created_at: new Date().toISOString(),
|
|
31
|
+
};
|
|
32
|
+
await fs.writeFile(TOKEN_FILE, JSON.stringify(tokenData, null, 2), "utf-8");
|
|
33
|
+
return true;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export async function clearStoredToken() {
|
|
37
|
+
try {
|
|
38
|
+
await fs.unlink(TOKEN_FILE);
|
|
39
|
+
return true;
|
|
40
|
+
} catch {
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export async function isTokenExpired() {
|
|
46
|
+
const token = await getStoredToken();
|
|
47
|
+
if (!token?.expires_at) return true;
|
|
48
|
+
const expiresAt = new Date(token.expires_at);
|
|
49
|
+
const now = new Date();
|
|
50
|
+
return expiresAt.getTime() - now.getTime() < 5 * 60 * 1000;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function getCredentialsPath() {
|
|
54
|
+
return TOKEN_FILE;
|
|
55
|
+
}
|
package/src/main.js
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import chalk from "chalk";
|
|
2
|
+
import figlet from "figlet";
|
|
3
|
+
import { Command } from "commander";
|
|
4
|
+
import { login, logout } from "./commands/login.js";
|
|
5
|
+
import { whoami } from "./commands/whoami.js";
|
|
6
|
+
import { wakeup } from "./commands/wakeup.js";
|
|
7
|
+
import { startChatFromCli } from "./commands/chat-session.js";
|
|
8
|
+
|
|
9
|
+
async function main() {
|
|
10
|
+
console.log(
|
|
11
|
+
chalk.cyan(
|
|
12
|
+
figlet.textSync("PAL CLI", {
|
|
13
|
+
font: "Standard",
|
|
14
|
+
horizontalLayout: "default",
|
|
15
|
+
}),
|
|
16
|
+
),
|
|
17
|
+
);
|
|
18
|
+
console.log(chalk.gray("Thin client — your API holds secrets & data.\n"));
|
|
19
|
+
|
|
20
|
+
const program = new Command("pal-cli");
|
|
21
|
+
|
|
22
|
+
program
|
|
23
|
+
.name("pal-cli")
|
|
24
|
+
.version("1.0.2")
|
|
25
|
+
.description("PAL command-line client (hosted API)");
|
|
26
|
+
|
|
27
|
+
program
|
|
28
|
+
.command("chat")
|
|
29
|
+
.description("Start a chat session (creates a conversation on the server)")
|
|
30
|
+
.action(() => startChatFromCli());
|
|
31
|
+
|
|
32
|
+
program.addCommand(login);
|
|
33
|
+
program.addCommand(logout);
|
|
34
|
+
program.addCommand(whoami);
|
|
35
|
+
program.addCommand(wakeup);
|
|
36
|
+
|
|
37
|
+
program.action(() => {
|
|
38
|
+
program.help();
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
await program.parseAsync(process.argv);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
main().catch((err) => {
|
|
45
|
+
console.error(chalk.red("pal-cli error:"), err);
|
|
46
|
+
process.exit(1);
|
|
47
|
+
});
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Maintainer: set these values before `npm publish` so end users need no environment variables.
|
|
3
|
+
*
|
|
4
|
+
* - PUBLISH_API_URL: public HTTPS URL of your deployed PAL server (same origin as Better Auth).
|
|
5
|
+
* - PUBLISH_GITHUB_CLIENT_ID: GitHub OAuth App "Client ID" only (public). Never put the client secret here.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export const PUBLISH_API_URL = "http://localhost:3005";
|
|
9
|
+
|
|
10
|
+
export const PUBLISH_GITHUB_CLIENT_ID = "Ov23lidEB9IMI5wCN9Nq";
|
|
11
|
+
|