Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 11 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ All output is structured JSON, making it ideal for programmatic consumption by A
## Installation

```bash
npm install -g lucas-cli
npm install -g lucasapp-cli
```

Or run directly with Bun:
Expand All @@ -34,6 +34,7 @@ lucas transactions list --from 2026-03-01

# Get a financial summary
lucas stats summary
lucas stats summary --year 2026 --month 3
```

## Commands
Expand Down Expand Up @@ -192,8 +193,16 @@ lucas loans delete <id> # Delete loan

```bash
lucas stats summary # Financial summary
lucas stats summary --year 2026 --month 3 # Inspect a specific month
lucas stats monthly # Monthly statistics
lucas stats by-category # Spending by category
lucas stats by-category --year 2026 --month 3 # Spending by category
```

When a newer CLI version is available, interactive terminals will show a short
update notice with the recommended command:

```bash
npm install -g lucasapp-cli@latest
```

### Categories
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "lucasapp-cli",
"version": "0.3.0",
"version": "0.3.1",
"description": "LucasApp CLI - Financial data management for AI agents",
"author": "StevenACZ",
"license": "MIT",
Expand Down
17 changes: 17 additions & 0 deletions src/commands/stats/by-category.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,30 @@
import { Command } from "commander";
import { apiRequest } from "../../lib/api-client.js";
import { setOptionalIntegerQueryParam } from "../../lib/query-params.js";
import { output } from "../../lib/output.js";

export const byCategoryCommand = new Command("by-category")
.description("Get statistics by category")
.option("--currency <code>", "Currency code")
.option("--year <year>", "Target year")
.option("--month <month>", "Target month (1-12)")
.action(async (opts) => {
const params: Record<string, string> = {};
if (opts.currency) params.currency = opts.currency;
setOptionalIntegerQueryParam(params, {
value: opts.year,
flag: "--year",
queryKey: "year",
min: 2000,
max: 2100,
});
setOptionalIntegerQueryParam(params, {
value: opts.month,
flag: "--month",
queryKey: "month",
min: 1,
max: 12,
});
const data = await apiRequest(
"GET",
"/api/stats/by-category",
Expand Down
17 changes: 17 additions & 0 deletions src/commands/stats/summary.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,30 @@
import { Command } from "commander";
import { apiRequest } from "../../lib/api-client.js";
import { setOptionalIntegerQueryParam } from "../../lib/query-params.js";
import { output } from "../../lib/output.js";

export const summaryCommand = new Command("summary")
.description("Get financial summary")
.option("--currency <code>", "Currency code")
.option("--year <year>", "Target year")
.option("--month <month>", "Target month (1-12)")
.action(async (opts) => {
const params: Record<string, string> = {};
if (opts.currency) params.currency = opts.currency;
setOptionalIntegerQueryParam(params, {
value: opts.year,
flag: "--year",
queryKey: "year",
min: 2000,
max: 2100,
});
setOptionalIntegerQueryParam(params, {
value: opts.month,
flag: "--month",
queryKey: "month",
min: 1,
max: 12,
});
const data = await apiRequest(
"GET",
"/api/stats/summary",
Expand Down
8 changes: 4 additions & 4 deletions src/commands/transactions/list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,13 @@ export const listTransactionsCommand = new Command("list")
.option("--take <n>", "Take N records")
.action(async (opts) => {
const params: Record<string, string> = {};
if (opts.from) params.from = opts.from;
if (opts.to) params.to = opts.to;
if (opts.from) params.startDate = opts.from;
if (opts.to) params.endDate = opts.to;
if (opts.categoryId) params.categoryId = opts.categoryId;
if (opts.accountId) params.accountId = opts.accountId;
if (opts.type) params.type = opts.type;
if (opts.skip) params.skip = opts.skip;
if (opts.take) params.take = opts.take;
if (opts.skip) params.offset = opts.skip;
if (opts.take) params.limit = opts.take;

const data = await apiRequest(
"GET",
Expand Down
7 changes: 5 additions & 2 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
#!/usr/bin/env node
import { Command } from "commander";
import { maybeNotifyForUpdate } from "./lib/update-notifier.js";
import { CLI_VERSION } from "./lib/version.js";

// Auth
import { loginCommand } from "./commands/auth/login.js";
Expand Down Expand Up @@ -55,7 +57,7 @@ const program = new Command();
program
.name("lucas")
.description("LucasApp CLI - Financial data management for AI agents")
.version("0.3.0");
.version(CLI_VERSION);

// Grupo: auth
const auth = program.command("auth").description("Authentication commands");
Expand Down Expand Up @@ -123,4 +125,5 @@ const exchangeRate = program
.description("Currency exchange");
exchangeRate.addCommand(convertCommand);

program.parse();
await maybeNotifyForUpdate(CLI_VERSION);
await program.parseAsync(process.argv);
2 changes: 1 addition & 1 deletion src/lib/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ export interface Credentials {
expiresAt: string;
}

const CONFIG_DIR = join(homedir(), ".config", "lucas");
export const CONFIG_DIR = join(homedir(), ".config", "lucas");
const CREDENTIALS_FILE = join(CONFIG_DIR, "credentials.json");
const DEFAULT_API_URL = "https://lucas.stevenacz.com";

Expand Down
25 changes: 25 additions & 0 deletions src/lib/query-params.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { output } from "./output.js";
import { parseOptionalNumber } from "./number-parser.js";

export function setOptionalIntegerQueryParam(
params: Record<string, string>,
input: {
value: unknown;
flag: string;
queryKey: string;
min: number;
max: number;
},
): void {
const parsed = parseOptionalNumber(input.value, input.flag);
if (parsed === undefined) return;

if (!Number.isInteger(parsed) || parsed < input.min || parsed > input.max) {
output.error(`Invalid value for ${input.flag}`, 400, {
value: input.value,
expected: `${input.min}-${input.max}`,
});
}

params[input.queryKey] = String(parsed);
}
124 changes: 124 additions & 0 deletions src/lib/update-notifier.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
import { existsSync, readFileSync, writeFileSync } from "fs";
import { join } from "path";
import { CONFIG_DIR, ensureConfigDir } from "./config.js";

const UPDATE_CACHE_FILE = join(CONFIG_DIR, "update-check.json");
const UPDATE_CHECK_INTERVAL_MS = 1000 * 60 * 60 * 12;
const PACKAGE_NAME = "lucasapp-cli";

interface UpdateCache {
lastCheckedAt: string;
latestVersion?: string;
}

export function parseVersion(version: string): number[] {
return version
.trim()
.replace(/^v/, "")
.split(".")
.map((part) => Number.parseInt(part, 10) || 0);
}

export function isVersionNewer(
currentVersion: string,
latestVersion: string,
): boolean {
const current = parseVersion(currentVersion);
const latest = parseVersion(latestVersion);
const length = Math.max(current.length, latest.length);

for (let index = 0; index < length; index += 1) {
const currentPart = current[index] ?? 0;
const latestPart = latest[index] ?? 0;

if (latestPart > currentPart) return true;
if (latestPart < currentPart) return false;
}

return false;
}

export function shouldRefreshUpdateCheck(
cache: UpdateCache | null,
nowMs: number = Date.now(),
): boolean {
if (!cache?.lastCheckedAt) return true;

const lastCheckedAtMs = new Date(cache.lastCheckedAt).getTime();
if (!Number.isFinite(lastCheckedAtMs)) return true;

return nowMs - lastCheckedAtMs >= UPDATE_CHECK_INTERVAL_MS;
}

function isInteractiveTerminal(): boolean {
return (
Boolean(process.stdout.isTTY && process.stderr.isTTY) &&
process.env.CI !== "true" &&
process.env.LUCAS_DISABLE_UPDATE_NOTIFIER !== "1"
);
}

function loadUpdateCache(): UpdateCache | null {
if (!existsSync(UPDATE_CACHE_FILE)) return null;

try {
return JSON.parse(readFileSync(UPDATE_CACHE_FILE, "utf-8")) as UpdateCache;
} catch {
return null;
}
}

function saveUpdateCache(cache: UpdateCache): void {
ensureConfigDir();
writeFileSync(UPDATE_CACHE_FILE, JSON.stringify(cache, null, 2));
}

async function fetchLatestVersion(): Promise<string | undefined> {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 1500);

try {
const response = await fetch(
`https://registry.npmjs.org/${PACKAGE_NAME}/latest`,
{
signal: controller.signal,
headers: {
Accept: "application/json",
},
},
);

