@hasna/economy 0.5.3 → 0.5.4

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.
@@ -4295,7 +4295,7 @@ import { join as join4, basename } from "path";
4295
4295
  import { existsSync as existsSync4, readFileSync } from "fs";
4296
4296
  import { homedir as homedir2 } from "os";
4297
4297
  import { join as join3 } from "path";
4298
- import { z } from "zod";
4298
+ import { z as z2 } from "zod";
4299
4299
  import { resolveClientTransport, resolveCredential } from "@hasna/contracts/client";
4300
4300
 
4301
4301
  // src/lib/cloud-storage.ts
@@ -4314,33 +4314,138 @@ import { createHasnaStorageClient } from "@hasna/contracts/client/storage";
4314
4314
  var LOCAL_STORAGE_OPT_IN_KEYS = ["HASNA_ECONOMY_LOCAL", "ECONOMY_LOCAL"];
4315
4315
  var CONTRACTS_AMBIENT_ENVIRONMENT = Symbol.for("hasna:contracts:ambientClientEnvironment");
4316
4316
 
4317
- // src/lib/accounts-store.ts
4318
- var ACCOUNTS_APP = "accounts";
4319
- var profileNameSchema = z.string().min(1).max(64).regex(/^[a-z0-9][a-z0-9-]*$/, "must be lowercase alphanumeric/hyphen and start with a letter or digit");
4317
+ // src/lib/subscriptions-registry.ts
4318
+ import { execFile } from "child_process";
4319
+ import { promisify } from "util";
4320
+ import { isAbsolute } from "path";
4321
+ import { z } from "zod";
4322
+ var execute = promisify(execFile);
4323
+ var readSubscriptionsMetadata = async (args, env) => {
4324
+ let stdout;
4325
+ try {
4326
+ const result = await execute("subscriptions", args, {
4327
+ env,
4328
+ encoding: "utf8",
4329
+ timeout: 30000,
4330
+ maxBuffer: 16 * 1024 * 1024,
4331
+ windowsHide: true
4332
+ });
4333
+ stdout = result.stdout;
4334
+ } catch (error) {
4335
+ const code = error?.code;
4336
+ if (code === "ENOENT")
4337
+ throw new Error("subscriptions: CLI not found on PATH; install the Subscriptions CLI to resolve station account metadata");
4338
+ const status = typeof code === "number" && Number.isInteger(code) ? `exit ${code}` : "execution limit or transport failure";
4339
+ throw new Error(`subscriptions: read-only metadata command failed (${status}); verify the installed CLI and its hosted credentials`);
4340
+ }
4341
+ try {
4342
+ return JSON.parse(stdout);
4343
+ } catch {
4344
+ throw new Error("subscriptions: metadata command returned invalid JSON");
4345
+ }
4346
+ };
4347
+ var nonempty = z.string().min(1);
4320
4348
  var profileSchema = z.object({
4321
- name: z.string(),
4322
- tool: z.string(),
4349
+ name: nonempty,
4350
+ tool: nonempty,
4323
4351
  email: z.string().optional(),
4324
4352
  displayName: z.string().optional(),
4325
- dir: z.string(),
4353
+ dir: nonempty.refine(isAbsolute).refine((value) => !/[\0\r\n]/.test(value)),
4354
+ createdAt: nonempty,
4355
+ lastUsedAt: z.string().optional(),
4326
4356
  description: z.string().optional(),
4327
- createdAt: z.string(),
4328
- lastUsedAt: z.string().optional()
4357
+ active: z.boolean(),
4358
+ applied: z.boolean(),
4359
+ switchedAway: z.object({ profile: nonempty, email: z.string().optional() }).optional()
4329
4360
  });
