ultrahope 0.1.10 → 0.1.11

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 CHANGED
@@ -12,14 +12,15 @@ npm install -g ultrahope
12
12
 
13
13
  ### Login
14
14
 
15
- Authenticate with your ultrahope account using device flow:
15
+ You can try Ultrahope without logging in first. The CLI automatically creates an anonymous session and allows up to 5 requests per day with the Anonymous plan limits.
16
+
17
+ When you want to keep going, authenticate with your Ultrahope account using device flow:
16
18
 
17
19
  ```bash
18
20
  ultrahope login
19
21
  ```
20
22
 
21
- This will display a URL and code. Open the URL in your browser, sign in, and enter the code to authorize the CLI.
22
- On first successful login, `${XDG_CONFIG_HOME:-~/.config}/ultrahope/config.toml` is created automatically if missing.
23
+ This will display a URL and code. Open the URL in your browser, sign in, and enter the code to authorize the CLI. On successful login, the CLI replaces the anonymous session with your authenticated one while keeping the local installation identity.
23
24
 
24
25
  ### Translate
25
26
 
@@ -106,7 +107,7 @@ models = ["mistral/ministral-3b", "xai/grok-code-fast-1"]
106
107
 
107
108
  ### Credentials
108
109
 
109
- Credentials are stored in `~/.config/ultrahope/credentials.json`.
110
+ Credentials and the local installation ID are stored in `~/.config/ultrahope/credentials.json`.
110
111
 
111
112
  ## Development
112
113
 