if (!response.ok) return undefined;

const payload = (await response.json()) as { version?: unknown };
return typeof payload.version === "string" ? payload.version : undefined;
} catch {
return undefined;
} finally {
clearTimeout(timeout);
}
}

export async function maybeNotifyForUpdate(
currentVersion: string,
): Promise<void> {
if (!isInteractiveTerminal()) return;

const cache = loadUpdateCache();
let latestVersion = cache?.latestVersion;

if (shouldRefreshUpdateCheck(cache)) {
latestVersion = await fetchLatestVersion();
saveUpdateCache({
lastCheckedAt: new Date().toISOString(),
...(latestVersion && { latestVersion }),
});
}

if (!latestVersion || !isVersionNewer(currentVersion, latestVersion)) return;

console.error(
`Update available for lucas: ${currentVersion} -> ${latestVersion}`,
);
console.error("Run: npm install -g lucasapp-cli@latest");
}
1 change: 1 addition & 0 deletions src/lib/version.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const CLI_VERSION = "0.3.1";
56 changes: 56 additions & 0 deletions tests/commands/stats.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { beforeEach, describe, expect, it, vi } from "vitest";

const apiRequest = vi.fn();
const outputSuccess = vi.fn();
const outputError = vi.fn((message: string) => {
throw new Error(message);
});

vi.mock("../../src/lib/api-client.js", () => ({
apiRequest,
}));

vi.mock("../../src/lib/output.js", () => ({
output: {
success: outputSuccess,
error: outputError,
},
}));

const { summaryCommand } = await import("../../src/commands/stats/summary.js");

describe("stats commands", () => {
beforeEach(() => {
apiRequest.mockReset();
outputSuccess.mockReset();
outputError.mockClear();
});

it("passes optional month and year to stats summary", async () => {
apiRequest.mockResolvedValue({});

await summaryCommand.parseAsync(
["--year", "2026", "--month", "3", "--currency", "PEN"],
{ from: "user" },
);

expect(apiRequest).toHaveBeenCalledWith(
"GET",
"/api/stats/summary",
undefined,
{
currency: "PEN",
year: "2026",
month: "3",
},
);
});

it("rejects invalid months for stats summary", async () => {
apiRequest.mockResolvedValue({});

await expect(
summaryCommand.parseAsync(["--month", "13"], { from: "user" }),
).rejects.toThrow("Invalid value for --month");
});
});
Loading
Loading