4330
- var toolDefSchema = z.object({
4331
- id: z.string(),
4332
- label: z.string(),
4333
- envVar: z.string(),
4334
- defaultDir: z.string(),
4335
- bin: z.string()
4361
+ var toolsSchema = z.array(z.object({
4362
+ id: nonempty,
4363
+ label: nonempty,
4364
+ envVar: nonempty,
4365
+ defaultDir: nonempty,
4366
+ bin: nonempty
4367
+ }));
4368
+ function unique(rows, identity) {
4369
+ if (new Set(rows.map(identity)).size !== rows.length) {
4370
+ throw new Error("subscriptions: duplicate identity in metadata response");
4371
+ }
4372
+ return rows;
4373
+ }
4374
+
4375
+ class SubscriptionsRegistry {
4376
+ read;
4377
+ transport = "api";
4378
+ registry = "subscriptions";
4379
+ env;
4380
+ profiles;
4381
+ tools;
4382
+ constructor(baseUrl, env, read = readSubscriptionsMetadata) {
4383
+ this.read = read;
4384
+ this.env = { ...env, HASNA_SUBSCRIPTIONS_API_URL: baseUrl.replace(/\/v1\/?$/, "") };
4385
+ }
4386
+ allProfiles() {
4387
+ return this.profiles ??= this.read(["list", "--json"], this.env).then((raw) => {
4388
+ const parsed = z.array(profileSchema).safeParse(raw);
4389
+ if (!parsed.success)
4390
+ throw new Error("subscriptions: invalid profile metadata response");
4391
+ return unique(parsed.data, (row) => JSON.stringify([row.tool, row.name]));
4392
+ });
4393
+ }
4394
+ async listProfiles(tool) {
4395
+ const rows = await this.allProfiles();
4396
+ return rows.filter((row) => !tool || row.tool === tool);
4397
+ }
4398
+ async findProfile(name, tool) {
4399
+ const matches = (await this.listProfiles(tool)).filter((row) => row.name === name);
4400
+ return matches.length === 1 ? matches[0] : undefined;
4401
+ }
4402
+ async appliedProfile(tool) {
4403
+ const matches = (await this.allProfiles()).filter((row) => row.tool === tool && row.applied);
4404
+ if (matches.length > 1)
4405
+ throw new Error("subscriptions: ambiguous station applied-profile binding");
4406
+ return matches[0];
4407
+ }
4408
+ async currentProfile(_tool) {
4409
+ return;
4410
+ }
4411
+ async listTools() {
4412
+ return this.tools ??= this.read(["tools", "list", "--json"], this.env).then((raw) => {
4413
+ const parsed = toolsSchema.safeParse(raw);
4414
+ if (!parsed.success)
4415
+ throw new Error("subscriptions: invalid tool metadata response");
4416
+ return unique(parsed.data, (row) => row.id);
4417
+ });
4418
+ }
4419
+ }
4420
+
4421
+ // src/lib/accounts-store.ts
4422
+ var ACCOUNTS_APP = "accounts";
4423
+ var profileNameSchema = z2.string().min(1).max(64).regex(/^[a-z0-9][a-z0-9-]*$/, "must be lowercase alphanumeric/hyphen and start with a letter or digit");
4424
+ var profileSchema2 = z2.object({
4425
+ name: z2.string(),
4426
+ tool: z2.string(),
4427
+ email: z2.string().optional(),
4428
+ displayName: z2.string().optional(),
4429
+ dir: z2.string(),
4430
+ description: z2.string().optional(),
4431
+ createdAt: z2.string(),
4432
+ lastUsedAt: z2.string().optional(),
4433
+ switchedAway: z2.object({ profile: z2.string().min(1), email: z2.string().optional() }).optional()
4434
+ });
4435
+ var toolDefSchema = z2.object({
4436
+ id: z2.string(),
4437
+ label: z2.string(),
4438
+ envVar: z2.string(),
4439
+ defaultDir: z2.string(),
4440
+ bin: z2.string()
4336
4441
  });
4337
- var storeSchema = z.object({
4338
- version: z.literal(1),
4339
- current: z.record(z.string(), z.string()).default({}),
4340
- applied: z.record(z.string(), z.string()).default({}),
4341
- toolLocks: z.record(z.string(), z.string()).default({}),
4342
- profiles: z.array(profileSchema).default([]),
4343
- tools: z.array(toolDefSchema).default([])
4442
+ var storeSchema = z2.object({
4443
+ version: z2.literal(1),
4444
+ current: z2.record(z2.string(), z2.string()).default({}),
4445
+ applied: z2.record(z2.string(), z2.string()).default({}),
4446
+ toolLocks: z2.record(z2.string(), z2.string()).default({}),
4447
+ profiles: z2.array(profileSchema2).default([]),
4448
+ tools: z2.array(toolDefSchema).default([])
4344
4449
  });
4345
4450
  function localRegistryAllowed(env = process.env) {
4346
4451
  return LOCAL_STORAGE_OPT_IN_KEYS.some((key) => {
@@ -4622,25 +4727,34 @@ class ApiStore {
4622
4727
  }
4623
4728
  }
4624
4729
  function resolveStore(env = process.env, options = {}) {
4730
+ const legacy = resolveRegistryTransport(ACCOUNTS_APP, env, options.credentials);
4731
+ if (legacy)
4732
+ return new ApiStore(legacy.baseUrl, legacy.apiKey);
4733
+ const current = resolveRegistryTransport("subscriptions", env, options.subscriptionsCredentials);
4734
+ if (current)
4735
+ return new SubscriptionsRegistry(current.baseUrl, env, options.readSubscriptionsMetadata);
4736
+ return unconfiguredStore(env);
4737
+ }
4738
+ function resolveRegistryTransport(app, env, credentials) {
4625
4739
  const clientEnv = env;
4626
- const credential = resolveCredential(ACCOUNTS_APP, clientEnv, options.credentials);
4740
+ const credential = resolveCredential(app, clientEnv, credentials);
4627
4741
  if (credential === null) {
4628
4742
  try {
4629
- resolveClientTransport(ACCOUNTS_APP, clientEnv, options.credentials ? { credentials: options.credentials } : {});
4743
+ resolveClientTransport(app, clientEnv, credentials ? { credentials } : {});
4630
4744
  } catch (err) {
4631
4745
  if (isNoAccountsCredentialConfigurationError(err))
4632
- return unconfiguredStore(env);
4746
+ return null;
4633
4747
  throw err;
4634
4748
  }
4635
- return unconfiguredStore(env);
4749
+ return null;
4636
4750
  }
4637
4751
  if (credential.tier === "pointer") {
4638
- throw new Error(`accounts: ${credential.source} names a secrets-vault item, and the local accounts API store resolves ` + "credentials synchronously, so it cannot complete the pointer per request. It is a deliberate " + "selection and is not resolved around. Use a literal tier instead \u2014 the Keychain item " + "hasna.credentials.accounts.api-key, ~/.hasna/accounts/config/credentials, or HASNA_ACCOUNTS_API_KEY.");
4752
+ throw new Error(`${app}: ${credential.source} names a secrets-vault item, and the registry adapter resolves ` + "credentials synchronously, so it cannot complete the pointer per request. It is a deliberate " + "selection and is not resolved around. Use a literal tier instead \u2014 the Keychain item " + `hasna.credentials.${app}.api-key, ~/.hasna/${app}/config/credentials, or HASNA_${app.toUpperCase()}_API_KEY.`);
4639
4753
  }
4640
- const resolution = resolveClientTransport(ACCOUNTS_APP, clientEnv, {
4641
- credentials: { ...options.credentials, apiKey: credential.apiKey }
4754
+ const resolution = resolveClientTransport(app, clientEnv, {
4755
+ credentials: { ...credentials, apiKey: credential.apiKey }
4642
4756
  });
4643
- return new ApiStore(resolution.baseUrl, credential.apiKey);
4757
+ return { baseUrl: resolution.baseUrl, apiKey: credential.apiKey };
4644
4758
  }
4645
4759
  function isNoAccountsCredentialConfigurationError(error) {
4646
4760
  if (typeof error !== "object" || error === null)
@@ -4657,10 +4771,10 @@ function unconfiguredStore(env) {
4657
4771
  return new NoRegistryStore;
4658
4772
  }
4659
4773
  function accountsLocalModeNotice() {
4660
- return `accounts: LOCAL mode (${LOCAL_STORAGE_OPT_IN_KEYS[0]}=1) \u2014 no Hasna Accounts credential resolved, so ` + "attribution reads the on-box JSON registry (~/.hasna/accounts/accounts.json) instead of accounts-serve. " + "To go hosted, put the fleet key in the Keychain item hasna.credentials.accounts.api-key, write " + "~/.hasna/accounts/config/credentials, or set HASNA_ACCOUNTS_API_KEY.";
4774
+ return `accounts: LOCAL mode (${LOCAL_STORAGE_OPT_IN_KEYS[0]}=1) \u2014 neither Accounts nor Subscriptions credentials resolved, so ` + "attribution reads the on-box JSON registry (~/.hasna/accounts/accounts.json) instead of accounts-serve. " + "For hosted attribution, configure the Subscriptions CLI through its normal sign-in or credential resolver.";
4661
4775
  }
4662
4776
  function accountsRefusalNotice() {
4663
- return "accounts: no Hasna Accounts credential resolved (Keychain item hasna.credentials.accounts.api-key, " + "~/.hasna/accounts/config/credentials, HASNA_ACCOUNTS_API_KEY), so spend attribution is omitted. The " + `on-box registry (~/.hasna/accounts/accounts.json) is NOT read without ${LOCAL_STORAGE_OPT_IN_KEYS[0]}=1 \u2014 ` + "economy fails closed instead of attributing hosted spend from an on-box file.";
4777
+ return "accounts: neither Accounts nor Subscriptions credentials resolved (legacy hasna.credentials.accounts.api-key, " + "~/.hasna/accounts/config/credentials, HASNA_ACCOUNTS_API_KEY; current hasna.credentials.subscriptions.api-key, " + "~/.hasna/subscriptions/config/credentials, HASNA_SUBSCRIPTIONS_API_KEY), so spend attribution is omitted. The " + `on-box registry (~/.hasna/accounts/accounts.json) is NOT read without ${LOCAL_STORAGE_OPT_IN_KEYS[0]}=1 \u2014 ` + "economy fails closed instead of attributing hosted spend from an on-box file.";
4664
4778
  }
4665
4779
  var accountsLocalNoticePrinted = false;
4666
4780
  function announceAccountsLocalMode() {
@@ -4748,7 +4862,10 @@ async function profileForEnvDir(store, tool, env) {
4748
4862
  if (!configuredDir)
4749
4863
  return null;
4750
4864
  const normalized = normalizeDir(configuredDir);
4751
- return (await store.listProfiles(tool.id)).find((profile) => normalizeDir(profile.dir) === normalized) ?? null;
4865
+ const matches = (await store.listProfiles(tool.id)).filter((profile) => normalizeDir(profile.dir) === normalized);
4866
+ if (matches.length > 1)
4867
+ throw new Error("account attribution: multiple profiles match the configured directory");
4868
+ return matches[0] ?? null;
4752
4869
  }
4753
4870
  async function resolveAccountForAgent(agent, env = process.env, options = {}) {
4754
4871
  const override = envOverride(agent, env);
@@ -4762,8 +4879,15 @@ async function resolveAccountForAgent(agent, env = process.env, options = {}) {
4762
4879
  continue;
4763
4880
  const envProfile = await profileForEnvDir(store, tool, env);
4764
4881
  if (envProfile)
4765
- return fromProfile(envProfile, "env");
4766
- const appliedName = appliedProfileName(toolId);
4882
+ return envProfile.switchedAway ? null : fromProfile(envProfile, "env");
4883
+ if (store.registry === "subscriptions" && env[tool.envVar])
4884
+ return null;
4885
+ if (store.appliedProfile) {
4886
+ const applied = await store.appliedProfile(toolId);
4887
+ if (applied)
4888
+ return applied.switchedAway ? null : fromProfile(applied, "applied");
4889
+ }
4890
+ const appliedName = store.registry === "subscriptions" ? undefined : appliedProfileName(toolId);
4767
4891
  if (appliedName) {
4768
4892
  const applied = await store.findProfile(appliedName, toolId);
4769
4893
  if (applied)
@@ -7305,7 +7429,7 @@ import { randomUUID } from "crypto";
7305
7429
  // package.json
7306
7430
  var package_default = {
7307
7431
  name: "@hasna/economy",
7308
- version: "0.5.3",
7432
+ version: "0.5.4",
7309
7433
  description: "AI coding cost tracker \u2014 CLI + MCP server + REST API for Claude Code, Codex, OpenCode, Cursor, Pi, and Hermes, with legacy Gemini CLI session and Gemini API billing ingestion",
7310
7434
  repository: {
7311
7435
  type: "git",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hasna/economy",
3
- "version": "0.5.3",
3
+ "version": "0.5.4",
4
4
  "description": "AI coding cost tracker — CLI + MCP server + REST API for Claude Code, Codex, OpenCode, Cursor, Pi, and Hermes, with legacy Gemini CLI session and Gemini API billing ingestion",
5
5
  "repository": {
6
6
  "type": "git",