@@ -36,7 +36,7 @@ function log(message, data) {
36
36
  // lib/api-client.ts
37
37
  var API_BASE_URL = process.env.ULTRAHOPE_API_URL ?? "https://ultrahope.dev";
38
38
  var InsufficientBalanceError = class extends Error {
39
- constructor(balance, plan = "free", hint, actions) {
39
+ constructor(balance, plan = "anonymous", hint, actions) {
40
40
  super("Token balance exhausted");
41
41
  this.balance = balance;
42
42
  this.plan = plan;
@@ -55,7 +55,7 @@ var InsufficientBalanceError = class extends Error {
55
55
  }
56
56
  } else {
57
57
  lines.push(
58
- "Error: Your free credit has been exhausted. Upgrade to Pro for unlimited requests with $5 included credit."
58
+ "Error: Anonymous usage is limited. Upgrade to Pro for unlimited requests with $1 included credit."
59
59
  );
60
60
  if (this.actions?.upgrade) {
61
61
  lines.push(` Upgrade: ${this.actions.upgrade}`);
@@ -65,11 +65,12 @@ var InsufficientBalanceError = class extends Error {
65
65
  }
66
66
  };
67
67
  var DailyLimitExceededError = class extends Error {
68
- constructor(count, limit, resetsAt) {
68
+ constructor(count, limit, resetsAt, plan = "anonymous") {
69
69
  super("Daily request limit reached");
70
70
  this.count = count;
71
71
  this.limit = limit;
72
72
  this.resetsAt = resetsAt;
73
+ this.plan = plan;
73
74
  this.name = "DailyLimitExceededError";
74
75
  }
75
76
  };
@@ -79,6 +80,26 @@ var UnauthorizedError = class extends Error {
79
80
  this.name = "UnauthorizedError";
80
81
  }
81
82
  };
83
+ var SubscriptionRequiredError = class extends Error {
84
+ constructor(subscribeUrl, hint) {
85
+ super("Active Pro subscription required");
86
+ this.subscribeUrl = subscribeUrl;
87
+ this.hint = hint;
88
+ this.name = "SubscriptionRequiredError";
89
+ }
90
+ formatMessage() {
91
+ const lines = [
92
+ "Error: This signed-in account requires an active Pro subscription."
93
+ ];
94
+ if (this.hint) {
95
+ lines.push(` ${this.hint}`);
96
+ }
97
+ if (this.subscribeUrl) {
98
+ lines.push(` Subscribe: ${this.subscribeUrl}`);
99
+ }
100
+ return lines.join("\n");
101
+ }
102
+ };
82
103
  var InvalidModelError = class extends Error {
83
104
  constructor(model, allowedModels, message) {
84
105
  super(message ?? `Model '${model}' is not supported.`);
@@ -88,7 +109,7 @@ var InvalidModelError = class extends Error {
88
109
  }
89
110
  };
90
111
  var InputLengthExceededError = class extends Error {
91
- constructor(count, limit, plan = "free", message) {
112
+ constructor(count, limit, plan = "anonymous", message) {
92
113
  super(
93
114
  message ?? `Input length ${count} exceeds the ${plan} plan limit of ${limit} characters.`
94
115
  );
@@ -141,12 +162,19 @@ function parseSseEvents(buffer) {
141
162
  }
142
163
  function handle402Error(error) {
143
164
  const payload = error;
165
+ if (payload?.error === "subscription_required") {
166
+ log("generate error (402 subscription_required)", error);
167
+ throw new SubscriptionRequiredError(
168
+ payload.actions?.subscribe,
169
+ payload.hint
170
+ );
171
+ }
144
172
  if (typeof payload?.balance === "number") {
145
173
  log("generate error (402 insufficient_balance)", error);
146
- const plan = payload.plan === "pro" || payload.plan === "free" ? payload.plan : "free";
174
+ const plan2 = payload.plan === "pro" ? "pro" : "anonymous";
147
175
  throw new InsufficientBalanceError(
148
176
  payload.balance,
149
- plan,
177
+ plan2,
150
178
  payload.hint,
151
179
  payload.actions
152
180
  );
@@ -154,14 +182,15 @@ function handle402Error(error) {
154
182
  const count = typeof payload?.count === "number" ? payload.count : 0;
155
183
  const limit = typeof payload?.limit === "number" ? payload.limit : 0;
156
184
  const resetsAt = payload?.resetsAt ?? "";
185
+ const plan = payload?.plan === "pro" ? "pro" : "anonymous";
157
186
  log("generate error (402 daily_limit)", error);
158
- throw new DailyLimitExceededError(count, limit, resetsAt);
187
+ throw new DailyLimitExceededError(count, limit, resetsAt, plan);
159
188
  }
160
189
  function throwInputLengthExceededError(error) {
161
190
  const payload = error;
162
191
  const count = typeof payload?.count === "number" ? payload.count : 0;
163
192
  const limit = typeof payload?.limit === "number" ? payload.limit : 0;
164
- const plan = payload?.plan === "free" ? payload.plan : "free";
193
+ const plan = payload?.plan === "anonymous" ? "anonymous" : "anonymous";
165
194
  const message = typeof payload?.message === "string" ? payload.message : `Input length ${count} exceeds the ${plan} plan limit of ${limit} characters.`;
166
195
  log("generate error (400 input_too_long)", error);
167
196
  throw new InputLengthExceededError(count, limit, plan, message);
@@ -185,25 +214,27 @@ function handle400Error(error) {
185
214
  throwInvalidModelError(error);
186
215
  }
187
216
  function createApiClient(token) {
188
- const headers = {
189
- "Content-Type": "application/json"
190
- };
217
+ const authHeaders = {};
191
218
  if (token) {
192
- headers.Authorization = `Bearer ${token}`;
219
+ authHeaders.Authorization = `Bearer ${token}`;
220
+ }
221
+ function jsonHeaders(extra) {
222
+ return {
223
+ ...authHeaders,
224
+ "Content-Type": "application/json",
225
+ ...extra
226
+ };
193
227
  }
194
228
  const client = createClient({
195
229
  baseUrl: API_BASE_URL,
196
- headers
230
+ headers: authHeaders
197
231
  });
198
232
  return {
199
233
  async *streamCommitMessage(req, options) {
200
234
  log("streamCommitMessage request", req);
201
235
  const res = await fetch(`${API_BASE_URL}/api/v1/commit-message/stream`, {
202
236
  method: "POST",
203
- headers: {
204
- ...headers,
205
- Accept: "text/event-stream"
206
- },
237
+ headers: jsonHeaders({ Accept: "text/event-stream" }),
207
238
  body: JSON.stringify(req),
208
239
  signal: options?.signal
209
240
  });
@@ -269,10 +300,7 @@ function createApiClient(token) {
269
300
  `${API_BASE_URL}/api/v1/commit-message/refine/stream`,
270
301
  {
271
302
  method: "POST",
272
- headers: {
273
- ...headers,
274
- Accept: "text/event-stream"
275
- },
303
+ headers: jsonHeaders({ Accept: "text/event-stream" }),
276
304
  body: JSON.stringify(req),
277
305
  signal: options?.signal
278
306
  }
@@ -337,7 +365,7 @@ function createApiClient(token) {
337
365
  log("generation_score request", req);
338
366
  const res = await fetch(`${API_BASE_URL}/api/v1/generation_score`, {
339
367
  method: "POST",
340
- headers,
368
+ headers: jsonHeaders(),
341
369
  body: JSON.stringify(req)
342
370
  });
343
371
  if (!res.ok) {
@@ -490,7 +518,7 @@ function createApiClient(token) {
490
518
  async requestDeviceCode() {
491
519
  const res = await fetch(`${API_BASE_URL}/api/auth/device/code`, {
492
520
  method: "POST",
493
- headers,
521
+ headers: jsonHeaders(),
494
522
  body: JSON.stringify({ client_id: "ultrahope-cli" })
495
523
  });
496
524
  if (!res.ok) {
@@ -502,7 +530,7 @@ function createApiClient(token) {
502
530
  async pollDeviceToken(deviceCode) {
503
531
  const res = await fetch(`${API_BASE_URL}/api/auth/device/token`, {
504
532
  method: "POST",
505
- headers,
533
+ headers: jsonHeaders(),
506
534
  body: JSON.stringify({
507
535
  grant_type: "urn:ietf:params:oauth:grant-type:device_code",
508
536
  device_code: deviceCode,
@@ -514,6 +542,32 @@ function createApiClient(token) {
514
542
  throw new Error(`API error: ${res.status} ${text}`);
515
543
  }
516
544
  return res.json();
545
+ },
546
+ async signInAnonymous() {
547
+ const res = await fetch(`${API_BASE_URL}/api/auth/sign-in/anonymous`, {
548
+ method: "POST",
549
+ headers: jsonHeaders(),
550
+ body: JSON.stringify({})
551
+ });
552
+ if (!res.ok) {
553
+ const text = await res.text();
554
+ throw new Error(`API error: ${res.status} ${text}`);
555
+ }
556
+ return res.json();
557
+ },
558
+ async deleteAnonymousUser() {
559
+ const res = await fetch(
560
+ `${API_BASE_URL}/api/auth/delete-anonymous-user`,
561
+ {
562
+ method: "POST",
563
+ headers: jsonHeaders(),
564
+ body: JSON.stringify({})
565
+ }
566
+ );
567
+ if (!res.ok) {
568
+ const text = await res.text();
569
+ throw new Error(`API error: ${res.status} ${text}`);
570
+ }
517
571
  }
518
572
  };
519
573
  }
@@ -565,6 +619,7 @@ function abortReasonForError(error) {
565
619
  }
566
620
 
567
621
  // lib/auth.ts
622
+ import { randomUUID } from "crypto";
568
623
  import * as fs from "fs";
569
624
  import * as os from "os";
570
625
  import * as path from "path";
@@ -574,19 +629,74 @@ function getCredentialsPath() {
574
629
  const filename = env && env !== "production" ? `credentials.${env}.json` : "credentials.json";
575
630
  return path.join(configDir, "ultrahope", filename);
576
631
  }
577
- async function getToken() {
632
+ async function getCredentials() {
578
633
  const credPath = getCredentialsPath();
579
634
  try {
580
635
  const content = await fs.promises.readFile(credPath, "utf-8");
581
636
  const creds = JSON.parse(content);
582
- return creds.access_token ?? null;
637
+ if (typeof creds.access_token !== "string" || creds.access_token.length === 0) {
638
+ return null;
639
+ }
640
+ return {
641
+ accessToken: creds.access_token,
642
+ authKind: creds.auth_kind === "anonymous" ? "anonymous" : "authenticated",
643
+ installationId: await ensureInstallationId(creds)
644
+ };
583
645
  } catch {
584
646
  return null;
585
647
  }
586
648
  }
649
+ async function writeCredentials(creds) {
650
+ const credPath = getCredentialsPath();
651
+ const dir = path.dirname(credPath);
652
+ await fs.promises.mkdir(dir, { recursive: true });
653
+ await fs.promises.writeFile(credPath, JSON.stringify(creds, null, 2), {
654
+ mode: 384
655
+ });
656
+ }
657
+ async function ensureInstallationId(creds) {
658
+ if (creds?.installation_id && creds.installation_id.length > 0) {
659
+ return creds.installation_id;
660
+ }
661
+ const installationId = randomUUID();
662
+ await writeCredentials({
663
+ access_token: creds?.access_token ?? "",
664
+ auth_kind: creds?.auth_kind,
665
+ installation_id: installationId
666
+ });
667
+ return installationId;
668
+ }
669
+ async function getInstallationId() {
670
+ const credPath = getCredentialsPath();
671
+ try {
672
+ const content = await fs.promises.readFile(credPath, "utf-8");
673
+ const creds = JSON.parse(content);
674
+ return await ensureInstallationId(creds);
675
+ } catch {
676
+ return await ensureInstallationId(null);
677
+ }
678
+ }
679
+ async function getToken() {
680
+ const existing = await getCredentials();
681
+ if (existing) {
682
+ return existing.accessToken;
683
+ }
684
+ const api = createApiClient();
685
+ const anonymousSession = await api.signInAnonymous();
686
+ await saveToken(anonymousSession.token, "anonymous");
687
+ return anonymousSession.token;
688
+ }
689
+ async function saveToken(token, authKind = "authenticated") {
690
+ const installationId = await getInstallationId();
691
+ await writeCredentials({
692
+ access_token: token,
693
+ auth_kind: authKind,
694
+ installation_id: installationId
695
+ });
696
+ }
587
697
 
588
698
  // lib/command-execution.ts
589
- import { randomUUID } from "crypto";
699
+ import { randomUUID as randomUUID2 } from "crypto";
590
700
 
591
701
  // lib/daily-limit-prompt.ts
592
702
  import * as readline from "readline";
@@ -730,9 +840,7 @@ async function showDailyLimitPrompt(info) {
730
840
  );
731
841
  }
732
842
  console.log("");
733
- console.log(
734
- `${theme.primary}Commit message generation was skipped${theme.reset}`
735
- );
843
+ console.log(`${theme.primary}Generation was skipped${theme.reset}`);
736
844
  console.log("");
737
845
  console.log(
738
846
  ui.bullet(`Daily request limit reached (${info.count} / ${info.limit})`)
@@ -745,7 +853,7 @@ async function showDailyLimitPrompt(info) {
745
853
  );
746
854
  console.log(` ${ui.link("ultrahope jj describe")}`);
747
855
  console.log("");
748
- console.log(`${theme.primary}Or upgrade your plan:${theme.reset}`);
856
+ console.log(`${theme.primary}Or upgrade to Pro:${theme.reset}`);
749
857
  console.log(` ${ui.link(PRICING_URL)}`);
750
858
  return;
751
859
  }
@@ -755,7 +863,7 @@ async function showDailyLimitPrompt(info) {
755
863
  `${theme.secondary} 1) Retry after the daily limit resets${theme.reset}`
756
864
  );
757
865
  console.log(
758
- `${theme.secondary} 2) Upgrade your plan to continue immediately${theme.reset}`
866
+ `${theme.secondary} 2) Upgrade to Pro to continue immediately${theme.reset}`
759
867
  );
760
868
  console.log("");
761
869
  const choice = await promptChoice();
@@ -883,12 +991,13 @@ async function handleUpgrade() {
883
991
 
884
992
  // lib/command-execution.ts
885
993
  function startCommandExecution(options) {
886
- const commandExecutionId = randomUUID();
994
+ const commandExecutionId = randomUUID2();
887
995
  const cliSessionId = commandExecutionId;
888
996
  const abortController = new AbortController();
889
997
  const commandExecutionPromise = options.api.commandExecution({
890
998
  commandExecutionId,
891
999
  cliSessionId,
1000
+ installationId: options.installationId,
892
1001
  command: options.command,
893
1002
  args: options.args,
894
1003
  api: options.apiPath,
@@ -933,8 +1042,12 @@ async function handleCommandExecutionError(error, options) {
933
1042
  console.error(error.formatMessage());
934
1043
  process.exit(1);
935
1044
  }
1045
+ if (error instanceof SubscriptionRequiredError) {
1046
+ console.error(error.formatMessage());
1047
+ process.exit(1);
1048
+ }
936
1049
  if (error instanceof InputLengthExceededError) {
937
- console.error("\x1B[31m\u2716\x1B[0m Input is too long for the Free plan.");
1050
+ console.error("\x1B[31m\u2716\x1B[0m Input is too long for anonymous usage.");
938
1051
  console.error(
939
1052
  ` Max allowed characters: ${error.limit}. Received: ${error.count}.`
940
1053
  );
@@ -1064,10 +1177,7 @@ async function* generateCommitMessages(options) {
1064
1177
  models
1065
1178
  });
1066
1179
  const token = await getToken();
1067
- if (!token) {
1068
- console.error("Error: Not authenticated. Run `ultrahope login` first.");
1069
- process.exit(1);
1070
- }
1180
+ const installationId = await getInstallationId();
1071
1181
  const api = createApiClient(token);
1072
1182
  const generateWithRetry = async function* (payload) {
1073
1183
  const maxAttempts = 3;
@@ -1077,6 +1187,7 @@ async function* generateCommitMessages(options) {
1077
1187
  const stream = options.refine ? api.streamCommitMessageRefine(
1078
1188
  {
1079
1189
  cliSessionId,
1190
+ installationId,
1080
1191
  model: payload.model,
1081
1192
  originalMessage: options.refine.originalMessage,
1082
1193
  refineInstruction: options.refine.refineInstruction
@@ -1085,6 +1196,7 @@ async function* generateCommitMessages(options) {
1085
1196
  ) : api.streamCommitMessage(
1086
1197
  {
1087
1198
  ...payload,
1199
+ installationId,
1088
1200
  input: diff,
1089
1201
  guide: options.guide
1090
1202
  },
@@ -3882,10 +3994,7 @@ async function commit(args2) {
3882
3994
  }
3883
3995
  try {
3884
3996
  const token = await getToken();
3885
- if (!token) {
3886
- console.error("Error: Not authenticated. Run `ultrahope login` first.");
3887
- process.exit(1);
3888
- }
3997
+ const installationId = await getInstallationId();
3889
3998
  const api = createApiClient(token);
3890
3999
  const apiClient = api;
3891
4000
  let guideHint;
@@ -3912,6 +4021,7 @@ async function commit(args2) {
3912
4021
  const apiPath = isRefineAttempt ? "/v1/commit-message/refine" : "/v1/commit-message";
3913
4022
  const { commandExecutionPromise, abortController, cliSessionId } = startCommandExecution({
3914
4023
  api,
4024
+ installationId,
3915
4025
  command: "commit",
3916
4026
  args: args2,
3917
4027
  apiPath,
package/dist/index.js CHANGED
@@ -36,7 +36,7 @@ function log(message, data) {
36
36
  // lib/api-client.ts
37
37
  var API_BASE_URL = process.env.ULTRAHOPE_API_URL ?? "https://ultrahope.dev";
38
38
  var InsufficientBalanceError = class extends Error {
39
- constructor(balance, plan = "free", hint, actions) {
39
+ constructor(balance, plan = "anonymous", hint, actions) {
40
40
  super("Token balance exhausted");
41
41
  this.balance = balance;
42
42
  this.plan = plan;
@@ -55,7 +55,7 @@ var InsufficientBalanceError = class extends Error {
55
55
  }
56
56
  } else {
57
57
  lines.push(
58
- "Error: Your free credit has been exhausted. Upgrade to Pro for unlimited requests with $5 included credit."
58
+ "Error: Anonymous usage is limited. Upgrade to Pro for unlimited requests with $1 included credit."
59
59
  );
60
60
  if (this.actions?.upgrade) {
61
61
  lines.push(` Upgrade: ${this.actions.upgrade}`);
@@ -65,11 +65,12 @@ var InsufficientBalanceError = class extends Error {
65
65
  }
66
66
  };
67
67
  var DailyLimitExceededError = class extends Error {
68
- constructor(count, limit, resetsAt) {
68
+ constructor(count, limit, resetsAt, plan = "anonymous") {
69
69
  super("Daily request limit reached");
70
70
  this.count = count;
71
71
  this.limit = limit;
72
72
  this.resetsAt = resetsAt;
73
+ this.plan = plan;
73
74
  this.name = "DailyLimitExceededError";
74
75
  }
75
76
  };
@@ -79,6 +80,26 @@ var UnauthorizedError = class extends Error {
79
80
  this.name = "UnauthorizedError";
80
81
  }
81
82
  };
83
+ var SubscriptionRequiredError = class extends Error {
84
+ constructor(subscribeUrl, hint) {
85
+ super("Active Pro subscription required");
86
+ this.subscribeUrl = subscribeUrl;
87
+ this.hint = hint;
88
+ this.name = "SubscriptionRequiredError";
89
+ }
90
+ formatMessage() {
91
+ const lines = [
92
+ "Error: This signed-in account requires an active Pro subscription."
93
+ ];
94
+ if (this.hint) {
95
+ lines.push(` ${this.hint}`);
96
+ }
97
+ if (this.subscribeUrl) {
98
+ lines.push(` Subscribe: ${this.subscribeUrl}`);
99
+ }
100
+ return lines.join("\n");
101
+ }
102
+ };
82
103
  var InvalidModelError = class extends Error {
83
104
  constructor(model, allowedModels, message) {
84
105
  super(message ?? `Model '${model}' is not supported.`);
@@ -88,7 +109,7 @@ var InvalidModelError = class extends Error {
88
109
  }
89
110
  };
90
111
  var InputLengthExceededError = class extends Error {
91
- constructor(count, limit, plan = "free", message) {
112
+ constructor(count, limit, plan = "anonymous", message) {
92
113
  super(
93
114
  message ?? `Input length ${count} exceeds the ${plan} plan limit of ${limit} characters.`
94
115
  );
@@ -141,12 +162,19 @@ function parseSseEvents(buffer) {
141
162
  }
142
163
  function handle402Error(error) {
143
164
  const payload = error;
165
+ if (payload?.error === "subscription_required") {
166
+ log("generate error (402 subscription_required)", error);
167
+ throw new SubscriptionRequiredError(
168
+ payload.actions?.subscribe,
169
+ payload.hint
170
+ );
171
+ }
144
172
  if (typeof payload?.balance === "number") {
145
173
  log("generate error (402 insufficient_balance)", error);
146
- const plan = payload.plan === "pro" || payload.plan === "free" ? payload.plan : "free";
174
+ const plan2 = payload.plan === "pro" ? "pro" : "anonymous";
147
175
  throw new InsufficientBalanceError(
148
176
  payload.balance,
149
- plan,
177
+ plan2,
150
178
  payload.hint,
151
179
  payload.actions
152
180
  );
@@ -154,14 +182,15 @@ function handle402Error(error) {
154
182
  const count = typeof payload?.count === "number" ? payload.count : 0;
155
183
  const limit = typeof payload?.limit === "number" ? payload.limit : 0;
156
184
  const resetsAt = payload?.resetsAt ?? "";
185
+ const plan = payload?.plan === "pro" ? "pro" : "anonymous";
157
186
  log("generate error (402 daily_limit)", error);
158
- throw new DailyLimitExceededError(count, limit, resetsAt);
187
+ throw new DailyLimitExceededError(count, limit, resetsAt, plan);
159
188
  }
160
189
  function throwInputLengthExceededError(error) {
161
190
  const payload = error;
162
191
  const count = typeof payload?.count === "number" ? payload.count : 0;
163
192
  const limit = typeof payload?.limit === "number" ? payload.limit : 0;
164
- const plan = payload?.plan === "free" ? payload.plan : "free";
193
+ const plan = payload?.plan === "anonymous" ? "anonymous" : "anonymous";
165
194
  const message = typeof payload?.message === "string" ? payload.message : `Input length ${count} exceeds the ${plan} plan limit of ${limit} characters.`;
166
195
  log("generate error (400 input_too_long)", error);
167
196
  throw new InputLengthExceededError(count, limit, plan, message);
@@ -185,25 +214,27 @@ function handle400Error(error) {
185
214
  throwInvalidModelError(error);
186
215
  }
187
216
  function createApiClient(token) {
188
- const headers = {
189
- "Content-Type": "application/json"
190
- };
217
+ const authHeaders = {};
191
218
  if (token) {
192
- headers.Authorization = `Bearer ${token}`;
219
+ authHeaders.Authorization = `Bearer ${token}`;
220
+ }
221
+ function jsonHeaders(extra) {
222
+ return {
223
+ ...authHeaders,
224
+ "Content-Type": "application/json",
225
+ ...extra
226
+ };
193
227
  }
194
228
  const client = createClient({
195
229
  baseUrl: API_BASE_URL,
196
- headers
230
+ headers: authHeaders
197
231
  });
198
232
  return {
199
233
  async *streamCommitMessage(req, options) {
200
234
  log("streamCommitMessage request", req);
201
235
  const res = await fetch(`${API_BASE_URL}/api/v1/commit-message/stream`, {
202
236
  method: "POST",
203
- headers: {
204
- ...headers,
205
- Accept: "text/event-stream"
206
- },
237
+ headers: jsonHeaders({ Accept: "text/event-stream" }),
207
238
  body: JSON.stringify(req),
208
239
  signal: options?.signal
209
240
  });
@@ -269,10 +300,7 @@ function createApiClient(token) {
269
300
  `${API_BASE_URL}/api/v1/commit-message/refine/stream`,
270
301
  {
271
302
  method: "POST",
272
- headers: {
273
- ...headers,
274
- Accept: "text/event-stream"
275
- },
303
+ headers: jsonHeaders({ Accept: "text/event-stream" }),
276
304
  body: JSON.stringify(req),
277
305
  signal: options?.signal
278
306
  }
@@ -337,7 +365,7 @@ function createApiClient(token) {
337
365
  log("generation_score request", req);
338
366
  const res = await fetch(`${API_BASE_URL}/api/v1/generation_score`, {
339
367
  method: "POST",
340
- headers,
368
+ headers: jsonHeaders(),
341
369
  body: JSON.stringify(req)
342
370
  });
343
371
  if (!res.ok) {
@@ -490,7 +518,7 @@ function createApiClient(token) {
490
518
  async requestDeviceCode() {
491
519
  const res = await fetch(`${API_BASE_URL}/api/auth/device/code`, {
492
520
  method: "POST",
493
- headers,
521
+ headers: jsonHeaders(),
494
522
  body: JSON.stringify({ client_id: "ultrahope-cli" })
495
523
  });
496
524
  if (!res.ok) {
@@ -502,7 +530,7 @@ function createApiClient(token) {
502
530
  async pollDeviceToken(deviceCode) {
503
531
  const res = await fetch(`${API_BASE_URL}/api/auth/device/token`, {
504
532
  method: "POST",
505
- headers,
533
+ headers: jsonHeaders(),
506
534
  body: JSON.stringify({
507
535
  grant_type: "urn:ietf:params:oauth:grant-type:device_code",
508
536
  device_code: deviceCode,
@@ -514,6 +542,32 @@ function createApiClient(token) {
514
542
  throw new Error(`API error: ${res.status} ${text}`);
515
543
  }
516
544
  return res.json();
545
+ },
546
+ async signInAnonymous() {
547
+ const res = await fetch(`${API_BASE_URL}/api/auth/sign-in/anonymous`, {
548
+ method: "POST",
549
+ headers: jsonHeaders(),
550
+ body: JSON.stringify({})
551
+ });
552
+ if (!res.ok) {
553
+ const text = await res.text();
554
+ throw new Error(`API error: ${res.status} ${text}`);
555
+ }
556
+ return res.json();
557
+ },
558
+ async deleteAnonymousUser() {
559
+ const res = await fetch(
560
+ `${API_BASE_URL}/api/auth/delete-anonymous-user`,
561
+ {
562
+ method: "POST",
563
+ headers: jsonHeaders(),
564
+ body: JSON.stringify({})
565
+ }
566
+ );
567
+ if (!res.ok) {
568
+ const text = await res.text();
569
+ throw new Error(`API error: ${res.status} ${text}`);
570
+ }
517
571
  }
518
572
  };
519
573
  }
@@ -565,6 +619,7 @@ function abortReasonForError(error) {
565
619
  }
566
620
 
567
621
  // lib/auth.ts
622
+ import { randomUUID } from "crypto";
568
623
  import * as fs from "fs";
569
624
  import * as os from "os";
570
625
  import * as path from "path";
@@ -574,29 +629,74 @@ function getCredentialsPath() {
574
629
  const filename = env && env !== "production" ? `credentials.${env}.json` : "credentials.json";
575
630
  return path.join(configDir, "ultrahope", filename);
576
631
  }
577
- async function getToken() {
632
+ async function getCredentials() {
578
633
  const credPath = getCredentialsPath();
579
634
  try {
580
635
  const content = await fs.promises.readFile(credPath, "utf-8");
581
636
  const creds = JSON.parse(content);
582
- return creds.access_token ?? null;
637
+ if (typeof creds.access_token !== "string" || creds.access_token.length === 0) {
638
+ return null;
639
+ }
640
+ return {
641
+ accessToken: creds.access_token,
642
+ authKind: creds.auth_kind === "anonymous" ? "anonymous" : "authenticated",
643
+ installationId: await ensureInstallationId(creds)
644
+ };
583
645
  } catch {
584
646
  return null;
585
647
  }
586
648
  }
587
- async function saveToken(token) {
649
+ async function writeCredentials(creds) {
588
650
  const credPath = getCredentialsPath();
589
651
  const dir = path.dirname(credPath);
590
652
  await fs.promises.mkdir(dir, { recursive: true });
591
- await fs.promises.writeFile(
592
- credPath,
593
- JSON.stringify({ access_token: token }, null, 2),
594
- { mode: 384 }
595
- );
653
+ await fs.promises.writeFile(credPath, JSON.stringify(creds, null, 2), {
654
+ mode: 384
655
+ });
656
+ }
657
+ async function ensureInstallationId(creds) {
658
+ if (creds?.installation_id && creds.installation_id.length > 0) {
659
+ return creds.installation_id;
660
+ }
661
+ const installationId = randomUUID();
662
+ await writeCredentials({
663
+ access_token: creds?.access_token ?? "",
664
+ auth_kind: creds?.auth_kind,
665
+ installation_id: installationId
666
+ });
667
+ return installationId;
668
+ }
669
+ async function getInstallationId() {
670
+ const credPath = getCredentialsPath();
671
+ try {
672
+ const content = await fs.promises.readFile(credPath, "utf-8");
673
+ const creds = JSON.parse(content);
674
+ return await ensureInstallationId(creds);
675
+ } catch {
676
+ return await ensureInstallationId(null);
677
+ }
678
+ }
679
+ async function getToken() {
680
+ const existing = await getCredentials();
681
+ if (existing) {
682
+ return existing.accessToken;
683
+ }
684
+ const api = createApiClient();
685
+ const anonymousSession = await api.signInAnonymous();
686
+ await saveToken(anonymousSession.token, "anonymous");
687
+ return anonymousSession.token;
688
+ }
689
+ async function saveToken(token, authKind = "authenticated") {
690
+ const installationId = await getInstallationId();
691
+ await writeCredentials({
692
+ access_token: token,
693
+ auth_kind: authKind,
694
+ installation_id: installationId
695
+ });
596
696
  }
597
697
 
598
698
  // lib/command-execution.ts
599
- import { randomUUID } from "crypto";
699
+ import { randomUUID as randomUUID2 } from "crypto";
600
700
 
601
701
  // lib/daily-limit-prompt.ts
602
702
  import * as readline from "readline";
@@ -740,9 +840,7 @@ async function showDailyLimitPrompt(info) {
740
840
  );
741
841
  }
742
842
  console.log("");
743
- console.log(
744
- `${theme.primary}Commit message generation was skipped${theme.reset}`
745
- );
843
+ console.log(`${theme.primary}Generation was skipped${theme.reset}`);
746
844
  console.log("");
747
845
  console.log(
748
846
  ui.bullet(`Daily request limit reached (${info.count} / ${info.limit})`)
@@ -755,7 +853,7 @@ async function showDailyLimitPrompt(info) {
755
853
  );
756
854
  console.log(` ${ui.link("ultrahope jj describe")}`);
757
855
  console.log("");
758
- console.log(`${theme.primary}Or upgrade your plan:${theme.reset}`);
856
+ console.log(`${theme.primary}Or upgrade to Pro:${theme.reset}`);
759
857
  console.log(` ${ui.link(PRICING_URL)}`);
760
858
  return;
761
859
  }
@@ -765,7 +863,7 @@ async function showDailyLimitPrompt(info) {
765
863
  `${theme.secondary} 1) Retry after the daily limit resets${theme.reset}`
766
864
  );
767
865
  console.log(
768
- `${theme.secondary} 2) Upgrade your plan to continue immediately${theme.reset}`
866
+ `${theme.secondary} 2) Upgrade to Pro to continue immediately${theme.reset}`
769
867
  );
770
868
  console.log("");
771
869
  const choice = await promptChoice();
@@ -893,12 +991,13 @@ async function handleUpgrade() {
893
991
 
894
992
  // lib/command-execution.ts
895
993
  function startCommandExecution(options) {
896
- const commandExecutionId = randomUUID();
994
+ const commandExecutionId = randomUUID2();
897
995
  const cliSessionId = commandExecutionId;
898
996
  const abortController = new AbortController();
899
997
  const commandExecutionPromise = options.api.commandExecution({
900
998
  commandExecutionId,
901
999
  cliSessionId,
1000
+ installationId: options.installationId,
902
1001
  command: options.command,
903
1002
  args: options.args,
904
1003
  api: options.apiPath,
@@ -943,8 +1042,12 @@ async function handleCommandExecutionError(error, options) {
943
1042
  console.error(error.formatMessage());
944
1043
  process.exit(1);
945
1044
  }
1045
+ if (error instanceof SubscriptionRequiredError) {
1046
+ console.error(error.formatMessage());
1047
+ process.exit(1);
1048
+ }
946
1049
  if (error instanceof InputLengthExceededError) {
947
- console.error("\x1B[31m\u2716\x1B[0m Input is too long for the Free plan.");
1050
+ console.error("\x1B[31m\u2716\x1B[0m Input is too long for anonymous usage.");
948
1051
  console.error(
949
1052
  ` Max allowed characters: ${error.limit}. Received: ${error.count}.`
950
1053
  );
@@ -1074,10 +1177,7 @@ async function* generateCommitMessages(options) {
1074
1177
  models
1075
1178
  });
1076
1179
  const token = await getToken();
1077
- if (!token) {
1078
- console.error("Error: Not authenticated. Run `ultrahope login` first.");
1079
- process.exit(1);
1080
- }
1180
+ const installationId = await getInstallationId();
1081
1181
  const api = createApiClient(token);
1082
1182
  const generateWithRetry = async function* (payload) {
1083
1183
  const maxAttempts = 3;
@@ -1087,6 +1187,7 @@ async function* generateCommitMessages(options) {
1087
1187
  const stream = options.refine ? api.streamCommitMessageRefine(
1088
1188
  {
1089
1189
  cliSessionId,
1190
+ installationId,
1090
1191
  model: payload.model,
1091
1192
  originalMessage: options.refine.originalMessage,
1092
1193
  refineInstruction: options.refine.refineInstruction
@@ -1095,6 +1196,7 @@ async function* generateCommitMessages(options) {
1095
1196
  ) : api.streamCommitMessage(
1096
1197
  {
1097
1198
  ...payload,
1199
+ installationId,
1098
1200
  input: diff,
1099
1201
  guide: options.guide
1100
1202
  },
@@ -3898,15 +4000,12 @@ function assertDiffAvailable(revision, diff) {
3898
4000
  process.exit(1);
3899
4001
  }
3900
4002
  }
3901
- async function initCommandExecutionContext(args2, models, diff, apiPath, guide, isSessionActive) {
4003
+ async function initCommandExecutionContext(args2, models, diff, apiPath, installationId, guide, isSessionActive) {
3902
4004
  const token = await getToken();
3903
- if (!token) {
3904
- console.error("Error: Not authenticated. Run `ultrahope login` first.");
3905
- process.exit(1);
3906
- }
3907
4005
  const api = createApiClient(token);
3908
4006
  const { commandExecutionPromise, abortController, cliSessionId } = startCommandExecution({
3909
4007
  api,
4008
+ installationId,
3910
4009
  command: "jj",
3911
4010
  args: ["describe", ...args2],
3912
4011
  apiPath,
@@ -3995,6 +4094,7 @@ async function describe(args2) {
3995
4094
  let refineMessage;
3996
4095
  let commandExecutionRun = 0;
3997
4096
  let isEscalation = false;
4097
+ const installationId = await getInstallationId();
3998
4098
  while (true) {
3999
4099
  const sessionId = ++commandExecutionRun;
4000
4100
  const isRefineAttempt = refineMessage !== void 0;
@@ -4003,6 +4103,7 @@ async function describe(args2) {
4003
4103
  models,
4004
4104
  diff,
4005
4105
  isRefineAttempt ? "/v1/commit-message/refine" : "/v1/commit-message/stream",
4106
+ installationId,
4006
4107
  composeGuidance(options.guide, guideHint),
4007
4108
  () => sessionId === commandExecutionRun
4008
4109
  );
@@ -4150,6 +4251,8 @@ async function jj(args2) {
4150
4251
  // commands/login.ts
4151
4252
  async function login(_args) {
4152
4253
  const api = createApiClient();
4254
+ const existingCredentials = await getCredentials();
4255
+ const anonymousToken = existingCredentials?.authKind === "anonymous" ? existingCredentials.accessToken : null;
4153
4256
  console.log("Requesting device code...");
4154
4257
  const deviceCode = await api.requestDeviceCode();
4155
4258
  console.log();
@@ -4163,8 +4266,18 @@ async function login(_args) {
4163
4266
  deviceCode.interval,
4164
4267
  deviceCode.expires_in
4165
4268
  );
4166
- await saveToken(token);
4269
+ await saveToken(token, "authenticated");
4167
4270
  await ensureGlobalConfigFile();
4271
+ if (anonymousToken) {
4272
+ try {
4273
+ await createApiClient(anonymousToken).deleteAnonymousUser();
4274
+ } catch (error) {
4275
+ const message = error instanceof Error ? error.message : String(error);
4276
+ console.error(
4277
+ `Warning: Failed to delete anonymous trial session. ${message}`
4278
+ );
4279
+ }
4280
+ }
4168
4281
  console.log("Successfully authenticated!");
4169
4282
  }
4170
4283
  async function pollForToken(api, deviceCode, interval, expiresIn) {
@@ -4247,10 +4360,7 @@ async function handleVcsCommitMessage(input, options, args2) {
4247
4360
  const models = resolveModels(options.cliModels);
4248
4361
  try {
4249
4362
  const token = await getToken();
4250
- if (!token) {
4251
- console.error("Error: Not authenticated. Run `ultrahope login` first.");
4252
- process.exit(1);
4253
- }
4363
+ const installationId = await getInstallationId();
4254
4364
  const api = createApiClient(token);
4255
4365
  const apiClient = api;
4256
4366
  let guideHint;
@@ -4277,6 +4387,7 @@ async function handleVcsCommitMessage(input, options, args2) {
4277
4387
  const apiPath = isRefineAttempt && options.target === "vcs-commit-message" ? "/v1/commit-message/refine" : TARGET_TO_API_PATH[options.target];
4278
4388
  const { commandExecutionPromise, abortController, cliSessionId } = startCommandExecution({
4279
4389
  api,
4390
+ installationId,
4280
4391
  command: "translate",
4281
4392
  args: args2,
4282
4393
  apiPath,
@@ -4354,10 +4465,7 @@ async function handleVcsCommitMessage(input, options, args2) {
4354
4465
  }
4355
4466
  async function handleGenericTarget(input, options, args2) {
4356
4467
  const token = await getToken();
4357
- if (!token) {
4358
- console.error("Error: Not authenticated. Run `ultrahope login` first.");
4359
- process.exit(1);
4360
- }
4468
+ const installationId = await getInstallationId();
4361
4469
  const api = createApiClient(token);
4362
4470
  const models = resolveModels(options.cliModels);
4363
4471
  const requestPayload = models.length === 1 ? { input, target: options.target, model: models[0] } : { input, target: options.target, models };
@@ -4367,6 +4475,7 @@ async function handleGenericTarget(input, options, args2) {
4367
4475
  commandExecutionPromise
4368
4476
  } = startCommandExecution({
4369
4477
  api,
4478
+ installationId,
4370
4479
  command: "translate",
4371
4480
  args: args2,
4372
4481
  apiPath: TARGET_TO_API_PATH[options.target],
@@ -4394,7 +4503,7 @@ async function handleGenericTarget(input, options, args2) {
4394
4503
  const maxAttempts = 3;
4395
4504
  for (let attempt = 0; attempt < maxAttempts; attempt++) {
4396
4505
  try {
4397
- return await generateFn({ cliSessionId, input, model });
4506
+ return await generateFn({ cliSessionId, installationId, input, model });
4398
4507
  } catch (error) {
4399
4508
  if (isAbortError2(error) || abortController.signal.aborted) throw error;
4400
4509
  if (isInvalidCliSessionIdError2(error) && attempt < maxAttempts - 1) {
@@ -4533,7 +4642,7 @@ function parseArgs(args2) {
4533
4642
  // package.json
4534
4643
  var package_default = {
4535
4644
  name: "ultrahope",
4536
- version: "0.1.10",
4645
+ version: "0.1.11",
4537
4646
  description: "LLM-powered development workflow assistant",
4538
4647
  type: "module",
4539
4648
  license: "MIT",
@@ -4618,11 +4727,15 @@ Usage: ultrahope <command>
4618
4727
  Commands:
4619
4728
  translate Translate input to various formats
4620
4729
  jj Jujutsu integration commands
4621
- login Authenticate with device flow
4730
+ login Authenticate with device flow and unlock full account usage
4622
4731
 
4623
4732
  Options:
4624
4733
  --version, -v Show version
4625
- --help, -h Show this help message`);
4734
+ --help, -h Show this help message
4735
+
4736
+ Plans:
4737
+ Anonymous: 5 requests/day and 40,000 chars/request in the CLI without login
4738
+ Pro: login required, paid usage, no anonymous limits`);
4626
4739
  }
4627
4740
  main().catch((err) => {
4628
4741
  console.error(err);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ultrahope",
3
- "version": "0.1.10",
3
+ "version": "0.1.11",
4
4
  "description": "LLM-powered development workflow assistant",
5
5
  "type": "module",
6
6
  "license": "MIT",