Skip to content

Commit 7aec873

Browse files
author
Prompsit CI
committed
Mirror 5b0a16f (2026-04-10)
1 parent a9cdc97 commit 7aec873

16 files changed

Lines changed: 438 additions & 42 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,3 +37,4 @@ tmp/
3737
# AI tool config
3838
.claude/
3939
/.playwright-mcp
40+
/.hex-skills

CLAUDE.md

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,17 @@ Prompsit CLI is a command-line interface for the Prompsit Translation API. Trans
2828

2929
**Architecture:** Layered (Presentation → Application → Domain → Infrastructure). Entry point: [src/index.ts](src/index.ts) → Commander.js commands or REPL ([repl/](src/repl/)).
3030

31+
## Related Projects
32+
33+
**Prompsit API** (`../prompsit-api/`) — the backend API that this CLI consumes via HTTP/REST. When adapting CLI to API changes, read the API project for context:
34+
35+
- **Entry point & agent instructions:** `../prompsit-api/CLAUDE.md`
36+
- **API endpoints:** `../prompsit-api/app/api/v1/` (translation, quality, jobs, user, etc.)
37+
- **Domain models:** `../prompsit-api/app/domain/`
38+
- **Documentation:** `../prompsit-api/docs/` (architecture, requirements, API spec, database schema)
39+
40+
> **Read-only reference.** Do not edit API files from this project — switch to the API working directory for changes.
41+
3142
## Quick Start
3243

