pi-telegram-plus 0.0.1
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 +318 -0
- package/index.ts +327 -0
- package/lib/attachments.ts +154 -0
- package/lib/callback-protocol.ts +9 -0
- package/lib/command-parser.ts +15 -0
- package/lib/commands/auth.ts +365 -0
- package/lib/commands/info.ts +158 -0
- package/lib/commands/lifecycle.ts +65 -0
- package/lib/commands/model.ts +188 -0
- package/lib/commands/register.ts +40 -0
- package/lib/commands/session.ts +281 -0
- package/lib/commands/settings.ts +129 -0
- package/lib/commands/telegram-commands.ts +162 -0
- package/lib/commands/tg-config.ts +128 -0
- package/lib/config.ts +171 -0
- package/lib/controller.ts +406 -0
- package/lib/heartbeat.ts +95 -0
- package/lib/html.ts +6 -0
- package/lib/markdown.ts +132 -0
- package/lib/menu-commands.ts +72 -0
- package/lib/polling.ts +255 -0
- package/lib/renderer.ts +284 -0
- package/lib/session-capture.ts +95 -0
- package/lib/status.ts +46 -0
- package/lib/telegram-api.ts +327 -0
- package/lib/telegram-ui.ts +208 -0
- package/lib/text-split.ts +59 -0
- package/lib/types.ts +123 -0
- package/package.json +53 -0
- package/pi-host.d.ts +8 -0
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { stat } from "node:fs/promises";
|
|
2
|
+
import { basename, resolve } from "node:path";
|
|
3
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
import { Type } from "typebox";
|
|
5
|
+
import type { TelegramTransport, TelegramTurn } from "./types.ts";
|
|
6
|
+
|
|
7
|
+
const MAX_ATTACHMENTS_PER_TURN = 10;
|
|
8
|
+
const DEFAULT_MAX_BYTES = 50 * 1024 * 1024;
|
|
9
|
+
|
|
10
|
+
function outboundAttachmentLimit(): number {
|
|
11
|
+
for (const name of ["PI_TELEGRAM_OUTBOUND_ATTACHMENT_MAX_BYTES", "TELEGRAM_MAX_ATTACHMENT_SIZE_BYTES"]) {
|
|
12
|
+
const raw = process.env[name]?.trim();
|
|
13
|
+
const value = raw ? Number(raw) : NaN;
|
|
14
|
+
if (Number.isSafeInteger(value) && value > 0) return value;
|
|
15
|
+
}
|
|
16
|
+
return DEFAULT_MAX_BYTES;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const SENSITIVE_PATH_PREFIXES = ["/etc", "/.ssh", "/root/.ssh"];
|
|
20
|
+
|
|
21
|
+
function isSensitivePath(resolved: string): boolean {
|
|
22
|
+
const home = process.env.HOME ?? "";
|
|
23
|
+
for (const prefix of SENSITIVE_PATH_PREFIXES) {
|
|
24
|
+
if (resolved.startsWith(prefix)) return true;
|
|
25
|
+
}
|
|
26
|
+
if (home && resolved.startsWith(home + "/.ssh")) return true;
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function isPhotoPath(path: string): boolean {
|
|
31
|
+
const normalized = path.toLowerCase();
|
|
32
|
+
return normalized.endsWith(".jpg") || normalized.endsWith(".jpeg") || normalized.endsWith(".png") || normalized.endsWith(".webp");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function sizeLimitError(path: string, size: number, max: number): string {
|
|
36
|
+
return `Attachment exceeds size limit (${size} bytes > ${max} bytes): ${path}`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
type ResolvedTelegramAttachment = {
|
|
40
|
+
path: string;
|
|
41
|
+
fileName: string;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
async function sendTelegramAttachment(
|
|
45
|
+
chatId: number,
|
|
46
|
+
attachment: ResolvedTelegramAttachment,
|
|
47
|
+
transport: TelegramTransport,
|
|
48
|
+
maxBytes: number,
|
|
49
|
+
onError?: (message: string) => Promise<void>,
|
|
50
|
+
): Promise<void> {
|
|
51
|
+
try {
|
|
52
|
+
const stats = await stat(attachment.path);
|
|
53
|
+
if (stats.size > maxBytes) {
|
|
54
|
+
throw new Error(sizeLimitError(attachment.path, stats.size, maxBytes));
|
|
55
|
+
}
|
|
56
|
+
if (isPhotoPath(attachment.path)) {
|
|
57
|
+
try {
|
|
58
|
+
await transport.sendChatAction(chatId, "upload_photo");
|
|
59
|
+
await transport.sendPhoto(chatId, attachment.path, attachment.fileName, true);
|
|
60
|
+
return;
|
|
61
|
+
} catch {
|
|
62
|
+
await transport.sendChatAction(chatId, "upload_document");
|
|
63
|
+
await transport.sendDocument(chatId, attachment.path, attachment.fileName);
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
await transport.sendChatAction(chatId, "upload_document");
|
|
68
|
+
await transport.sendDocument(chatId, attachment.path, attachment.fileName);
|
|
69
|
+
return;
|
|
70
|
+
} catch (error) {
|
|
71
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
72
|
+
if (onError) {
|
|
73
|
+
await onError(`Failed to send attachment ${attachment.fileName}: ${message}`);
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
throw error;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function formatDirectAttachmentErrorSummary(errors: string[]): string {
|
|
81
|
+
if (errors.length === 0) return "";
|
|
82
|
+
return ` (${errors.length} failed, first: ${errors[0]})`;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function registerTelegramAttachmentTool(
|
|
86
|
+
pi: ExtensionAPI,
|
|
87
|
+
deps: {
|
|
88
|
+
getActiveTurn: () => TelegramTurn | undefined;
|
|
89
|
+
getDefaultChatId?: () => number | undefined;
|
|
90
|
+
transport: TelegramTransport;
|
|
91
|
+
},
|
|
92
|
+
): void {
|
|
93
|
+
pi.registerTool({
|
|
94
|
+
name: "tg_attach",
|
|
95
|
+
label: "Telegram Attach",
|
|
96
|
+
description: "Send one or more local files to Telegram immediately (uses current active turn chat, or default chat config when no active turn exists).",
|
|
97
|
+
promptSnippet: "Send local files to Telegram immediately.",
|
|
98
|
+
promptGuidelines: [
|
|
99
|
+
"When handling a Telegram-originated request and the user asked for a file or generated artifact, call tg_attach with the local path instead of only mentioning the path in text.",
|
|
100
|
+
],
|
|
101
|
+
parameters: Type.Object({
|
|
102
|
+
paths: Type.Array(Type.String({ description: "Local file path to attach" }), { minItems: 1, maxItems: MAX_ATTACHMENTS_PER_TURN }),
|
|
103
|
+
}),
|
|
104
|
+
async execute(_toolCallId, params) {
|
|
105
|
+
const maxBytes = outboundAttachmentLimit();
|
|
106
|
+
const pendingAttachments: ResolvedTelegramAttachment[] = [];
|
|
107
|
+
for (const rawPath of params.paths) {
|
|
108
|
+
const resolved = resolve(rawPath);
|
|
109
|
+
if (isSensitivePath(resolved)) throw new Error(`Attachment path not allowed (sensitive): ${rawPath}`);
|
|
110
|
+
const stats = await stat(resolved);
|
|
111
|
+
if (!stats.isFile()) throw new Error(`Not a file: ${rawPath}`);
|
|
112
|
+
if (stats.size > maxBytes) throw new Error(sizeLimitError(rawPath, stats.size, maxBytes));
|
|
113
|
+
pendingAttachments.push({ path: resolved, fileName: basename(resolved) });
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (params.paths.length > MAX_ATTACHMENTS_PER_TURN) {
|
|
117
|
+
throw new Error(`Attachment limit reached (${MAX_ATTACHMENTS_PER_TURN})`);
|
|
118
|
+
}
|
|
119
|
+
const turn = deps.getActiveTurn();
|
|
120
|
+
const chatId = turn?.chatId ?? deps.getDefaultChatId?.();
|
|
121
|
+
if (chatId === undefined) {
|
|
122
|
+
throw new Error("tg_attach can only be used with an active Telegram chat or configured default chat id");
|
|
123
|
+
}
|
|
124
|
+
const failed: string[] = [];
|
|
125
|
+
for (const attachment of pendingAttachments) {
|
|
126
|
+
await sendTelegramAttachment(chatId, attachment, deps.transport, maxBytes, async (message) => {
|
|
127
|
+
failed.push(message);
|
|
128
|
+
await deps.transport.sendText(chatId, message).catch(() => undefined);
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
return {
|
|
132
|
+
content: [{
|
|
133
|
+
type: "text" as const,
|
|
134
|
+
text: `\nSent ${pendingAttachments.length} Telegram attachment(s).${formatDirectAttachmentErrorSummary(failed)}`,
|
|
135
|
+
}],
|
|
136
|
+
details: { paths: pendingAttachments.map((attachment) => attachment.path) },
|
|
137
|
+
};
|
|
138
|
+
},
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export async function sendQueuedTelegramAttachments(
|
|
143
|
+
turn: TelegramTurn,
|
|
144
|
+
transport: TelegramTransport,
|
|
145
|
+
): Promise<void> {
|
|
146
|
+
if (turn.attachmentsSent) return;
|
|
147
|
+
turn.attachmentsSent = true;
|
|
148
|
+
const maxBytes = outboundAttachmentLimit();
|
|
149
|
+
for (const attachment of turn.queuedAttachments) {
|
|
150
|
+
await sendTelegramAttachment(turn.chatId, attachment, transport, maxBytes, async (message) => {
|
|
151
|
+
await transport.sendText(turn.chatId, message).catch(() => undefined);
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export const UI_CALLBACK_PREFIX = "tgplus:ui:";
|
|
2
|
+
|
|
3
|
+
export function encodeUiCallback(value: string): string {
|
|
4
|
+
return `${UI_CALLBACK_PREFIX}${value}`;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function decodeUiCallback(data: string): string | undefined {
|
|
8
|
+
return data.startsWith(UI_CALLBACK_PREFIX) ? data.slice(UI_CALLBACK_PREFIX.length) : undefined;
|
|
9
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parse and normalize Telegram slash commands.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export function parseLeadingCommand(text: string): { name: string; args: string } | undefined {
|
|
6
|
+
const match = text.match(/^\/([^\s@]+)(?:\s+([\s\S]*))?$/);
|
|
7
|
+
if (!match) return undefined;
|
|
8
|
+
return { name: match[1], args: match[2] ?? "" };
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function normalizeLeadingCommand(text: string, botUsername: string | undefined): string {
|
|
12
|
+
if (!botUsername) return text;
|
|
13
|
+
const escaped = botUsername.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
14
|
+
return text.replace(new RegExp(`^(\\/[^\\s@]+)@${escaped}(\\s|$)`, "i"), "$1$2");
|
|
15
|
+
}
|
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
import type { CommandRegistry } from "./register.ts";
|
|
2
|
+
import type { CapturedAgentSession } from "../types.ts";
|
|
3
|
+
|
|
4
|
+
// ── Types ───────────────────────────────────────────────────────────────
|
|
5
|
+
|
|
6
|
+
type AuthType = "oauth" | "api_key";
|
|
7
|
+
|
|
8
|
+
type ProviderOption = {
|
|
9
|
+
id: string;
|
|
10
|
+
name: string;
|
|
11
|
+
authType: AuthType;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
// ── Helpers ──────────────────────────────────────────────────────────────
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Get the auth status label for a provider (shown on button labels).
|
|
18
|
+
* Aligns with p-tui OAuthSelectorComponent.formatStatusIndicator().
|
|
19
|
+
*/
|
|
20
|
+
function getProviderStatusLabel(
|
|
21
|
+
session: CapturedAgentSession,
|
|
22
|
+
providerId: string,
|
|
23
|
+
authType: AuthType,
|
|
24
|
+
): string {
|
|
25
|
+
const authStorage = session.modelRegistry.authStorage;
|
|
26
|
+
const cred = authStorage.get(providerId);
|
|
27
|
+
|
|
28
|
+
if (authType === "oauth") {
|
|
29
|
+
if (cred?.type === "oauth") return "✓ configured";
|
|
30
|
+
return "";
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// API key
|
|
34
|
+
if (cred?.type === "api_key") return "✓ configured";
|
|
35
|
+
const status = session.modelRegistry.getProviderAuthStatus(providerId);
|
|
36
|
+
if (status.configured) {
|
|
37
|
+
switch (status.source) {
|
|
38
|
+
case "environment":
|
|
39
|
+
return "✓ env";
|
|
40
|
+
case "runtime":
|
|
41
|
+
return "✓ runtime";
|
|
42
|
+
case "fallback":
|
|
43
|
+
case "models_json_key":
|
|
44
|
+
case "models_json_command":
|
|
45
|
+
return "✓ configured";
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return "";
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Format as a select option label (name + status). */
|
|
52
|
+
function formatLabel(option: ProviderOption, session: CapturedAgentSession): string {
|
|
53
|
+
const status = getProviderStatusLabel(session, option.id, option.authType);
|
|
54
|
+
return status ? `${option.name} ${status}` : option.name;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Collect OAuth providers from AuthStorage.getOAuthProviders().
|
|
59
|
+
* Aligns with p-tui's OAuth section in getLoginProviderOptions().
|
|
60
|
+
*/
|
|
61
|
+
function collectOAuthProviders(session: CapturedAgentSession): ProviderOption[] {
|
|
62
|
+
return session.modelRegistry.authStorage.getOAuthProviders().map((p) => ({
|
|
63
|
+
id: p.id,
|
|
64
|
+
name: p.name,
|
|
65
|
+
authType: "oauth" as AuthType,
|
|
66
|
+
}));
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Collect API key providers from ModelRegistry's unique provider list.
|
|
71
|
+
* Excludes provider IDs already present in the OAuth list (OAuth-only providers
|
|
72
|
+
* should not appear twice). Aligns with p-tui's API key section in
|
|
73
|
+
* getLoginProviderOptions() + isApiKeyLoginProvider().
|
|
74
|
+
*/
|
|
75
|
+
function collectApiKeyProviders(session: CapturedAgentSession): ProviderOption[] {
|
|
76
|
+
const authStorage = session.modelRegistry.authStorage;
|
|
77
|
+
const oauthIds = new Set(authStorage.getOAuthProviders().map((p) => p.id));
|
|
78
|
+
const modelProviderIds = [
|
|
79
|
+
...new Set(session.modelRegistry.getAll().map((m) => m.provider)),
|
|
80
|
+
];
|
|
81
|
+
|
|
82
|
+
return modelProviderIds
|
|
83
|
+
.filter((id) => !oauthIds.has(id))
|
|
84
|
+
.map((id) => ({
|
|
85
|
+
id,
|
|
86
|
+
name: session.modelRegistry.getProviderDisplayName(id),
|
|
87
|
+
authType: "api_key" as AuthType,
|
|
88
|
+
}))
|
|
89
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Run the OAuth login flow (Telegram-specific mode).
|
|
94
|
+
*
|
|
95
|
+
* Unlike p-tui, Telegram cannot auto-open a browser. This uses a
|
|
96
|
+
* "show URL → user manually pastes back" flow:
|
|
97
|
+
* 1. Bot sends the auth URL as inline text (copyable)
|
|
98
|
+
* 2. User completes OAuth in their browser
|
|
99
|
+
* 3. User replies with the redirect URL
|
|
100
|
+
* 4. onManualCodeInput captures the URL and finishes auth
|
|
101
|
+
*/
|
|
102
|
+
async function runOAuthLogin(
|
|
103
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
104
|
+
ui: any,
|
|
105
|
+
session: CapturedAgentSession,
|
|
106
|
+
provider: ProviderOption,
|
|
107
|
+
): Promise<boolean> {
|
|
108
|
+
const authStorage = session.modelRegistry.authStorage;
|
|
109
|
+
const oauthProviders = authStorage.getOAuthProviders();
|
|
110
|
+
const oauthProvider = oauthProviders.find((p) => p.id === provider.id);
|
|
111
|
+
if (!oauthProvider) {
|
|
112
|
+
ui.notify(`OAuth provider not found: ${provider.id}`, "error");
|
|
113
|
+
return false;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
try {
|
|
117
|
+
await authStorage.login(provider.id, {
|
|
118
|
+
onAuth: (info: { url: string; instructions?: string }) => {
|
|
119
|
+
const lines = [
|
|
120
|
+
`🔗 Open this URL in your browser to authenticate <b>${provider.name}</b>:`,
|
|
121
|
+
``,
|
|
122
|
+
info.url,
|
|
123
|
+
];
|
|
124
|
+
if (info.instructions) {
|
|
125
|
+
lines.push(``, info.instructions);
|
|
126
|
+
}
|
|
127
|
+
lines.push(``, `Then reply with the redirect URL or authorization code.`);
|
|
128
|
+
ui.notify(lines.join("\n"), "info");
|
|
129
|
+
},
|
|
130
|
+
onDeviceCode: (info: {
|
|
131
|
+
verificationUri: string;
|
|
132
|
+
userCode: string;
|
|
133
|
+
expiresInSeconds?: number;
|
|
134
|
+
}) => {
|
|
135
|
+
const lines = [
|
|
136
|
+
`🔗 Open this URL in your browser:`,
|
|
137
|
+
info.verificationUri,
|
|
138
|
+
``,
|
|
139
|
+
`Code: <b>${info.userCode}</b>`,
|
|
140
|
+
];
|
|
141
|
+
if (info.expiresInSeconds) {
|
|
142
|
+
lines.push(`Expires in ${info.expiresInSeconds}s`);
|
|
143
|
+
}
|
|
144
|
+
lines.push(``, `Then reply with the redirect URL after authorization.`);
|
|
145
|
+
ui.notify(lines.join("\n"), "info");
|
|
146
|
+
},
|
|
147
|
+
onPrompt: async (prompt: {
|
|
148
|
+
message: string;
|
|
149
|
+
placeholder?: string;
|
|
150
|
+
allowEmpty?: boolean;
|
|
151
|
+
}) => {
|
|
152
|
+
const value = await ui.input(prompt.message, prompt.placeholder);
|
|
153
|
+
if (!value && !prompt.allowEmpty) throw new Error("Login cancelled");
|
|
154
|
+
return value ?? "";
|
|
155
|
+
},
|
|
156
|
+
onManualCodeInput: async () => {
|
|
157
|
+
const value = await ui.input(
|
|
158
|
+
"Paste the redirect URL or authorization code after completing authentication in your browser",
|
|
159
|
+
);
|
|
160
|
+
if (!value) throw new Error("Login cancelled");
|
|
161
|
+
return value;
|
|
162
|
+
},
|
|
163
|
+
onSelect: async (prompt: {
|
|
164
|
+
message: string;
|
|
165
|
+
options: Array<{ label: string; id: string }>;
|
|
166
|
+
}) => {
|
|
167
|
+
const labels = prompt.options.map((o) => o.label);
|
|
168
|
+
const choice = await ui.select(prompt.message, labels);
|
|
169
|
+
if (!choice) return undefined;
|
|
170
|
+
return prompt.options[labels.indexOf(choice)]?.id;
|
|
171
|
+
},
|
|
172
|
+
onProgress: (message: string) => ui.notify(message, "info"),
|
|
173
|
+
});
|
|
174
|
+
session.modelRegistry.refresh();
|
|
175
|
+
ui.notify(`✅ OAuth login complete: ${provider.name}`, "info");
|
|
176
|
+
return true;
|
|
177
|
+
} catch (error) {
|
|
178
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
179
|
+
if (msg !== "Login cancelled") {
|
|
180
|
+
ui.notify(`OAuth login failed: ${msg}`, "error");
|
|
181
|
+
}
|
|
182
|
+
return false;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Run the API key login flow.
|
|
188
|
+
* Uses inputSecret when available; the user's message is auto-deleted
|
|
189
|
+
* to protect the sensitive key.
|
|
190
|
+
*/
|
|
191
|
+
async function runApiKeyLogin(
|
|
192
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
193
|
+
ui: any,
|
|
194
|
+
session: CapturedAgentSession,
|
|
195
|
+
provider: ProviderOption,
|
|
196
|
+
): Promise<boolean> {
|
|
197
|
+
const authStorage = session.modelRegistry.authStorage;
|
|
198
|
+
|
|
199
|
+
const apiKey = await (ui.inputSecret?.(`API key for ${provider.name}`, "sk-...") ??
|
|
200
|
+
ui.input(`API key for ${provider.name}`, "sk-..."));
|
|
201
|
+
if (!apiKey) {
|
|
202
|
+
ui.notify("Login cancelled.", "info");
|
|
203
|
+
return false;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
authStorage.set(provider.id, { type: "api_key", key: apiKey.trim() });
|
|
207
|
+
session.modelRegistry.refresh();
|
|
208
|
+
ui.notify(
|
|
209
|
+
`✅ API key set for ${provider.name}. The message with your API key has been deleted for safety.`,
|
|
210
|
+
"info",
|
|
211
|
+
);
|
|
212
|
+
return true;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// ── Register commands ───────────────────────────────────────────────────
|
|
216
|
+
|
|
217
|
+
export function registerAuthCommands(
|
|
218
|
+
registry: CommandRegistry,
|
|
219
|
+
deps: { getSession: () => CapturedAgentSession | undefined },
|
|
220
|
+
): void {
|
|
221
|
+
// ── /login ────────────────────────────────────────────────────────────
|
|
222
|
+
registry.registerCommand("login", {
|
|
223
|
+
description: "Set API key or run OAuth login for a provider",
|
|
224
|
+
handler: async (args, ctx) => {
|
|
225
|
+
const ui = ctx.ui as typeof ctx.ui & {
|
|
226
|
+
inputSecret?: (
|
|
227
|
+
title: string,
|
|
228
|
+
placeholder?: string,
|
|
229
|
+
) => Promise<string | undefined>;
|
|
230
|
+
};
|
|
231
|
+
const session = deps.getSession();
|
|
232
|
+
if (!session) {
|
|
233
|
+
ui.notify("No active session", "error");
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// ── Quick arg: pass a provider ID to jump straight to input ────
|
|
238
|
+
// e.g. /login anthropic → API key prompt (Anthropic)
|
|
239
|
+
// /login anthropic-subscription → OAuth flow
|
|
240
|
+
const directId = args.trim();
|
|
241
|
+
if (directId) {
|
|
242
|
+
const oauthProviders =
|
|
243
|
+
session.modelRegistry.authStorage.getOAuthProviders();
|
|
244
|
+
const oauthProvider = oauthProviders.find((p) => p.id === directId);
|
|
245
|
+
if (oauthProvider) {
|
|
246
|
+
await runOAuthLogin(ui, session, {
|
|
247
|
+
id: directId,
|
|
248
|
+
name: oauthProvider.name,
|
|
249
|
+
authType: "oauth",
|
|
250
|
+
});
|
|
251
|
+
} else {
|
|
252
|
+
await runApiKeyLogin(ui, session, {
|
|
253
|
+
id: directId,
|
|
254
|
+
name: session.modelRegistry.getProviderDisplayName(directId),
|
|
255
|
+
authType: "api_key",
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// ═════════════════════════════════════════════════════════════════
|
|
262
|
+
// Phase 1: Select auth type (aligns with p-tui showLoginAuthTypeSelector)
|
|
263
|
+
// ═════════════════════════════════════════════════════════════════
|
|
264
|
+
const SUBSCRIPTION_LABEL = "Use a subscription";
|
|
265
|
+
const API_KEY_LABEL = "Use an API key";
|
|
266
|
+
const authTypeChoice = await ui.select("Select authentication method:", [
|
|
267
|
+
SUBSCRIPTION_LABEL,
|
|
268
|
+
API_KEY_LABEL,
|
|
269
|
+
]);
|
|
270
|
+
if (!authTypeChoice) return;
|
|
271
|
+
|
|
272
|
+
const authType: AuthType =
|
|
273
|
+
authTypeChoice === SUBSCRIPTION_LABEL ? "oauth" : "api_key";
|
|
274
|
+
|
|
275
|
+
// ═════════════════════════════════════════════════════════════════
|
|
276
|
+
// Phase 2: Select provider (filtered by authType + status + back nav)
|
|
277
|
+
// Aligns with p-tui showLoginProviderSelector(authType)
|
|
278
|
+
// ═════════════════════════════════════════════════════════════════
|
|
279
|
+
const providers: ProviderOption[] =
|
|
280
|
+
authType === "oauth"
|
|
281
|
+
? collectOAuthProviders(session).sort((a, b) =>
|
|
282
|
+
a.name.localeCompare(b.name),
|
|
283
|
+
)
|
|
284
|
+
: collectApiKeyProviders(session);
|
|
285
|
+
|
|
286
|
+
const typeLabel = authType === "oauth" ? "subscription" : "API key";
|
|
287
|
+
if (providers.length === 0) {
|
|
288
|
+
ui.notify(`No ${typeLabel} providers available.`, "warning");
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
const BACK_LABEL = "← Back";
|
|
293
|
+
const labels = [
|
|
294
|
+
BACK_LABEL,
|
|
295
|
+
...providers.map((p) => formatLabel(p, session)),
|
|
296
|
+
];
|
|
297
|
+
const choice = await ui.select(`Select provider (${typeLabel}):`, labels);
|
|
298
|
+
if (!choice || choice === BACK_LABEL) return;
|
|
299
|
+
|
|
300
|
+
const idx = labels.indexOf(choice) - 1;
|
|
301
|
+
const provider = providers[idx];
|
|
302
|
+
if (!provider) return;
|
|
303
|
+
|
|
304
|
+
// ═════════════════════════════════════════════════════════════════
|
|
305
|
+
// Phase 3: Execute login
|
|
306
|
+
// ═════════════════════════════════════════════════════════════════
|
|
307
|
+
if (provider.authType === "oauth") {
|
|
308
|
+
await runOAuthLogin(ui, session, provider);
|
|
309
|
+
} else {
|
|
310
|
+
await runApiKeyLogin(ui, session, provider);
|
|
311
|
+
}
|
|
312
|
+
},
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
// ── /logout ───────────────────────────────────────────────────────────
|
|
316
|
+
// Improvement: shows each provider's auth type (subscription / API key)
|
|
317
|
+
registry.registerCommand("logout", {
|
|
318
|
+
description: "Remove stored credentials for a provider",
|
|
319
|
+
handler: async (_args, ctx) => {
|
|
320
|
+
const ui = ctx.ui;
|
|
321
|
+
const session = deps.getSession();
|
|
322
|
+
if (!session) {
|
|
323
|
+
ui.notify("No active session", "error");
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
const stored = session.modelRegistry.authStorage.list();
|
|
328
|
+
if (stored.length === 0) {
|
|
329
|
+
ui.notify("No stored credentials to remove.", "info");
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// Show auth type context
|
|
334
|
+
const oauthIds = new Set(
|
|
335
|
+
session.modelRegistry.authStorage
|
|
336
|
+
.getOAuthProviders()
|
|
337
|
+
.map((p) => p.id),
|
|
338
|
+
);
|
|
339
|
+
const labels = stored.map((id) => {
|
|
340
|
+
const name = session.modelRegistry.getProviderDisplayName(id);
|
|
341
|
+
const typeLabel = oauthIds.has(id) ? "subscription" : "API key";
|
|
342
|
+
return `${name} (${typeLabel})`;
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
const choice = await ui.select("Remove credentials for:", labels);
|
|
346
|
+
if (!choice) return;
|
|
347
|
+
const idx = labels.indexOf(choice);
|
|
348
|
+
if (idx < 0) return;
|
|
349
|
+
const providerId = stored[idx];
|
|
350
|
+
|
|
351
|
+
const confirmed = await ui.confirm(
|
|
352
|
+
"Logout",
|
|
353
|
+
`Remove stored credentials for ${session.modelRegistry.getProviderDisplayName(providerId)}?`,
|
|
354
|
+
);
|
|
355
|
+
if (!confirmed) return;
|
|
356
|
+
|
|
357
|
+
session.modelRegistry.authStorage.logout(providerId);
|
|
358
|
+
session.modelRegistry.refresh();
|
|
359
|
+
ui.notify(
|
|
360
|
+
`Logged out from ${session.modelRegistry.getProviderDisplayName(providerId)}.`,
|
|
361
|
+
"info",
|
|
362
|
+
);
|
|
363
|
+
},
|
|
364
|
+
});
|
|
365
|
+
}
|