3344
```bash
@@ -132,4 +143,4 @@ src/
132143
- [ ] Critical rules align with current requirements
133144
- [ ] No duplicated content across documents
134145

135-
**Last Updated:** 2026-02-26
146+
**Last Updated:** 2026-04-10

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
[![license](https://img.shields.io/npm/l/prompsit-cli)](https://www.npmjs.com/package/prompsit-cli)
66
[![node](https://img.shields.io/node/v/prompsit-cli)](https://nodejs.org/)
77

8-
One CLI for the entire Prompsit API services. Translate text and documents, evaluate translation quality, score parallel corpora with Bicleaner-AI, and annotate multilingual datasets with Monotextor — from your terminal or an interactive REPL.
8+
One CLI for the entire Prompsit Translation API. Translate text and documents, evaluate translation quality, score parallel corpora with Bicleaner-AI, and annotate monolingual data with Monotextor — from your terminal or an interactive REPL.
99

1010
## Quick Start
1111

src/api/models.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -361,6 +361,54 @@ export interface AnnotateParams {
361361
lidModel: string;
362362
}
363363

364+
// --- Device Flow schemas (RFC 8628) ---
365+
366+
/**
367+
* Device authorization response from POST /v1/auth/device.
368+
*
369+
* Fields:
370+
* - device_code: Opaque 32-byte base64 token for polling
371+
* - user_code: Human-readable XXXX-XXXX format
372+
* - verification_uri: Short URL (RFC 8628 §3.3)
373+
* - verification_uri_complete: URL with embedded user_code for QR codes (§3.3.1)
374+
* - expires_in: Device code TTL in seconds (default 600s)
375+
* - interval: Minimum polling interval in seconds (default 5s)
376+
*/
377+
export const DeviceAuthorizationResponseSchema = z.object({
378+
device_code: z.string(),
379+
user_code: z.string(),
380+
verification_uri: z.string(),
381+
verification_uri_complete: z.string().optional(),
382+
expires_in: z.number().default(600),
383+
interval: z.number().default(5),
384+
});
385+
386+
/**
387+
* Device token success response from POST /v1/auth/device/token.
388+
*
389+
* All fields are REQUIRED in the API (app/schemas/auth.py:277-320).
390+
* Do NOT copy nullable/optional patterns from TokenResponseSchema — that models
391+
* POST /v1/auth/token where refresh_token IS nullable. Device flow is different.
392+
*/
393+
export const DeviceTokenResponseSchema = z.object({
394+
access_token: z.string(),
395+
refresh_token: z.string(),
396+
token_type: z.string().default("Bearer"),
397+
expires_in: z.number(),
398+
plan: z.string(),
399+
account_id: z.string(),
400+
prompsit_secret: z.string(),
401+
});
402+
403+
/**
404+
* Device token error response from POST /v1/auth/device/token (HTTP 400).
405+
* RFC 8628 §3.5 error codes for polling.
406+
*/
407+
export const DeviceTokenErrorSchema = z.object({
408+
error: z.enum(["authorization_pending", "slow_down", "expired_token", "access_denied"]),
409+
error_description: z.string().optional(),
410+
});
411+
364412
/**
365413
* Inferred TypeScript types from Zod schemas.
366414
* Single source of truth: types automatically match schema definitions.
@@ -377,3 +425,6 @@ export type JobStatusResponse = z.infer<typeof JobStatusResponseSchema>;
377425
export type DocJobCreateResponse = z.infer<typeof DocJobCreateResponseSchema>;
378426
export type DataJobCreateResponse = z.infer<typeof DataJobCreateResponseSchema>;
379427
export type UserUsageResponse = z.infer<typeof UserUsageResponseSchema>;
428+
export type DeviceAuthorizationResponse = z.infer<typeof DeviceAuthorizationResponseSchema>;
429+
export type DeviceTokenResponse = z.infer<typeof DeviceTokenResponseSchema>;
430+
export type DeviceTokenError = z.infer<typeof DeviceTokenErrorSchema>;

src/api/resources/auth.ts

Lines changed: 76 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,17 @@
33
// Uses public transport (token endpoint does not require Authorization header).
44

55
import type { HttpTransport } from "../transport.ts";
6-
import { TokenResponseSchema, type TokenResponse } from "../models.ts";
6+
import {
7+
TokenResponseSchema,
8+
DeviceAuthorizationResponseSchema,
9+
DeviceTokenResponseSchema,
10+
DeviceTokenErrorSchema,
11+
type TokenResponse,
12+
type DeviceAuthorizationResponse,
13+
type DeviceTokenResponse,
14+
} from "../models.ts";
715
import { Endpoint, GrantType } from "../../shared/constants.ts";
16+
import { APIError } from "../../errors/contracts.ts";
817

918
/**
1019
* Auth API resource for OAuth2 token operations.
@@ -71,4 +80,70 @@ export class AuthResource {
7180
);
7281
return TokenResponseSchema.parse(data);
7382
}
83+
84+
/**
85+
* Initiate device authorization flow (RFC 8628 §3.1).
86+
*
87+
* @returns Device code, user code, and verification URI for browser auth
88+
*/
89+
async requestDeviceCode(): Promise<DeviceAuthorizationResponse> {
90+
const data = await this.transport.request<unknown>(
91+
"POST",
92+
`${this.baseUrl}${Endpoint.AUTH_DEVICE}`,
93+
{},
94+
true // public client
95+
);
96+
return DeviceAuthorizationResponseSchema.parse(data);
97+
}
98+
99+
/**
100+
* Poll device token endpoint (RFC 8628 §3.4).
101+
*
102+
* Returns discriminated union to encapsulate RFC 8628 error semantics.
103+
* Uses requestRaw with throwHttpErrors:false because transport.request<T>()
104+
* catches HTTP 400 via handleError() → parseApiError() which doesn't understand
105+
* OAuth2 error format {error, error_description} — the RFC 8628 fields are lost.
106+
*/
107+
async pollDeviceToken(
108+
deviceCode: string
109+
): Promise<
110+
| { status: "success"; data: DeviceTokenResponse }
111+
| { status: "pending" }
112+
| { status: "slow_down" }
113+
| { status: "expired" }
114+
| { status: "denied" }
115+
> {
116+
const raw = await this.transport.requestRaw(
117+
"POST",
118+
`${this.baseUrl}${Endpoint.AUTH_DEVICE_TOKEN}`,
119+
{
120+
json: {
121+
device_code: deviceCode,
122+
grant_type: GrantType.DEVICE_CODE,
123+
},
124+
throwHttpErrors: false,
125+
},
126+
true // public client
127+
);
128+
129+
if (raw.statusCode === 200) {
130+
const body: unknown = JSON.parse(raw.body.toString());
131+
return { status: "success", data: DeviceTokenResponseSchema.parse(body) };
132+
}
133+
134+
if (raw.statusCode === 400) {
135+
const body: unknown = JSON.parse(raw.body.toString());
136+
const err = DeviceTokenErrorSchema.parse(body);
137+
if (err.error === "authorization_pending") return { status: "pending" };
138+
if (err.error === "slow_down") return { status: "slow_down" };
139+
if (err.error === "expired_token") return { status: "expired" };
140+
return { status: "denied" };
141+
}
142+
143+
throw new APIError(
144+
`Unexpected status ${String(raw.statusCode)}`,
145+
"E_DEVICE_FLOW",
146+
raw.statusCode
147+
);
148+
}
74149
}

src/api/transport.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
// Three request methods: request<T> (JSON), requestRaw (buffer+headers), requestToFile (streaming).
44

55
import { createWriteStream } from "node:fs";
6+
import * as os from "node:os";
67
import { pipeline } from "node:stream/promises";
78

89
import got, {
@@ -25,6 +26,7 @@ import { getSettings } from "../config/settings.ts";
2526
import { getAccessToken } from "../config/credentials.ts";
2627
import { generateTraceId } from "./trace.ts";
2728
import { getTraceId } from "../logging/index.ts";
29+
import { getVersion } from "../shared/version.ts";
2830
import { logCurl, isCurlEnabled } from "./curl.ts";
2931
import {
3032
HEADER_AUTHORIZATION,
@@ -85,6 +87,8 @@ function createGotHooks() {
8587
// Inject X-Request-ID header — reuse trace_id from AsyncLocalStorage context,
8688
// fallback to fresh ID if called outside trace context (e.g. startup health check)
8789
options.headers["X-Request-ID"] = getTraceId() || generateTraceId();
90+
// Identify CLI in API access logs (searchable in Loki as structured_metadata)
91+
options.headers["User-Agent"] = `prompsit-cli/${getVersion()} (${os.platform()})`;
8892
};
8993

9094
const afterResponse: AfterResponseHook = (response) => {

src/commands/auth.ts

Lines changed: 55 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -6,60 +6,86 @@ import { Command } from "@commander-js/extra-typings";
66
import { getApiClient, resetApiClient } from "../api/client.ts";
77
import { saveTokens, clearTokens } from "../config/credentials.ts";
88
import { setSigintExit } from "../cli/exit.ts";
9-
import { promptHidden, promptInput } from "../cli/prompts.ts";
109
import { terminal } from "../output/index.ts";
1110
import { t } from "../i18n/index.ts";
1211
import { getLogger } from "../logging/index.ts";
1312
import { ErrorCode } from "../errors/codes.ts";
1413
import { failCommand, handleCommandError } from "./error-handler.ts";
14+
import { runDeviceFlow } from "./device-flow.ts";
15+
import { getCurrentAbortSignal } from "../runtime/request-context.ts";
1516

1617
const log = getLogger(import.meta.url);
1718

1819
/**
1920
* Login command.
2021
*
21-
* Authenticates via OAuth2 ROPC flow. Supports flags or interactive input.
22+
* Two authentication paths:
23+
* - No flags: Device Flow (RFC 8628) — opens browser for Google OAuth.
24+
* Works for both new registration and existing user recovery.
25+
* - With -a/-s flags: OAuth2 ROPC flow (backward compatible).
26+
*
2227
* Stores tokens in ~/.prompsit/credentials.json.
2328
*/
2429
export const loginCommand = new Command("login")
25-
.description("Authenticate with the Prompsit API")
30+
.description("Sign in or register with the Prompsit API")
2631
.option("-a, --account <email>", "Account email address")
2732
.option("-s, --secret <key>", "API secret key")
2833
.action(async (options) => {
2934
const startMs = Date.now();
3035
try {
3136
log.debug("Login action entered");
32-
// Resolve account and secret (flags or interactive)
33-
const account = options.account ?? (await promptInput(t("auth.login.prompt_account")));
34-
const secret = options.secret ?? (await promptHidden(t("auth.login.prompt_secret")));
35-
36-
if (!account || !secret) {
37-
failCommand(ErrorCode.AUTH_FAILED, t("auth.login.credentials_required"));
38-
return;
39-
}
4037

41-
// Authenticate via OAuth2
42-
terminal.info(t("auth.login.authenticating"));
43-
log.debug("Sending auth request", { account });
44-
const response = await getApiClient().auth.getToken(account, secret);
45-
log.info("Authentication completed", { duration_ms: String(Date.now() - startMs) });
38+
if (options.account && options.secret) {
39+
// ROPC path: login with existing credentials (backward compatible)
40+
terminal.info(t("auth.login.authenticating"));
41+
log.debug("Sending ROPC auth request", { account: options.account });
42+
const response = await getApiClient().auth.getToken(options.account, options.secret);
43+
log.info("ROPC authentication completed", {
44+
duration_ms: String(Date.now() - startMs),
45+
});
4646

47-
// Save tokens (normalize nullable -> undefined for credential store)
48-
saveTokens({
49-
accessToken: response.access_token,
50-
refreshToken: response.refresh_token ?? undefined,
51-
accountId: account,
52-
expiresIn: response.expires_in ?? undefined,
53-
plan: response.plan,
54-
});
47+
saveTokens({
48+
accessToken: response.access_token,
49+
refreshToken: response.refresh_token ?? undefined,
50+
accountId: options.account,
51+
expiresIn: response.expires_in ?? undefined,
52+
plan: response.plan,
53+
});
54+
resetApiClient();
55+
terminal.success(t("auth.login.success"));
56+
} else if (options.account || options.secret) {
57+
// Partial flags: require both
58+
failCommand(ErrorCode.AUTH_FAILED, t("auth.login.credentials_required"));
59+
} else {
60+
// Device Flow path: browser-based sign-in / registration
61+
log.debug("Starting device flow");
62+
const result = await runDeviceFlow(
63+
getApiClient().auth,
64+
getCurrentAbortSignal()
65+
);
5566

56-
// Reset client to pick up new credentials
57-
resetApiClient();
67+
saveTokens({
68+
accessToken: result.accessToken,
69+
refreshToken: result.refreshToken,
70+
accountId: result.accountId,
71+
expiresIn: result.expiresIn,
72+
plan: result.plan,
73+
prompsitSecret: result.prompsitSecret,
74+
});
75+
resetApiClient();
5876

59-
terminal.success(t("auth.login.success"));
77+
// Show hint for future ROPC login
78+
terminal.dim(
79+
t("auth.device.secret_hint", {
80+
cmd: "login",
81+
account: result.accountId,
82+
secret: result.prompsitSecret,
83+
})
84+
);
85+
}
6086
} catch (error: unknown) {
61-
// Ctrl+C during interactive readline: POSIX SIGINT exit code
62-
if ((error as Error).message === "Cancelled") {
87+
// Ctrl+C during interactive readline or device flow polling
88+
if ((error as Error).message === "Cancelled" || (error as Error).message === "Request cancelled") {
6389
setSigintExit();
6490
return;
6591
}

0 commit comments

Comments
 (0)